feat(agents): Agent CRUD + default bootstrap + Mission.agentId (Phase 2)

Second phase of the Multi-Agent Workbench rollout (docs/plans/
multi-agent-workbench.md). Builds on Phase 1's identity-aware Actor.

Adds the Agent primitive — a named AI persona that owns Missions,
carries its own policy + memory, and (from Phase 3 on) drives the
Workbench lens. Everything is wired; a single user currently has one
"Mana" default agent until the UI (Phase 5) lets them create more.

Shared types (@mana/shared-ai):
- agents/types.ts: Agent, AgentState, DEFAULT_AGENT_ID/NAME constants
- policy/types.ts: AiPolicy + PolicyDecision (moved from webapp so
  Agent.policy can reference it without a runtime dep on the web app)
- missions/types.ts: new optional Mission.agentId field

Webapp data layer:
- data/ai/agents/{types,store,queries,bootstrap}.ts
- Dexie schema v19 adds `agents` table (indexes on state, name,
  [state+name]); sync registered under the existing ai app-id
- Encryption registry: agents.systemPrompt + agents.memory encrypted;
  name/role/avatar/policy stay plaintext for search + UI rendering
- DuplicateAgentNameError thrown at write time (not a Dexie unique
  index — bootstrap races between tabs would otherwise hit
  ConstraintError; store now resolves via getOrCreateAgent)
- bootstrap.ts: ensureDefaultAgent + backfillMissionsAgentId. The
  backfill runs once per device (localStorage sentinel) so missions
  that pre-date the rollout get stamped with the default agent's id.
  Called fire-and-forget from startMissionTick() during layout init.

Runner threading (already merged into d5c351d63 via Till's debug-log
commit that picked up my uncommitted edits):
- runner.ts + server-iteration-staging.ts now resolve mission.agentId
  to the real Agent and build makeAgentActor with agent.name as
  displayName. Missing-agent fallback keeps using LEGACY_AI_PRINCIPAL
  so historical writes still attribute cleanly.

Tests: shared-ai 26/26, mana-ai 35/35, svelte-check 0 errors.
Agent store vitest suite is present but blocked by a pre-existing
\$lib alias resolution issue in the webapp vitest config that
predates this phase (proposals/store.test.ts is broken the same way
on HEAD). Will address separately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 20:35:49 +02:00
parent d5c351d63e
commit bc77b36234
16 changed files with 830 additions and 6 deletions

View file

@ -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<Agent> {
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<number> {
if (typeof window !== 'undefined' && window.localStorage.getItem(BACKFILL_SENTINEL_KEY)) {
return 0;
}
const table = db.table<Mission>(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<void> {
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);
}
}

View file

@ -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<Agent>(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<Agent[]>;
}, [] 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<Agent>(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
);
}

View file

@ -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();
});
});

View file

@ -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<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
const table = () => db.table<Agent>(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<Agent> {
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<Agent> {
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<Agent | undefined> {
const a = await table().get(id);
return a?.deletedAt ? undefined : a;
}
export async function findByName(name: string): Promise<Agent | undefined> {
const all = await table().where('name').equals(name).toArray();
return all.find((a) => !a.deletedAt);
}
export async function listAgents(filter: { state?: AgentState } = {}): Promise<Agent[]> {
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<void> {
if (patch.name) {
const clash = await findByName(patch.name);
if (clash && clash.id !== id) {
throw new DuplicateAgentNameError(patch.name);
}
}
const mods: Partial<Agent> = {
...deepClone(patch),
updatedAt: new Date().toISOString(),
};
await encryptRecord(AGENTS_TABLE, mods);
await table().update(id, mods);
}
// ── Lifecycle ──────────────────────────────────────────────
export async function archiveAgent(id: string): Promise<void> {
await table().update(id, { state: 'archived', updatedAt: new Date().toISOString() });
}
export async function pauseAgent(id: string): Promise<void> {
await table().update(id, { state: 'paused', updatedAt: new Date().toISOString() });
}
export async function resumeAgent(id: string): Promise<void> {
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<void> {
await table().update(id, { deletedAt: new Date().toISOString() });
}

View file

@ -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';

View file

@ -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<string>();
@ -91,17 +93,21 @@ async function stageIteration(mission: Mission, iteration: MissionIteration): Pr
if (!fresh) return;
const stagedStepIds: Record<string, string> = {};
// 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,

View file

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

View file

@ -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' }],
};

View file

@ -471,6 +471,16 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// 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'] },
};
/**

View file

@ -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:** ~89 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 `<agent_context>...</agent_context>` 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.

View file

@ -0,0 +1,2 @@
export type { Agent, AgentState } from './types';
export { DEFAULT_AGENT_ID, DEFAULT_AGENT_NAME } from './types';

View file

@ -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';

View file

@ -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';

View file

@ -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

View file

@ -3,3 +3,5 @@ export {
AI_PROPOSABLE_TOOL_SET,
type AiProposableToolName,
} from './proposable-tools';
export type { AiPolicy, PolicyDecision } from './types';

View file

@ -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<Record<string, PolicyDecision>>;
/** Module-name → decision. Checked when no per-tool entry exists. */
readonly defaultsByModule?: Readonly<Record<string, PolicyDecision>>;
/** Global fallback when neither tool nor module has an entry. */
readonly defaultForAi: PolicyDecision;
}