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));