diff --git a/apps/mana/apps/web/src/lib/data/ai/agents/bootstrap.ts b/apps/mana/apps/web/src/lib/data/ai/agents/bootstrap.ts new file mode 100644 index 000000000..f2937a5f8 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/agents/bootstrap.ts @@ -0,0 +1,106 @@ +/** + * Default-agent bootstrap + legacy-mission backfill. + * + * Runs once at app-shell init (via `startMissionTick` in setup.ts or a + * layout effect). Idempotent — safe to call on every mount, cross-tab + * races resolve via the store's `getOrCreateAgent` upsert. + * + * Flow: + * 1. Ensure a "Mana" agent exists with `id = DEFAULT_AGENT_ID`. This + * id mirrors `LEGACY_AI_PRINCIPAL` so historic events and fresh + * actors map to the same principal. + * 2. One-off migration: any mission without `agentId` gets the + * default agent's id. This is a Dexie write so the change flows + * through sync like any user edit. + * + * The migration step is gated by a localStorage sentinel so it runs + * only once per device per rollout. A user could manually re-trigger + * it via `resetBackfillSentinel()`, but there is no UI for that — it + * exists purely for test + recovery plumbing. + */ + +import { db } from '../../database'; +import type { Mission } from '../missions/types'; +import { MISSIONS_TABLE } from '../missions/types'; +import { getOrCreateAgent } from './store'; +import type { Agent } from './types'; +import { DEFAULT_AGENT_ID, DEFAULT_AGENT_NAME } from './types'; + +const BACKFILL_SENTINEL_KEY = 'mana:agents:default-backfill:v1'; + +/** + * Create the default agent if missing. Returns the materialized agent + * record. Safe under concurrent tabs — the store's `getOrCreateAgent` + * dedupes on the stable id. + */ +export async function ensureDefaultAgent(): Promise { + return getOrCreateAgent({ + id: DEFAULT_AGENT_ID, + name: DEFAULT_AGENT_NAME, + avatar: '🤖', + role: 'Standard-Assistent für alle Missionen', + }); +} + +/** + * Backfill `agentId` on every mission that predates the Multi-Agent + * rollout. Runs at most once per device via a localStorage sentinel. + * Returns the number of rows that were actually updated. + */ +export async function backfillMissionsAgentId(targetAgentId: string): Promise { + if (typeof window !== 'undefined' && window.localStorage.getItem(BACKFILL_SENTINEL_KEY)) { + return 0; + } + const table = db.table(MISSIONS_TABLE); + const all = await table.toArray(); + const pending = all.filter((m) => !m.agentId && !m.deletedAt); + if (pending.length === 0) { + if (typeof window !== 'undefined') { + window.localStorage.setItem(BACKFILL_SENTINEL_KEY, new Date().toISOString()); + } + return 0; + } + + // Batched update — Dexie has no bulkUpdate for partial fields, so + // we iterate. These writes go through the sync pipeline like any + // other update. + const now = new Date().toISOString(); + await db.transaction('rw', table, async () => { + for (const m of pending) { + await table.update(m.id, { agentId: targetAgentId, updatedAt: now }); + } + }); + + if (typeof window !== 'undefined') { + window.localStorage.setItem(BACKFILL_SENTINEL_KEY, now); + } + return pending.length; +} + +/** + * Convenience that does both: ensures the default agent exists, then + * backfills mission agentIds. Fires and forgets from the app-shell + * init; errors are logged but never thrown. + */ +export async function runAgentsBootstrap(): Promise { + try { + const agent = await ensureDefaultAgent(); + const migrated = await backfillMissionsAgentId(agent.id); + if (migrated > 0) { + console.info(`[agents] backfilled agentId on ${migrated} legacy mission(s) → ${agent.name}`); + } + } catch (err) { + console.error('[agents] bootstrap failed:', err); + } +} + +// ─── Test / recovery helpers ────────────────────────────── + +/** Clear the backfill sentinel so the next call re-runs the migration. + * Not wired into any UI; exported for integration tests + manual + * recovery via the browser console. */ +export function resetBackfillSentinel(): void { + if (typeof window !== 'undefined') { + window.localStorage.removeItem(BACKFILL_SENTINEL_KEY); + } +} diff --git a/apps/mana/apps/web/src/lib/data/ai/agents/queries.ts b/apps/mana/apps/web/src/lib/data/ai/agents/queries.ts new file mode 100644 index 000000000..cd645342e --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/agents/queries.ts @@ -0,0 +1,38 @@ +/** + * Svelte 5 reactive queries over the `agents` Dexie table. + */ + +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { db } from '../../database'; +import { decryptRecords } from '../../crypto'; +import type { Agent, AgentState } from './types'; +import { AGENTS_TABLE } from './types'; + +export interface UseAgentsOptions { + state?: AgentState; +} + +/** All non-deleted agents, newest first. */ +export function useAgents(options: UseAgentsOptions = {}) { + const { state } = options; + return useLiveQueryWithDefault(async () => { + const all = await db.table(AGENTS_TABLE).orderBy('createdAt').reverse().toArray(); + const visible = all.filter((a) => !a.deletedAt); + const filtered = state ? visible.filter((a) => a.state === state) : visible; + return decryptRecords(AGENTS_TABLE, filtered) as Promise; + }, [] as Agent[]); +} + +/** Single agent by id, reactively. Returns `null` when the agent + * doesn't exist or was soft-deleted. */ +export function useAgent(id: string) { + return useLiveQueryWithDefault( + async () => { + const a = await db.table(AGENTS_TABLE).get(id); + if (!a || a.deletedAt) return null; + const [decrypted] = await decryptRecords(AGENTS_TABLE, [a]); + return decrypted as Agent; + }, + null as Agent | null + ); +} diff --git a/apps/mana/apps/web/src/lib/data/ai/agents/store.test.ts b/apps/mana/apps/web/src/lib/data/ai/agents/store.test.ts new file mode 100644 index 000000000..83e18b8e0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/agents/store.test.ts @@ -0,0 +1,120 @@ +import 'fake-indexeddb/auto'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mirror the existing ai/* test suite mocks: funnel-tracking + the +// triggers registry touch browser-only globals at import time. +vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() })); +vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() })); +vi.mock('$lib/triggers/inline-suggest', () => ({ + checkInlineSuggestion: vi.fn().mockResolvedValue(null), +})); +import { db } from '../../database'; +import { + createAgent, + updateAgent, + deleteAgent, + archiveAgent, + getAgent, + listAgents, + findByName, + getOrCreateAgent, + DuplicateAgentNameError, +} from './store'; +import { AGENTS_TABLE, DEFAULT_AGENT_ID, DEFAULT_AGENT_NAME } from './types'; + +beforeEach(async () => { + await db.table(AGENTS_TABLE).clear(); +}); + +describe('createAgent', () => { + it('inserts an agent with sane defaults', async () => { + const a = await createAgent({ name: 'Travel Planner', role: 'plans trips' }); + expect(a.id).toBeTruthy(); + expect(a.state).toBe('active'); + expect(a.maxConcurrentMissions).toBe(1); + expect(a.policy).toBeDefined(); + }); + + it('refuses duplicate names among non-deleted agents', async () => { + await createAgent({ name: 'Dup', role: 'x' }); + await expect(createAgent({ name: 'Dup', role: 'y' })).rejects.toBeInstanceOf( + DuplicateAgentNameError + ); + }); + + it('allows reusing a name after the clashing agent is soft-deleted', async () => { + const first = await createAgent({ name: 'Zombie', role: 'first' }); + await deleteAgent(first.id); + const second = await createAgent({ name: 'Zombie', role: 'second' }); + expect(second.id).not.toBe(first.id); + expect(second.role).toBe('second'); + }); +}); + +describe('getOrCreateAgent', () => { + it('creates on first call, returns existing on second', async () => { + const a = await getOrCreateAgent({ + id: DEFAULT_AGENT_ID, + name: DEFAULT_AGENT_NAME, + role: 'default', + }); + const b = await getOrCreateAgent({ + id: DEFAULT_AGENT_ID, + name: DEFAULT_AGENT_NAME, + role: 'default', + }); + expect(a.id).toBe(DEFAULT_AGENT_ID); + expect(b.id).toBe(a.id); + const all = await listAgents(); + expect(all).toHaveLength(1); + }); +}); + +describe('updateAgent', () => { + it('applies a patch and refreshes updatedAt', async () => { + const a = await createAgent({ name: 'Edit me', role: 'r' }); + await updateAgent(a.id, { role: 'new role' }); + const after = await getAgent(a.id); + expect(after?.role).toBe('new role'); + }); + + it('refuses to rename into a clashing existing name', async () => { + await createAgent({ name: 'Taken', role: 'x' }); + const mover = await createAgent({ name: 'Original', role: 'y' }); + await expect(updateAgent(mover.id, { name: 'Taken' })).rejects.toBeInstanceOf( + DuplicateAgentNameError + ); + }); + + it("allows renaming to the agent's own current name (no-op)", async () => { + const a = await createAgent({ name: 'Stable', role: 'x' }); + await expect(updateAgent(a.id, { name: 'Stable' })).resolves.toBeUndefined(); + }); +}); + +describe('listAgents + findByName', () => { + it('lists only non-deleted agents, newest first', async () => { + await createAgent({ name: 'A', role: 'x' }); + await new Promise((r) => setTimeout(r, 2)); + const b = await createAgent({ name: 'B', role: 'y' }); + await deleteAgent(b.id); + await new Promise((r) => setTimeout(r, 2)); + await createAgent({ name: 'C', role: 'z' }); + const list = await listAgents(); + expect(list.map((a) => a.name)).toEqual(['C', 'A']); + }); + + it('filters by state when requested', async () => { + const a = await createAgent({ name: 'Active', role: 'x' }); + const b = await createAgent({ name: 'Paused', role: 'y' }); + await archiveAgent(b.id); + const activeOnly = await listAgents({ state: 'active' }); + expect(activeOnly.map((x) => x.id)).toEqual([a.id]); + }); + + it('findByName returns the matching non-deleted row', async () => { + await createAgent({ name: 'Needle', role: 'x' }); + expect((await findByName('Needle'))?.name).toBe('Needle'); + expect(await findByName('Nope')).toBeUndefined(); + }); +}); diff --git a/apps/mana/apps/web/src/lib/data/ai/agents/store.ts b/apps/mana/apps/web/src/lib/data/ai/agents/store.ts new file mode 100644 index 000000000..c338cf4e5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/agents/store.ts @@ -0,0 +1,169 @@ +/** + * Agent store — CRUD for the named AI personas that own Missions. + * + * See `docs/plans/multi-agent-workbench.md`. All writes go through + * Dexie + encryption pipeline the same way notes/missions do; the + * `systemPrompt` + `memory` fields are encrypted per the registry. + * + * Name uniqueness is enforced at write time here (not via a Dexie + * unique index) because the default-agent-bootstrap race between two + * browser tabs would otherwise throw ConstraintError. The store's + * `findByName` pre-check is racy in principle, but our write layer + * is single-threaded per tab; cross-tab races resolve via LWW on + * sync — whichever tab's write was later wins. + */ + +import { db } from '../../database'; +import { encryptRecord } from '../../crypto'; +import { DEFAULT_AI_POLICY } from '../policy'; +import type { AiPolicy } from '@mana/shared-ai'; +import type { Agent, AgentState } from './types'; +import { AGENTS_TABLE } from './types'; + +/** JSON-roundtrip deep clone to strip Svelte 5 `$state` proxies before + * records hit Dexie. Same pattern used in mission/store.ts. */ +function deepClone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +const table = () => db.table(AGENTS_TABLE); + +// ── Create ───────────────────────────────────────────────── + +export interface CreateAgentInput { + id?: string; + name: string; + role: string; + avatar?: string; + systemPrompt?: string; + memory?: string; + policy?: AiPolicy; + maxTokensPerDay?: number; + maxConcurrentMissions?: number; + state?: AgentState; +} + +export class DuplicateAgentNameError extends Error { + constructor(public readonly name: string) { + super(`Agent name already in use: ${name}`); + this.name = 'DuplicateAgentNameError'; + } +} + +/** Insert a new agent. Throws DuplicateAgentNameError if the name is + * already taken by a non-deleted agent. */ +export async function createAgent(input: CreateAgentInput): Promise { + const existing = await findByName(input.name); + if (existing) throw new DuplicateAgentNameError(input.name); + + const now = new Date().toISOString(); + const agent: Agent = { + id: input.id ?? crypto.randomUUID(), + createdAt: now, + updatedAt: now, + name: input.name, + role: input.role, + avatar: input.avatar, + systemPrompt: input.systemPrompt, + memory: input.memory, + policy: deepClone(input.policy ?? DEFAULT_AI_POLICY), + maxTokensPerDay: input.maxTokensPerDay, + maxConcurrentMissions: input.maxConcurrentMissions ?? 1, + state: input.state ?? 'active', + }; + const toWrite = { ...agent }; + await encryptRecord(AGENTS_TABLE, toWrite); + await table().add(toWrite); + return agent; +} + +/** + * Idempotent create: returns the agent with the given id if it exists + * (non-deleted), otherwise creates it. Used by the default-agent + * bootstrap to survive concurrent tab initialization races. + */ +export async function getOrCreateAgent(input: CreateAgentInput & { id: string }): Promise { + const existing = await getAgent(input.id); + if (existing) return existing; + try { + return await createAgent(input); + } catch (err) { + if (err instanceof DuplicateAgentNameError) { + // Another tab raced us. Refetch by id (bootstrap passes a stable + // id) or fall back to the by-name lookup. + const reload = await getAgent(input.id); + if (reload) return reload; + const byName = await findByName(input.name); + if (byName) return byName; + } + throw err; + } +} + +// ── Read ─────────────────────────────────────────────────── + +export async function getAgent(id: string): Promise { + const a = await table().get(id); + return a?.deletedAt ? undefined : a; +} + +export async function findByName(name: string): Promise { + const all = await table().where('name').equals(name).toArray(); + return all.find((a) => !a.deletedAt); +} + +export async function listAgents(filter: { state?: AgentState } = {}): Promise { + const all = await table().orderBy('createdAt').reverse().toArray(); + const visible = all.filter((a) => !a.deletedAt); + return filter.state ? visible.filter((a) => a.state === filter.state) : visible; +} + +// ── Update ───────────────────────────────────────────────── + +export interface AgentPatch { + name?: string; + role?: string; + avatar?: string; + systemPrompt?: string; + memory?: string; + policy?: AiPolicy; + maxTokensPerDay?: number; + maxConcurrentMissions?: number; + state?: AgentState; +} + +export async function updateAgent(id: string, patch: AgentPatch): Promise { + if (patch.name) { + const clash = await findByName(patch.name); + if (clash && clash.id !== id) { + throw new DuplicateAgentNameError(patch.name); + } + } + const mods: Partial = { + ...deepClone(patch), + updatedAt: new Date().toISOString(), + }; + await encryptRecord(AGENTS_TABLE, mods); + await table().update(id, mods); +} + +// ── Lifecycle ────────────────────────────────────────────── + +export async function archiveAgent(id: string): Promise { + await table().update(id, { state: 'archived', updatedAt: new Date().toISOString() }); +} + +export async function pauseAgent(id: string): Promise { + await table().update(id, { state: 'paused', updatedAt: new Date().toISOString() }); +} + +export async function resumeAgent(id: string): Promise { + await table().update(id, { state: 'active', updatedAt: new Date().toISOString() }); +} + +/** Soft-delete. Missions owned by the agent keep running; the Workbench + * renders them as "ghost-agent" until archived separately. Bringing an + * agent back requires an explicit undelete (not yet surfaced in UI). */ +export async function deleteAgent(id: string): Promise { + await table().update(id, { deletedAt: new Date().toISOString() }); +} diff --git a/apps/mana/apps/web/src/lib/data/ai/agents/types.ts b/apps/mana/apps/web/src/lib/data/ai/agents/types.ts new file mode 100644 index 000000000..c0b2d0b42 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/agents/types.ts @@ -0,0 +1,12 @@ +/** + * Webapp-local re-export of Agent types from @mana/shared-ai, plus + * storage-layer constants. + * + * Keeps module code from importing directly from the shared package, + * so a future rename of the shared path only touches this file. + */ + +export type { Agent, AgentState } from '@mana/shared-ai'; +export { DEFAULT_AGENT_ID, DEFAULT_AGENT_NAME } from '@mana/shared-ai'; + +export const AGENTS_TABLE = 'agents'; diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/server-iteration-staging.ts b/apps/mana/apps/web/src/lib/data/ai/missions/server-iteration-staging.ts index 1ccc09ef0..50d5f4e00 100644 --- a/apps/mana/apps/web/src/lib/data/ai/missions/server-iteration-staging.ts +++ b/apps/mana/apps/web/src/lib/data/ai/missions/server-iteration-staging.ts @@ -22,6 +22,8 @@ import { MISSIONS_TABLE } from './types'; import { createProposal } from '../proposals/store'; import { getMission } from './store'; import { runAsAsync, makeAgentActor, LEGACY_AI_PRINCIPAL } from '../../events/actor'; +import { getAgent } from '../agents/store'; +import { DEFAULT_AGENT_NAME } from '../agents/types'; import type { Mission, MissionIteration, PlanStep } from './types'; const processedIterations = new Set(); @@ -91,17 +93,21 @@ async function stageIteration(mission: Mission, iteration: MissionIteration): Pr if (!fresh) return; const stagedStepIds: Record = {}; + // Resolve the owning agent once per iteration (not per step) — agent + // identity doesn't change mid-iteration. Legacy missions or missions + // whose agent was deleted fall back to the legacy principal. + const owningAgent = fresh.agentId ? await getAgent(fresh.agentId) : null; + const actorAgentId = owningAgent?.id ?? LEGACY_AI_PRINCIPAL; + const actorDisplayName = owningAgent?.name ?? DEFAULT_AGENT_NAME; + for (const step of iteration.plan) { const intent = step.intent; if (intent.kind !== 'toolCall') continue; if (step.proposalId) continue; // already staged - // Phase 1: server-iteration writes under the legacy AI principal. - // Phase 2 will swap this for the mission's owning-agent identity - // once agents are wired into the data layer. const actor = makeAgentActor({ - agentId: LEGACY_AI_PRINCIPAL, - displayName: 'Mana', + agentId: actorAgentId, + displayName: actorDisplayName, missionId: mission.id, iterationId: iteration.id, rationale: step.summary || iteration.summary || mission.objective, diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/setup.ts b/apps/mana/apps/web/src/lib/data/ai/missions/setup.ts index ce55c65bb..1bf762e3c 100644 --- a/apps/mana/apps/web/src/lib/data/ai/missions/setup.ts +++ b/apps/mana/apps/web/src/lib/data/ai/missions/setup.ts @@ -20,6 +20,7 @@ import { llmOrchestrator } from '@mana/shared-llm'; import { aiPlanTask } from '$lib/llm-tasks/ai-plan'; import { runDueMissions, type MissionRunnerDeps } from './runner'; import { registerDefaultInputResolvers } from './default-resolvers'; +import { runAgentsBootstrap } from '../agents/bootstrap'; import type { AiPlanInput, AiPlanOutput } from './planner/types'; /** Default interval between tick scans. One minute is fine for foreground use. */ @@ -47,6 +48,12 @@ export function startMissionTick(intervalMs: number = DEFAULT_TICK_INTERVAL_MS): if (tickHandle !== null) return stopMissionTick; registerDefaultInputResolvers(); + // Multi-Agent Workbench: ensure a default "Mana" agent exists and + // backfill agentId on legacy missions. Fire-and-forget — the runner + // itself tolerates missions without an agentId during the migration + // window. See docs/plans/multi-agent-workbench.md §Phase 2d. + void runAgentsBootstrap(); + const tickOnce = async () => { // Guard against overlap — a slow LLM run could pile up multiple ticks. if (ticking) return; diff --git a/apps/mana/apps/web/src/lib/data/ai/module.config.ts b/apps/mana/apps/web/src/lib/data/ai/module.config.ts index 727d6833b..ea2cd6063 100644 --- a/apps/mana/apps/web/src/lib/data/ai/module.config.ts +++ b/apps/mana/apps/web/src/lib/data/ai/module.config.ts @@ -3,6 +3,8 @@ * * Sync surface: * - `aiMissions` — long-lived user-authored AI work items (cross-device) + * - `agents` — named AI personas that own Missions (cross-device). + * See docs/plans/multi-agent-workbench.md. * * NOT in this config: * - `pendingProposals` — intentionally local-only (see proposals/types.ts). @@ -14,5 +16,5 @@ import type { ModuleConfig } from '$lib/data/module-registry'; export const aiModuleConfig: ModuleConfig = { appId: 'ai', - tables: [{ name: 'aiMissions' }], + tables: [{ name: 'aiMissions' }, { name: 'agents' }], }; diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 1b8927b7f..4bfc2d8d7 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -471,6 +471,16 @@ export const ENCRYPTION_REGISTRY: Record = { // Singleton markdown document ("Was soll Mana über dich wissen?"). // Free-form user text — encrypt the content, leave the fixed id plaintext. kontextDoc: { enabled: true, fields: ['content'] }, + + // ─── AI Agents ─────────────────────────────────────────── + // Named AI personas (docs/plans/multi-agent-workbench.md). `name` + + // `role` + `avatar` stay plaintext because `name` is the display-key + // (search + picker + historic Actor.displayName cache) and the role + // is a short label. `systemPrompt` + `memory` carry user-specific + // context — often referencing private goals / projects / people — + // and belong under encryption. Policy + budgets + state are pure + // structural fields. + agents: { enabled: true, fields: ['systemPrompt', 'memory'] }, }; /** diff --git a/docs/plans/multi-agent-workbench.md b/docs/plans/multi-agent-workbench.md new file mode 100644 index 000000000..ddd35d778 --- /dev/null +++ b/docs/plans/multi-agent-workbench.md @@ -0,0 +1,239 @@ +# Plan: Multi-Agent Workbench — benannte KI-Agenten als erstklassige Bürger + +**Status:** Draft, 2026-04-15 +**Scope:** Upgrade vom Single-User-Workbench zum "Orchestration-Cockpit" mit mehreren benannten AI-Agenten, die autonom auf den Daten des einen Users arbeiten. Keine Team-Features (anderer User) in dieser Iteration. +**Motivation:** Heute sind Missionen "nackte Arbeitsaufträge" ohne Identität. Bei 10 laufenden Missionen fehlt die ordnende Identität. Agenten geben jedem Bündel Missionen + Persönlichkeit + Memory ein Zuhause und machen die Workbench zu einem echten Control-Room. +**Verwandte Docs:** [`docs/future/AI_AGENTS_IDEAS.md`](../future/AI_AGENTS_IDEAS.md), [`docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md`](../architecture/COMPANION_BRAIN_ARCHITECTURE.md) §20, [`docs/plans/ai-mission-key-grant.md`](./ai-mission-key-grant.md). + +--- + +## Entscheidungen (baked in) + +| Frage | Entscheidung | Begründung | +|---|---|---| +| **Konzept eines Agents** | Option C (Hybrid): Metadaten + Policy + **persistente Memory** die in jede Planner-Prompt injiziert wird | 80% der "echten Agent"-UX für 20% Mehraufwand gegenüber rein additivem Modell. Memory ist kein eigener Denk-Loop, bleibt upgrade-bar. | +| **Refactor-Tiefe** | **L3** — `Actor` wird identitätsbewusst (`{kind, principalId, displayName}`) | Wir sind nicht live. Der Actor-Layer ist frisch. Der Cutover ist *jetzt* billig, später teuer. Alle Follow-ups (Per-Agent-Policy, Timeline-Filter, zukünftige Team-Features) setzen darauf auf. | +| **Scene↔Agent-Beziehung** | **Orthogonal** (Option Y). Scenes können optional `viewingAsAgentId` setzen. | "Agents sind Bürger, Scenes sind Fenster". Keine 1:1-Zwangsbindung — User kann mehrere Scenes auf denselben Agent zeigen. | +| **Agent-Memory** | **Feld auf Agent** (`memory: string`), manuell durch User editierbar. Keine Versionierung, keine Self-Modification. | Simpel. Versionierung + self-modifying ist ein eigenes Projekt (evals, drift, loops). | +| **Default-Agent-Migration** | Auto-Anlage eines "Mana"-Default-Agents bei erster Mission-Sichtung. Alle Legacy-Missions ohne `agentId` werden auf diesen migriert. User-Level-AiPolicy wird seine Policy. | Keine User-Action nötig für Bestandsdaten. User kann ihn später umbenennen / aufteilen. | +| **Agent-Löschung** | Soft-Delete (`deletedAt`). Aktive Missionen des gelöschten Agents werden nicht abgebrochen, laufen orphan-active weiter. Workbench zeigt sie grau. | Kein Daten-Verlust durch Klick. User kann bewusst Missions separat archivieren. | +| **Budget** | `maxTokensPerDay: number` pro Agent. Globaler Default ableitbar. Rollender 24h-Counter in Prometheus + Dexie-Side. | 10 Agents × paralleler Ticks können sonst schnell teuer. Harte Stopp-Semantik wenn Budget überschritten. | +| **Concurrency** | `maxConcurrentMissions` Feld pro Agent (Default: 1). `mana-ai` Tick respektiert das pro Agent. | Verhindert dass 10 Agents × N Missionen den LLM-Pool + PG-Pool überlasten. | +| **Mission Key-Grants** | Bleiben **per-Mission**, kein Redesign. UI zeigt zusätzlich Agent-Avatar + -Name im Consent-Dialog. | Crypto-Modell unverändert. Nur Display erweitert. | +| **Policy-Scope** | AiPolicy wandert von User-global auf Agent-Level. Default-Agent erbt die heutige User-Policy. | Konsistent mit "jede Mission gehört einem Agent". Verschiedene Agents können verschiedene Tool-Zugriffe haben. | +| **System-Prompt & Role** | Optional `systemPrompt: string` (technisch) + `role: string` (UI-Beschreibung). Nur `role` ist Pflicht. | Beide sind separat — Role erklärt dem User, systemPrompt erklärt dem LLM. | +| **Encryption von Agent-Feldern** | `name`, `role`, `avatar`, `policy`, `state` plaintext. `systemPrompt` + `memory` encrypted (Registry-Eintrag). | Name ist Display-Key (Search, Index). Prompt + Memory enthalten oft Kontext über den User → sensibel. | + +--- + +## Datenmodell + +### Neuer Record-Typ + +```ts +// packages/shared-ai/src/agents/types.ts +export interface Agent { + readonly id: string; + readonly createdAt: string; + readonly updatedAt: string; + + /** Display name, e.g. "Cashflow Watcher". Indexed (lookup key). */ + name: string; + /** Emoji or media ID for the avatar. */ + avatar?: string; + /** Short user-facing description: what is this agent for? */ + role: string; + + /** Optional prepend to every Planner prompt for missions owned by + * this agent. Encrypted at rest. */ + systemPrompt?: string; + /** Persistent, user-curated context markdown. Injected into every + * Planner prompt. Encrypted at rest. */ + memory?: string; + + /** Per-tool allowlist/propose/deny — the heart of what the agent is + * allowed to do autonomously. Replaces the user-level AiPolicy. */ + policy: AiPolicy; + + /** Budget — rolling 24h window, enforced by mana-ai. */ + maxTokensPerDay?: number; + /** How many missions this agent may run in parallel. Default 1. */ + maxConcurrentMissions: number; + + state: 'active' | 'paused' | 'archived'; + deletedAt?: string; +} +``` + +### Erweiterte bestehende Typen + +```ts +// Mission gets an owner: +export interface Mission { + // ...existing fields + /** Owning agent. Missing on legacy records; migration creates a + * "Default Mana" agent and assigns them to it. */ + agentId?: string; +} + +// Scene gets an optional lens: +export interface WorkbenchScene { + // ...existing fields + /** When set, this scene "belongs to" this agent — its Workbench + * timeline + proposal filters default to scope the agent. Does NOT + * restrict which apps see data; purely a lens. */ + viewingAsAgentId?: string; +} + +// Actor becomes identity-aware: +export interface Actor { + readonly kind: 'user' | 'ai' | 'system'; + /** UUID of the principal. For 'user' that's the userId; for 'ai' + * that's the agentId; for 'system' that's a sentinel like + * 'system:projection' or 'system:mission-runner'. */ + readonly principalId: string; + /** Display name cached on the record — so historic events still + * show "Cashflow Watcher" even after the agent is renamed. */ + readonly displayName: string; + /** Only for kind='ai'. */ + readonly missionId?: string; + readonly iterationId?: string; + readonly rationale?: string; +} +``` + +**Migration-Semantik:** Alte Events / Records mit `Actor {kind: 'ai', ...}` ohne `principalId` werden bei Read-Time auf den Default-Agent gemappt (Compat-Layer in `data/events/actor.ts`). + +--- + +## Phasen + +### Phase 0 — RFC + Datenmodell fixieren (0.5 Tag) + +- [ ] Dieses Dokument durchsprechen, Decision-Table ist Einsatzpunkt. +- [ ] Datenmodell in `packages/shared-ai/src/agents/types.ts` anlegen. +- [ ] Encryption-Registry-Eintrag vorbereiten: `agents: { enabled: true, fields: ['systemPrompt', 'memory'] }`. + +### Phase 1 — Actor-Identität (L3-Cutover) (2 Tage) + +Der zentrale Refactor. Alles andere hängt davon ab. + +- [ ] `Actor` in `@mana/shared-ai/src/actor.ts` erweitern um `principalId` + `displayName`. Compat-Layer: bei Read, alte Events ohne Felder → `principalId = 'legacy:user'` / `'legacy:ai-default'`, `displayName = 'Unbekannt'`. +- [ ] `USER_ACTOR` Helper: `makeUserActor(userId, displayName)`. +- [ ] Neue Helpers: `makeAgentActor(agent, mission, iteration, rationale)` und `SYSTEM_ACTOR` mit definierten `principalId`-Strings (`system:projection`, `system:mission-runner`, `system:stream`). +- [ ] **Touch-Points im Webapp** — `data/events/`, `data/ai/proposals/`, `data/ai/missions/runner.ts`, `data/ai/revert/`, alle Module-Stores die `USER_ACTOR` nutzen. Grep-Lauf, dann systematischer Rewrite. +- [ ] **Touch-Points im mana-ai** — `iteration-writer.ts` schreibt heute `{kind: 'system', source: 'mission-runner'}` → wird zu `{kind: 'ai', principalId: agentId, displayName: agent.name, missionId, iterationId}`. +- [ ] **Touch-Points in mana-sync** — keine. `sync_changes.actor` ist JSONB, akzeptiert neues Schema transparent. +- [ ] Tests anpassen: `packages/shared-ai/src/actor.test.ts`, alle Event-bezogenen Webapp-Tests. + +### Phase 2 — Agent CRUD + Daten-Layer (1.5 Tage) + +- [ ] Neue Dexie-Tabelle `agents` in `apps/mana/apps/web/src/lib/data/database.ts`. Indizes: `by-userId`, `by-name`, `by-state`. +- [ ] `apps/mana/apps/web/src/lib/data/ai/agents/store.ts` — CRUD: `createAgent`, `updateAgent`, `archiveAgent`, `deleteAgent`, `useAgents()` liveQuery-Hook, `useAgent(id)`. +- [ ] Encryption Registry + Dexie-Hooks fürs `systemPrompt` + `memory` Feld. +- [ ] Sync-Appregistry: `appId='ai-agents'` für die Tabelle. +- [ ] **Default-Agent-Bootstrap** — Layout-Effect beim Login: wenn 0 Agents existieren, lege "Mana" (Emoji `🤖`) an mit der aktuellen User-Level-AiPolicy. +- [ ] **Mission-Migration** — beim ersten Boot nach Rollout: alle `missions.agentId === undefined` kriegen `agentId = defaultAgent.id` (einmaliger Backfill, idempotent). + +### Phase 3 — mana-ai runner verstehhagent-bewusst (1 Tag) + +- [ ] `ServerMission` bekommt `agentId`. Projektion liest das Feld aus. +- [ ] **Agent-Projektion** serverseitig — analog zu `mission_snapshots` bauen wir `agent_snapshots` (LWW über `sync_changes` für `table='agents'`), scoped auf `mana_ai` Schema. +- [ ] `planOneMission` lädt den Agent, injiziert `systemPrompt + memory` in die Planner-Messages vor der Mission-Instruction. Budget-Check: wenn Agent-Budget überschritten → Mission skip mit `state='budget-exceeded'`, Metrik `mana_ai_budget_exceeded_total{agent=}`. +- [ ] **Per-Agent Concurrency-Guard** — der Tick tracked `activeMissionsByAgent` in memory, weiter nur wenn unter `maxConcurrentMissions`. +- [ ] **Audit + Metriken** — `mana_ai_agent_decisions_total{agent, decision}` (decision = `ran | skipped-budget | skipped-concurrency | skipped-paused`). +- [ ] Server-iteration-writer: Actor-JSON bekommt `principalId = agentId`, `displayName = agent.name`. + +### Phase 4 — Policy pro Agent (1 Tag) + +- [ ] `AiPolicy` wandert von `$lib/data/ai/policy.ts` (user-scoped Store) auf ein Feld am Agent. Store bleibt als Helper, nimmt aber Agent als Argument. +- [ ] `pendingProposals` Writer: liest Policy vom auslösenden Agent, nicht mehr global. +- [ ] `mana-ai`s tools.ts filtert die Tool-Allowlist per Agent-Policy vor jedem Tick. +- [ ] Settings-Page "Automatisierungs-Einstellungen" wandert zur Agent-Detail-Seite (jeder Agent hat seine eigene Policy-Tabelle). Legacy-Settings-Route redirected zum Default-Agent. + +### Phase 5 — UI: Agents-Modul + Scene-Binding (2 Tage) + +- [ ] Neues Modul `apps/mana/apps/web/src/lib/modules/ai-agents/ListView.svelte` — `/companion/agents` oder als App-Tab "Agents". CRUD + Policy-Editor + Memory-Editor + Budget/Concurrency-Felder. +- [ ] `AgentPicker.svelte` Komponente — Dropdown mit Avatar + Name, einsetzbar in Mission-Create-Flow + Scene-Settings. +- [ ] Mission-Create-Flow (`ai-missions/ListView.svelte`): neuer Schritt "Welcher Agent führt das aus?". Default: letzter-verwendeter oder "Mana". +- [ ] `SceneAppBar.svelte` — wenn `scene.viewingAsAgentId` gesetzt: Agent-Avatar-Dot auf dem Tab, Tooltip mit Name. +- [ ] Scene-Settings-Dialog: "An Agent binden" (optional) + "Bindung lösen". + +### Phase 6 — Observability (0.5 Tag) + +- [ ] AI-Workbench-Timeline (`ai-workbench/ListView.svelte`): Filter-Dropdown "Alle Agents | [Agent1] | [Agent2] …". Bucket-Header zeigt Agent-Avatar + -Name statt nur `missionId`. +- [ ] `AiProposalInbox`-Card: Agent-Avatar + -Name oben links, Tooltip mit Mission-Titel + Rationale. +- [ ] Budget-Anzeige: mini-Fortschrittsbalken im Agent-Tile ("23% Budget heute"). + +### Phase 7 — Rollout (0.5 Tag) + +- [ ] Feature-Flag `PUBLIC_MULTI_AGENT_WORKBENCH=true` default (sind wir pre-live). Setting kann genutzt werden falls wir graduelle Migration im Webapp wollen — aktuell voll an. +- [ ] Docs-Update: [`apps/mana/CLAUDE.md`](../../apps/mana/CLAUDE.md) — AI-Workbench-Abschnitt erweitern. `services/mana-ai/CLAUDE.md` → Agent-Projektion + per-Agent-Metriken. +- [ ] User-Doc in `apps/docs/src/content/docs/architecture/security.mdx` — Abschnitt zu Agenten-Scope (ein Agent sieht deine Daten genau wie du; Mission-Key-Grants pro Agent sichtbar). + +**Gesamtaufwand:** ~8–9 Arbeitstage. + +--- + +## Dateien (neu / modifiziert) + +**Neu:** +- `packages/shared-ai/src/agents/types.ts` + `index.ts` +- `packages/shared-ai/src/agents/default-agent.ts` (Bootstrap-Konstanten) +- `apps/mana/apps/web/src/lib/data/ai/agents/store.ts` + `queries.ts` +- `apps/mana/apps/web/src/lib/modules/ai-agents/ListView.svelte` + `module.config.ts` +- `apps/mana/apps/web/src/lib/components/ai/AgentPicker.svelte` +- `services/mana-ai/src/db/agents-projection.ts` +- `docs/plans/multi-agent-workbench.md` (dieses Dokument) + +**Modifiziert:** +- `packages/shared-ai/src/actor.ts` — Identity-erweitert +- `packages/shared-ai/src/missions/types.ts` — `agentId` +- `packages/shared-ai/src/policy.ts` — Policy-Shape bleibt, Owner wandert auf Agent +- `apps/mana/apps/web/src/lib/types/workbench-scenes.ts` — `viewingAsAgentId?` +- `apps/mana/apps/web/src/lib/data/crypto/registry.ts` — `agents` Eintrag +- `apps/mana/apps/web/src/lib/data/database.ts` — Tabelle + Indizes +- `apps/mana/apps/web/src/lib/data/events/actor.ts` — Compat-Layer +- `apps/mana/apps/web/src/lib/data/ai/missions/runner.ts` — Agent-bewusst +- `apps/mana/apps/web/src/lib/data/ai/policy.ts` — Agent-scoped +- `apps/mana/apps/web/src/lib/components/workbench/SceneAppBar.svelte` — Agent-Avatar +- `apps/mana/apps/web/src/lib/modules/ai-missions/ListView.svelte` — AgentPicker +- `apps/mana/apps/web/src/lib/modules/ai-workbench/ListView.svelte` — Agent-Filter +- `services/mana-ai/src/db/missions-projection.ts` — `agentId` durchreichen +- `services/mana-ai/src/db/iteration-writer.ts` — Agent-Actor +- `services/mana-ai/src/cron/tick.ts` — Budget + Concurrency +- `services/mana-ai/src/metrics.ts` — Per-Agent-Metriken + +--- + +## Risiken & Gegenmassnahmen + +| Risiko | Mitigation | +|---|---| +| **Actor-Cutover bricht alle historischen Events** | Compat-Layer in `actor.ts` bei Read. Alte Events fallen auf `'legacy:*'` principalIds zurück, Timeline zeigt "Unbekannt". Kein Data-Loss. | +| **Default-Agent-Bootstrap-Race** beim Login (zwei Tabs starten parallel) | Bootstrap via `getOrCreate`-Pattern mit Dexie-Transaction. Idempotent: zweiter Call findet existierenden Agent. | +| **Agent-Memory wird zu lang → LLM-Prompt explodiert** | Harte 4000-char Warnung in der Memory-Editor-UI. Runner trunkiert auf 8000 chars mit Log-Warnung. | +| **Systemprompt-Injection über Memory-Feld** | Memory + systemPrompt werden in `...` Delimiter gewrappt. Output weiterhin via `parsePlannerResponse` validiert. | +| **Budget-Exhaustion während laufender Mission** | Check vor neuem Planner-Call, nicht mid-mission. Laufende Iteration darf fertig werden. Nächste Iteration der gleichen Mission im nächsten Tick wartet bis Counter-Reset. | +| **Concurrency-Guard im Single-Process-Runner ist race-free**, beim Multi-Instance-Deploy nicht | Advisory-Locks auf `mana_ai.agent_concurrency` bei Multi-Instance-Rollout (nicht in dieser Phase). | +| **Soft-deleted Agent hat laufende Mission → UI zeigt Ghost-Agent** | Ghost-Agent-Marker: greyer Avatar + "archiviert" Tooltip. Missions laufen fertig, Revert bleibt möglich. | + +--- + +## Nicht-Ziele + +- **Kein Agent-to-Agent Messaging.** Agents laufen unabhängig. Wenn später nötig, ist das ein eigenes Projekt. +- **Kein Meta-Planner über Agents.** Agents erzeugen sich keine Missionen selbst. User bleibt Mission-Creator (optional: Templates als Hilfsmittel). +- **Keine Team-Features.** Andere User oder geteilte Daten kommen in einem separaten Plan nach dieser Iteration. +- **Keine Agent-Memory-Self-Modification.** Memory wird nur vom User editiert. Evals + Drift-Kontrolle + Safe-Updates sind ein eigenes Thema. +- **Keine Per-Agent-Encryption-Domains.** Alle Agents sehen alle Daten des einen Users. Mission-Key-Grants bleiben per-Mission. +- **Keine neuen UI-Konzepte jenseits Modul-Tab + Picker.** Wir bauen nichts neu, was sich nicht im bestehenden Scene/App-Modell abbilden lässt. + +--- + +## Offene Fragen (vor Phase 1) + +1. **Agent-Name-Uniqueness:** erzwingen oder erlauben? → Empfehlung: erzwingen (Dexie-Unique-Index), UI-Error bei Duplikat. +2. **"system"-Actor-Renaming:** heutige `{kind:'system', source:'projection'}` Actors — kriegen `principalId = 'system:projection'`? Oder je-System-Source eigener principalId? → Empfehlung: je Source (`system:projection`, `system:stream`, `system:mission-runner`, `system:migration`). Einfacher filterbar. +3. **Legacy-User-Policy-Migration:** eine einmalige Wanderung zur Default-Agent-Policy, und danach ist die User-Setting-UI weg? Oder behalten wir einen "User-wide override"? → Empfehlung: wandern lassen, UI weg. Sauber. +4. **Scene-Agent-Binding-Default:** wenn User eine neue Scene anlegt, bind sie an den "aktuellen Agent" oder explicit leer? → Empfehlung: explicit leer. User bindet manuell wenn er will. diff --git a/packages/shared-ai/src/agents/index.ts b/packages/shared-ai/src/agents/index.ts new file mode 100644 index 000000000..a4719e619 --- /dev/null +++ b/packages/shared-ai/src/agents/index.ts @@ -0,0 +1,2 @@ +export type { Agent, AgentState } from './types'; +export { DEFAULT_AGENT_ID, DEFAULT_AGENT_NAME } from './types'; diff --git a/packages/shared-ai/src/agents/types.ts b/packages/shared-ai/src/agents/types.ts new file mode 100644 index 000000000..1e73c0776 --- /dev/null +++ b/packages/shared-ai/src/agents/types.ts @@ -0,0 +1,72 @@ +/** + * Agent — named AI persona that owns a set of Missions, carries its + * own policy + memory, and shows up as an identity in the Workbench. + * + * The long-form motivation + decisions are in + * `docs/plans/multi-agent-workbench.md`. Key invariants relevant for + * this type: + * + * - `name` is display-unique per user (enforced at write time in the + * store, not by the DB layer — Dexie's unique indexes can't be + * added retroactively without a schema bump). + * - `systemPrompt` + `memory` are encrypted at rest (registry entry + * in `apps/mana/apps/web/src/lib/data/crypto/registry.ts`). The + * rest of the record is plaintext for search + list rendering. + * - `displayName` is NOT on this type — the Agent IS the display + * source (`name` + `avatar`). The cached `displayName` on actors + * is a snapshot-for-history copy that survives rename/delete. + * - `policy` is a value stored on the agent as of Phase 4; in + * Phase 2+3 it's already on the record but the runner still reads + * the legacy user-level policy. Having the field now avoids a + * second migration later. + */ + +import type { AiPolicy } from '../policy/types'; + +export type AgentState = 'active' | 'paused' | 'archived'; + +export interface Agent { + readonly id: string; + readonly createdAt: string; + readonly updatedAt: string; + + /** Display name, e.g. "Cashflow Watcher". Unique per user. */ + name: string; + /** Emoji or media id. No default — UI falls back to a first-letter + * avatar tile when unset. */ + avatar?: string; + /** Short user-facing description: what is this agent for? Shown in + * the agent list + picker. */ + role: string; + + /** Optional prepend to every Planner prompt for missions owned by + * this agent. Encrypted at rest. */ + systemPrompt?: string; + /** Persistent, user-curated context markdown. Injected into every + * Planner prompt for missions owned by this agent. Encrypted at + * rest. */ + memory?: string; + + /** Per-tool allowlist/propose/deny. Replaces the user-level AiPolicy + * in Phase 4; pre-populated with the default policy at create time + * so the runner can start reading it even while still consulting + * the legacy user-level store. */ + policy: AiPolicy; + + /** Budget — rolling 24h window, enforced by mana-ai. Undefined = + * no daily budget; 0 = fully paused. */ + maxTokensPerDay?: number; + /** Max concurrent missions the runner may execute for this agent. + * Default 1 (serial). */ + maxConcurrentMissions: number; + + state: AgentState; + deletedAt?: string; +} + +/** Identifier used for the auto-created default agent on first login. + * Matches `LEGACY_AI_PRINCIPAL` so events emitted before the default + * agent was materialized remain attributable after the backfill. */ +export const DEFAULT_AGENT_ID = 'legacy:ai-default'; +/** Display name for the default agent. User can rename anytime. */ +export const DEFAULT_AGENT_NAME = 'Mana'; diff --git a/packages/shared-ai/src/index.ts b/packages/shared-ai/src/index.ts index 35ac92f31..58d9cf58f 100644 --- a/packages/shared-ai/src/index.ts +++ b/packages/shared-ai/src/index.ts @@ -71,4 +71,9 @@ export { AI_PROPOSABLE_TOOL_NAMES, AI_PROPOSABLE_TOOL_SET, type AiProposableToolName, + type AiPolicy, + type PolicyDecision, } from './policy'; + +export type { Agent, AgentState } from './agents'; +export { DEFAULT_AGENT_ID, DEFAULT_AGENT_NAME } from './agents'; diff --git a/packages/shared-ai/src/missions/types.ts b/packages/shared-ai/src/missions/types.ts index 90a8f31ae..cccf812f8 100644 --- a/packages/shared-ai/src/missions/types.ts +++ b/packages/shared-ai/src/missions/types.ts @@ -115,6 +115,15 @@ export interface Mission { iterations: readonly MissionIteration[]; userId?: string; deletedAt?: string; + /** + * Owning agent (Multi-Agent Workbench, Phase 2). Missing on legacy + * records; the default-agent bootstrap backfills them with + * `DEFAULT_AGENT_ID` on first login after rollout. Runner reads this + * to build the Actor; when undefined it falls back to + * `LEGACY_AI_PRINCIPAL` so writes still attribute cleanly during the + * migration window. + */ + agentId?: string; /** * Key-Grant for server-side execution on encrypted inputs. When set, * `mana-ai` can decrypt the referenced records without the user's diff --git a/packages/shared-ai/src/policy/index.ts b/packages/shared-ai/src/policy/index.ts index 15b9bea74..11c2a3de3 100644 --- a/packages/shared-ai/src/policy/index.ts +++ b/packages/shared-ai/src/policy/index.ts @@ -3,3 +3,5 @@ export { AI_PROPOSABLE_TOOL_SET, type AiProposableToolName, } from './proposable-tools'; + +export type { AiPolicy, PolicyDecision } from './types'; diff --git a/packages/shared-ai/src/policy/types.ts b/packages/shared-ai/src/policy/types.ts new file mode 100644 index 000000000..189599f51 --- /dev/null +++ b/packages/shared-ai/src/policy/types.ts @@ -0,0 +1,25 @@ +/** + * AiPolicy — per-tool / per-module / global decision tree that the + * AI Workbench consults before any tool call. + * + * user → always `auto` (user IS the decision) + * system → always `auto` (trusted subsystem) + * ai → tools[name] ?? defaultsByModule[tool.module] ?? defaultForAi + * + * Today this type lives in shared-ai because it's a data-shape + * concern: both the webapp and (from Phase 4 on) the mana-ai runner + * need to read the same policy shape off an Agent record. The runtime + * pieces — tool-registry lookup, active-policy singleton — stay in the + * webapp. + */ + +export type PolicyDecision = 'auto' | 'propose' | 'deny'; + +export interface AiPolicy { + /** Tool-name → decision. Checked first. */ + readonly tools: Readonly>; + /** Module-name → decision. Checked when no per-tool entry exists. */ + readonly defaultsByModule?: Readonly>; + /** Global fallback when neither tool nor module has an entry. */ + readonly defaultForAi: PolicyDecision; +}