mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
98668b69a2
commit
7c89eb625e
6 changed files with 162 additions and 15 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue