diff --git a/docs/SURFACE.md b/docs/SURFACE.md index 44a3fa74c..13e6d03d3 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -475,6 +475,35 @@ Workspace surfaces are a different thing and are not a directory selector: `surfaces.workspace` names the revisions a step pins and writes back to, not a path on the host running the CLI. +### Continuing an agent's CLI session (`resume`) + +`f.agent` reports the CLI session it ran as in `AgentResult.sessionId` +(Claude's session id, Codex's thread id). Pass it as a later step's `resume`, +or the `resume:` key on a declarative agent step, to continue that conversation +instead of starting a new one: + +```ts +const first = await f.agent("fix", { task: "Address the review feedback." }); +// ...later, in a run on the same host... +await f.agent("fix-again", { task: "Address the new feedback.", resume: first.sessionId }); +``` + +- Claude runs `claude -p --resume `; Codex runs `codex exec resume `. + Any other CLI refuses the step rather than silently starting cold. +- The CLI finds the session in its own store (`~/.claude`, `~/.codex`), so a + resume works only where that store survives — the same machine, or a + sandbox reused for the same pull request. A session the store no longer has + fails the step. +- The id is checked lexically, identically by `flows check` and the kernel: + 1-200 of `A-Z a-z 0-9 . _ : -`, starting with a letter or digit, so it can + never be read as a CLI flag. `resume` is not supported with + `transport: "relay"`. +- `sessionId` is read from the attempt's journaled transcript evidence, so it + is reported whether the agent answered in text or with a JSON object. It is + omitted when the CLI reported none. +- Setting `resume` is part of the step's spec hash; omitting it hashes exactly + as before. + ### Supported TypeScript LLM calls The local authored executor supports these signatures: diff --git a/evidence/agent-resume/01-red.txt b/evidence/agent-resume/01-red.txt new file mode 100644 index 000000000..46de06911 --- /dev/null +++ b/evidence/agent-resume/01-red.txt @@ -0,0 +1,12 @@ +# packages/sdk/src and packages/surface/src at origin/main (22cd1836) via git stash; tests and testdata from this branch. +$ (cd packages/sdk && npx vitest run tests/agent-resume.test.ts tests/spec-parity.test.ts) + × spec parity: one dialect at the SDK<->kernel boundary > compiles agent-resume to the pinned canonical JSON 5ms + × spec parity: one dialect at the SDK<->kernel boundary > hashes agent-resume to the pinned spec_hash 1ms + × spec parity: one dialect at the SDK<->kernel boundary > round-trips agent-resume across the kernel dialect 1ms +Error: Failed to load url ../src/agent-resume.js (resolved id: ../src/agent-resume.js) in /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/agent-resume/packages/sdk/tests/agent-resume.test.ts. Does the file exist? +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯ +CompileError: spec compile failed: +CompileError: spec compile failed: +CompileError: spec compile failed: + Test Files 2 failed (2) + Tests 3 failed | 46 passed (49) diff --git a/evidence/agent-resume/02-sdk-typecheck-build.txt b/evidence/agent-resume/02-sdk-typecheck-build.txt new file mode 100644 index 000000000..d4ab42280 --- /dev/null +++ b/evidence/agent-resume/02-sdk-typecheck-build.txt @@ -0,0 +1,13 @@ +$ (cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tests) +$ npm run typecheck +> @relayflows/sdk@2.0.42 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +exit=0 +$ npm run build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.42 typecheck:tests +> tsc -p tsconfig.tests.json diff --git a/evidence/agent-resume/03-sdk-vitest.txt b/evidence/agent-resume/03-sdk-vitest.txt new file mode 100644 index 000000000..e4a5de82d --- /dev/null +++ b/evidence/agent-resume/03-sdk-vitest.txt @@ -0,0 +1,15 @@ +# Supersedes the earlier capture, whose output filter (Test Files|Tests|×) hid a collection-time FAIL: the verb-field lint, fixed in aa115352. +# RELAYFLOWD_BIN points at this branch's kernel build (kernel/target/debug/relayflowd); the ambient value named a missing toolchain binary. +$ (cd packages/sdk && RELAYFLOWD_BIN=$PWD/../../kernel/target/debug/relayflowd npx vitest run) | grep -E "FAIL|Test Files|Tests | × " + ✓ built flows CLI against live relayflowd > hn-monitor analyze-story FAILS verification when the CLI omits required schema fields 340ms + FAIL tests/authored-node-runtime.test.ts [ tests/authored-node-runtime.test.ts ] + Test Files 1 failed | 272 passed | 1 skipped (274) + Tests 4278 passed | 20 skipped (4298) + +# The one FAIL is environmental: the test pins bun 1.4.0 and this host has 1.4.2. + FAIL tests/authored-node-runtime.test.ts [ tests/authored-node-runtime.test.ts ] +AssertionError: expected '1.4.2' to be '1.4.0' // Object.is equality + +Expected: "1.4.0" +Received: "1.4.2" + diff --git a/evidence/agent-resume/04-green.txt b/evidence/agent-resume/04-green.txt new file mode 100644 index 000000000..1582db3be --- /dev/null +++ b/evidence/agent-resume/04-green.txt @@ -0,0 +1,15 @@ +$ (cd packages/sdk && npx vitest run tests/agent-resume.test.ts tests/spec-parity.test.ts) + Test Files 2 passed (2) + Tests 75 passed (75) +$ (cd kernel && cargo test -p relayflowd-core) +test result: ok. 86 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.71s +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s +test result: ok. 21 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s +$ node scripts/generate-json-schema.mjs && git diff --stat -- packages/schema +Generated packages/schema/flows.schema.json (74 definitions) + packages/schema/flows.schema.json | 10 ++++++++++ + 1 file changed, 10 insertions(+) +$ (cd packages/schema && bun run test) + 86 pass + 0 fail diff --git a/evidence/agent-resume/05-real-cli-resume.txt b/evidence/agent-resume/05-real-cli-resume.txt new file mode 100644 index 000000000..c5557292e --- /dev/null +++ b/evidence/agent-resume/05-real-cli-resume.txt @@ -0,0 +1,10 @@ +# Missing session, real Claude CLI (2.x on this host): +$ claude -p --resume 00000000-0000-4000-8000-0000000000aa "hi" --output-format json +No conversation found with session ID: 00000000-0000-4000-8000-0000000000aa +exit=1 + +# Resume from a different directory than the session was created in: +$ (cd A && claude -p "Remember the code word PELICAN-42. Reply only with OK." --output-format json) +A session: 1908508f-989e-4159-82b0-6a676c3d74d3 result: OK +$ (cd B && claude -p --resume 1908508f-989e-4159-82b0-6a676c3d74d3 "What was the code word? Reply with just it." --output-format json) +B session: 1908508f-989e-4159-82b0-6a676c3d74d3 result: 'PELICAN-42' error: False diff --git a/kernel/relayflowd-core/src/spec.rs b/kernel/relayflowd-core/src/spec.rs index f3b80fea5..c6e124624 100644 --- a/kernel/relayflowd-core/src/spec.rs +++ b/kernel/relayflowd-core/src/spec.rs @@ -180,7 +180,13 @@ impl RunSpec { step.id ))); } - if let StepKind::Agent { surfaces, cwd, .. } = &step.kind { + if let StepKind::Agent { + surfaces, + cwd, + resume, + .. + } = &step.kind + { if let Some(cwd) = cwd && !is_run_root_relative_path(cwd) { @@ -189,6 +195,14 @@ impl RunSpec { cwd: cwd.clone(), }); } + if let Some(resume) = resume + && !is_cli_session_id(resume) + { + return Err(SpecError::InvalidAgentResume { + step: step.id.clone(), + resume: resume.clone(), + }); + } for workspace in &surfaces.workspace { if path_surface_identity(&workspace.surface).is_none() { return Err(SpecError::InvalidWorkspaceSurface { @@ -311,6 +325,17 @@ fn is_run_root_relative_path(path: &str) -> bool { if namespace.is_empty() && !components.is_empty()) } +/// Whether a declared agent `resume` is a CLI session id: 1-200 of +/// `A-Z a-z 0-9 . _ : -`, starting with a letter or digit, so it can never be +/// read as a CLI flag. The SDK's `agent-resume.ts` applies the same rule, and +/// `testdata/agent-resume-cases.json` is the corpus both are tested against. +pub fn is_cli_session_id(id: &str) -> bool { + let mut chars = id.chars(); + chars.next().is_some_and(|c| c.is_ascii_alphanumeric()) + && id.len() <= 200 + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '-')) +} + pub fn is_canonical_external_surface(path: &str) -> bool { path_surface_identity(path).is_some() } @@ -363,6 +388,7 @@ const STEP_AGENT_FIELDS: &[&str] = &[ "cli_identity", "model", "cwd", + "resume", "transport", "recovery_mode", "surfaces", @@ -386,16 +412,15 @@ fn reject_unknown_step_fields(value: &Value) -> Result<(), SpecError> { // default directory under a spec that declared otherwise. The // shape is checked before serde so a null, a number or an // object is refused instead of silently defaulted. - if let Some(cwd) = object.get("cwd") - && !cwd.is_string() - { - return Err(SpecError::Malformed(format!( - "step {}: cwd must be a string", - object - .get("id") - .and_then(Value::as_str) - .unwrap_or("?") - ))); + for field in ["cwd", "resume"] { + if let Some(value) = object.get(field) + && !value.is_string() + { + return Err(SpecError::Malformed(format!( + "step {}: {field} must be a string", + object.get("id").and_then(Value::as_str).unwrap_or("?") + ))); + } } STEP_AGENT_FIELDS } @@ -538,6 +563,12 @@ pub enum StepKind { /// filesystem. Absent means the run root itself. #[serde(default, skip_serializing_if = "Option::is_none")] cwd: Option, + /// A CLI session the attached worker continues instead of starting a + /// new one (an earlier step's reported session id). The kernel checks + /// its shape and carries it verbatim; resuming is the worker's, which + /// shares the CLI's session store. + #[serde(default, skip_serializing_if = "Option::is_none")] + resume: Option, /// How the attached worker invokes the declared CLI. The kernel does /// not implement either transport; it journals and dispatches the /// choice so the worker can honor it deterministically. @@ -874,6 +905,10 @@ pub enum SpecError { "agent step {step} declares working directory {cwd:?}, which is not a run-root-relative path (no absolute paths, empty components, \".\" or \"..\")" )] InvalidAgentCwd { step: String, cwd: String }, + #[error( + "agent step {step} declares resume {resume:?}, which is not a CLI session id (1-200 of A-Z a-z 0-9 . _ : -, starting with a letter or digit)" + )] + InvalidAgentResume { step: String, resume: String }, #[error("agent step {step} declares non-canonical external surface {path:?}")] InvalidExternalSurface { step: String, path: String }, #[error("agent step {step} declares non-canonical workspace surface {surface:?}")] diff --git a/kernel/relayflowd-core/tests/spec_parity.rs b/kernel/relayflowd-core/tests/spec_parity.rs index 54be5a21f..ac5eb701f 100644 --- a/kernel/relayflowd-core/tests/spec_parity.rs +++ b/kernel/relayflowd-core/tests/spec_parity.rs @@ -328,3 +328,61 @@ fn agent_timeout_bounds_match_the_kernel_corpus() { let parsed = RunSpec::parse(&value).unwrap(); assert!(serde_json::to_value(parsed).unwrap()["steps"][0].get("timeout_ms").is_none()); } + +/// `resume` (continue a recorded CLI session) crosses the boundary exactly as +/// `cwd` does: declared on one agent step and not on another, with identical +/// canonical bytes and hash on both sides. +#[test] +fn agent_resume_has_identical_canonical_bytes_and_hash() { + assert_parity( + include_str!("../../../testdata/agent-resume.spec.canonical.json"), + include_str!("../../../testdata/agent-resume.spec.sha256"), + ); +} + +#[test] +fn agent_resume_declaration_acceptance_matches_the_sdk_corpus() { + let cases: Vec = + serde_json::from_str(include_str!("../../../testdata/agent-resume-cases.json")).unwrap(); + for case in cases { + let spec = serde_json::json!({"steps":[{ + "id":"s","type":"agent","instruction":"work","resume":case["resume"], + }]}); + let accepted = RunSpec::parse(&spec) + .and_then(|spec| spec.validate()) + .is_ok(); + assert_eq!( + accepted, + case["valid"].as_bool().unwrap(), + "{}", + case["name"] + ); + } +} + +/// An undeclared `resume` is absent from the re-serialized spec, so every +/// fixture committed before the field existed keeps its bytes and hash; a +/// non-string spelling fails closed instead of reading as absence. +#[test] +fn agent_resume_is_absent_when_undeclared_and_must_be_a_string() { + let value = + serde_json::json!({"steps": [{"id": "agent", "type": "agent", "instruction": "work"}]}); + let parsed = RunSpec::parse(&value).expect("an agent step without resume must parse"); + parsed.validate().expect("and must validate"); + assert!( + serde_json::to_value(parsed).unwrap()["steps"][0] + .get("resume") + .is_none() + ); + for resume in [ + serde_json::json!(null), + serde_json::json!(7), + serde_json::json!({}), + ] { + let spec = serde_json::json!({"steps": [{"id": "agent", "type": "agent", "instruction": "work", "resume": resume}]}); + assert!( + RunSpec::parse(&spec).is_err(), + "resume {resume} must be refused" + ); + } +} diff --git a/packages/schema/flows.schema.json b/packages/schema/flows.schema.json index 9ec59e97b..ca4b4f003 100644 --- a/packages/schema/flows.schema.json +++ b/packages/schema/flows.schema.json @@ -1453,6 +1453,11 @@ "description": "Directory the declared CLI is spawned in, as a path relative to the run\nroot — the flow-runner's working directory, which is also where the CLI\nruns when this is absent. Absolute paths, `.`, `..` and empty components\nare refused lexically by `flows check` and by the kernel; the worker that\nspawns the CLI additionally requires the symlink-free directory to exist\ninside the symlink-free run root. A declaration, not a sandbox: nothing\nstops a CLI from writing outside it. Not supported with\n`transport: 'relay'`, where the agent runs on a host this process cannot\nresolve. See docs/SURFACE.md.", "type": "string" }, + "resume": { + "title": "resume", + "description": "A CLI session id to continue: the `AgentResult.sessionId` an earlier step\nreported. Claude and Codex resume it in place of starting a new session;\nany other CLI refuses the step. 1-200 of A-Z a-z 0-9 . _ : -, starting\nwith a letter or digit. Not supported with `transport: 'relay'`, where the\nagent runs on a host whose session store this worker cannot see.", + "type": "string" + }, "transport": { "title": "transport", "description": "Dispatch transport (flows#385). `'direct'` (default) spawns the CLI as\na local subprocess. `'relay'` posts to agent-relay so the agent\nregisters as a first-class workspace participant.", @@ -2484,6 +2489,11 @@ "description": "Run-root-relative directory the attached worker spawns the CLI in. The\nkernel checks the shape and does no I/O: existence and containment are\ndecided by the worker, on the host that shares the agent's filesystem.", "type": "string" }, + "resume": { + "title": "resume", + "description": "CLI session id the worker resumes; the kernel checks its shape only.", + "type": "string" + }, "transport": { "title": "transport", "description": "Dispatch transport (flows#385). Kernel passes through untouched;\nthe SDK worker selects between direct-spawn and agent-relay routing.", diff --git a/packages/sdk/src/adapters/base.ts b/packages/sdk/src/adapters/base.ts index a4ff2c275..e2d6d8efd 100644 --- a/packages/sdk/src/adapters/base.ts +++ b/packages/sdk/src/adapters/base.ts @@ -84,7 +84,10 @@ export interface HeadlessAdapter { classifyModelProbeFailure?(output: string): ModelProbeFailure | undefined; /** Agent-step worker argv for an instruction under an optional model. */ - buildAgentInvocation(instruction: string, model?: string): CliInvocation; + buildAgentInvocation(instruction: string, model?: string, resume?: string): CliInvocation; + + /** Whether `buildAgentInvocation` honors `resume`; a step asking another CLI to resume is refused. */ + readonly resumable?: true; /** LLM-step worker argv for a prompt under an optional model. */ buildLlmInvocation(prompt: string, model?: string): CliInvocation; diff --git a/packages/sdk/src/adapters/claude.ts b/packages/sdk/src/adapters/claude.ts index 84660a3e3..10ddd864d 100644 --- a/packages/sdk/src/adapters/claude.ts +++ b/packages/sdk/src/adapters/claude.ts @@ -14,6 +14,7 @@ const MODEL_PROBE_PROMPT = 'Reply with exactly RELAYFLOWS_MODEL_READY and nothin export const claudeAdapter: HeadlessAdapter = { kind: 'claude', defaultModel: 'claude-opus-5', + resumable: true, buildIdentification(): CliAdapterIdentification { return { invocation: { args: ['auth', 'status', '--help'], timeoutMs: 10_000 } }; @@ -46,10 +47,12 @@ export const claudeAdapter: HeadlessAdapter = { return undefined; }, - buildAgentInvocation(instruction: string, model?: string): CliInvocation { + buildAgentInvocation(instruction: string, model?: string, resume?: string): CliInvocation { return { args: [ '-p', + // Continues the recorded session (it is found by id, from any cwd). + ...(resume === undefined ? [] : ['--resume', resume]), // Symmetric to codex's --dangerously-bypass-approvals-and-sandbox: in // agent mode the flow explicitly delegates writes. Without this // claude's headless mode prompts for tool approval, gets no TTY, diff --git a/packages/sdk/src/adapters/codex.ts b/packages/sdk/src/adapters/codex.ts index 032ca8f7c..abc6f8e09 100644 --- a/packages/sdk/src/adapters/codex.ts +++ b/packages/sdk/src/adapters/codex.ts @@ -13,6 +13,7 @@ const MODEL_PROBE_PROMPT = 'Reply with exactly RELAYFLOWS_MODEL_READY and nothin * inline shape in `cli-adapter.ts` — only the packaging changed. */ export const codexAdapter: HeadlessAdapter = { kind: 'codex', + resumable: true, buildIdentification(): CliAdapterIdentification { return { invocation: { args: ['login', 'status', '--help'], timeoutMs: 10_000 } }; @@ -36,7 +37,7 @@ export const codexAdapter: HeadlessAdapter = { return providerUsageLimited(output) ? { cause: 'provider_usage_limited' } : undefined; }, - buildAgentInvocation(instruction: string, model?: string): CliInvocation { + buildAgentInvocation(instruction: string, model?: string, resume?: string): CliInvocation { return { args: [ // Real steps must retain Codex's native session under CODEX_HOME (or @@ -44,7 +45,8 @@ export const codexAdapter: HeadlessAdapter = { // and uploads it to Relayhistory; --ephemeral silently leaves nothing // for the bounded flush to capture. Readiness probes above stay // ephemeral because they are diagnostics, not workflow steps. - 'exec', '--skip-git-repo-check', + // `exec resume ` continues a recorded thread with the same flags. + ...(resume === undefined ? ['exec'] : ['exec', 'resume']), '--skip-git-repo-check', // Agent-mode is where the flow explicitly delegates code changes to // the CLI. Without this flag codex prompts for approval on every // write, gets nothing (no TTY), and completes "successfully" without @@ -52,6 +54,7 @@ export const codexAdapter: HeadlessAdapter = { // read-only and does NOT get the bypass. '--dangerously-bypass-approvals-and-sandbox', ...(model === undefined ? [] : ['--model', model]), + ...(resume === undefined ? [] : [resume]), ...promptOperand(instruction), ], timeoutMs: 0, diff --git a/packages/sdk/src/agent-resume.ts b/packages/sdk/src/agent-resume.ts new file mode 100644 index 000000000..16ee07689 --- /dev/null +++ b/packages/sdk/src/agent-resume.ts @@ -0,0 +1,36 @@ +/** + * Resuming an agent step's CLI session. + * + * `resume` names a session the declared CLI recorded on an earlier step — + * read back as `AgentResult.sessionId` — so this step continues that + * conversation instead of starting cold. The CLI stores the session (Claude + * under `~/.claude`, Codex under `~/.codex`); resuming only works where that + * store survives, such as a sandbox reused for the same pull request. + * + * The declaration is lexical and identical in both dialects: the kernel's + * `relayflowd_core::spec` applies the same rule, and + * `testdata/agent-resume-cases.json` is the corpus both are tested against. + * An id must start with a letter or digit, so it can never be read as a CLI + * flag, and stays within the characters session and thread ids use. + */ +const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/; + +/** Why a declared `resume` is not a session id, or `undefined` when it is one. */ +export function agentResumeDeclarationError(resume: unknown): string | undefined { + const expected = 'expected a CLI session id'; + if (typeof resume !== 'string') return `${expected} (got ${resume === null ? 'null' : typeof resume})`; + if (!SESSION_ID.test(resume)) { + return `${expected}: 1-200 of A-Z a-z 0-9 . _ : -, starting with a letter or digit`; + } + return undefined; +} + +/** + * A relay-dispatched agent runs on another host, whose CLI session store this + * worker cannot see; resuming there would silently start a fresh session. + */ +export function agentResumeTransportError(resume: unknown, transport: unknown): string | undefined { + if (resume === undefined || transport !== 'relay') return undefined; + return 'resume is not supported with transport "relay": the agent runs on another host, ' + + "whose CLI session store this worker cannot see"; +} diff --git a/packages/sdk/src/agent-transcript.ts b/packages/sdk/src/agent-transcript.ts index ec7dbba2d..89c6d5f7c 100644 --- a/packages/sdk/src/agent-transcript.ts +++ b/packages/sdk/src/agent-transcript.ts @@ -495,13 +495,15 @@ export function buildTranscriptDigest( return digest; } - // Codex: only `turn.completed` and `item.completed{agent_message}` are - // consumed today; tool activity is counted by item type until a fixture - // pins the frame shapes (plan U3). + // Codex: `thread.started` (the thread to resume), `turn.completed` and + // `item.completed{agent_message}` are consumed today; tool activity is + // counted by item type until a fixture pins the frame shapes (plan U3). const terminal = [...frames].reverse().find(f => f.type === 'turn.completed'); const usage = record(terminal?.usage) ? terminal.usage : undefined; + const thread = label(str(frames.find(f => f.type === 'thread.started')?.thread_id)); digest.result = { provider: 'codex', + ...(thread === undefined ? {} : { session_id: thread }), ...(usage === undefined ? {} : { usage: { ...(num(usage.input_tokens) === undefined ? {} : { input: num(usage.input_tokens) }), ...(num(usage.output_tokens) === undefined ? {} : { output: num(usage.output_tokens) }), diff --git a/packages/sdk/src/authored-step-output.ts b/packages/sdk/src/authored-step-output.ts index 7cc5bdb53..d594e12ca 100644 --- a/packages/sdk/src/authored-step-output.ts +++ b/packages/sdk/src/authored-step-output.ts @@ -23,6 +23,8 @@ export interface AuthoredStepContext { readonly stepEdges?: (step: string) => AuthoredStepEdges | undefined; /** Cancels the wait for an adopted child run that is not yet terminal. */ readonly signal?: AbortSignal; + /** Sees the accepted completion's payload (its evidence, not only its output). */ + readonly onCompleted?: (payload: Readonly>) => void; } /** @@ -149,6 +151,7 @@ export async function readCompletedStepOutput( await recordAuthoredChild(journal, context.rootRunId, { step: stepId, runId, state: 'completed', completionReason: reason, ...edges, }); + context.onCompleted?.(completed.payload as unknown as Readonly>); return completed.payload.output; } diff --git a/packages/sdk/src/authored-worker-step.ts b/packages/sdk/src/authored-worker-step.ts index 0f163dfc8..9b44f7eb4 100644 --- a/packages/sdk/src/authored-worker-step.ts +++ b/packages/sdk/src/authored-worker-step.ts @@ -9,6 +9,7 @@ import { classifyOutcome, type RunLifecycleOptions, type RunReport } from './cli import type { PreflightDiagnostic } from './preflight.js'; import { AuthoredFlowExecutionError } from './authored-flow-error.js'; import { agentCwdDeclarationError, agentCwdTransportError } from './agent-cwd.js'; +import { agentResumeDeclarationError, agentResumeTransportError } from './agent-resume.js'; import type { JournalClient } from './journal-client.js'; import { SPEC_SCHEMA_VERSION, type FlowSpec, type PermissionsSpec, type StepSpec } from './spec.js'; import { isSurfaceCompletionReason, readCompletedStepOutput, readRecordedOutcome, readSuccessfulOutput, type AuthoredStepContext, type RecordedRunOutcome } from './authored-step-output.js'; @@ -46,7 +47,7 @@ export function authoredWorkerRunner( ...(stepEdges === undefined ? {} : { stepEdges }), ...(waitOptions.signal === undefined ? {} : { signal: waitOptions.signal }), }; - async function run(step: StepSpec): Promise { + async function run(step: StepSpec, onCompleted?: AuthoredStepContext['onCompleted']): Promise { const id = step.id; const authoring: FlowSpec = { version: SPEC_SCHEMA_VERSION, name: `${definition.name}/${id}`, steps: [step], ...(headerBudget === undefined ? {} : { budget: parseBudget(headerBudget) }) }; // The kernel never resolves a `cli` on its own — every declarative @@ -152,7 +153,8 @@ export function authoredWorkerRunner( details, ); } - return readCompletedStepOutput(journal, outcome.run_id, id, journalSteps, context, execution.report.status); + return readCompletedStepOutput(journal, outcome.run_id, id, journalSteps, + onCompleted === undefined ? context : { ...context, onCompleted }, execution.report.status); }; const admissionKey = authoredChildAdmissionKey(rootRunId, id); const admit = async () => budget === undefined @@ -250,6 +252,11 @@ export function authoredWorkerRunner( `f.agent options.recoveryMode must be 'reset', 'inspect', or 'manual' (got ${JSON.stringify(options.recoveryMode)}).`, ); } + const resumeProblem = options.resume === undefined ? undefined + : agentResumeDeclarationError(options.resume) ?? agentResumeTransportError(options.resume, options.transport); + if (resumeProblem !== undefined) { + throw new AuthoredFlowExecutionError('agent_cli_unresolved', `f.agent options.resume: ${resumeProblem}.`); + } const cwdTransport = agentCwdTransportError(declaredCwd, options.transport); if (cwdTransport !== undefined) { throw new AuthoredFlowExecutionError('agent_cli_unresolved', `f.agent options.${cwdTransport}.`); @@ -263,6 +270,7 @@ export function authoredWorkerRunner( 'f.agent timeout requires direct transport and maxIterations 1.'); } } + let sessionId: string | undefined; const permissions = options.permissions; const permissionsSnapshot = permissions === undefined ? undefined : snapshotJsonValue(permissions, 'f.agent options.permissions') as unknown as PermissionsSpec; @@ -275,12 +283,13 @@ export function authoredWorkerRunner( ...(options.cli === undefined ? {} : { cli: options.cli }), ...(options.model === undefined ? {} : { model: options.model }), ...(declaredCwd === undefined ? {} : { cwd: declaredCwd }), + ...(options.resume === undefined ? {} : { resume: options.resume }), ...(options.transport === undefined ? {} : { transport: options.transport }), ...(options.maxIterations === undefined ? {} : { maxIterations: options.maxIterations }), ...(options.transportRetries === undefined ? {} : { transportRetries: options.transportRetries }), ...(options.recoveryMode === undefined ? {} : { recoveryMode: options.recoveryMode }), ...(verification === undefined ? {} : { verification }), - }); + }, (payload) => { sessionId = journaledSessionId(payload); }); if (typeof output !== 'object' || output === null || Array.isArray(output)) { throw new AuthoredFlowExecutionError('journal_protocol_violation', `step "${id}" produced a non-object output`); } @@ -294,7 +303,10 @@ export function authoredWorkerRunner( const journaled = 'artifacts' in output ? output.artifacts : undefined; const artifacts = Array.isArray(journaled) && journaled.every(entry => typeof entry === 'string') ? [...journaled] : []; - return { completionReason: 'success', summary: typeof stdout === 'string' ? stdout : JSON.stringify(output), artifacts }; + return { + completionReason: 'success', summary: typeof stdout === 'string' ? stdout : JSON.stringify(output), artifacts, + ...(sessionId === undefined ? {} : { sessionId }), + }; }, async llm(id: string, prompt: string, options?: LlmOptions, verification?: NamedGate): Promise { if (options !== undefined && (typeof options !== 'object' || options === null || options.output === undefined)) { @@ -411,3 +423,16 @@ function stepDetails( }; return Object.keys(details).length === 0 ? undefined : details; } + +/** + * The CLI session the worker journaled for an agent attempt + * (`trajectory_tail.transcript.result.session_id`). Evidence, so it is read on + * the JSON-answer path too, and offered only when it is a session id a later + * `resume` would accept (a bounded label never is one). + */ +function journaledSessionId(payload: Readonly>): string | undefined { + const field = (value: unknown, key: string): unknown => + value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record)[key] : undefined; + const id = field(field(field(payload.trajectory_tail, 'transcript'), 'result'), 'session_id'); + return typeof id === 'string' && agentResumeDeclarationError(id) === undefined ? id : undefined; +} diff --git a/packages/sdk/src/cli-adapter.ts b/packages/sdk/src/cli-adapter.ts index 80d6679b1..e45edf4d5 100644 --- a/packages/sdk/src/cli-adapter.ts +++ b/packages/sdk/src/cli-adapter.ts @@ -84,8 +84,14 @@ export function agentExecution( kind: CliAdapterKind, instruction: string, model?: string, + resume?: string, ): CliInvocation { - return registeredAdapters()[kind].buildAgentInvocation(instruction, model); + return registeredAdapters()[kind].buildAgentInvocation(instruction, model, resume); +} + +/** Whether this CLI can continue a recorded session (`f.agent` `resume`). */ +export function agentResumable(kind: CliAdapterKind): boolean { + return registeredAdapters()[kind].resumable === true; } /** Reuse the workspace-free model probe's provider flags for a real LLM call. */ diff --git a/packages/sdk/src/compile.ts b/packages/sdk/src/compile.ts index 6d84cd7b2..87f57b082 100644 --- a/packages/sdk/src/compile.ts +++ b/packages/sdk/src/compile.ts @@ -253,6 +253,7 @@ function compileStep(step: StepSpec): StepSpec { ...(s.cli !== undefined ? { cli: s.cli } : {}), ...(s.model !== undefined ? { model: s.model } : {}), ...(s.cwd !== undefined ? { cwd: s.cwd } : {}), + ...(s.resume !== undefined ? { resume: s.resume } : {}), ...(s.transport !== undefined ? { transport: s.transport } : {}), recoveryMode, ...(s.surfaces !== undefined ? { surfaces: s.surfaces } : {}), @@ -464,7 +465,7 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown { const unionKeys = [ 'id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory', 'requirements', 'input', 'command', 'timeout_ms', 'lease_ms', 'on_non_zero', 'prompt', 'model', 'cli', 'instruction', - 'cwd', 'recovery_mode', 'surfaces', 'permissions', 'transport', 'cli_identity', + 'cwd', 'resume', 'recovery_mode', 'surfaces', 'permissions', 'transport', 'cli_identity', ] as const; const step = requireKernelObject(value, unionKeys, at); if (step['cli_identity'] !== undefined) { @@ -479,7 +480,7 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown { : type === 'llm' ? ['prompt', 'model', 'cli', 'cli_identity'] as const : type === 'agent' - ? ['timeout_ms', 'instruction', 'cli', 'model', 'recovery_mode', 'surfaces', 'permissions', 'cwd', 'transport', 'cli_identity'] as const + ? ['timeout_ms', 'instruction', 'cli', 'model', 'recovery_mode', 'surfaces', 'permissions', 'cwd', 'resume', 'transport', 'cli_identity'] as const : []; assertKernelKeys(step, [...commonKeys, ...typeKeys], at); const transportRetries = step['retry'] === undefined @@ -516,7 +517,7 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown { instruction: step['instruction'], ...(step['timeout_ms'] === undefined ? {} : { timeoutMs: step['timeout_ms'] }), ...(step['recovery_mode'] !== undefined ? { recoveryMode: step['recovery_mode'] } : {}), - ...copyDefined(step, ['cli', 'model', 'surfaces', 'cwd', 'transport']), + ...copyDefined(step, ['cli', 'model', 'surfaces', 'cwd', 'resume', 'transport']), ...(step['permissions'] !== undefined ? { permissions: kernelPermissionsToAuthoring(step['permissions'], `${at}.permissions`) } : {}), @@ -691,6 +692,7 @@ function toKernelStep(step: StepSpec, cliIdentity?: string): ResolvedKernelStepS ...(step.cli !== undefined ? { cli: step.cli } : {}), ...(step.model !== undefined ? { model: step.model } : {}), ...(step.cwd !== undefined ? { cwd: step.cwd } : {}), + ...(step.resume !== undefined ? { resume: step.resume } : {}), ...(step.transport !== undefined ? { transport: step.transport } : {}), recovery_mode: step.recoveryMode ?? 'reset', }; diff --git a/packages/sdk/src/spec.ts b/packages/sdk/src/spec.ts index 51a0db928..acc7783b3 100644 --- a/packages/sdk/src/spec.ts +++ b/packages/sdk/src/spec.ts @@ -320,6 +320,14 @@ export interface AgentStepSpec extends BaseStepSpec { * resolve. See docs/SURFACE.md. */ cwd?: string; + /** + * A CLI session id to continue: the `AgentResult.sessionId` an earlier step + * reported. Claude and Codex resume it in place of starting a new session; + * any other CLI refuses the step. 1-200 of A-Z a-z 0-9 . _ : -, starting + * with a letter or digit. Not supported with `transport: 'relay'`, where the + * agent runs on a host whose session store this worker cannot see. + */ + resume?: string; /** * Dispatch transport (flows#385). `'direct'` (default) spawns the CLI as * a local subprocess. `'relay'` posts to agent-relay so the agent @@ -527,6 +535,8 @@ export interface KernelAgentStep extends KernelStepCommon { * decided by the worker, on the host that shares the agent's filesystem. */ cwd?: string; + /** CLI session id the worker resumes; the kernel checks its shape only. */ + resume?: string; /** * Dispatch transport (flows#385). Kernel passes through untouched; * the SDK worker selects between direct-spawn and agent-relay routing. diff --git a/packages/sdk/src/step-fields.ts b/packages/sdk/src/step-fields.ts index 79a1f3f79..01d8e2258 100644 --- a/packages/sdk/src/step-fields.ts +++ b/packages/sdk/src/step-fields.ts @@ -38,5 +38,5 @@ export const STEP_COMMON_FIELDS = [ export const STEP_FIELDS_BY_TYPE = { deterministic: ['command', 'timeoutMs', 'lease_ms', 'onNonZero'], llm: ['prompt', 'model', 'cli', 'output'], - agent: ['timeoutMs', 'instruction', 'agent', 'cli', 'model', 'cwd', 'transport', 'surfaces', 'recoveryMode', 'permissions', 'output'], + agent: ['timeoutMs', 'instruction', 'agent', 'cli', 'model', 'cwd', 'resume', 'transport', 'surfaces', 'recoveryMode', 'permissions', 'output'], } as const satisfies Record; diff --git a/packages/sdk/src/validate.ts b/packages/sdk/src/validate.ts index c68de0232..5458efd81 100644 --- a/packages/sdk/src/validate.ts +++ b/packages/sdk/src/validate.ts @@ -20,6 +20,7 @@ import { SPEC_SCHEMA_VERSION } from './spec.js'; import { validateOutputDeclaration } from './output-schema.js'; import { modelNameError } from './model-name.js'; import { agentCwdDeclarationError, agentCwdTransportError } from './agent-cwd.js'; +import { agentResumeDeclarationError, agentResumeTransportError } from './agent-resume.js'; import { unknownKeyErrors } from './unknown-keys.js'; import { stepDependencyErrors } from './step-dependencies.js'; import { inputBindingErrors } from './input-binding.js'; @@ -513,6 +514,7 @@ class Validator { this.validateCli(st.cli, at); this.validateModel(st.model, at); this.validateCwd(st, at); + this.validateResume(st, at); if (st.surfaces !== undefined) this.validateSurfaces(st.surfaces, `${at}.surfaces`); if (st.permissions !== undefined) this.validatePermissions(st.permissions, `${at}.permissions`); } @@ -540,6 +542,18 @@ class Validator { if (unsupported !== undefined) this.fail(`${at}.${unsupported}`); } + /** `resume` is a session id, checked lexically as the kernel checks it (`agent-resume.ts`). */ + private validateResume(st: AgentStepSpec, at: string): void { + if (st.resume === undefined) return; + const problem = agentResumeDeclarationError(st.resume); + if (problem !== undefined) { + this.fail(`${at}.resume: ${problem}`); + return; + } + const unsupported = agentResumeTransportError(st.resume, st.transport); + if (unsupported !== undefined) this.fail(`${at}.${unsupported}`); + } + private validateModel(model: unknown, at: string, required = false): void { // Rejecting the empty string matters: it would reach the CLI as // RELAYFLOW_MODEL='', which reads as "declared, and declared as diff --git a/packages/sdk/src/worker-cli.ts b/packages/sdk/src/worker-cli.ts index 3c7c0432a..d3e811063 100644 --- a/packages/sdk/src/worker-cli.ts +++ b/packages/sdk/src/worker-cli.ts @@ -18,6 +18,7 @@ import { childStop } from './child-stop.js'; import { reapOnExit } from './agent-reaper.js'; import { agentExecution, + agentResumable, llmExecution, cliAdapterKind, resolveCliModel, @@ -122,8 +123,10 @@ export async function runAgentCli( relayContext?: AgentRelayContext, processEnvironment: NodeJS.ProcessEnv = process.env, cliIdentity?: string, - // Trailing parameter preserves existing positional callers. + // Trailing parameters preserve existing positional callers. stepTimeoutMs?: number, + /** A recorded CLI session to continue (`f.agent` `resume`); agent mode only. */ + resume?: string, ): Promise { signal?.throwIfAborted(); if (signal !== undefined && process.platform === 'win32') { @@ -133,6 +136,11 @@ export async function runAgentCli( const effectiveModel = resolveCliModel(cliIdentity ?? cli, model); const argv0 = cliInvocationArgv0(cli, cliIdentity); + if (resume !== undefined && (mode !== 'agent' || transport === 'relay' || !agentResumable(kind))) { + // Running without the session would silently start a new conversation. + return { exit_code: null, stdout_tail: '', + stderr_tail: `resume is refused for CLI ${JSON.stringify(cli)} (${mode}, ${transport}): only a directly spawned Claude or Codex agent can continue a recorded session` }; + } if (mode === 'agent' && transport === 'relay') { return runViaAgentRelay(kind, instruction, wakeContext, effectiveModel, relayContext, cwd, signal); } @@ -193,7 +201,7 @@ export async function runAgentCli( // Where this attempt's journal is, so the agent can run `flows status` on // itself. Only a worker with a data dir knows; an ad-hoc spawn exports nothing. applyStepEnvironment(env, sidechannel); - const invocation = mode === 'llm' ? llmExecution(kind, instruction, effectiveModel) : agentExecution(kind, instruction, effectiveModel); + const invocation = mode === 'llm' ? llmExecution(kind, instruction, effectiveModel) : agentExecution(kind, instruction, effectiveModel, resume); if (stepTimeoutMs !== undefined) invocation.timeoutMs = stepTimeoutMs; diff --git a/packages/sdk/src/worker.ts b/packages/sdk/src/worker.ts index 4afb28317..348317f62 100644 --- a/packages/sdk/src/worker.ts +++ b/packages/sdk/src/worker.ts @@ -156,7 +156,7 @@ export class AgentWorker extends EventEmitter { spec.transport === 'relay' ? 'relay' : 'direct', { runId: dispatch.run_id, stepId: dispatch.step_id, idempotencyKey: dispatch.idempotency_key, dataDir: this.options.dataDir, resultSchema: spec.verification?.json_schema }, - this.options.environment, spec.cli_identity, spec.timeout_ms) + this.options.environment, spec.cli_identity, spec.timeout_ms, spec.resume) : Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'agent step has no declared CLI' })); const { result, usage } = workerSpend(completed, effectiveModel); const cost = reportedCost(completed, effectiveModel); diff --git a/packages/sdk/tests/agent-resume.test.ts b/packages/sdk/tests/agent-resume.test.ts new file mode 100644 index 000000000..498d81db0 --- /dev/null +++ b/packages/sdk/tests/agent-resume.test.ts @@ -0,0 +1,201 @@ +import { chmodSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import type { Server } from 'node:net'; +import { afterEach, describe, expect, it } from 'vitest'; +import { flow } from '@relayflows/surface'; +import { agentResumeDeclarationError, agentResumeTransportError } from '../src/agent-resume.js'; +import { buildTranscriptDigest } from '../src/agent-transcript.js'; +import { claudeAdapter } from '../src/adapters/claude.js'; +import { codexAdapter } from '../src/adapters/codex.js'; +import { agentResumable } from '../src/cli-adapter.js'; +import { compileYaml, toKernelSpec } from '../src/compile.js'; +import { executeAuthoredFlow } from '../src/authored-flow-executor.js'; +import { kernelToAuthoring, validateSpec } from '../src/index.js'; +import { JournalClient } from '../src/journal-client.js'; +import { runAgentCli } from '../src/worker-cli.js'; +import { sendOk, sendResult, sockPath, startLoopback } from './journal-client-loopback.js'; + +const SESSION = '1908508f-989e-4159-82b0-6a676c3d74d3'; +const cases: Array<{ name: string; resume: string; valid: boolean }> = + JSON.parse(readFileSync(new URL('../../../testdata/agent-resume-cases.json', import.meta.url), 'utf8')); + +describe('the resume declaration rule is the kernel rule', () => { + for (const testCase of cases) { + it(testCase.name, () => { + expect(agentResumeDeclarationError(testCase.resume) === undefined).toBe(testCase.valid); + }); + } + + it('refuses a non-string and refuses resume over the relay transport', () => { + expect(agentResumeDeclarationError(7)).toMatch(/session id/); + expect(agentResumeTransportError(SESSION, 'relay')).toMatch(/relay/); + expect(agentResumeTransportError(SESSION, 'direct')).toBeUndefined(); + }); +}); + +describe('declarative resume', () => { + const agent = (extra: Record) => ({ version: '0.1.0', steps: [{ id: 'a', type: 'agent', instruction: 'x', ...extra }] }); + + it('is accepted as a session id and refused otherwise, or over relay', () => { + expect(validateSpec(agent({ resume: SESSION })).ok).toBe(true); + expect(validateSpec(agent({ resume: '--dangerously-skip-permissions' })).errors.join(' ')).toContain('steps[0].resume'); + expect(validateSpec(agent({ resume: SESSION, transport: 'relay' })).errors.join(' ')).toContain('resume is not supported with transport "relay"'); + }); + + it('round-trips through the kernel dialect', () => { + const authored = compileYaml(`version: '0.1.0'\nsteps:\n - id: a\n type: agent\n instruction: x\n resume: ${SESSION}\n`); + expect(toKernelSpec(authored).steps[0]).toMatchObject({ resume: SESSION }); + expect(kernelToAuthoring(toKernelSpec(authored))).toEqual(authored); + }); +}); + +describe('the adapters continue a recorded session', () => { + it('claude passes --resume before the task operand, and nothing when not resuming', () => { + const args = claudeAdapter.buildAgentInvocation('fix it', 'm', SESSION).args; + expect(args.slice(0, 3)).toEqual(['-p', '--resume', SESSION]); + expect(args.slice(-2)).toEqual(['--', 'fix it']); + expect(claudeAdapter.buildAgentInvocation('fix it', 'm').args).not.toContain('--resume'); + }); + + it('codex runs exec resume with the same flags and the thread id before the task operand', () => { + const args = codexAdapter.buildAgentInvocation('fix it', 'm', SESSION).args; + expect(args.slice(0, 2)).toEqual(['exec', 'resume']); + expect(args).toContain('--dangerously-bypass-approvals-and-sandbox'); + expect(args.slice(-3)).toEqual([SESSION, '--', 'fix it']); + expect(codexAdapter.buildAgentInvocation('fix it', 'm').args[1]).toBe('--skip-git-repo-check'); + }); + + it('only claude and codex can resume', () => { + expect(agentResumable('claude')).toBe(true); + expect(agentResumable('codex')).toBe(true); + expect(agentResumable('relayflows-wrapper-v1')).toBe(false); + }); + + it('a step asking another CLI, or the relay, to resume is refused rather than run cold', async () => { + for (const [cli, transport] of [['./custom-wrapper', 'direct'], ['claude', 'relay']] as const) { + const result = await runAgentCli(cli, 'fix it', undefined, 'm', undefined, undefined, 'agent', undefined, undefined, + transport, undefined, process.env, undefined, undefined, SESSION); + expect(result.exit_code).toBeNull(); + expect(result.stderr_tail).toMatch(/resume is refused/); + } + }); +}); + +describe('the session a CLI ran as is recorded', () => { + it('codex: the thread.started id is the session id', () => { + const digest = buildTranscriptDigest([ + { type: 'thread.started', thread_id: SESSION }, + { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1 } }, + ], 'codex', { exit_code: 0, stdout_tail: '', stderr_tail: '' }, {}); + expect(digest.result).toMatchObject({ provider: 'codex', session_id: SESSION }); + }); +}); + +function setupProject(): string { + const root = mkdtempSync(join(tmpdir(), 'agent-resume-')); + const wrapper = join(root, 'adapter.mjs'); + writeFileSync(wrapper, `#!/usr/bin/env node +import { receiveWrapperRequest } from ${JSON.stringify(resolve('../../testdata/preflight/wrapper-session.mjs'))}; +if (process.argv[2] === 'auth') process.exit(0); +await receiveWrapperRequest(); +process.stdout.write('unused'); +`); + chmodSync(wrapper, 0o755); + writeFileSync(join(root, 'flows.json'), JSON.stringify({ cli: wrapper, models: ['test-model'] })); + writeFileSync(join(root, 'package.json'), '{"type":"module"}'); + symlinkSync(resolve('node_modules'), join(root, 'node_modules')); + return root; +} + +/** A fake kernel journaling what a worker journals: output plus the transcript evidence. */ +function startServer(sock: string, seen: Array>, output: Record, sessionId: string): Server { + let nextRun = 1; + const stepByRun = new Map(); + return startLoopback(sock, { + hello: ctx => sendOk(ctx), + 'run.start': (ctx, params) => { + const step = ((params.spec as Record)['steps'] as Record[])[0]!; + seen.push(step); + const runId = `agent-resume-run-${nextRun++}`; + stepByRun.set(runId, { id: step['id'] as string, type: step['type'] as string }); + sendResult(ctx, { run_id: runId, status: 'completed', completion_reason: 'success', completed_steps: 1 }); + }, + 'journal.read': (ctx, params) => { + const step = stepByRun.get(params.run_id as string)!; + sendResult(ctx, { entries: [{ + entry_type: 'step.completed', step_id: step.id, + payload: { + completionReason: 'success', disposition: 'step_done', + output: step.type === 'agent' ? output : { exit_code: 0, stdout_tail: '', stderr_tail: '' }, + ...(step.type === 'agent' ? { trajectory_tail: { transcript: { result: { provider: 'claude', session_id: sessionId } } } } : {}), + }, + }] }); + }, + }); +} + +describe('f.agent resume and sessionId', () => { + let server: Server | undefined; + let path: string | undefined; + let root: string | undefined; + afterEach(async () => { + if (server !== undefined) await new Promise(done => server!.close(() => done())); + if (path !== undefined) rmSync(path, { force: true }); + if (root !== undefined) rmSync(root, { recursive: true, force: true }); + server = undefined; path = undefined; root = undefined; + }); + + async function drive(output: Record, sessionId: string, body: Parameters[1]) { + root = setupProject(); + path = sockPath(); + const seen: Array> = []; + server = startServer(path, seen, output, sessionId); + const client = new JournalClient(path, { requestTimeoutMs: 2000 }); + await client.connect(); + await client.hello('agent-resume-test'); + try { + const result = await executeAuthoredFlow(flow('resume-test', body as never), client, undefined, { + flowPath: join(root, 'resume-test.flow.ts'), localAgentStream: 'test-stream', + }); + return { result, seen }; + } finally { + client.close(); + } + } + + it('carries resume into the kernel spec and reports the session the step ran as', async () => { + let reported: unknown; + const { result, seen } = await drive({ exit_code: 0, stdout_tail: 'done', stderr_tail: '' }, SESSION, async (f) => { + reported = (await f.agent('fix', { task: 'fix it', resume: SESSION })).sessionId; + f.done('success'); + }); + expect(result.completionReason).toBe('success'); + expect(seen.find(step => step['type'] === 'agent')).toMatchObject({ resume: SESSION }); + expect(reported).toBe(SESSION); + }); + + it('reports the session for an agent that answered with a JSON object too', async () => { + let reported: unknown; + await drive({ summary: 's', replies: [] }, SESSION, async (f) => { + reported = (await f.agent('fix', { task: 'fix it' })).sessionId; + f.done('success'); + }); + expect(reported).toBe(SESSION); + }); + + it('never offers a truncated label as a session id', async () => { + let reported: unknown = 'unset'; + await drive({ exit_code: 0, stdout_tail: 'done', stderr_tail: '' }, `${'a'.repeat(240)}…[16 bytes truncated]`, async (f) => { + reported = (await f.agent('fix', { task: 'fix it' })).sessionId; + f.done('success'); + }); + expect(reported).toBeUndefined(); + }); + + it('refuses an invalid resume before anything runs', async () => { + await expect(drive({ exit_code: 0, stdout_tail: '', stderr_tail: '' }, SESSION, async (f) => { + await f.agent('fix', { task: 'fix it', resume: '--yolo' }); + })).rejects.toThrow(/options\.resume: expected a CLI session id/); + }); +}); diff --git a/packages/sdk/tests/spec-parity.test.ts b/packages/sdk/tests/spec-parity.test.ts index 00a4779fe..f03830f57 100644 --- a/packages/sdk/tests/spec-parity.test.ts +++ b/packages/sdk/tests/spec-parity.test.ts @@ -27,7 +27,7 @@ function fixture(name: string): string { } describe('spec parity: one dialect at the SDK<->kernel boundary', () => { - for (const name of ['hello-deterministic', 'hello-ladder', 'hello-llm', 'hello-agent', 'step-memory', 'step-placement', 'agent-cwd', 'agent-timeout']) { + for (const name of ['hello-deterministic', 'hello-ladder', 'hello-llm', 'hello-agent', 'step-memory', 'step-placement', 'agent-cwd', 'agent-resume', 'agent-timeout']) { it(`compiles ${name} to the pinned canonical JSON`, () => { const yaml = fixture(`${name}.flow.yaml`); const canonical = compileYamlToCanonicalJson(yaml); diff --git a/packages/sdk/tests/verb-field-lint.test.ts b/packages/sdk/tests/verb-field-lint.test.ts index 88bb79a25..38c84f51f 100644 --- a/packages/sdk/tests/verb-field-lint.test.ts +++ b/packages/sdk/tests/verb-field-lint.test.ts @@ -81,6 +81,7 @@ const VERB_FIELD_VALUES: Record = { permissions: { accessPreset: 'readonly' }, output: { type: 'object' }, cwd: '/tmp/foreign-cwd', + resume: '1908508f-989e-4159-82b0-6a676c3d74d3', transport: 'direct', // Not `'fail'`: the default is normalized away on compile, so a default // value could pass the YAML round trip on a step type that never allows it. @@ -205,7 +206,7 @@ describe('closed per-verb step fields', () => { expect(STEP_FIELDS_BY_TYPE).toEqual({ deterministic: ['command', 'timeoutMs', 'lease_ms', 'onNonZero'], llm: ['prompt', 'model', 'cli', 'output'], - agent: ['timeoutMs', 'instruction', 'agent', 'cli', 'model', 'cwd', 'transport', 'surfaces', 'recoveryMode', 'permissions', 'output'], + agent: ['timeoutMs', 'instruction', 'agent', 'cli', 'model', 'cwd', 'resume', 'transport', 'surfaces', 'recoveryMode', 'permissions', 'output'], }); expect(CROSS_VERB_STEP_FIELDS.map(({ label }) => label).sort()).toEqual([ 'agent foreign command', @@ -221,6 +222,7 @@ describe('closed per-verb step fields', () => { 'deterministic foreign permissions', 'deterministic foreign prompt', 'deterministic foreign recoveryMode', + 'deterministic foreign resume', 'deterministic foreign surfaces', 'deterministic foreign transport', 'llm foreign agent', @@ -231,6 +233,7 @@ describe('closed per-verb step fields', () => { 'llm foreign onNonZero', 'llm foreign permissions', 'llm foreign recoveryMode', + 'llm foreign resume', 'llm foreign surfaces', 'llm foreign timeoutMs', 'llm foreign transport', diff --git a/packages/surface/src/context.ts b/packages/surface/src/context.ts index d5d392615..c80dd2d22 100644 --- a/packages/surface/src/context.ts +++ b/packages/surface/src/context.ts @@ -17,6 +17,12 @@ export interface AgentResult { * final message is a JSON object (that object is the output, unmodified). */ artifacts: string[]; + /** + * The CLI session this step ran as (Claude's session id, Codex's thread + * id), when the CLI reported one. Pass it as a later step's `resume` to + * continue the same conversation. + */ + sessionId?: string; } /** Per-step declarations: validated and recorded, not currently enforced (gate 8 / #442). @@ -52,6 +58,14 @@ export interface AgentOptions { * `transport: 'relay'`. See docs/SURFACE.md. */ cwd?: string; + /** + * Continue a CLI session instead of starting a new one: an earlier step's + * `AgentResult.sessionId`. Claude and Codex resume it from their own + * session store (`~/.claude`, `~/.codex`), so it works only where that + * store survives — such as a sandbox reused for the same pull request. + * Other CLIs refuse the step. Not supported with `transport: 'relay'`. + */ + resume?: string; /** * Dispatch transport (flows#385). `'direct'` (default) spawns the CLI as a * local subprocess. `'relay'` posts to agent-relay so the agent registers diff --git a/testdata/agent-resume-cases.json b/testdata/agent-resume-cases.json new file mode 100644 index 000000000..793450548 --- /dev/null +++ b/testdata/agent-resume-cases.json @@ -0,0 +1,72 @@ +[ + { + "name": "accept '1908508f-989e-4159-82b0-6a676c3d74d3'", + "resume": "1908508f-989e-4159-82b0-6a676c3d74d3", + "valid": true + }, + { + "name": "accept '019a2b3c-4d5e-7f80-9123-456789abcdef'", + "resume": "019a2b3c-4d5e-7f80-9123-456789abcdef", + "valid": true + }, + { + "name": "accept 'thread_abc.123:x'", + "resume": "thread_abc.123:x", + "valid": true + }, + { + "name": "accept 'a'", + "resume": "a", + "valid": true + }, + { + "name": "refuse ''", + "resume": "", + "valid": false + }, + { + "name": "refuse '-c'", + "resume": "-c", + "valid": false + }, + { + "name": "refuse '--dangerously-skip-permissions'", + "resume": "--dangerously-skip-permissions", + "valid": false + }, + { + "name": "refuse ' 1908508f'", + "resume": " 1908508f", + "valid": false + }, + { + "name": "refuse '1908508f '", + "resume": "1908508f ", + "valid": false + }, + { + "name": "refuse 'abc/def'", + "resume": "abc/def", + "valid": false + }, + { + "name": "refuse 'abc def'", + "resume": "abc def", + "valid": false + }, + { + "name": "refuse 'abc\\x00'", + "resume": "abc\u0000", + "valid": false + }, + { + "name": "refuse 'é1908508f'", + "resume": "é1908508f", + "valid": false + }, + { + "name": "refuse 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'", + "resume": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "valid": false + } +] diff --git a/testdata/agent-resume.flow.yaml b/testdata/agent-resume.flow.yaml new file mode 100644 index 000000000..2feb54b0d --- /dev/null +++ b/testdata/agent-resume.flow.yaml @@ -0,0 +1,19 @@ +# One agent continues the CLI session an earlier step recorded. `resume` is +# declared on one agent step only and survives the whole boundary — SDK +# authoring -> compiler -> canonical JSON -> kernel parse -> kernel +# re-serialize -> the same sha256 — and is absent from the step that does not +# declare it. +version: '0.1.0' +name: agent-resume +description: A second wake continues the first wake's CLI session. +steps: + - id: first-wake + type: agent + cli: claude + instruction: "Address the review feedback." + - id: second-wake + type: agent + cli: claude + dependsOn: [first-wake] + instruction: "Address the new review feedback." + resume: 1908508f-989e-4159-82b0-6a676c3d74d3 diff --git a/testdata/agent-resume.spec.canonical.json b/testdata/agent-resume.spec.canonical.json new file mode 100644 index 000000000..9121ffade --- /dev/null +++ b/testdata/agent-resume.spec.canonical.json @@ -0,0 +1 @@ +{"description":"A second wake continues the first wake's CLI session.","name":"agent-resume","steps":[{"cli":"claude","depends_on":[],"id":"first-wake","instruction":"Address the review feedback.","max_iterations":1,"recovery_mode":"reset","retry":{"initial_backoff_ms":100,"jitter_percent":20,"max_backoff_ms":60000,"multiplier":2},"type":"agent","verification":{}},{"cli":"claude","depends_on":["first-wake"],"id":"second-wake","instruction":"Address the new review feedback.","max_iterations":1,"recovery_mode":"reset","resume":"1908508f-989e-4159-82b0-6a676c3d74d3","retry":{"initial_backoff_ms":100,"jitter_percent":20,"max_backoff_ms":60000,"multiplier":2},"type":"agent","verification":{}}],"version":"0.1.0"} diff --git a/testdata/agent-resume.spec.sha256 b/testdata/agent-resume.spec.sha256 new file mode 100644 index 000000000..c352dd7d8 --- /dev/null +++ b/testdata/agent-resume.spec.sha256 @@ -0,0 +1 @@ +e47ba00d0fd7bdf6e136456670028c2f53f1b3df320ac374bba284acd1c0987c