Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion examples/proactive-issue-resolver/persona.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
"enabled": true,
"scopes": [
"workspace"
]
],
"trajectories": true,
"aiMemory": true
},
"onEvent": "./agent.ts",
"harness": "claude",
Expand Down
9 changes: 8 additions & 1 deletion examples/review-agent/persona.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@
"enabled": true,
"scopes": [
"workspace"
]
],
"trajectories": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Enabling trajectories and aiMemory under memory contradicts the README's explicit warning that memory is not wired (ctx.memory is a stub in v1). This creates a misleading configuration where the config signals these features are active, but the underlying runtime infrastructure for this persona does not support them.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/review-agent/persona.json, line 19:

<comment>Enabling `trajectories` and `aiMemory` under `memory` contradicts the README's explicit warning that memory is not wired (`ctx.memory` is a stub in v1). This creates a misleading configuration where the config signals these features are active, but the underlying runtime infrastructure for this persona does not support them.</comment>

<file context>
@@ -15,7 +15,14 @@
       "workspace"
-    ]
+    ],
+    "trajectories": {
+      "enabled": true,
+      "autoCompact": true
</file context>

"enabled": true,
"autoCompact": true
},
"aiMemory": {
"enabled": true
}
},
"onEvent": "./agent.ts",
"harness": "codex",
Expand Down
42 changes: 20 additions & 22 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
PERSONA_TAGS,
readSkillCacheMarker,
renderPersonaInputs,
resolveAiMemory,
resolveMcpServersLenient,
resolvePersonaInputs,
resolveSidecar,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 } : {})
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 3 additions & 17 deletions packages/deploy/src/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
13 changes: 0 additions & 13 deletions packages/deploy/src/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,6 @@ export async function preflightPersona(personaPath: string): Promise<DeployPrefl
);
}

// Trajectory recording is an enforced capability for deployed agents: the
// runtime auto-records the persona's decisions (the "why") and the deploy
// surface auto-injects the `ai-hist` MCP (the "how" + "why" retrieval). A
// cloud persona may not opt out — recording is on unless `recordTrajectories`
// is explicitly `false`, which we reject here loudly rather than silently
// shipping a deployed agent with no history.
if (persona.recordTrajectories === false) {
throw new Error(
`persona "${persona.id}" sets recordTrajectories:false but trajectory recording is required for cloud deploy — ` +
`remove the override (recording is on by default) to deploy.`
);
}

if (!persona.onEvent) {
throw new Error(
`persona "${persona.id}" declares cloud:true but is missing "onEvent" (path to the agent file)`
Expand Down
52 changes: 48 additions & 4 deletions packages/persona-kit/schemas/persona.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,6 @@
"$ref": "#/definitions/PersonaMemory",
"description": "Memory subsystem opt-in. Wires the agent-assistant memory adapter at runtime; the persona spec only declares intent, not implementation details (api keys, adapter type, etc. come from workforce env)."
},
"recordTrajectories": {
"type": "boolean",
"description": "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."
},
"onEvent": {
"type": "string",
"description": "Relative POSIX path to the TypeScript (or compiled .js / .mjs) file whose default export is the deploy-time event handler. Resolved relative to the persona JSON's directory at deploy time. Required by the JSON Schema whenever {@link cloud } is `true` (any cloud persona needs an entrypoint, regardless of whether triggers are declared); the deploy CLI enforces the same rule. The parser itself keeps the field optional so partially-authored specs still parse."
Expand Down Expand Up @@ -556,6 +552,28 @@
},
"dedupMs": {
"type": "number"
},
"trajectories": {
"anyOf": [
{
"type": "boolean"
},
{
"$ref": "#/definitions/PersonaTrajectoryConfig"
}
],
"description": "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 `<root>/<personaId>/compacted/<runId>.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."
Expand All @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions packages/persona-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ export type {
PersonaIntegrationConfig,
PersonaIntegrationTrigger,
PersonaIntent,
PersonaAiMemoryConfig,
PersonaMemory,
PersonaMemoryConfig,
PersonaMemoryScope,
PersonaTrajectoryConfig,
PersonaMount,
PersonaPermissions,
PersonaSchedule,
Expand Down Expand Up @@ -99,6 +101,8 @@ export {
parseOnEvent,
parsePermissions,
parsePersonaSpec,
resolveAiMemory,
resolveTrajectoryRecording,
parseSchedules,
parseSkills,
parseStringList,
Expand Down
8 changes: 4 additions & 4 deletions packages/persona-kit/src/interactive-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion packages/persona-kit/src/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading