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
7 changes: 7 additions & 0 deletions examples/notion-essay-pr/notion-essay-pr.smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,13 @@ class MockNotionEssayRuntime {
/* unused */
}
},
trajectory: {
async chapter() {},
async note() {},
async decide() {},
async error() {},
async done() {}
},
log: () => undefined
};
}
Expand Down
3 changes: 2 additions & 1 deletion packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"dependencies": {
"@agent-assistant/proactive": "^0.4.32",
"@agentworkforce/persona-kit": "workspace:*",
"@relayfile/adapter-core": "^0.3.29"
"@relayfile/adapter-core": "^0.3.29",
"agent-trajectories": "^0.5.3"
}
}
49 changes: 49 additions & 0 deletions packages/runtime/src/cloud-defaults.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import type { PersonaSpec } from '@agentworkforce/persona-kit';
import { createCloudRuntimeDefaults } from './cloud-defaults.js';
import type { WorkforceAgentContext, WorkforceDeploymentContext } from './types.js';

const persona: PersonaSpec = {
id: 'demo',
intent: 'documentation',
tags: ['documentation'],
description: 'test persona',
skills: [],
harness: 'claude',
model: 'anthropic/claude-3-5-sonnet',
systemPrompt: 'be helpful',
harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 },
cloud: true
};

const agent: WorkforceAgentContext = {
id: 'agent_1',
deployedName: 'docs-demo',
spawnedByAgentId: null
};

const deployment: WorkforceDeploymentContext = {
id: 'deployment_1',
triggerKind: 'clock',
parentDeploymentId: null
};

const base = { persona, agent, deployment, workspaceId: 'ws-1', log: () => {} };

test('cloud trajectoryRoot: explicit TRAJECTORY_ROOT wins', () => {
const defaults = createCloudRuntimeDefaults({
...base,
env: { TRAJECTORY_ROOT: '/custom/root', WORKFORCE_SANDBOX_ROOT: '/srv/work' }
});
assert.equal(defaults.trajectoryRoot, '/custom/root');
});

test('cloud trajectoryRoot: defaults to <workspaceRoot>/.trajectories in a cloud workspace', () => {
const defaults = createCloudRuntimeDefaults({
...base,
env: { WORKFORCE_SANDBOX_ROOT: '/srv/work' }
});
assert.equal(defaults.trajectoryRoot, path.join('/srv/work', '.trajectories'));
});
74 changes: 72 additions & 2 deletions packages/runtime/src/cloud-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
resolveMcpServersLenient,
resolvePersonaInputs,
resolveStringMapLenient,
type AiHistMcpConfig,
type PersonaSpec,
type RelayMcpConfig
} from '@agentworkforce/persona-kit';
Expand Down Expand Up @@ -62,11 +63,24 @@ export interface CloudRuntimeDefaults {
workflow?: WorkflowContext;
llm?: LlmContext;
harnessRunner: (args: HarnessRunArgs) => Promise<HarnessRunResult>;
/**
* Resolved trajectory root for this deployment, or `undefined` when there is
* no cloud workspace and `TRAJECTORY_ROOT` is unset (local/tests stay opt-in,
* so the runtime never writes to a developer's cwd). The runner threads this
* into `buildCtx` as the recorder write-root; the harness spec passes the
* same value to the ai-hist MCP — that single source keeps write-root and
* MCP-root identical.
*/
trajectoryRoot?: string;
}

export function createCloudRuntimeDefaults(options: CloudDefaultOptions): CloudRuntimeDefaults {
const env = options.env ?? process.env;
const root = resolveCloudWorkspaceRoot(env);
// Single source of truth for the trajectory root, computed once. Both the
// recorder write-root (threaded into buildCtx by the runner) and the ai-hist
// MCP env (in the harness spec) consume this exact value.
const trajectoryRoot = resolveCloudTrajectoryRoot(env, root);
const isSandboxOptional = options.persona.sandbox === false;
const baseSandbox = createProcessSandbox(root, env);
const sandbox = isSandboxOptional
Expand All @@ -89,14 +103,36 @@ export function createCloudRuntimeDefaults(options: CloudDefaultOptions): CloudR
files,
...(workflow ? { workflow } : {}),
...(llm ? { llm } : {}),
...(trajectoryRoot ? { trajectoryRoot } : {}),
harnessRunner: createProcessHarnessRunner({
...options,
workspaceRoot: root,
env
env,
trajectoryRoot
})
};
}

/**
* Resolve the trajectory root for a cloud deployment. An explicit
* `TRAJECTORY_ROOT` always wins. Otherwise it defaults to
* `<workspaceRoot>/.trajectories` — but ONLY in a real cloud workspace
* (configured mount or an accessible `/workspace`), so local/test runs stay
* opt-in via env and never write to a developer's cwd.
*/
function resolveCloudTrajectoryRoot(env: NodeJS.ProcessEnv, workspaceRoot: string): string | undefined {
const explicit = env.TRAJECTORY_ROOT?.trim();
if (explicit) return explicit;
const hasCloudWorkspace =
firstNonEmpty(
env.WORKFORCE_SANDBOX_ROOT,
env.WORKFORCE_WORKSPACE_DIR,
env.RELAYFILE_MOUNT_ROOT,
env.RELAYFILE_ROOT
) !== undefined || canAccessSync('/workspace');
return hasCloudWorkspace ? path.join(workspaceRoot, '.trajectories') : undefined;
}

function resolveCloudWorkspaceRoot(env: NodeJS.ProcessEnv): string {
const configured = firstNonEmpty(
env.WORKFORCE_SANDBOX_ROOT,
Expand Down Expand Up @@ -413,6 +449,7 @@ function delay(ms: number): Promise<void> {
function createProcessHarnessRunner(args: CloudDefaultOptions & {
workspaceRoot: string;
env: NodeJS.ProcessEnv;
trajectoryRoot?: string;
}): (run: HarnessRunArgs) => Promise<HarnessRunResult> {
return async (run) => {
// harness/model/systemPrompt are optional on the persona spec (pure
Expand Down Expand Up @@ -461,6 +498,14 @@ function createProcessHarnessRunner(args: CloudDefaultOptions & {
});
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);
const specInput = {
harness,
personaId: args.persona.id,
Expand All @@ -471,7 +516,8 @@ function createProcessHarnessRunner(args: CloudDefaultOptions & {
permissions: args.persona.permissions,
task,
name: args.persona.id,
workingDirectory: cwd
workingDirectory: cwd,
...(aiHist ? { aiHist } : {})
};
const brokerRelayHarness = relayMcp && (harness === 'claude' || harness === 'codex');
let spec = buildNonInteractiveSpec({
Expand Down Expand Up @@ -599,6 +645,30 @@ 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.
*/
function resolveAiHistFromEnv(
env: NodeJS.ProcessEnv,
defaultTrajectoryRoot?: 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();
return {
...(trajectoryRoot ? { trajectoryRoot } : {}),
...(dbPath ? { dbPath } : {})
};
Comment on lines +666 to +669

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If both trajectoryRoot and dbPath are empty or undefined, this function currently returns an empty object {}. This causes aiHist to be truthy, which injects an empty aiHist configuration into the harness spec and starts the "ai-hist" MCP server unnecessarily even when trajectory recording is completely inactive. Returning undefined when both are empty avoids starting the unnecessary MCP server.

  if (!trajectoryRoot && !dbPath) return undefined;
  return {
    ...(trajectoryRoot ? { trajectoryRoot } : {}),
    ...(dbPath ? { dbPath } : {})
  };

}

async function resolveAgentRelayBrokerMcpArgs(args: {
cli: 'claude' | 'codex';
env: NodeJS.ProcessEnv;
Expand Down
27 changes: 26 additions & 1 deletion packages/runtime/src/ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
WorkforceDeploymentContext,
WorkflowContext
} from './types.js';
import { attachTrajectoryRecorder, createTrajectoryRecorder } from './trajectory.js';

type AgentInputValue = string | number | boolean | null | undefined;

Expand Down Expand Up @@ -55,6 +56,14 @@ export interface CtxBuildOptions {
integrations?: Record<string, unknown>;
log?: WorkforceCtx['log'];
harnessRunner: WorkforceCtx['harness']['run'];
/**
* Root directory for per-run trajectory contract files. Defaults to
* `env.TRAJECTORY_ROOT`; when neither resolves, trajectory recording is
* disabled (the runtime never writes to the process cwd). The cloud runtime
* sets this to the same value it passes to the injected ai-hist MCP so the
* MCP reads back exactly what the runtime wrote.
*/
trajectoryRoot?: string;
}

const NOOP_MEMORY: MemoryContext = {
Expand Down Expand Up @@ -126,6 +135,17 @@ 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.
const trajectoryRecorder = createTrajectoryRecorder({
personaId: options.persona.id,
agentName,
workspaceId: options.workspaceId,
recordTrajectories: options.persona.recordTrajectories,
...(options.trajectoryRoot ? { trajectoryRoot: options.trajectoryRoot } : {}),
log
});
const ctx: WorkforceCtx = {
persona: buildPersonaContext(options.persona, mergedAgentInputValues),
agent: {
Expand All @@ -135,7 +155,7 @@ export function buildCtx(options: CtxBuildOptions): WorkforceCtx {
},
deployment,
workspaceId: options.workspaceId,
agentName: options.agentName ?? options.persona.id,
agentName,
llm: options.llm ?? UNAVAILABLE_LLM,
harness: { run: options.harnessRunner },
sandbox: options.sandbox,
Expand All @@ -144,8 +164,12 @@ export function buildCtx(options: CtxBuildOptions): WorkforceCtx {
memory: options.memory ?? defaultMemoryFor(options.persona.memory, options.workspaceId, log),
workflow: options.workflow ?? UNAVAILABLE_WORKFLOW,
schedule: options.schedule ?? UNAVAILABLE_SCHEDULE,
trajectory: trajectoryRecorder.context,
log
};
// The runner drives the recorder's lifecycle (begin/complete/fail) via this
// non-enumerable handle, keeping those internals off the public ctx surface.
attachTrajectoryRecorder(ctx, trajectoryRecorder);

// Optional per-integration subsystems attach as named ctx fields. The
// cloud-default runtime does not populate this — handlers read provider
Expand Down Expand Up @@ -186,6 +210,7 @@ const CORE_CTX_FIELDS: ReadonlySet<string> = new Set([
'memory',
'workflow',
'schedule',
'trajectory',
'log'
]);

Expand Down
17 changes: 15 additions & 2 deletions packages/runtime/src/runner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AgentSpec, PersonaSpec } from '@agentworkforce/persona-kit';
import { createCloudRuntimeDefaults } from './cloud-defaults.js';
import { buildCtx, type CtxBuildOptions } from './ctx.js';
import { getTrajectoryRecorder, type TrajectoryRecorder } from './trajectory.js';
import { isWorkforceHandler } from './handler.js';
import { shimEnvelope, type RawGatewayEnvelope } from './shim.js';
import type {
Expand Down Expand Up @@ -123,6 +124,10 @@ export async function startRunner(options: StartRunnerOptions): Promise<void> {
: {}),
...(options.subsystems?.schedule ? { schedule: options.subsystems.schedule } : {}),
...(options.subsystems?.log ? { log: options.subsystems.log } : {}),
// Recorder write-root resolved once by cloud-defaults; identical to the
// value cloud-defaults passes to the ai-hist MCP. Undefined locally/in
// tests (recording stays opt-in via TRAJECTORY_ROOT).
...(cloudDefaults.trajectoryRoot ? { trajectoryRoot: cloudDefaults.trajectoryRoot } : {}),
...(Object.keys(integrations).length > 0 ? { integrations } : {})
});

Expand All @@ -134,14 +139,15 @@ export async function startRunner(options: StartRunnerOptions): Promise<void> {
integrations: options.persona.integrations ? Object.keys(options.persona.integrations) : []
});

const recorder = getTrajectoryRecorder(ctx);
const stream = options.envelopes ?? readEnvelopesFromStdin();
for await (const raw of stream) {
const event = shimEnvelope(raw);
if (!event) {
ctx.log('warn', 'runner.envelope.unsupported', { rawId: raw.id, rawType: raw.type });
continue;
}
await dispatch(ctx, handlerFn, event);
await dispatch(ctx, handlerFn, event, recorder);
}

ctx.log('info', 'runner.envelope-stream.ended', { persona: options.persona.id });
Expand All @@ -155,9 +161,13 @@ function defaultRunnerLog(level: 'debug' | 'info' | 'warn' | 'error', message: s
async function dispatch(
ctx: Parameters<WorkforceHandler>[0],
fn: WorkforceHandler,
event: WorkforceEvent
event: WorkforceEvent,
recorder: TrajectoryRecorder
): Promise<void> {
const t0 = Date.now();
// Open a trajectory for this run. The handler narrates via ctx.trajectory.*;
// begin/complete/fail never throw (recording is best-effort observability).
await recorder.begin(event);
try {
await fn(ctx, event);
ctx.log('info', 'runner.handler.ok', {
Expand All @@ -166,6 +176,8 @@ async function dispatch(
type: event.source === 'cron' ? 'cron.tick' : event.type,
durationMs: Date.now() - t0
});
// Auto-finalize (no-op if the handler already called ctx.trajectory.done).
await recorder.complete();
} catch (err) {
ctx.log('error', 'runner.handler.error', {
eventId: event.id,
Expand All @@ -176,6 +188,7 @@ async function dispatch(
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined
});
await recorder.fail(err);
// Surface the failure to the outer process so the deploy layer can
// retry. Throwing here would tear down the for-await loop; the deploy
// layer reads the structured log line above instead.
Expand Down
Loading
Loading