mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 18:26:41 +02:00
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:
parent
d5c351d63e
commit
bc77b36234
16 changed files with 830 additions and 6 deletions
106
apps/mana/apps/web/src/lib/data/ai/agents/bootstrap.ts
Normal file
106
apps/mana/apps/web/src/lib/data/ai/agents/bootstrap.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
38
apps/mana/apps/web/src/lib/data/ai/agents/queries.ts
Normal file
38
apps/mana/apps/web/src/lib/data/ai/agents/queries.ts
Normal 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
|
||||
);
|
||||
}
|
||||
120
apps/mana/apps/web/src/lib/data/ai/agents/store.test.ts
Normal file
120
apps/mana/apps/web/src/lib/data/ai/agents/store.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
169
apps/mana/apps/web/src/lib/data/ai/agents/store.ts
Normal file
169
apps/mana/apps/web/src/lib/data/ai/agents/store.ts
Normal 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() });
|
||||
}
|
||||
12
apps/mana/apps/web/src/lib/data/ai/agents/types.ts
Normal file
12
apps/mana/apps/web/src/lib/data/ai/agents/types.ts
Normal 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';
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' }],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue