diff --git a/examples/proactive-issue-resolver/persona.json b/examples/proactive-issue-resolver/persona.json index ce578099..5b1cad8b 100644 --- a/examples/proactive-issue-resolver/persona.json +++ b/examples/proactive-issue-resolver/persona.json @@ -18,7 +18,9 @@ "enabled": true, "scopes": [ "workspace" - ] + ], + "trajectories": true, + "aiMemory": true }, "onEvent": "./agent.ts", "harness": "claude", diff --git a/examples/review-agent/persona.json b/examples/review-agent/persona.json index 48f63416..0612059e 100644 --- a/examples/review-agent/persona.json +++ b/examples/review-agent/persona.json @@ -15,7 +15,14 @@ "enabled": true, "scopes": [ "workspace" - ] + ], + "trajectories": { + "enabled": true, + "autoCompact": true + }, + "aiMemory": { + "enabled": true + } }, "onEvent": "./agent.ts", "harness": "codex", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f0c1fa47..79fbcd37 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -40,6 +40,7 @@ import { PERSONA_TAGS, readSkillCacheMarker, renderPersonaInputs, + resolveAiMemory, resolveMcpServersLenient, resolvePersonaInputs, resolveSidecar, @@ -506,9 +507,7 @@ function buildSelection(spec: PersonaSpec, kind: 'repo' | 'local'): PersonaSelec ...(spec.mcpServers ? { mcpServers: spec.mcpServers } : {}), ...(spec.permissions ? { permissions: spec.permissions } : {}), ...(spec.mount ? { mount: spec.mount } : {}), - ...(typeof spec.recordTrajectories === 'boolean' - ? { recordTrajectories: spec.recordTrajectories } - : {}), + ...(spec.memory !== undefined ? { memory: spec.memory } : {}), ...(sidecar.claudeMd ? { claudeMd: sidecar.claudeMd } : {}), ...(sidecar.claudeMdContent ? { claudeMdContent: sidecar.claudeMdContent } : {}), ...(sidecar.claudeMd || sidecar.claudeMdContent @@ -558,19 +557,18 @@ function resolveRelayMcpFromEnv(env: NodeJS.ProcessEnv): RelayMcpConfig | undefi } /** - * Resolve the `ai-hist` MCP config for a session. Injection is ON by default - * (trajectory recording is the default), so this returns a config unless the - * operator explicitly opts the environment out via `WORKFORCE_AIHIST_DISABLED` - * (useful where a site does not want the bundled MCP enabled). The persona-level - * opt-out (`recordTrajectories: false`) is enforced by the caller, not here. - * `TRAJECTORY_ROOT` / `AI_HIST_DB` flow through when set; otherwise the MCP - * falls back to its own discovery defaults. + * Build the `ai-hist` MCP config for a session. Only called when the persona + * opts into recall via `memory.aiMemory` (off by default). `TRAJECTORY_ROOT` + * (env) seeds the "why" read-root; `dbPathOverride` (from `memory.aiMemory.dbPath`) + * else `AI_HIST_DB` env seeds the "how" history DB. Anything unset falls back to + * the MCP's own discovery defaults. */ -function resolveAiHistFromEnv(env: NodeJS.ProcessEnv): AiHistMcpConfig | undefined { - const disabled = env.WORKFORCE_AIHIST_DISABLED?.trim(); - if (disabled === '1' || disabled === 'true') return undefined; +function resolveAiHistConfig( + env: NodeJS.ProcessEnv, + dbPathOverride?: string +): AiHistMcpConfig { const trajectoryRoot = env.TRAJECTORY_ROOT?.trim(); - const dbPath = env.AI_HIST_DB?.trim(); + const dbPath = dbPathOverride?.trim() || env.AI_HIST_DB?.trim(); return { ...(trajectoryRoot ? { trajectoryRoot } : {}), ...(dbPath ? { dbPath } : {}) @@ -1466,10 +1464,10 @@ function runDryRun(selection: PersonaSelection): number { let spec: InteractiveSpec; try { const relayMcp = resolveRelayMcpFromEnv(process.env); - const aiHist = - effectiveSelection.recordTrajectories === false - ? undefined - : resolveAiHistFromEnv(process.env); + const aiMemory = resolveAiMemory(effectiveSelection.memory); + const aiHist = aiMemory.enabled + ? resolveAiHistConfig(process.env, aiMemory.dbPath) + : undefined; spec = buildInteractiveSpec({ harness, personaId, @@ -1852,10 +1850,10 @@ async function runInteractive( } const relayMcp = resolveRelayMcpFromEnv(process.env); - const aiHist = - effectiveSelection.recordTrajectories === false - ? undefined - : resolveAiHistFromEnv(process.env); + const aiMemory = resolveAiMemory(effectiveSelection.memory); + const aiHist = aiMemory.enabled + ? resolveAiHistConfig(process.env, aiMemory.dbPath) + : undefined; const spec = buildInteractiveSpec({ harness, personaId, diff --git a/packages/deploy/src/deploy.test.ts b/packages/deploy/src/deploy.test.ts index e9fb5652..425a6530 100644 --- a/packages/deploy/src/deploy.test.ts +++ b/packages/deploy/src/deploy.test.ts @@ -254,27 +254,13 @@ test('preflightPersona refuses when cloud is not true', async () => { } }); -test('preflightPersona refuses a cloud persona that opts out of trajectory recording', async () => { +test('preflightPersona accepts a cloud persona that opts into memory facets', async () => { const { personaPath, cleanup } = await withTempPersona( - basePersonaJson({ recordTrajectories: false }) - ); - try { - await assert.rejects( - preflightPersona(personaPath), - /recordTrajectories:false but trajectory recording is required/ - ); - } finally { - await cleanup(); - } -}); - -test('preflightPersona accepts a cloud persona with recordTrajectories explicitly true', async () => { - const { personaPath, cleanup } = await withTempPersona( - basePersonaJson({ recordTrajectories: true }) + basePersonaJson({ memory: { trajectories: true, aiMemory: true } }) ); try { const pre = await preflightPersona(personaPath); - assert.equal(pre.persona.recordTrajectories, true); + assert.deepEqual(pre.persona.memory, { trajectories: true, aiMemory: true }); } finally { await cleanup(); } diff --git a/packages/deploy/src/preflight.ts b/packages/deploy/src/preflight.ts index ba6d351d..2f47e97a 100644 --- a/packages/deploy/src/preflight.ts +++ b/packages/deploy/src/preflight.ts @@ -55,19 +55,6 @@ export async function preflightPersona(personaPath: string): Promise//compacted/.json` (root = `TRAJECTORY_ROOT` env or the cloud workspace default). Object form lets you toggle compaction." + }, + "aiMemory": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/PersonaAiMemoryConfig" + } + ], + "description": "Opt into ai-memory recall (the \"how\" + \"why\" retrieval). **Off by default.** When enabled, the persona loads the ai-hist MCP so it can recall its own compacted trajectories (the why) and cross-tool prompt/session history (the how). Object form lets you override the history DB path." } }, "description": "Long-form memory configuration. Defaults are applied by the runtime, not the parser — the spec keeps only what the author actually wrote. `enabled` defaults to true when the object form is present." @@ -568,6 +586,32 @@ "global" ], "description": "Memory scope semantics, mirroring the agent-assistant memory adapter: `workspace` memory persists across users in a workspace, `user` memory follows an individual user's invocations, and `global` memory is shared across every invocation of the deployed agent." + }, + "PersonaTrajectoryConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "autoCompact": { + "type": "boolean", + "description": "Run mechanical+markdown compaction on completion. Defaults to true." + } + }, + "description": "Decision-trajectory recording config (the \"why\"). `enabled` defaults to true in object form, so `{ autoCompact: false }` means \"record, don't compact\". The store root is never per-persona — it's resolved once from `TRAJECTORY_ROOT` (or the cloud default) so the recorder's write-root always matches the ai-hist MCP's read-root." + }, + "PersonaAiMemoryConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "dbPath": { + "type": "string", + "description": "Override the ai-hist history DB path; else `AI_HIST_DB` env / discovery." + } + }, + "description": "ai-memory recall config (the \"how\" + \"why\" retrieval via the ai-hist MCP). `enabled` defaults to true in object form." } }, "$id": "https://agentworkforce.dev/schemas/persona.schema.json" diff --git a/packages/persona-kit/src/index.ts b/packages/persona-kit/src/index.ts index 8d508f48..034504de 100644 --- a/packages/persona-kit/src/index.ts +++ b/packages/persona-kit/src/index.ts @@ -32,9 +32,11 @@ export type { PersonaIntegrationConfig, PersonaIntegrationTrigger, PersonaIntent, + PersonaAiMemoryConfig, PersonaMemory, PersonaMemoryConfig, PersonaMemoryScope, + PersonaTrajectoryConfig, PersonaMount, PersonaPermissions, PersonaSchedule, @@ -99,6 +101,8 @@ export { parseOnEvent, parsePermissions, parsePersonaSpec, + resolveAiMemory, + resolveTrajectoryRecording, parseSchedules, parseSkills, parseStringList, diff --git a/packages/persona-kit/src/interactive-spec.ts b/packages/persona-kit/src/interactive-spec.ts index 9c8ffc3a..097322be 100644 --- a/packages/persona-kit/src/interactive-spec.ts +++ b/packages/persona-kit/src/interactive-spec.ts @@ -123,7 +123,7 @@ export interface BuildInteractiveSpecInput { * persona has retrieval access to its own decision trajectories (the "why") * and cross-tool prompt/session history (the "how"). A persona-declared * server literally named `ai-hist` takes precedence (it is not overwritten). - * Callers resolve this from env + the persona's `recordTrajectories` flag and + * Callers resolve this from env + the persona's `memory.aiMemory` opt-in and * pass it explicitly — this function reads no environment itself. Wired for * claude and codex; opencode still warns that MCP injection is unsupported. */ @@ -320,9 +320,9 @@ export function buildInteractiveSpec(input: BuildInteractiveSpecInput): Interact const relayMcpServer = input.relayMcp ? buildRelaycastMcpServer(input.relayMcp) : undefined; - // ai-hist is injected by default for personas with trajectory recording on - // (callers gate `input.aiHist` on `recordTrajectories !== false`). A - // persona-declared `ai-hist` server wins, same as relaycast. + // ai-hist is injected only for personas that opt into recall + // (callers gate `input.aiHist` on `memory.aiMemory`). A persona-declared + // `ai-hist` server wins, same as relaycast. const aiHistServer = input.aiHist ? buildAiHistMcpServer(input.aiHist) : undefined; const injectsRelaycast = relayMcpServer !== undefined && personaMcpServers?.relaycast === undefined; const injectsAiHist = aiHistServer !== undefined && personaMcpServers?.['ai-hist'] === undefined; diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index 9ff7d1ef..e482efd6 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -141,7 +141,13 @@ test('parsePersonaSpec accepts the Relayfile-VFS example personas', () => { // Triggers moved to agent.ts; persona declares the github/slack connections. assert.ok(reviewAgent.integrations?.github); assert.ok(reviewAgent.integrations?.slack); - assert.deepEqual(reviewAgent.memory, { enabled: true, scopes: ['workspace'] }); + // Example opts into both memory facets (object form) alongside long-form memory. + assert.deepEqual(reviewAgent.memory, { + enabled: true, + scopes: ['workspace'], + trajectories: { enabled: true, autoCompact: true }, + aiMemory: { enabled: true } + }); const linearShipper = parsePersonaFixture('examples/linear-shipper/persona.json'); assert.equal(linearShipper.id, 'linear-shipper'); diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index 678b78a6..833142fa 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -21,10 +21,12 @@ import type { PersonaIntegrationConfig, PersonaIntegrationTrigger, PersonaIntent, + PersonaAiMemoryConfig, PersonaMemory, PersonaMemoryConfig, PersonaMemoryScope, PersonaMount, + PersonaTrajectoryConfig, PersonaPermissions, PersonaSchedule, PersonaSelection, @@ -858,7 +860,7 @@ export function parseMemory(value: unknown, context: string): PersonaMemory | un if (!isObject(value)) { throw new Error(`${context} must be a boolean or an object if provided`); } - const { enabled, scopes, ttlDays, autoPromote, dedupMs } = value; + const { enabled, scopes, ttlDays, autoPromote, dedupMs, trajectories, aiMemory } = value; const out: PersonaMemoryConfig = {}; if (enabled !== undefined) { if (typeof enabled !== 'boolean') { @@ -902,9 +904,100 @@ export function parseMemory(value: unknown, context: string): PersonaMemory | un } out.dedupMs = dedupMs; } + if (trajectories !== undefined) { + out.trajectories = parseTrajectoryConfig(trajectories, `${context}.trajectories`); + } + if (aiMemory !== undefined) { + out.aiMemory = parseAiMemoryConfig(aiMemory, `${context}.aiMemory`); + } + return out; +} + +function parseTrajectoryConfig( + value: unknown, + context: string +): boolean | PersonaTrajectoryConfig { + if (typeof value === 'boolean') return value; + if (!isObject(value)) { + throw new Error(`${context} must be a boolean or an object if provided`); + } + const out: PersonaTrajectoryConfig = {}; + if (value.enabled !== undefined) { + if (typeof value.enabled !== 'boolean') { + throw new Error(`${context}.enabled must be a boolean if provided`); + } + out.enabled = value.enabled; + } + if (value.autoCompact !== undefined) { + if (typeof value.autoCompact !== 'boolean') { + throw new Error(`${context}.autoCompact must be a boolean if provided`); + } + out.autoCompact = value.autoCompact; + } return out; } +function parseAiMemoryConfig( + value: unknown, + context: string +): boolean | PersonaAiMemoryConfig { + if (typeof value === 'boolean') return value; + if (!isObject(value)) { + throw new Error(`${context} must be a boolean or an object if provided`); + } + const out: PersonaAiMemoryConfig = {}; + if (value.enabled !== undefined) { + if (typeof value.enabled !== 'boolean') { + throw new Error(`${context}.enabled must be a boolean if provided`); + } + out.enabled = value.enabled; + } + if (value.dbPath !== undefined) { + if (typeof value.dbPath !== 'string' || !value.dbPath.trim()) { + throw new Error(`${context}.dbPath must be a non-empty string if provided`); + } + out.dbPath = value.dbPath; + } + return out; +} + +/** + * Resolve the opt-in `memory.trajectories` facet (the "why" write side). + * Off unless the persona declares it: `true`, or an object whose + * `enabled !== false`. The boolean `memory: true` shorthand does NOT enable it. + */ +export function resolveTrajectoryRecording(memory: PersonaMemory | undefined): { + enabled: boolean; + autoCompact?: boolean; +} { + if (!memory || typeof memory === 'boolean') return { enabled: false }; + const value = memory.trajectories; + if (value === undefined) return { enabled: false }; + if (typeof value === 'boolean') return { enabled: value }; + return { + enabled: value.enabled !== false, + ...(value.autoCompact !== undefined ? { autoCompact: value.autoCompact } : {}) + }; +} + +/** + * Resolve the opt-in `memory.aiMemory` facet (the "how"+"why" recall side that + * loads the ai-hist MCP). Off unless declared; `memory: true` does NOT enable it. + */ +export function resolveAiMemory(memory: PersonaMemory | undefined): { + enabled: boolean; + dbPath?: string; +} { + if (!memory || typeof memory === 'boolean') return { enabled: false }; + const value = memory.aiMemory; + if (value === undefined) return { enabled: false }; + if (typeof value === 'boolean') return { enabled: value }; + return { + enabled: value.enabled !== false, + ...(value.dbPath ? { dbPath: value.dbPath } : {}) + }; +} + function parseCapabilityValue(value: unknown, context: string): CapabilityValue { if (typeof value === 'boolean') return value; if (!isObject(value) || Array.isArray(value)) { @@ -997,7 +1090,6 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): integrations, capabilities, memory, - recordTrajectories, onEvent } = value; @@ -1096,11 +1188,6 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): if (useSubscription !== undefined && typeof useSubscription !== 'boolean') { throw new Error(`persona[${expectedIntent}].useSubscription must be a boolean if provided`); } - if (recordTrajectories !== undefined && typeof recordTrajectories !== 'boolean') { - throw new Error( - `persona[${expectedIntent}].recordTrajectories must be a boolean if provided` - ); - } const parsedIntegrations = parseIntegrations( integrations, `persona[${expectedIntent}].integrations` @@ -1140,7 +1227,6 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): ...(parsedIntegrations ? { integrations: parsedIntegrations } : {}), ...(parsedCapabilities ? { capabilities: parsedCapabilities } : {}), ...(parsedMemory !== undefined ? { memory: parsedMemory } : {}), - ...(typeof recordTrajectories === 'boolean' ? { recordTrajectories } : {}), ...(parsedOnEvent !== undefined ? { onEvent: parsedOnEvent } : {}) }; } diff --git a/packages/persona-kit/src/plan.test.ts b/packages/persona-kit/src/plan.test.ts index 02fdb6d2..b97fe38e 100644 --- a/packages/persona-kit/src/plan.test.ts +++ b/packages/persona-kit/src/plan.test.ts @@ -39,16 +39,18 @@ test('buildPersonaSpawnPlan returns the persona, cli, and args for claude', () = assert.equal(plan.initialPrompt, undefined); }); -test('buildPersonaSpawnPlan injects ai-hist by default for recorded trajectories', () => { - const plan = buildPersonaSpawnPlan(persona(), { processEnv: cleanEnv }); +test('buildPersonaSpawnPlan injects ai-hist when memory.aiMemory is opted in', () => { + const plan = buildPersonaSpawnPlan(persona({ memory: { aiMemory: true } }), { + processEnv: cleanEnv + }); const mcpIdx = plan.args.indexOf('--mcp-config'); assert.ok(mcpIdx >= 0, 'expected --mcp-config'); const payload = JSON.parse(plan.args[mcpIdx + 1]); assertAiHistServer(payload.mcpServers['ai-hist'], {}); }); -test('buildPersonaSpawnPlan threads ai-hist env overrides from processEnv', () => { - const plan = buildPersonaSpawnPlan(persona(), { +test('buildPersonaSpawnPlan threads ai-hist env overrides when aiMemory is on', () => { + const plan = buildPersonaSpawnPlan(persona({ memory: { aiMemory: true } }), { processEnv: { TRAJECTORY_ROOT: '/repo/.trajectories', AI_HIST_DB: '/tmp/ai-history.db' @@ -62,21 +64,32 @@ test('buildPersonaSpawnPlan threads ai-hist env overrides from processEnv', () = }); }); -test('buildPersonaSpawnPlan honors recordTrajectories false and ai-hist env disable', () => { - const optedOut = buildPersonaSpawnPlan(persona({ recordTrajectories: false }), { +test('buildPersonaSpawnPlan: memory.aiMemory.dbPath overrides the history DB', () => { + const plan = buildPersonaSpawnPlan(persona({ memory: { aiMemory: { dbPath: '/custom/hist.db' } } }), { processEnv: cleanEnv }); - const optedOutMcpIdx = optedOut.args.indexOf('--mcp-config'); - assert.equal(JSON.parse(optedOut.args[optedOutMcpIdx + 1]).mcpServers['ai-hist'], undefined); + const mcpIdx = plan.args.indexOf('--mcp-config'); + const payload = JSON.parse(plan.args[mcpIdx + 1]); + assert.deepEqual(payload.mcpServers['ai-hist'].env, { AI_HIST_DB: '/custom/hist.db' }); +}); + +test('buildPersonaSpawnPlan omits ai-hist when memory.aiMemory is not opted in', () => { + // Default (no memory) — off. + const off = buildPersonaSpawnPlan(persona(), { processEnv: cleanEnv }); + const offIdx = off.args.indexOf('--mcp-config'); + assert.equal(JSON.parse(off.args[offIdx + 1]).mcpServers['ai-hist'], undefined); + + // `memory: true` enables long-form memory only, NOT the aiMemory facet. + const longFormOnly = buildPersonaSpawnPlan(persona({ memory: true }), { processEnv: cleanEnv }); + const lfIdx = longFormOnly.args.indexOf('--mcp-config'); + assert.equal(JSON.parse(longFormOnly.args[lfIdx + 1]).mcpServers['ai-hist'], undefined); - const envDisabled = buildPersonaSpawnPlan(persona(), { - processEnv: { WORKFORCE_AIHIST_DISABLED: 'true' } as NodeJS.ProcessEnv + // Explicit opt-out. + const explicitOff = buildPersonaSpawnPlan(persona({ memory: { aiMemory: false } }), { + processEnv: cleanEnv }); - const envDisabledMcpIdx = envDisabled.args.indexOf('--mcp-config'); - assert.equal( - JSON.parse(envDisabled.args[envDisabledMcpIdx + 1]).mcpServers['ai-hist'], - undefined - ); + const eoIdx = explicitOff.args.indexOf('--mcp-config'); + assert.equal(JSON.parse(explicitOff.args[eoIdx + 1]).mcpServers['ai-hist'], undefined); }); test('buildPersonaSpawnPlan emits initialPrompt for codex', () => { diff --git a/packages/persona-kit/src/plan.ts b/packages/persona-kit/src/plan.ts index cba3c207..628f7d0a 100644 --- a/packages/persona-kit/src/plan.ts +++ b/packages/persona-kit/src/plan.ts @@ -4,6 +4,7 @@ import { type InteractiveConfigFile } from './interactive-spec.js'; import { resolvePersonaInputs, renderPersonaInputs } from './inputs.js'; +import { resolveAiMemory } from './parse.js'; import { materializeSkills } from './skills.js'; import type { Harness, @@ -126,11 +127,9 @@ export interface PlanOptions { inputValues?: Record; } -function resolveAiHistFromEnv(env: NodeJS.ProcessEnv): AiHistMcpConfig | false { - const disabled = env.WORKFORCE_AIHIST_DISABLED?.trim(); - if (disabled === '1' || disabled === 'true') return false; +function resolveAiHistConfig(env: NodeJS.ProcessEnv, dbPathOverride?: string): AiHistMcpConfig { const trajectoryRoot = env.TRAJECTORY_ROOT?.trim(); - const dbPath = env.AI_HIST_DB?.trim(); + const dbPath = dbPathOverride?.trim() || env.AI_HIST_DB?.trim(); return { ...(trajectoryRoot ? { trajectoryRoot } : {}), ...(dbPath ? { dbPath } : {}) @@ -247,8 +246,8 @@ export function buildPersonaSpawnPlan( persona.systemPrompt, inputResolution.values ); - const aiHist = - persona.recordTrajectories === false ? false : resolveAiHistFromEnv(processEnv); + const aiMemory = resolveAiMemory(persona.memory); + const aiHist = aiMemory.enabled ? resolveAiHistConfig(processEnv, aiMemory.dbPath) : false; const skills = materializeSkills( persona.skills, harness, diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index 8c34290b..9046393b 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -321,6 +321,44 @@ export interface PersonaMemoryConfig { ttlDays?: number; autoPromote?: boolean; dedupMs?: number; + /** + * Opt into decision-trajectory recording (the "why"). **Off by default.** + * When enabled, the runtime auto-records this persona's decisions per run and + * writes a compacted contract artifact to + * `//compacted/.json` (root = `TRAJECTORY_ROOT` env or + * the cloud workspace default). Object form lets you toggle compaction. + */ + trajectories?: boolean | PersonaTrajectoryConfig; + /** + * Opt into ai-memory recall (the "how" + "why" retrieval). **Off by default.** + * When enabled, the persona loads the ai-hist MCP so it can recall its own + * compacted trajectories (the why) and cross-tool prompt/session history + * (the how). Object form lets you override the history DB path. + */ + aiMemory?: boolean | PersonaAiMemoryConfig; +} + +/** + * Decision-trajectory recording config (the "why"). `enabled` defaults to true + * in object form, so `{ autoCompact: false }` means "record, don't compact". + * The store root is never per-persona — it's resolved once from + * `TRAJECTORY_ROOT` (or the cloud default) so the recorder's write-root always + * matches the ai-hist MCP's read-root. + */ +export interface PersonaTrajectoryConfig { + enabled?: boolean; + /** Run mechanical+markdown compaction on completion. Defaults to true. */ + autoCompact?: boolean; +} + +/** + * ai-memory recall config (the "how" + "why" retrieval via the ai-hist MCP). + * `enabled` defaults to true in object form. + */ +export interface PersonaAiMemoryConfig { + enabled?: boolean; + /** Override the ai-hist history DB path; else `AI_HIST_DB` env / discovery. */ + dbPath?: string; } export type PersonaMemory = boolean | PersonaMemoryConfig; @@ -506,17 +544,6 @@ export interface PersonaSpec { * details (api keys, adapter type, etc. come from workforce env). */ memory?: PersonaMemory; - /** - * Decision-trajectory recording opt-out. Recording is **on by default**: - * the runtime auto-records this persona's decisions/reasoning per run (the - * "why") and the deploy CLI auto-injects the `ai-hist` MCP server (the - * "how" + "why" retrieval surface) into {@link mcpServers}. Set to `false` - * to opt a persona out entirely — but note deploy preflight REJECTS a - * cloud persona with `recordTrajectories: false` (trajectory recording is - * an enforced capability for deployed agents). Omit (or `true`) for the - * default enforced-on behavior. - */ - recordTrajectories?: boolean; /** * Relative POSIX path to the TypeScript (or compiled .js / .mjs) file * whose default export is the deploy-time event handler. Resolved @@ -544,12 +571,12 @@ export interface PersonaSelection { permissions?: PersonaPermissions; mount?: PersonaMount; /** - * Carried through from {@link PersonaSpec.recordTrajectories}. When not - * `false`, launchers inject the `ai-hist` MCP server so the session can - * recall its own decision trajectories (the "why") and prior history (the - * "how"). Omitted/`true` ⇒ recording on (default). + * Carried through from {@link PersonaSpec.memory}. Launchers read its opt-in + * facets: `memory.aiMemory` gates injecting the `ai-hist` MCP (recall), and + * `memory.trajectories` gates runtime decision-trajectory recording. Both are + * off unless declared. Use {@link resolveAiMemory} / {@link resolveTrajectoryRecording}. */ - recordTrajectories?: boolean; + memory?: PersonaMemory; /** * Effective sidecar config for the persona. Modes default to `overwrite` * when a path or inlined content exists; otherwise the mode field is omitted. diff --git a/packages/runtime/src/cloud-defaults.ts b/packages/runtime/src/cloud-defaults.ts index eb2f4e37..305c69bb 100644 --- a/packages/runtime/src/cloud-defaults.ts +++ b/packages/runtime/src/cloud-defaults.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import { buildNonInteractiveSpec, renderPersonaInputs, + resolveAiMemory, resolveMcpServersLenient, resolvePersonaInputs, resolveStringMapLenient, @@ -492,14 +493,14 @@ function createProcessHarnessRunner(args: CloudDefaultOptions & { await assertDirectory(cwd); const task = run.prompt; const relayMcp = resolveRelayMcpFromEnv(args.env); - // Inject the ai-hist MCP (the "why" + "how" retrieval surface) by default, - // unless the persona disabled trajectory recording. resolveAiHistFromEnv - // keys off the SAME TRAJECTORY_ROOT the runtime recorder writes to, so the - // MCP reads back exactly what this deployment wrote. - const aiHist = - args.persona.recordTrajectories === false - ? undefined - : resolveAiHistFromEnv(args.env, args.trajectoryRoot); + // Inject the ai-hist MCP (the "why" + "how" retrieval surface) only when the + // persona opts into recall via `memory.aiMemory` (off by default). + // resolveAiHistFromEnv keys off the SAME TRAJECTORY_ROOT the runtime recorder + // writes to, so the MCP reads back exactly what this deployment wrote. + const aiMemory = resolveAiMemory(args.persona.memory); + const aiHist = aiMemory.enabled + ? resolveAiHistFromEnv(args.env, args.trajectoryRoot, aiMemory.dbPath) + : undefined; const specInput = { harness, personaId: args.persona.id, @@ -646,23 +647,21 @@ function resolveRelayMcpFromEnv(env: NodeJS.ProcessEnv): RelayMcpConfig | undefi } /** - * Resolve the ai-hist MCP config from env. Mirrors the CLI helper so cloud and - * local spawns inject the same server. `WORKFORCE_AIHIST_DISABLED` (`1`/`true`) - * is the escape hatch. Both fields are optional — when `TRAJECTORY_ROOT` is - * unset the MCP falls back to its own discovery, which matches the runtime - * recorder (also keyed off `TRAJECTORY_ROOT`): with no root, the recorder - * writes nothing, so there is nothing to mis-read. + * Resolve the ai-hist MCP config. Only called when the persona opts into recall + * via `memory.aiMemory`. The "why" read-root mirrors the runtime recorder's + * write-root (env `TRAJECTORY_ROOT` wins, else the deployment default), so the + * MCP reads back exactly what the recorder wrote. The "how" DB comes from the + * persona's `memory.aiMemory.dbPath` override, else `AI_HIST_DB` env. */ function resolveAiHistFromEnv( env: NodeJS.ProcessEnv, - defaultTrajectoryRoot?: string + defaultTrajectoryRoot?: string, + dbPathOverride?: string ): AiHistMcpConfig | undefined { - const disabled = env.WORKFORCE_AIHIST_DISABLED?.trim(); - if (disabled === '1' || disabled === 'true') return undefined; // env.TRAJECTORY_ROOT wins; otherwise the deployment default (same value the // recorder writes to) — keeps the MCP read-root identical to the write-root. const trajectoryRoot = env.TRAJECTORY_ROOT?.trim() || defaultTrajectoryRoot; - const dbPath = env.AI_HIST_DB?.trim(); + const dbPath = dbPathOverride?.trim() || env.AI_HIST_DB?.trim(); return { ...(trajectoryRoot ? { trajectoryRoot } : {}), ...(dbPath ? { dbPath } : {}) diff --git a/packages/runtime/src/ctx.ts b/packages/runtime/src/ctx.ts index 1289169f..c4788fc3 100644 --- a/packages/runtime/src/ctx.ts +++ b/packages/runtime/src/ctx.ts @@ -1,4 +1,4 @@ -import type { PersonaSpec } from '@agentworkforce/persona-kit'; +import { resolveTrajectoryRecording, type PersonaSpec } from '@agentworkforce/persona-kit'; import type { LlmContext, MemoryContext, @@ -136,13 +136,15 @@ export function buildCtx(options: CtxBuildOptions): WorkforceCtx { const log = options.log ?? defaultLog; const files = options.files ?? filesFromSandbox(options.sandbox); const agentName = options.agentName ?? options.persona.id; - // Per-persona trajectory recorder (the WHY). No-op unless recording is on - // (`recordTrajectories !== false`) and a trajectory root resolves. + // Per-persona trajectory recorder (the WHY). Opt-in: no-op unless the + // persona declares `memory.trajectories` AND a trajectory root resolves. + const trajectory = resolveTrajectoryRecording(options.persona.memory); const trajectoryRecorder = createTrajectoryRecorder({ personaId: options.persona.id, agentName, workspaceId: options.workspaceId, - recordTrajectories: options.persona.recordTrajectories, + recordTrajectories: trajectory.enabled, + ...(trajectory.autoCompact !== undefined ? { autoCompact: trajectory.autoCompact } : {}), ...(options.trajectoryRoot ? { trajectoryRoot: options.trajectoryRoot } : {}), log }); diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 6d4ac37d..524c650b 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -362,8 +362,8 @@ export interface CompactedTrajectoryContract { * Auto-recording trajectory surface. Handlers narrate their decision * trajectory (the WHY) through these methods; the runtime opens a trajectory * around each run and emits the compacted contract on completion. Every method - * is safe to call even when recording is disabled (`recordTrajectories: false` - * or no resolvable `TRAJECTORY_ROOT`) — it then no-ops. + * is safe to call even when recording is disabled (no `memory.trajectories` + * opt-in, or no resolvable `TRAJECTORY_ROOT`) — it then no-ops. */ export interface TrajectoryContext { /** Open a new logical phase of work within the run. */ @@ -424,7 +424,7 @@ export interface WorkforceCtx { schedule: ScheduleContext; /** * Auto-recorded decision trajectory (the WHY). No-op when recording is - * disabled (`persona.recordTrajectories: false` or no resolvable + * disabled (no `persona.memory.trajectories` opt-in or no resolvable * `TRAJECTORY_ROOT`), so it is always safe to call from a handler. */ trajectory: TrajectoryContext; diff --git a/packages/workload-router/src/index.ts b/packages/workload-router/src/index.ts index 7c715f9d..4a94721a 100644 --- a/packages/workload-router/src/index.ts +++ b/packages/workload-router/src/index.ts @@ -128,9 +128,7 @@ export function resolvePersona(intent: PersonaIntent, profile: RoutingProfile | ...(spec.mcpServers ? { mcpServers: spec.mcpServers } : {}), ...(spec.permissions ? { permissions: spec.permissions } : {}), ...(spec.mount ? { mount: spec.mount } : {}), - ...(typeof spec.recordTrajectories === 'boolean' - ? { recordTrajectories: spec.recordTrajectories } - : {}), + ...(spec.memory !== undefined ? { memory: spec.memory } : {}), ...sidecarSelectionFields(resolveSidecar(spec)) }; }