From 7c89eb625ed9ada4392edce7782af366d6aa9b60 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 15 Apr 2026 22:08:42 +0200 Subject: [PATCH] feat(ai): workbench agent filter + proposal agent chip + docs (Phase 6+7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/mana/CLAUDE.md | 14 +++-- .../lib/components/ai/AiProposalInbox.svelte | 31 ++++++++- .../web/src/lib/data/ai/timeline/queries.ts | 7 +++ .../lib/modules/ai-workbench/ListView.svelte | 39 +++++++++++- .../COMPANION_BRAIN_ARCHITECTURE.md | 63 +++++++++++++++++++ services/mana-ai/CLAUDE.md | 23 +++++-- 6 files changed, 162 insertions(+), 15 deletions(-) diff --git a/apps/mana/CLAUDE.md b/apps/mana/CLAUDE.md index 30e59d79a..4a899f764 100644 --- a/apps/mana/CLAUDE.md +++ b/apps/mana/CLAUDE.md @@ -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 `` 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:` 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 `` 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** — `` 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 diff --git a/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte b/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte index 6a3ef41ab..5355cf0d8 100644 --- a/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte +++ b/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte @@ -99,8 +99,16 @@ {#each proposals.value as p (p.id)}
- - KI schlägt vor + {#if p.actor?.kind === 'ai'} + + 🤖 + {p.actor.displayName} + + schlägt vor + {:else} + + KI schlägt vor + {/if} {#if showModuleBadge && p.intent.kind === 'toolCall'} {@const mod = getTool(p.intent.toolName)?.module ?? '?'} {mod} @@ -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; diff --git a/apps/mana/apps/web/src/lib/data/ai/timeline/queries.ts b/apps/mana/apps/web/src/lib/data/ai/timeline/queries.ts index 99588b63c..86b4e3e19 100644 --- a/apps/mana/apps/web/src/lib/data/ai/timeline/queries.ts +++ b/apps/mana/apps/web/src/lib/data/ai/timeline/queries.ts @@ -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, }); } } diff --git a/apps/mana/apps/web/src/lib/modules/ai-workbench/ListView.svelte b/apps/mana/apps/web/src/lib/modules/ai-workbench/ListView.svelte index b06a83a44..97c608149 100644 --- a/apps/mana/apps/web/src/lib/modules/ai-workbench/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/ai-workbench/ListView.svelte @@ -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(null); let missionFilter = $state(null); + let agentFilter = $state(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} + {#if tab === 'audit'} @@ -193,6 +213,7 @@ {:else}
    {#each buckets as b (b.key)} + {@const bucketAgent = agentById.get(b.agentId)}
  1. @@ -201,6 +222,11 @@
    + + {bucketAgent?.avatar ?? '🤖'} + + {bucketAgent?.name ?? b.agentDisplayName} + · {missionTitleById.get(b.missionId) ?? b.missionId} {#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)); diff --git a/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md b/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md index d112eb074..c6e2a54cf 100644 --- a/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md +++ b/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md @@ -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:' + 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 `` 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. diff --git a/services/mana-ai/CLAUDE.md b/services/mana-ai/CLAUDE.md index 3a76319ca..63f531b84 100644 --- a/services/mana-ai/CLAUDE.md +++ b/services/mana-ai/CLAUDE.md @@ -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] `` 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