feat(ai): workbench agent filter + proposal agent chip + docs (Phase 6+7)

Phase 6 — Multi-Agent observability:
- AI Workbench timeline gets a per-agent filter (dropdown with avatars)
  alongside module + mission. TimelineBucket gains agentId +
  agentDisplayName, projected off the bucket's first AI actor.
- Bucket header now leads with the agent's avatar + name (lookup via
  the live useAgents query so renamed agents reflect instantly) and
  falls back to Actor.displayName for deleted agents.
- AiProposalInbox card header replaces the generic Sparkle + "KI
  schlägt vor" with an agent chip "🤖 Cashflow Watcher schlägt vor"
  using the cached Actor.displayName. Ghost-agent label preserved
  via the cached displayName even when the agent record is gone.

Phase 7 — Docs:
- docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md §22 added:
  data model, identity flow, tick gate order, Scene-Agent binding
  semantics, non-goals.
- services/mana-ai/CLAUDE.md status bumped to v0.5 (Multi-Agent
  Workbench) with the per-agent runner features + metrics listed.
- apps/mana/CLAUDE.md AI Workbench section rewritten to cover the
  Agent primitive, per-agent policy, scene lens, and the updated
  timeline header.

Multi-Agent rollout is code-complete end-to-end:
  Phase 0 Plan ✓  Phase 4 Policy-per-agent ✓
  Phase 1 Actor identity ✓  Phase 5 Agent UI + Scene lens ✓
  Phase 2 Agent CRUD ✓  Phase 6 Observability ✓
  Phase 3 Tick agent-aware ✓  Phase 7 Docs ✓

Tests: webapp svelte-check 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 22:08:42 +02:00
parent 98668b69a2
commit 7c89eb625e
6 changed files with 162 additions and 15 deletions

View file

@ -171,14 +171,16 @@ Svelte 5 runes are mandatory — no legacy `let count = 0; $: doubled = count *
The companion is a **second actor** that works alongside the human in every module. Full pipeline live end-to-end:
- **Actor attribution** — every event, record, and sync row carries `{ kind: 'user' | 'ai' | 'system' }`. Code: `src/lib/data/events/actor.ts`.
- **AI policy** — per-tool `auto | propose | deny`. Proposable tool names come from `@mana/shared-ai`'s `AI_PROPOSABLE_TOOL_NAMES`; the mana-ai service runs a boot-time drift guard against the same list. Code: `src/lib/data/ai/policy.ts`.
- **Proposal inbox** — drop `<AiProposalInbox module="…" />` into any module page to render pending proposals inline with approve / freitext-reject buttons. Wired in `/todo`, `/calendar`, `/places`, `/drink`, `/food`.
- **Missions** — long-lived autonomous work items at `/companion/missions` with concept + objective + linked inputs + cadence. Both the foreground tick AND the server-side `mana-ai` service produce plans; `data/ai/missions/server-iteration-staging.ts` translates server-source iterations into local Proposals on sync.
- **Actor attribution** — every event, record, and sync row carries `{ kind, principalId, displayName }` (+ mission/iteration/rationale for AI). `principalId` is the userId / agentId / `system:<source>` sentinel; `displayName` is cached at write time so rename doesn't rewrite history. Factories in `@mana/shared-ai/src/actor.ts`; runtime ambient context in `src/lib/data/events/actor.ts`.
- **Agents** — named AI personas that own Missions. `/ai-agents` module for CRUD (policy editor, memory, budget, concurrency). Default "Mana" agent auto-bootstrapped on first login; legacy missions backfilled. `data/ai/agents/{store,queries,bootstrap}.ts`.
- **AI policy** — per-tool `auto | propose | deny`. Lives on the agent (`agent.policy`). Proposable tool names come from `@mana/shared-ai`'s `AI_PROPOSABLE_TOOL_NAMES`; the mana-ai service runs a boot-time drift guard against the same list. Resolution in `src/lib/data/ai/policy.ts`; executor loads `agent.policy` for every AI write.
- **Proposal inbox** — drop `<AiProposalInbox module="…" />` into any module page to render pending proposals inline with approve / freitext-reject buttons. Cards show the owning agent's name + avatar chip. Wired in `/todo`, `/calendar`, `/places`, `/drink`, `/food`.
- **Missions** — long-lived autonomous work items at `/ai-missions` with concept + objective + linked inputs + cadence + **owning agent** (AgentPicker in the create flow). Both the foreground tick AND the server-side `mana-ai` service produce plans under the agent's identity; `data/ai/missions/server-iteration-staging.ts` translates server-source iterations into local Proposals on sync.
- **Input picker**`<MissionInputPicker>` sources candidates from the `input-index` registry (notes / kontext / goals / tasks / calendar). The Runner resolves via the parallel `input-resolvers` registry. Encrypted tables (notes, tasks, …) decrypt client-side only.
- **Workbench timeline**`/companion/workbench` renders every AI-attributed event grouped by mission iteration. Each bucket has a **Revert button** that undoes the iteration's writes via `data/ai/revert/` (TaskCreated → delete, TaskCompleted → uncomplete, etc., newest-first).
- **Scene lens** — workbench scenes can bind to an agent via `scene.viewingAsAgentId` (context menu → "An Agent binden…"). Pure UI lens, not a data-scope change. `SceneAppBar` shows the agent avatar on bound scene tabs.
- **Workbench timeline**`/ai-workbench` renders every AI-attributed event grouped by mission iteration with per-**agent** filter, per-module, per-mission. Each bucket header shows agent avatar + name + mission title. Per-bucket **Revert button** undoes the iteration's writes via `data/ai/revert/` (TaskCreated → delete, TaskCompleted → uncomplete, etc., newest-first). Separate **"Datenzugriff"** tab exposes the server-side decrypt audit (for missions with Key-Grants).
Full architecture (Planner prompt + parser in `@mana/shared-ai`, server-side runner, Postgres actor column, materialized snapshots, Prometheus metrics + status.mana.how integration): [`docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md` §20](../../docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md).
Full architecture (Planner prompt + parser in `@mana/shared-ai`, server-side runner, Postgres actor column, materialized snapshots, Multi-Agent gating, Prometheus metrics + status.mana.how integration): [`docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md`](../../docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md) §20 (AI Workbench) + §21 (Mission Grants) + §22 (Multi-Agent Workbench).
## Reference Documents

View file

@ -99,8 +99,16 @@
{#each proposals.value as p (p.id)}
<article class="card" class:busy={busyId === p.id}>
<header class="header">
<Sparkle size={16} weight="fill" />
<span class="label">KI schlägt vor</span>
{#if p.actor?.kind === 'ai'}
<span class="agent-chip" title={`Mission: ${p.actor.missionId.slice(0, 8)}…`}>
<span class="agent-avatar-dot">🤖</span>
<span class="agent-name">{p.actor.displayName}</span>
</span>
<span class="label">schlägt vor</span>
{:else}
<Sparkle size={16} weight="fill" />
<span class="label">KI schlägt vor</span>
{/if}
{#if showModuleBadge && p.intent.kind === 'toolCall'}
{@const mod = getTool(p.intent.toolName)?.module ?? '?'}
<span class="module-badge">{mod}</span>
@ -204,6 +212,25 @@
letter-spacing: 0.02em;
text-transform: lowercase;
}
.agent-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.0625rem 0.375rem;
border-radius: 999px;
background: color-mix(in oklab, var(--color-primary, #6b5bff) 12%, transparent);
color: color-mix(in oklab, var(--color-primary, #6b5bff) 95%, var(--color-fg, #000));
font-size: 0.75rem;
text-transform: none;
letter-spacing: 0;
}
.agent-avatar-dot {
font-size: 0.875rem;
line-height: 1;
}
.agent-name {
font-weight: 600;
}
.intent {
margin: 0.375rem 0 0;

View file

@ -57,6 +57,11 @@ export interface TimelineBucket {
rationale: string;
firstTimestamp: string;
events: DomainEvent[];
/** Owning Agent cached off the first AI actor in the bucket. All
* events in a bucket share the same (agentId, missionId, iterationId)
* tuple so reading from the first is lossless. */
agentId: string;
agentDisplayName: string;
}
export function bucketByIteration(events: readonly DomainEvent[]): TimelineBucket[] {
@ -79,6 +84,8 @@ export function bucketByIteration(events: readonly DomainEvent[]): TimelineBucke
rationale: a.rationale,
firstTimestamp: e.meta.timestamp,
events: [e],
agentId: a.principalId,
agentDisplayName: a.displayName,
});
}
}

View file

@ -6,6 +6,7 @@
import { ArrowSquareOut, ArrowCounterClockwise } from '@mana/shared-icons';
import { useAiTimeline, bucketByIteration } from '$lib/data/ai/timeline/queries';
import { useMissions } from '$lib/data/ai/missions/queries';
import { useAgents } from '$lib/data/ai/agents/queries';
import { revertIteration } from '$lib/data/ai/revert/revert-iteration';
import { fetchDecryptAudit, type AuditRow } from '$lib/data/ai/audit/queries';
import { isMissionGrantsEnabled } from '$lib/api/config';
@ -13,6 +14,7 @@
let moduleFilter = $state<string | null>(null);
let missionFilter = $state<string | null>(null);
let agentFilter = $state<string | null>(null);
const events = $derived(
useAiTimeline({
@ -21,9 +23,18 @@
limit: 500,
})
);
const buckets = $derived(bucketByIteration(events.value));
const allBuckets = $derived(bucketByIteration(events.value));
// Agent filter is applied client-side after bucketing because the
// useAiTimeline query is keyed by module/mission only today. If the
// volume ever grows large enough for this to matter, push it into
// the query.
const buckets = $derived(
agentFilter ? allBuckets.filter((b) => b.agentId === agentFilter) : allBuckets
);
const missions = $derived(useMissions());
const missionTitleById = $derived(new Map(missions.value.map((m) => [m.id, m.title])));
const agents = $derived(useAgents());
const agentById = $derived(new Map(agents.value.map((a) => [a.id, a])));
const allModules = $derived(Array.from(new Set(events.value.map((e) => e.meta.appId))).sort());
function describeEvent(e: DomainEvent): string {
@ -147,6 +158,15 @@
{/each}
</select>
</label>
<label>
<span class="lbl">Agent</span>
<select bind:value={agentFilter}>
<option value={null}>alle</option>
{#each agents.value as a (a.id)}
<option value={a.id}>{a.avatar ?? '🤖'} {a.name}</option>
{/each}
</select>
</label>
</div>
{#if tab === 'audit'}
@ -193,6 +213,7 @@
{:else}
<ol class="timeline">
{#each buckets as b (b.key)}
{@const bucketAgent = agentById.get(b.agentId)}
<li class="bucket">
<header class="bucket-head">
<div class="when">
@ -201,6 +222,11 @@
</div>
<div class="title-col">
<span class="mission-title">
<span class="agent-avatar" title={bucketAgent?.name ?? b.agentDisplayName}>
{bucketAgent?.avatar ?? '🤖'}
</span>
<span class="agent-name">{bucketAgent?.name ?? b.agentDisplayName}</span>
<span class="mission-sep">·</span>
{missionTitleById.get(b.missionId) ?? b.missionId}
</span>
{#if b.rationale}
@ -373,6 +399,17 @@
.when .time {
font-size: 0.6875rem;
}
.agent-avatar {
display: inline-block;
margin-right: 0.25rem;
}
.agent-name {
font-weight: 600;
}
.mission-sep {
margin: 0 0.25rem;
color: hsl(var(--color-muted-foreground));
}
.mission-title {
font-weight: 600;
color: hsl(var(--color-primary));

View file

@ -1892,3 +1892,66 @@ Vollstaendiger Plan: [`docs/plans/ai-mission-key-grant.md`](../plans/ai-mission-
- Cross-User-Missions (pro Grant genau ein User).
- Automatische Key-Rotation (Master-Key-Rotation invalidiert alle Grants → User re-consented beim naechsten Edit).
- Grant-Sync-Konflikte (werden ueber normales LWW aufgeloest; bei Scope-Mismatch wirft der Resolver `scope-violation` und die Mission pausiert).
## 22. Multi-Agent Workbench (ab 2026-04-15)
Upgrade von Single-User, Single-AI ("Mana") zu Single-User, Multi-Agent. Missionen gehoeren jetzt einem benannten Agent; Scenes koennen als Lens an einen Agent gebunden werden. Full context + decisions: [`docs/plans/multi-agent-workbench.md`](../plans/multi-agent-workbench.md).
### Datenmodell
```
Agent {
id, name (unique per user), avatar, role,
systemPrompt, memory, // encrypted at rest
policy: AiPolicy, // per-tool + per-module + global default
maxConcurrentMissions, maxTokensPerDay,
state: active|paused|archived
}
Mission.agentId?: string // owning agent; legacy records backfill-stamped
WorkbenchScene.viewingAsAgentId?: string // UI lens, does not affect data scope
Actor {
kind: 'user' | 'ai' | 'system',
principalId: string, // userId | agentId | 'system:<source>'
displayName: string, // cached at write time — rename doesn't rewrite history
// AI-only:
missionId?, iterationId?, rationale?
}
```
### Identity flow
1. User creates an agent in `/ai-agents`. Default "Mana" agent is auto-bootstrapped on first login and inherits the existing user-level policy.
2. Missions are created under an agent (`AgentPicker` in the create flow). Legacy missions were backfilled to the default agent via localStorage-sentinelled one-shot migration.
3. `executor.executeTool` loads `getAgent(actor.principalId).policy` for every AI write; `mana-ai` does the same server-side via `agent_snapshots` (LWW projection mirroring `mission_snapshots`).
4. Every write stamps `Actor.displayName = agent.name` at write time. Workbench timeline + Revert remain correct even after the agent is renamed or deleted (ghost-agent marker on tab).
5. `mana-ai` tick filters `AI_AVAILABLE_TOOLS` by agent policy before the prompt, injects plaintext `systemPrompt + memory` in an `<agent_context>` delimiter block (ciphertext fields stay server-invisible by design — foreground runner picks up encrypted context).
6. Scenes optionally bind `viewingAsAgentId`. Pure UI lens: SceneAppBar shows the agent avatar on the scene tab, Workbench timeline defaults its filter to that agent. No data-scope change.
### Gate order in the server tick
Before an LLM call even happens:
- `agent.state === 'archived'` → skip silently, bump `agentDecisionsTotal{decision='skipped-archived'}`
- `agent.state === 'paused'` → same with `skipped-paused`
- `activeRuns[agentId] >= agent.maxConcurrentMissions``skipped-concurrency`, defer to next tick
- Otherwise → `ran`
Missions without an owning agent don't produce this metric; `plansWrittenBackTotal` is the universal "did we run" counter.
### Scene-Agent binding semantics
`scene.viewingAsAgentId` is a **lens**, not a scope. The open apps in the scene still read the same user data regardless of which agent (if any) is bound. The binding drives:
- Agent avatar on the scene tab (SceneAppBar)
- Default `agent` filter in the AI Workbench timeline
- Default selected agent when creating a mission from a bound scene (future — Phase 8 polish)
This is deliberately orthogonal: one agent can appear in many scenes; one scene can be unbound ("neutral workspace"). Binding is set/changed via the scene context menu → "An Agent binden…" dialog.
### Nicht-Ziele
- **Kein Agent-to-Agent Messaging.** Agents laufen unabhaengig.
- **Kein Meta-Planner ueber Agents.** Agents erzeugen sich keine Missionen selbst; der User bleibt Mission-Creator.
- **Keine Team-Features.** Andere User / geteilte Daten kommen in einem separaten Plan nach dieser Iteration.
- **Keine Agent-Memory-Self-Modification.** Memory wird nur vom User editiert.
- **Keine Per-Agent-Encryption-Domains.** Alle Agents sehen alle Daten des einen Users. Mission-Key-Grants bleiben per-Mission.

View file

@ -52,12 +52,23 @@ Was steht (Phase 0-2, Backend):
Was offen ist (Phase 3, Frontend):
- [ ] Webapp `MissionGrantDialog` + Consent-Flow im `/companion/missions`-Editor.
- [ ] Revoke-Button + "Mission → Datenzugriff" Audit-Tab in `/companion/workbench`.
- [ ] Scope-Change-UX: neue Records → Re-Consent-Prompt.
- [ ] `GET /internal/audit?missionId=` Endpoint (read-only) fuer die UI.
- [ ] Feature-Flag `PUBLIC_AI_MISSION_GRANTS=false` default + Rollout (till → beta → alpha).
- [ ] Produktions-Keypair generieren + in Mac-Mini Secrets ablegen.
- [x] Webapp `MissionGrantDialog` + Consent-Flow im Mission-Detail.
- [x] Revoke-Button + "Datenzugriff" Audit-Tab im Workbench.
- [x] `GET /api/v1/me/ai-audit` JWT-gated Endpoint live.
- [x] Feature-Flag `PUBLIC_AI_MISSION_GRANTS` + Cloudflare-Tunnel.
- [x] Produktions-Keypair auf Mac-Mini unter `secrets/mana-ai/`.
## Status: v0.5 (Multi-Agent Workbench)
Der Runner wird agent-bewusst — Missionen gehoeren einem benannten Agent, Policy und Memory leben auf dem Agent, Concurrency + Budget werden pro Agent respektiert.
- [x] `mana_ai.agent_snapshots` Tabelle (LWW-Projektion von `agents` aus `sync_changes`).
- [x] `refreshAgentSnapshots` + `loadActiveAgents` parallel zum Mission-Snapshot-Refresh.
- [x] `ServerMission.agentId` + `ServerAgent.policy` durchgereicht.
- [x] Tick resolvt pro Mission den Agent, gated `archived`/`paused`/`concurrency`, schreibt iteration unter `makeAgentActor(agent)` Identitaet.
- [x] `<agent_context>` Prompt-Block mit plaintext `role` + `systemPrompt` + `memory` (ciphertext wird uebersprungen).
- [x] `filterToolsByAgentPolicy` schneidet `deny`-Tools raus bevor der Planner sie sieht.
- [x] Metrik `mana_ai_agent_decisions_total{decision}`.
## Port: 3067