From dafb81be7bf6dd2e60374838d61425a251787e42 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:35:39 +0800 Subject: [PATCH 01/57] subagent: implement structured output for in-process backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seam vocabulary (SubagentStartRequest.outputSchema, SubagentResult .structured) existed but no in-process backend honored it — spawn/fork advertised outputSchema: false. This lands the missing half: - dsh-tools gains a structured-output JSON Schema subset (json-schema.ts): StructuredOutputSchema, assertSupportedOutputSchema (rejects loud outside the enforced subset, every violation listed), validateStructuredValue (path-qualified issues, total). outputSchema's seam type becomes this raw JSON-Schema subset instead of the author-facing SchemaSpec DSL — the schema travels verbatim to the model as a forced tool's parameters. - dsh-subagent-inprocess gains the shared structured runtime: one global structured_output capture tool (placeholder parameters) + a prepend:true agent/request listener doing FINAL-REQUEST enforcement (strip for plain agents, per-run schema for structured children — survives downstream request-replacing listeners) + an agent/turn-continuation veto that stops a child's turn once captured (no wasted extra model step). Lifetime is refcounted by backends (plugin lifetime) AND live runs (start→settle). - startInProcessRun drives the capture: subset asserted before the child exists, instruction appended to the child's system prompt, clean-finish nudge loop (structuredNudgeRetries, backend Config, default 1), captured value on result.structured; a clean finish without a capture settles 'error' (never a silent success with a missing field). - spawn + fork flip outputSchema: true and inject 'tools'. --- docs/cordis-catalog/events.md | 6 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/subagent.md | 4 +- docs/module-graph.md | 3 +- packages/core/tools/README.md | 6 + packages/core/tools/src/index.ts | 10 + packages/core/tools/src/json-schema.ts | 322 ++++++++++++++ packages/core/tools/tests/json-schema.spec.ts | 254 +++++++++++ packages/subagent/subagent-fork/README.md | 3 +- packages/subagent/subagent-fork/src/index.ts | 35 +- .../tests/multi-subagent.spec.ts | 4 +- .../subagent-fork/tests/subagent-fork.spec.ts | 16 +- .../subagent/subagent-inprocess/README.md | 21 +- .../subagent/subagent-inprocess/package.json | 1 + .../subagent/subagent-inprocess/src/index.ts | 92 +++- .../subagent-inprocess/src/structured.ts | 193 +++++++++ .../tests/structured.spec.ts | 393 ++++++++++++++++++ .../tests/subagent-inprocess.spec.ts | 6 +- .../subagent/subagent-inprocess/tsconfig.json | 3 + packages/subagent/subagent-spawn/README.md | 5 +- packages/subagent/subagent-spawn/src/index.ts | 48 ++- .../subagent/subagent-spawn/tests/harness.ts | 2 +- .../tests/subagent-spawn.spec.ts | 16 +- packages/subagent/subagent/src/types.ts | 14 +- .../subagent/subagent/tests/service.spec.ts | 4 +- .../subagent-mock/tests/subagent-mock.spec.ts | 4 +- 26 files changed, 1402 insertions(+), 65 deletions(-) create mode 100644 packages/core/tools/src/json-schema.ts create mode 100644 packages/core/tools/tests/json-schema.spec.ts create mode 100644 packages/subagent/subagent-inprocess/src/structured.ts create mode 100644 packages/subagent/subagent-inprocess/tests/structured.spec.ts diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5fbcecc4eb..c85b459069 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -287,7 +287,7 @@ A tool was registered or unregistered (the available tool set changed). 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:87`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:97`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall @@ -299,7 +299,7 @@ Waterfall AFTER a tool runs — where hook plugins inspect the result and accept Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:82`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:92`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall @@ -311,7 +311,7 @@ Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook pl Types: [ToolExecution](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:66`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:76`](../../packages/core/tools/src/index.ts) ## Inherited events (cordis core + loader/hmr/timer) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0e1833fd9f..951e182000 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -203,7 +203,7 @@ async execute(exec: ToolExecution): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:268`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:278`](../../packages/core/tools/src/index.ts) ## `ctx.web` — `WebService` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 32b5224773..8747f76566 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -20,7 +20,7 @@ interface SubagentCapabilities { ## The start request -What a caller asks for when starting a subagent. The tool layer builds this from the model's `{ description, prompt }` plus its own config; the service validates the start-time capabilities against the named provider, then passes it to `provider.start`. `parent` is REQUIRED — in-process backends read `parent.session.header` for the working directory, the `parentSession` lineage, and the delegation depth. The three optional fields (`outputSchema`, `maxDepth`, `toolFilter`) each gate on the matching `SubagentCapabilities` flag. +What a caller asks for when starting a subagent. The tool layer builds this from the model's `{ description, prompt }` plus its own config; the service validates the start-time capabilities against the named provider, then passes it to `provider.start`. `parent` is REQUIRED — in-process backends read `parent.session.header` for the working directory, the `parentSession` lineage, and the delegation depth. The three optional fields (`outputSchema`, `maxDepth`, `toolFilter`) each gate on the matching `SubagentCapabilities` flag. `outputSchema` is an object-rooted JSON Schema within the subset `assertSupportedOutputSchema` (dsh-tools) enforces — a schema outside it is rejected loud at start; the in-process backends realize it with a forced `structured_output` capture tool (see the [driver README](../../packages/subagent/subagent-inprocess/README.md)). ```ts type-equiv interface SubagentStartRequest { @@ -28,7 +28,7 @@ interface SubagentStartRequest { parent: Agent signal?: AbortSignal agentOptions?: AgentOptions - outputSchema?: SchemaSpec + outputSchema?: StructuredOutputSchema maxDepth?: number toolFilter?: { allow?: string[]; deny?: string[] } } diff --git a/docs/module-graph.md b/docs/module-graph.md index 50ef532fa4..e8f1edfaa5 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -104,6 +104,7 @@ graph TD subagent-inprocess --> llm subagent-inprocess --> session subagent-inprocess --> subagent + subagent-inprocess --> tools subagent-mock --> agent subagent-mock --> llm subagent-mock --> subagent @@ -169,7 +170,7 @@ graph TD | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | | `hooks-claude` | `agent`, `hook-protocol`, `llm`, `session`, `subagent`, `tools` | | `subagent-acp` | `agent`, `llm`, `subagent` | -| `subagent-inprocess` | `agent`, `llm`, `session`, `subagent` | +| `subagent-inprocess` | `agent`, `llm`, `session`, `subagent`, `tools` | | `subagent-mock` | `agent`, `llm`, `subagent` | | `tool-subagent` | `agent`, `llm`, `subagent`, `tools` | | `acp-agent` | `acp`, `agent-core`, `app-boot`, `session-persistence-jsonl` | diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 65039aea58..d43f24e123 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -71,6 +71,12 @@ A `defineTool` tool also **validates the model-generated arguments against its ` See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details. +### Structured-output schema subset + +A separate vocabulary for callers that DEMAND a machine-readable value from an agent — the subagent seam's `SubagentStartRequest.outputSchema` (and, by extension, a workflow's `agent({ schema })`). Unlike `SchemaSpec` (the author-facing DSL for tool parameters), a `StructuredOutputSchema` is an object-rooted **raw JSON Schema subset** as data: it travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated against it. + +The subset is deliberately narrow and REJECTS LOUD outside it — accepting a keyword the validator doesn't enforce would validate less than the schema promises (accepted-then-ignored). Supported: single-string `type` (`object`/`array`/`string`/`number`/`integer`/`boolean`/`null`; type arrays rejected), `properties`/`required`/`additionalProperties` (boolean; every `required` key must be declared), `items`, scalar-only `enum`/`const`; annotations (`description`/`title`/`default`/`examples`) are ignored but must still be JSON data. `assertSupportedOutputSchema(schema)` throws `OutputSchemaError` (`code: 'UNSUPPORTED_SCHEMA'`, listing every violation) for anything else; `validateStructuredValue(schema, value)` returns path-qualified violations (empty = valid, total — never throws). + ### Tool-owned UI presentation A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods that return a **`card`-tagged render intent** (a discriminated union — a tool declares its card kind once and a UI bridge switches on `card`): diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index dd0ed918db..39dafd6f1a 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -28,6 +28,16 @@ export { type JsonSchemaObject, } from './schema.ts' +export { + assertSupportedOutputSchema, + validateStructuredValue, + OutputSchemaError, + type StructuredOutputSchema, + type StructuredSchemaNode, + type StructuredSchemaType, + type StructuredScalar, +} from './json-schema.ts' + // The render-intent vocabulary a tool declares via `presentCall`/`presentResult` // lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools` // stays the single public surface for consumers (producers + the ACP bridge). diff --git a/packages/core/tools/src/json-schema.ts b/packages/core/tools/src/json-schema.ts new file mode 100644 index 0000000000..35eeb240a4 --- /dev/null +++ b/packages/core/tools/src/json-schema.ts @@ -0,0 +1,322 @@ +/** + * Structured-output JSON Schema subset: the vocabulary a caller uses to demand + * a machine-readable result from a subagent (`SubagentStartRequest.outputSchema`) + * or a workflow `agent()` call. + * + * This is deliberately NOT full JSON Schema. The schema travels verbatim to the + * model as a forced tool's `parameters`, and the value the model produces is + * validated here — so every accepted keyword must be one this module actually + * enforces. Accepting a keyword we don't enforce would validate less than the + * schema promises (accepted-then-ignored), so anything outside the subset is + * REJECTED LOUD by {@link assertSupportedOutputSchema} instead. The subset: + * + * - `type` — a single string (`object`/`array`/`string`/`number`/`integer`/ + * `boolean`/`null`); type ARRAYS (`["string","null"]`) are rejected. + * - `properties`/`required`/`additionalProperties` (boolean) on objects; every + * `required` key must be declared in `properties`. `additionalProperties` + * absent keeps standard JSON Schema semantics (extra keys allowed). + * - `items` on arrays (absent ⇒ any JSON items). + * - `enum` (non-empty, scalars only) and `const` (scalar) on scalar types. + * - Annotations `description`/`title`/`default`/`examples` are allowed and + * ignored (they constrain nothing), except that they must still be JSON data + * — the schema is serialized onto the wire, so a non-JSON annotation would be + * silently mangled. + * + * Values checked by {@link validateStructuredValue} are expected to be plain + * host-realm JSON data (model tool-call arguments are parsed wire JSON; a + * caller holding foreign-realm data materializes it first). + * + * @module dsh-tools/json-schema + */ + +import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' + +/** The scalar values `enum`/`const` may carry (finite numbers only). */ +export type StructuredScalar = string | number | boolean | null + +/** The `type` keywords the subset accepts. */ +export type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' + +/** + * One node of the structured-output schema subset. Recursive via `properties` + * and `items`; see the module doc for the exact keyword semantics. + */ +export interface StructuredSchemaNode { + type: StructuredSchemaType + /** Nested property schemas (`type: 'object'` only). */ + properties?: Record + /** Required property names; each must appear in `properties`. */ + required?: string[] + /** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */ + additionalProperties?: boolean + /** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */ + items?: StructuredSchemaNode + /** Allowed values (scalar types only). */ + enum?: StructuredScalar[] + /** The single allowed value (scalar types only). */ + const?: StructuredScalar + /** Annotation, ignored for validation. */ + description?: string + /** Annotation, ignored for validation. */ + title?: string + /** Annotation, ignored for validation (must still be JSON data). */ + default?: unknown + /** Annotation, ignored for validation (must still be JSON data). */ + examples?: unknown +} + +/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */ +export type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' } + +/** + * Thrown by {@link assertSupportedOutputSchema} when a schema falls outside the + * supported subset. Extends {@link HarnessError} (`code: 'UNSUPPORTED_SCHEMA'`) + * so seam code and tool results can route on it; `violations` lists every + * offending path, not just the first. + */ +export class OutputSchemaError extends HarnessError { + /** The individual violation messages, in walk order. */ + readonly violations: string[] + + constructor(violations: string[]) { + super(`unsupported output schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA') + this.name = 'OutputSchemaError' + this.violations = violations + } +} + +/** The keywords the subset accepts, checked (`constraint`) or ignored (`annotation`). */ +const CONSTRAINT_KEYWORDS = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const']) +const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples']) + +const SCHEMA_TYPES: readonly StructuredSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'] + +/** Whether a value is a non-null, non-array object (structural, realm-agnostic). */ +function isObjectLike(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Whether a value is a supported scalar (`enum`/`const` member): string, finite number, boolean, or null. */ +function isStructuredScalar(value: unknown): value is StructuredScalar { + return value === null || typeof value === 'string' || typeof value === 'boolean' + || (typeof value === 'number' && Number.isFinite(value)) +} + +/** + * Whether a value is JSON data (annotation payloads only): scalars, arrays, and + * object-likes of such values. Realm-agnostic on purpose (no prototype check) — + * the schema may have been materialized from another realm; structural JSON-ness + * is what the wire needs. Cycles are rejected via `seen`. + */ +function isJsonData(value: unknown, seen: Set): boolean { + if (isStructuredScalar(value)) return true + // The scalar check above already returned for null, so `object` here is a real object. + if (typeof value !== 'object') return false + if (seen.has(value)) return false + seen.add(value) + try { + if (Array.isArray(value)) return value.every(entry => isJsonData(entry, seen)) + return Object.values(value).every(entry => isJsonData(entry, seen)) + } finally { + seen.delete(value) + } +} + +/** Collect subset violations for one schema node (recursive walk). */ +function checkSchemaNode(node: unknown, path: string, violations: string[], seen: Set): void { + if (!isObjectLike(node)) { + violations.push(`${path} must be a schema object`) + return + } + if (seen.has(node)) { + violations.push(`${path} is circular`) + return + } + seen.add(node) + + for (const key of Object.keys(node)) { + if (CONSTRAINT_KEYWORDS.has(key)) continue + if (ANNOTATION_KEYWORDS.has(key)) { + if (!isJsonData(node[key], new Set())) violations.push(`${path}.${key} annotation must be JSON data`) + continue + } + violations.push(`${path}.${key} is not a supported keyword (subset: type/properties/required/additionalProperties/items/enum/const + annotations)`) + } + if (typeof node.description !== 'undefined' && typeof node.description !== 'string') { + violations.push(`${path}.description must be a string`) + } + if (typeof node.title !== 'undefined' && typeof node.title !== 'string') { + violations.push(`${path}.title must be a string`) + } + + const type = node.type + if (typeof type !== 'string' || !(SCHEMA_TYPES as readonly unknown[]).includes(type)) { + violations.push(Array.isArray(type) + ? `${path}.type must be a single type string (type arrays are not supported)` + : `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`) + seen.delete(node) + return + } + const schemaType = type as StructuredSchemaType + + // Keywords that only make sense on one type are rejected elsewhere — an + // `items` on an object (or `properties` on a string) is a schema-author bug + // the subset surfaces rather than ignores. + const allowedFor: Record = { + properties: ['object'], + required: ['object'], + additionalProperties: ['object'], + items: ['array'], + enum: ['string', 'number', 'integer', 'boolean', 'null'], + const: ['string', 'number', 'integer', 'boolean', 'null'], + } + for (const [key, types] of Object.entries(allowedFor)) { + if (key in node && !types.includes(schemaType)) { + violations.push(`${path}.${key} is not supported on type "${schemaType}"`) + } + } + + switch (schemaType) { + case 'object': { + const properties = node.properties + if (properties !== undefined) { + if (!isObjectLike(properties)) { + violations.push(`${path}.properties must be an object of schemas`) + } else { + for (const [key, child] of Object.entries(properties)) { + checkSchemaNode(child, `${path}.properties.${key}`, violations, seen) + } + } + } + const required = node.required + if (required !== undefined) { + if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) { + violations.push(`${path}.required must be an array of strings`) + } else { + const declared = isObjectLike(properties) ? properties : {} + for (const key of required) { + if (!(key in declared)) violations.push(`${path}.required names "${key}" which is not in properties`) + } + } + } + if (node.additionalProperties !== undefined && typeof node.additionalProperties !== 'boolean') { + violations.push(`${path}.additionalProperties must be a boolean`) + } + break + } + case 'array': { + if (node.items !== undefined) checkSchemaNode(node.items, `${path}.items`, violations, seen) + break + } + case 'string': + case 'number': + case 'integer': + case 'boolean': + case 'null': { + const allowed = node.enum + if (allowed !== undefined) { + if (!Array.isArray(allowed) || allowed.length === 0 || !allowed.every(entry => isStructuredScalar(entry))) { + violations.push(`${path}.enum must be a non-empty array of scalars`) + } + } + if ('const' in node && !isStructuredScalar(node.const)) { + violations.push(`${path}.const must be a scalar`) + } + break + } + /* v8 ignore start -- defensive: schemaType was membership-checked against SCHEMA_TYPES above, so no runtime value reaches here */ + default: + assertNever(schemaType, 'assertSupportedOutputSchema') + /* v8 ignore stop */ + } + + seen.delete(node) +} + +/** + * Assert `schema` is a supported {@link StructuredOutputSchema} — object-rooted + * and entirely within the enforced subset. Throws {@link OutputSchemaError} + * (`UNSUPPORTED_SCHEMA`) listing EVERY violation; returns (and narrows) on + * success. Call this at the seam boundary, before any child is created. + * @param schema - the caller-supplied schema (unknown until asserted). + */ +export function assertSupportedOutputSchema(schema: unknown): asserts schema is StructuredOutputSchema { + const violations: string[] = [] + checkSchemaNode(schema, 'schema', violations, new Set()) + if (violations.length === 0 && (schema as StructuredSchemaNode).type !== 'object') { + violations.push('schema.type must be "object" (structured output is object-rooted)') + } + if (violations.length > 0) throw new OutputSchemaError(violations) +} + +/** Collect violations for one value against an (already asserted) schema node. */ +function checkValue(node: StructuredSchemaNode, value: unknown, path: string): string[] { + switch (node.type) { + case 'object': { + if (!isObjectLike(value)) return [`"${path}" must be an object`] + const violations: string[] = [] + const properties = node.properties ?? {} + for (const key of node.required ?? []) { + if (value[key] === undefined) violations.push(`missing required property "${path}.${key}"`) + } + for (const [key, child] of Object.entries(properties)) { + if (value[key] === undefined) continue + violations.push(...checkValue(child, value[key], `${path}.${key}`)) + } + if (node.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!(key in properties)) violations.push(`"${path}.${key}" is not a declared property (additionalProperties: false)`) + } + } + return violations + } + case 'array': { + if (!Array.isArray(value)) return [`"${path}" must be an array`] + if (!node.items) return [] + const items = node.items + return value.flatMap((entry, index) => checkValue(items, entry, `${path}[${index}]`)) + } + case 'string': { + if (typeof value !== 'string') return [`"${path}" must be a string`] + break + } + case 'number': { + if (typeof value !== 'number' || !Number.isFinite(value)) return [`"${path}" must be a finite number`] + break + } + case 'integer': { + if (typeof value !== 'number' || !Number.isInteger(value)) return [`"${path}" must be an integer`] + break + } + case 'boolean': { + if (typeof value !== 'boolean') return [`"${path}" must be a boolean`] + break + } + case 'null': { + if (value !== null) return [`"${path}" must be null`] + break + } + default: + return assertNever(node.type, 'validateStructuredValue') + } + // Scalar constraint checks, shared by every scalar branch above. + if (node.enum && !node.enum.includes(value)) { + return [`"${path}" must be one of ${JSON.stringify(node.enum)}`] + } + if ('const' in node && value !== node.const) { + return [`"${path}" must be ${JSON.stringify(node.const)}`] + } + return [] +} + +/** + * Validate a value against an (already {@link assertSupportedOutputSchema}- + * asserted) schema. Returns human-readable, path-qualified violation messages + * — empty means valid. Total: never throws, however malformed the value. + * @param schema - the asserted schema to check against. + * @param value - the candidate value (e.g. parsed tool-call arguments). + * @returns every violation found, in walk order (empty = valid). + */ +export function validateStructuredValue(schema: StructuredOutputSchema, value: unknown): string[] { + return checkValue(schema, value, 'value') +} diff --git a/packages/core/tools/tests/json-schema.spec.ts b/packages/core/tools/tests/json-schema.spec.ts new file mode 100644 index 0000000000..e7635b06f3 --- /dev/null +++ b/packages/core/tools/tests/json-schema.spec.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest' +import { + assertSupportedOutputSchema, + OutputSchemaError, + validateStructuredValue, + type StructuredOutputSchema, +} from '../src/json-schema.ts' + +/** Assert-and-narrow helper: the asserted schema, typed. */ +function asserted(schema: unknown): StructuredOutputSchema { + assertSupportedOutputSchema(schema) + return schema +} + +/** The violations OutputSchemaError carries for a bad schema (throws if it passes). */ +function violationsOf(schema: unknown): string[] { + try { + assertSupportedOutputSchema(schema) + } catch (error: unknown) { + if (error instanceof OutputSchemaError) return error.violations + throw error + } + throw new Error('expected the schema to be rejected') +} + +describe('assertSupportedOutputSchema', () => { + it('accepts a representative subset schema (all supported keywords)', () => { + const schema = asserted({ + type: 'object', + description: 'a finding', + title: 'Finding', + properties: { + file: { type: 'string', description: 'path' }, + line: { type: 'integer' }, + severity: { type: 'string', enum: ['low', 'high'] }, + kind: { type: 'string', const: 'bug' }, + score: { type: 'number' }, + confirmed: { type: 'boolean' }, + parent: { type: 'null' }, + tags: { type: 'array', items: { type: 'string' } }, + nested: { + type: 'object', + properties: { x: { type: 'number', default: 3, examples: [1, 2] } }, + additionalProperties: false, + }, + anything: { type: 'array' }, + }, + required: ['file', 'line'], + additionalProperties: true, + }) + expect(schema.type).toBe('object') + }) + + it('rejects a non-object root (scalar/array-rooted schemas)', () => { + expect(violationsOf({ type: 'string' })).toEqual(['schema.type must be "object" (structured output is object-rooted)']) + expect(violationsOf({ type: 'array', items: { type: 'string' } })) + .toContain('schema.type must be "object" (structured output is object-rooted)') + }) + + it('rejects non-object schema nodes and missing/unknown type', () => { + expect(violationsOf('nope')).toEqual(['schema must be a schema object']) + expect(violationsOf(null)).toEqual(['schema must be a schema object']) + expect(violationsOf([])).toEqual(['schema must be a schema object']) + expect(violationsOf({})).toEqual(['schema.type must be one of object/array/string/number/integer/boolean/null']) + expect(violationsOf({ type: 'tuple' })[0]).toMatch(/type must be one of/) + expect(violationsOf({ type: 'object', properties: { a: 'str' } })).toEqual(['schema.properties.a must be a schema object']) + }) + + it('rejects type ARRAYS with a dedicated message', () => { + expect(violationsOf({ type: ['string', 'null'] })) + .toEqual(['schema.type must be a single type string (type arrays are not supported)']) + }) + + it('rejects unsupported constraint keywords loudly (never accepted-then-ignored)', () => { + for (const keyword of ['oneOf', 'anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) { + const bad = violationsOf({ type: 'object', [keyword]: [] }) + expect(bad.some(v => v.includes(`schema.${keyword} is not a supported keyword`))).toBe(true) + } + }) + + it('reports EVERY violation, not just the first', () => { + const bad = violationsOf({ + type: 'object', + pattern: 'x', + properties: { a: { type: 'weird' }, b: { type: 'string', minimum: 1 } }, + }) + expect(bad.length).toBe(3) + }) + + it('rejects keywords on the wrong type (items on object, properties on string, enum on object)', () => { + expect(violationsOf({ type: 'object', items: { type: 'string' } })) + .toEqual(['schema.items is not supported on type "object"']) + expect(violationsOf({ type: 'object', properties: { a: { type: 'string', properties: {} } } })) + .toEqual(['schema.properties.a.properties is not supported on type "string"']) + expect(violationsOf({ type: 'object', enum: [1] })) + .toEqual(['schema.enum is not supported on type "object"']) + expect(violationsOf({ type: 'object', properties: { a: { type: 'array', const: 1 } } })) + .toEqual(['schema.properties.a.const is not supported on type "array"']) + }) + + it('validates required: must be string[] naming declared properties', () => { + expect(violationsOf({ type: 'object', required: 'file' })) + .toEqual(['schema.required must be an array of strings']) + expect(violationsOf({ type: 'object', required: [1] })) + .toEqual(['schema.required must be an array of strings']) + expect(violationsOf({ type: 'object', properties: { a: { type: 'string' } }, required: ['b'] })) + .toEqual(['schema.required names "b" which is not in properties']) + expect(violationsOf({ type: 'object', required: ['a'] })) + .toEqual(['schema.required names "a" which is not in properties']) + }) + + it('validates additionalProperties must be boolean and enum/const must be scalars', () => { + expect(violationsOf({ type: 'object', additionalProperties: {} })) + .toEqual(['schema.additionalProperties must be a boolean']) + expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [] } } })) + .toEqual(['schema.properties.a.enum must be a non-empty array of scalars']) + expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [{}] } } })) + .toEqual(['schema.properties.a.enum must be a non-empty array of scalars']) + expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: 'x' } } })) + .toEqual(['schema.properties.a.enum must be a non-empty array of scalars']) + expect(violationsOf({ type: 'object', properties: { a: { type: 'number', enum: [Number.NaN] } } })) + .toEqual(['schema.properties.a.enum must be a non-empty array of scalars']) + expect(violationsOf({ type: 'object', properties: { a: { type: 'string', const: {} } } })) + .toEqual(['schema.properties.a.const must be a scalar']) + }) + + it('rejects non-string description/title and non-JSON annotation payloads', () => { + expect(violationsOf({ type: 'object', description: 7 })) + .toEqual(['schema.description must be a string']) + expect(violationsOf({ type: 'object', title: 7 })) + .toEqual(['schema.title must be a string']) + expect(violationsOf({ type: 'object', default: () => 1 })) + .toEqual(['schema.default annotation must be JSON data']) + expect(violationsOf({ type: 'object', examples: [undefined] })) + .toEqual(['schema.examples annotation must be JSON data']) + expect(violationsOf({ type: 'object', examples: [Number.POSITIVE_INFINITY] })) + .toEqual(['schema.examples annotation must be JSON data']) + // A cyclic annotation payload is caught by the JSON-data walk. + const cyclicAnnotation: Record = {} + cyclicAnnotation.self = cyclicAnnotation + expect(violationsOf({ type: 'object', default: cyclicAnnotation })) + .toEqual(['schema.default annotation must be JSON data']) + // Object/array annotations that ARE JSON data pass. + asserted({ type: 'object', default: { a: [1, 'x', null, true] } }) + }) + + it('rejects a circular schema instead of recursing forever', () => { + const node: Record = { type: 'object' } + node.properties = { self: node } + expect(violationsOf(node)).toEqual(['schema.properties.self is circular']) + }) + + it('accepts the same subschema object reused in two SIBLING positions (a DAG, not a cycle)', () => { + const leaf = { type: 'string' } + asserted({ type: 'object', properties: { a: leaf, b: leaf } }) + }) +}) + +describe('validateStructuredValue', () => { + const schema = asserted({ + type: 'object', + properties: { + file: { type: 'string' }, + line: { type: 'integer' }, + score: { type: 'number' }, + confirmed: { type: 'boolean' }, + parent: { type: 'null' }, + severity: { type: 'string', enum: ['low', 'high'] }, + kind: { type: 'string', const: 'bug' }, + tags: { type: 'array', items: { type: 'string' } }, + free: { type: 'array' }, + nested: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'], additionalProperties: false }, + }, + required: ['file'], + }) + + it('accepts a fully valid value (empty violations)', () => { + expect(validateStructuredValue(schema, { + file: 'a.ts', line: 3, score: 0.5, confirmed: true, parent: null, + severity: 'high', kind: 'bug', tags: ['x'], free: [1, { any: true }], nested: { x: 1 }, + })).toEqual([]) + }) + + it('reports missing required and wrong root type', () => { + expect(validateStructuredValue(schema, {})).toEqual(['missing required property "value.file"']) + expect(validateStructuredValue(schema, 'nope')).toEqual(['"value" must be an object']) + expect(validateStructuredValue(schema, [])).toEqual(['"value" must be an object']) + }) + + it('type-checks every scalar branch with path-qualified messages', () => { + expect(validateStructuredValue(schema, { file: 1 })).toEqual(['"value.file" must be a string']) + expect(validateStructuredValue(schema, { file: 'a', line: 1.5 })).toEqual(['"value.line" must be an integer']) + expect(validateStructuredValue(schema, { file: 'a', line: 'x' })).toEqual(['"value.line" must be an integer']) + expect(validateStructuredValue(schema, { file: 'a', score: 'x' })).toEqual(['"value.score" must be a finite number']) + expect(validateStructuredValue(schema, { file: 'a', score: Number.NaN })).toEqual(['"value.score" must be a finite number']) + expect(validateStructuredValue(schema, { file: 'a', confirmed: 'yes' })).toEqual(['"value.confirmed" must be a boolean']) + expect(validateStructuredValue(schema, { file: 'a', parent: 0 })).toEqual(['"value.parent" must be null']) + }) + + it('enforces enum membership and const equality', () => { + expect(validateStructuredValue(schema, { file: 'a', severity: 'mid' })) + .toEqual(['"value.severity" must be one of ["low","high"]']) + expect(validateStructuredValue(schema, { file: 'a', kind: 'feature' })) + .toEqual(['"value.kind" must be "bug"']) + }) + + it('checks arrays per index; an items-less array accepts anything', () => { + expect(validateStructuredValue(schema, { file: 'a', tags: 'x' })).toEqual(['"value.tags" must be an array']) + expect(validateStructuredValue(schema, { file: 'a', tags: ['ok', 2] })).toEqual(['"value.tags[1]" must be a string']) + expect(validateStructuredValue(schema, { file: 'a', free: [{ deep: [1] }, null] })).toEqual([]) + }) + + it('recurses into nested objects: required + additionalProperties: false', () => { + expect(validateStructuredValue(schema, { file: 'a', nested: {} })) + .toEqual(['missing required property "value.nested.x"']) + expect(validateStructuredValue(schema, { file: 'a', nested: { x: 1, y: 2 } })) + .toEqual(['"value.nested.y" is not a declared property (additionalProperties: false)']) + expect(validateStructuredValue(schema, { file: 'a', nested: 3 })) + .toEqual(['"value.nested" must be an object']) + }) + + it('a required key present-but-undefined counts as missing', () => { + expect(validateStructuredValue(schema, { file: undefined })).toEqual(['missing required property "value.file"']) + }) + + it('collects multiple violations across branches in one pass', () => { + expect(validateStructuredValue(schema, { line: 'x', severity: 'mid' })).toEqual([ + 'missing required property "value.file"', + '"value.line" must be an integer', + '"value.severity" must be one of ["low","high"]', + ]) + }) + + it('null-typed const/enum work through the scalar path', () => { + const nullish = asserted({ type: 'object', properties: { a: { type: 'null', const: null } } }) + expect(validateStructuredValue(nullish, { a: null })).toEqual([]) + }) + + it('rejects a non-object properties value in the schema walk', () => { + expect(violationsOf({ type: 'object', properties: [] })) + .toEqual(['schema.properties must be an object of schemas']) + }) + + it('an object schema without properties/required only type-checks its value', () => { + const bare = asserted({ type: 'object' }) + expect(validateStructuredValue(bare, { any: ['thing'] })).toEqual([]) + expect(validateStructuredValue(bare, 7)).toEqual(['"value" must be an object']) + }) + + it('validateStructuredValue throws on a type the assert would never let through (assertNever backstop)', () => { + const forged = { type: 'tuple' } as unknown as StructuredOutputSchema + expect(() => validateStructuredValue(forged, 1)).toThrow(/tuple/) + }) +}) diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index c691d56355..7b43f82261 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -12,12 +12,13 @@ The seam this rides on: `CreateAgentOptions.seed` (added on `dsh-agent`, threade ## Capabilities -`{ outputSchema: false, depthLimit: true, toolFilter: false }` — identical to spawn (the depth/model/output behavior is the shared driver's). +`{ outputSchema: true, depthLimit: true, toolFilter: false }` — identical to spawn (the depth/model/structured-output behavior is the shared driver's). ## Config | Key | Meaning | |---|---| | `providerName` | Registry name on `ctx.subagents` (default `fork`). | +| `structuredNudgeRetries` | How many times a structured run re-prompts a child that finished cleanly without calling `structured_output` (default 1). | See [`dsh-subagent-spawn`](../subagent-spawn/README.md) for the run lifecycle, model inheritance, and depth tracking — all shared. diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index 02c1811d82..39dfbfd440 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -25,19 +25,25 @@ import z from 'schemastery' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' +import { acquireStructuredRuntime, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-fork' -export const inject = ['subagents', 'agents'] +export const inject = ['subagents', 'agents', 'tools'] -/** Config: the registry name to register the provider under. */ +/** Config: the registry name to register the provider under, plus structured-run tuning. */ export interface Config { /** Provider name on `ctx.subagents` (default `fork`). */ providerName: string + /** + * How many times a structured run re-prompts a child that finished cleanly + * without calling `structured_output` before giving up (default 1). + */ + structuredNudgeRetries: number } export const Config: z = z.object({ providerName: z.string().default('fork'), + structuredNudgeRetries: z.natural().default(1), }) /** @@ -57,18 +63,24 @@ export function completedTurnPrefix(parent: Agent): SessionEvent[] { } /** - * The fork provider. Supports `depthLimit`; NOT `outputSchema`/`toolFilter` this - * cut (the service rejects a request needing either before `start` runs). + * The fork provider. Supports `depthLimit` and `outputSchema` (via the shared + * in-process structured runtime); NOT `toolFilter` this cut (the service + * rejects a request needing it before `start` runs). */ class ForkProvider implements SubagentProvider { - readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false } + readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: false } - constructor(readonly name: string, private readonly ctx: Context) {} + constructor( + readonly name: string, + private readonly ctx: Context, + private readonly structuredNudgeRetries: number, + ) {} start(request: SubagentStartRequest) { const seed = completedTurnPrefix(request.parent) return startInProcessRun(this.ctx, request, { providerName: this.name, + structuredNudgeRetries: this.structuredNudgeRetries, // Only pass a seed when there's a completed turn to inherit; an empty seed // is equivalent to a fresh child, so omit it to keep the session unseeded. ...seed.length > 0 ? { seed } : {}, @@ -77,5 +89,12 @@ class ForkProvider implements SubagentProvider { } export function apply(ctx: Context, config: Config): void { - ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx)) + // Hold the structured runtime for the plugin's lifetime (see the spawn + // backend — same two-level lifetime: backends for availability, runs for + // mid-run survival across a backend unload). + ctx.effect(() => { + const acquisition = acquireStructuredRuntime(ctx) + return () => { acquisition.release() } + }, 'subagent-fork structured runtime') + ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx, config.structuredNudgeRetries)) } diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index 1f932fbaf9..82caf25948 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -30,8 +30,8 @@ async function setup(script: Script) { await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - await ctx.plugin(Spawn, { providerName: 'spawn' }) - await ctx.plugin(fork, { providerName: 'fork' }) + await ctx.plugin(Spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) + await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) return { ctx, parent } diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 56441a656f..4abba075c3 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -37,7 +37,7 @@ async function setup(script: Script) { await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - await ctx.plugin(fork, { providerName: 'fork' }) + await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) return { ctx, parent } @@ -161,16 +161,20 @@ describe('dsh-subagent-fork', () => { await run.dispose() }) - it('advertises depthLimit but not outputSchema/toolFilter', async () => { + it('advertises depthLimit and outputSchema but not toolFilter', async () => { const { ctx } = await setup([]) - expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false }) + expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: false }) }) it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) await ctx.plugin(AgentRegistry) - const fiber = await ctx.plugin(fork, { providerName: 'fork' }) + // The backend injects 'tools' for the structured runtime, so the registry + // (and its systemPrompt dependency) must be live for the fiber to activate. + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + const fiber = await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 }) expect(ctx.subagents.list()).toEqual(['fork']) await fiber.dispose() expect(ctx.subagents.list()).toEqual([]) @@ -179,12 +183,12 @@ describe('dsh-subagent-fork', () => { it('has the namespace-plugin export shape (no stray default)', () => { expect('default' in fork).toBe(false) expect(fork.name).toBe('subagent-fork') - expect(fork.inject).toEqual(['subagents', 'agents']) + expect(fork.inject).toEqual(['subagents', 'agents', 'tools']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(fork) as Record expect(unwrapped).toBe(fork) expect(unwrapped.name).toBe('subagent-fork') - expect(unwrapped.inject).toEqual(['subagents', 'agents']) + expect(unwrapped.inject).toEqual(['subagents', 'agents', 'tools']) expect(typeof unwrapped.apply).toBe('function') }) }) diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index af6d5792a9..1cbdccaee6 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -8,16 +8,27 @@ The shared **in-process subagent run driver**. A pure library (no provider, no r Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`): -1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability); -2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, the optional `options.seed` (fork's completed-turn prefix; omitted for a fresh child), and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the system prompt is NOT inherited); -3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); -4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. +1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability); a `request.outputSchema` is asserted against the supported subset (`assertSupportedOutputSchema` from [dsh-tools](../../core/tools/README.md)) before any child exists; +2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, the optional `options.seed` (fork's completed-turn prefix; omitted for a fresh child), and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the system prompt is NOT inherited; a structured run appends the `structured_output` instruction after the caller's prompt); +3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); a structured child that finished a turn CLEANLY without calling `structured_output` is re-prompted (a nudge — a fresh turn) up to `options.structuredNudgeRetries` times; +4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field). `dispose()` delegates to `AgentHandle.dispose()` (stop loop → await quiescence → remove session); `cancel()` cancels the child's in-flight turn. A cancel landing before any `turn/end` (the pre-turn window) still settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`. ### `InProcessRunOptions` -`{ providerName: string; seed?: SessionEvent[] }` — the per-backend inputs: the provider name (for error context) and the optional child-session seed. +`{ providerName: string; seed?: SessionEvent[]; structuredNudgeRetries: number }` — the per-backend inputs: the provider name (for error context), the optional child-session seed, and the structured-run nudge budget (REQUIRED, resolved from the backend's validated Config — the driver never fills it with a hidden default). + +### Structured output: `acquireStructuredRuntime(ctx): StructuredAcquisition` + +The mechanism behind `outputSchema` for in-process children. One globally registered `structured_output` capture tool (its registered parameters are a placeholder) plus two listeners, registered once per root context and shared by every holder: + +- an `agent/request` waterfall listener registered `prepend: true` that post-processes `await next()` — **final-request enforcement**: the request that hits the wire never carries `structured_output` for an agent without a structured run, and always carries the run's OWN schema (as the tool's `parameters`) for one that has it. Per-agent shaping lives here because the tool registry and prompt assembly are context-global while schemas differ per concurrent child; cooperative mutate-then-`next()` would not survive a downstream listener returning a replacement request. +- an `agent/turn-continuation` listener that stops a child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step. + +The capture tool validates each call against the run's schema (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError result the model retries in-turn; a valid call records the value. + +Lifetime is refcounted with two kinds of holder: each backend acquires for its plugin lifetime (`apply`), and each structured RUN holds its own acquisition from start to settle — so unregistration can never precede a live run's settle, and the runtime disposes only when the last backend AND the last run are gone. `release()` is idempotent per acquisition. ### `depthOf(agent): number` diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index f3bd774554..41c46e00d3 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -26,6 +26,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 4b8d2d4c99..4293505995 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -18,7 +18,22 @@ import type { Context } from 'cordis' import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent' import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' +import { + acquireStructuredRuntime, + STRUCTURED_OUTPUT_INSTRUCTION, + STRUCTURED_OUTPUT_NUDGE, + type StructuredAcquisition, +} from './structured.ts' + +export { + acquireStructuredRuntime, + STRUCTURED_OUTPUT_TOOL, + STRUCTURED_OUTPUT_INSTRUCTION, + STRUCTURED_OUTPUT_NUDGE, + type StructuredAcquisition, +} from './structured.ts' declare module '@deepseek-ai/dsh-agent' { interface AgentOptions { @@ -76,6 +91,13 @@ export interface InProcessRunOptions { * parent's log (FORK), or `undefined` for a fresh child (SPAWN). */ readonly seed?: SessionEvent[] + /** + * How many times a structured run re-prompts a child that finished a turn + * cleanly WITHOUT calling `structured_output` (see the structured module). + * REQUIRED, resolved from the backend's validated Config — per the explicit- + * defaulting rule, the driver never fills it with a hidden fallback. + */ + readonly structuredNudgeRetries: number } /** @@ -98,6 +120,10 @@ export function startInProcessRun( if (request.maxDepth !== undefined && childDepth > request.maxDepth) { throw new SubagentDepthError(childDepth, request.maxDepth) } + // Assert the schema subset BEFORE any child exists (the service has already + // capability-gated; this rejects a schema outside the enforced subset loud). + const schema = request.outputSchema + if (schema !== undefined) assertSupportedOutputSchema(schema) const childId = AgentId(randomUUID()) // The child's OWN events begin after the seed (fork seeds the parent's @@ -109,13 +135,24 @@ export function startInProcessRun( // Inherit the parent's model by default (a child with no model cannot run); // an explicit `request.agentOptions.model` overrides it. The parent's // systemPrompt is NOT inherited — a fresh child is a clean specialist unless - // the caller supplies one. + // the caller supplies one. A structured run appends the structured_output + // instruction after whatever prompt the caller supplied. + const callerPrompt = request.agentOptions?.systemPrompt + const systemPrompt = schema === undefined + ? callerPrompt + : [callerPrompt, STRUCTURED_OUTPUT_INSTRUCTION].filter(text => text !== undefined && text.length > 0).join('\n\n') const agentOptions: AgentOptions = { ...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {}, ...request.agentOptions, + ...systemPrompt !== undefined ? { systemPrompt } : {}, subagentDepth: childDepth, } + // The structured runtime is held for the WHOLE run (acquired before the child + // exists, released when the result settles), so a backend hot-reload mid-run + // cannot unregister the capture tool out from under this live child. + const structured: StructuredAcquisition | undefined = schema !== undefined ? acquireStructuredRuntime(ctx) : undefined + const handle: AgentHandle = ctx.agents.create({ agentId: childId, sessionId: SessionId(randomUUID()), @@ -130,6 +167,7 @@ export function startInProcessRun( agentOptions, }) const child = handle.agent + if (structured && schema !== undefined) structured.attach(child, schema) // Bridge the request's abort signal to the child (the consumer also bridges // its own exec.signal, but a backend-level bridge keeps the contract local). @@ -154,9 +192,30 @@ export function startInProcessRun( if (request.signal?.aborted) return { output: [], stopReason: 'aborted' } child.send(request.prompt) await child.whenIdle() - return readResult(child, seedLength, cancelled) + if (structured) { + // Nudge loop: a child that finished a turn CLEANLY without calling + // structured_output gets re-prompted, up to the backend-configured + // retry count. An errored/aborted turn is not nudged — its failure is + // the honest result. (This also covers a cancel: a cancelled turn ends + // `aborted`, and a pre-turn cancel leaves no `turn/end` at all, so + // neither reads `completed`.) + let nudges = options.structuredNudgeRetries + while ( + structured.captured(child) === undefined && nudges > 0 + && lastOwnTurnEnd(child, seedLength)?.data.reason.kind === 'completed' + ) { + nudges -= 1 + child.send([{ type: 'text', text: STRUCTURED_OUTPUT_NUDGE }]) + await child.whenIdle() + } + } + return readResult(child, seedLength, cancelled, structured ? { captured: structured.captured(child) } : undefined) } finally { request.signal?.removeEventListener('abort', onAbort) + if (structured) { + structured.detach(child) + structured.release() + } } })() @@ -173,6 +232,12 @@ export function startInProcessRun( } } +/** The child's OWN last `turn/end` event (events at or after `seedLength`), if any. */ +function lastOwnTurnEnd(child: Agent, seedLength: number): SessionEvent<'turn/end'> | undefined { + return child.session.events.slice(seedLength) + .findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end') +} + /** * Read a settled child's terminal result from its session log, scoped to the * child's OWN events (everything at or after `seedLength` — fork seeds the @@ -184,12 +249,29 @@ export function startInProcessRun( * logged (a cancel landed in the pre-turn window, before any turn ran), the * run settles `aborted` per the {@link SubagentRun.cancel} contract rather than * the generic no-turn `error`. + * + * A structured run (`structured` present) additionally reports the captured + * value on {@link SubagentResult.structured}. A structured child that finished + * CLEANLY without ever capturing (the nudges ran out) settles `error` — a clean + * finish without the demanded structured result is a failure, not a success + * with a missing field; a non-`completed` reason keeps its own honest mapping. */ -function readResult(child: Agent, seedLength: number, cancelled: boolean): SubagentResult { +function readResult( + child: Agent, + seedLength: number, + cancelled: boolean, + structured?: { captured?: { value: unknown } | undefined }, +): SubagentResult { const own = child.session.events.slice(seedLength) const lastMessage = own.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message') const lastEnd = own.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end') const output: ContentBlock[] = lastMessage ? structuredClone(lastMessage.data.content) : [] - if (lastEnd === undefined && cancelled) return { output, stopReason: 'aborted' } - return { output, stopReason: toStopReason(lastEnd?.data.reason) } + const stopReason: SubagentStopReason = lastEnd === undefined && cancelled + ? 'aborted' + : toStopReason(lastEnd?.data.reason) + if (structured) { + if (structured.captured) return { output, structured: structured.captured.value, stopReason } + if (stopReason === 'completed') return { output, stopReason: 'error' } + } + return { output, stopReason } } diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts new file mode 100644 index 0000000000..da371ac8f4 --- /dev/null +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -0,0 +1,193 @@ +/** + * Structured-output support for the in-process subagent backends: the mechanism + * behind `SubagentStartRequest.outputSchema` for children that run as agents on + * the same context. + * + * The model-facing surface is one globally registered `structured_output` tool + * whose REGISTERED parameters are a placeholder — the real schema is per run. + * Because the tool registry and prompt assembly are context-global while + * schemas differ per child (two concurrent structured runs may carry different + * schemas), per-agent shaping happens on the `agent/request` waterfall with a + * `prepend: true` listener that post-processes `await next()` — FINAL-REQUEST + * enforcement: whatever downstream listeners mutated or replaced, the request + * that hits the wire never carries `structured_output` for an agent without a + * structured run, and always carries the run's OWN schema for one that has it. + * (Cooperative mutate-then-`next()` would not survive a downstream listener + * returning a replacement request — see the waterfall composition caveat in + * docs/architecture.md.) + * + * A companion `agent/turn-continuation` listener stops a child's turn once its + * output is captured — without it, the loop's default "had tool calls ⇒ + * continue" buys a wasted extra model step per structured child. + * + * Lifetime is refcounted with two kinds of holder: each backend acquires for + * its plugin lifetime (so the tool exists before any run), and each structured + * RUN acquires from start to settle (so a backend hot-reload mid-run cannot + * unregister the capture tool out from under a live child). Registrations are + * effects on the ROOT context — their natural upper bound is app teardown — and + * the refcount disposes them when the last holder releases. + * + * @module @deepseek-ai/dsh-subagent-inprocess/structured + */ + +import type { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ContentBlock, GenerateOptions, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { ContinuationDecision } from '@deepseek-ai/dsh-agent' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools' + +/** The model-facing tool name a structured child must call to finish. */ +export const STRUCTURED_OUTPUT_TOOL = 'structured_output' + +/** The per-child instruction appended to a structured child's system prompt. */ +export const STRUCTURED_OUTPUT_INSTRUCTION + = 'When you have your final answer, you MUST report it by calling the ' + + `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. ` + + 'Do not finish with a plain text answer: only the tool call counts as your result.' + +/** The nudge sent when a structured child finishes cleanly without calling the tool. */ +export const STRUCTURED_OUTPUT_NUDGE + = `You finished without calling \`${STRUCTURED_OUTPUT_TOOL}\`. ` + + `Call \`${STRUCTURED_OUTPUT_TOOL}\` now with your final result matching its parameter schema.` + +/** One structured run's state: the schema to enforce and the captured value, once recorded. */ +interface RunState { + readonly schema: StructuredOutputSchema + captured?: { value: unknown } +} + +/** The per-root-context runtime: run states plus the shared registrations. */ +interface StructuredRuntime { + refs: number + readonly states: WeakMap + readonly disposers: (() => void)[] +} + +/** One root context ⇒ one runtime (multi-app test isolation). */ +const runtimes = new WeakMap() + +/** + * One holder's handle on the shared structured runtime. `release()` is + * idempotent per acquisition; the runtime's registrations are disposed when the + * LAST holder (backend plugin or live run) releases. + */ +export interface StructuredAcquisition { + /** Enforce `schema` on `agent`'s requests and start capturing its `structured_output` call. */ + attach(agent: Agent, schema: StructuredOutputSchema): void + /** The captured value, once the child called the tool with valid arguments. */ + captured(agent: Agent): { value: unknown } | undefined + /** Stop enforcing/capturing for `agent` (WeakMap-backed; safe to call twice). */ + detach(agent: Agent): void + /** Drop this holder's reference (idempotent); the last release unregisters everything. */ + release(): void +} + +/** + * Acquire the per-root-context structured runtime, registering the capture tool + * and the two waterfall listeners on the FIRST acquisition. See the module doc + * for the enforcement and lifetime design. + * @param ctx - any context of the app; the runtime keys off `ctx.root`. + * @returns this holder's handle (attach/captured/detach + idempotent release). + */ +export function acquireStructuredRuntime(ctx: Context): StructuredAcquisition { + const root: Context = ctx.root + let runtime = runtimes.get(root) + if (!runtime) { + runtime = { refs: 0, states: new WeakMap(), disposers: [] } + runtimes.set(root, runtime) + registerRuntime(root, runtime) + } + runtime.refs += 1 + + let released = false + return { + attach(agent: Agent, schema: StructuredOutputSchema): void { + runtime.states.set(agent, { schema }) + }, + captured(agent: Agent): { value: unknown } | undefined { + return runtime.states.get(agent)?.captured + }, + detach(agent: Agent): void { + runtime.states.delete(agent) + }, + release(): void { + if (released) return + released = true + runtime.refs -= 1 + if (runtime.refs > 0) return + runtimes.delete(root) + for (const dispose of runtime.disposers.splice(0)) dispose() + }, + } +} + +/** Register the capture tool + the two listeners on the root context (first acquire). */ +function registerRuntime(root: Context, runtime: StructuredRuntime): void { + // The registered parameters are a PLACEHOLDER: the request listener below + // swaps in the run's real schema per child, and strips the tool entirely for + // every agent without a structured run — so this shape is never model-visible. + runtime.disposers.push(root.tools.register({ + name: STRUCTURED_OUTPUT_TOOL, + description: + 'Report your final structured result. Call this exactly once, when your answer is complete; ' + + 'the arguments must match this tool\'s parameter schema exactly.', + parameters: { type: 'object', properties: {} }, + execute(args: unknown, exec: ToolExecution): Promise { + const state = exec.agent ? runtime.states.get(exec.agent) : undefined + if (!state) { + // Reachable only if a non-structured agent somehow calls the tool (the + // request listener strips it, so the model never sees it) — fail loud + // rather than capture into nowhere. + throw new Error(`${STRUCTURED_OUTPUT_TOOL} is only available to subagents started with an output schema`) + } + const violations = validateStructuredValue(state.schema, args) + // ToolArgsError → isError result with INVALID_ARGS: the model retries + // within the same turn, exactly like a schema-validated defineTool call. + if (violations.length > 0) throw new ToolArgsError(violations) + state.captured = { value: args } + return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }]) + }, + })) + + // FINAL-REQUEST enforcement (prepend: true = first registered = OUTERMOST + // wrapper): post-process whatever the downstream listeners and the core + // produced, so a downstream listener returning a replacement request cannot + // leak the tool to other agents or erase the child's schema. + runtime.disposers.push(root.on('agent/request', async function ( + this: unknown, agent: Agent, _turn: number, _step: number, _options: GenerateOptions, next: () => Promise, + ): Promise { + const final = await next() + const state = runtime.states.get(agent) + if (state) { + const schemaEntry: ToolSchema = { + name: STRUCTURED_OUTPUT_TOOL, + description: + 'Report your final structured result. Call this exactly once, when your answer is complete; ' + + 'the arguments must match this tool\'s parameter schema exactly.', + // ToolSchema.parameters is the wire-level JSON Schema object; the + // asserted subset type is structurally exactly that. + parameters: state.schema as unknown as Record, + } + final.tools = [...(final.tools ?? []).filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), schemaEntry] + return final + } + // No structured run: strip the placeholder if present; leave an absent + // tools field absent (an adapter may treat `tools: []` and no tools + // differently on the wire). + if (final.tools?.some(tool => tool.name === STRUCTURED_OUTPUT_TOOL)) { + final.tools = final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL) + } + return final + }, { prepend: true })) + + // Stop a structured child's turn once its output is captured: the default + // "had tool calls ⇒ continue" would otherwise buy a wasted extra model step + // after every successful capture. + runtime.disposers.push(root.on('agent/turn-continuation', function ( + this: unknown, agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise, + ): Promise { + if (runtime.states.get(agent)?.captured) return Promise.resolve({ action: 'stop' }) + return next() + })) +} diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts new file mode 100644 index 0000000000..643000de2c --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -0,0 +1,393 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService, { type GenerateOptions } from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import * as Invariants from '@deepseek-ai/dsh-invariants' +import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import * as spawn from '../../subagent-spawn/src/index.ts' +import * as fork from '../../subagent-fork/src/index.ts' +import { + acquireStructuredRuntime, + STRUCTURED_OUTPUT_INSTRUCTION, + STRUCTURED_OUTPUT_TOOL, +} from '../src/structured.ts' + +type Script = ConstructorParameters[0] + +const SCHEMA: StructuredOutputSchema = { + type: 'object', + properties: { answer: { type: 'number' }, note: { type: 'string' } }, + required: ['answer'], +} + +/** + * Real loop + scripted mock model + the REAL spawn backend (which acquires the + * structured runtime at apply, exactly as shipped). The mock model script + * drives the child's structured_output calls. + */ +async function setup(script: Script, options?: { nudges?: number; withFork?: boolean }) { + const ctx = new Context() + const adapter = new MockAdapter(script) + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + const fiber = await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: options?.nudges ?? 1 }) + const forkFiber = options?.withFork + ? await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: options?.nudges ?? 1 }) + : undefined + ctx.llm.registerAdapter(['mock'], adapter) + const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + return { ctx, parent, adapter, fiber, forkFiber } +} + +function structuredRequest(parent: SubagentStartRequest['parent'], extra?: Partial): SubagentStartRequest { + return { prompt: [{ type: 'text', text: 'produce the answer' }], parent, outputSchema: SCHEMA, ...extra } +} + +/** The tool names of one recorded model request. */ +function toolNames(request: GenerateOptions): string[] { + return (request.tools ?? []).map(tool => tool.name) +} + +describe('in-process structured output', () => { + it('captures a valid structured_output call and surfaces result.structured', async () => { + const { ctx, parent } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42, note: 'done' }), + ]) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(result.structured).toEqual({ answer: 42, note: 'done' }) + await run.dispose() + }) + + it('stops the turn after a successful capture — no extra model step is spent', async () => { + const { ctx, parent, adapter } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), + textResponse('MUST NOT BE CONSUMED'), + ]) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + await run.result + // Default continuation would run a second step after the tool call; the + // structured runtime's turn-continuation veto stops the turn instead. + expect(adapter.requests.length).toBe(1) + await run.dispose() + }) + + it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => { + const { ctx, parent } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }), + toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), + ]) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const result = await run.result + expect(result.structured).toEqual({ answer: 7 }) + expect(result.stopReason).toBe('completed') + // The child's log carries the isError tool/result for the invalid call. + const child = ctx.agents.get(run.id)! + const results = child.session.events.filter(e => e.type === 'tool/result') + expect(results.length).toBe(2) + expect((results[0]!.data as { isError?: boolean }).isError).toBe(true) + await run.dispose() + }) + + it('nudges a child that finished cleanly without calling the tool, then captures', async () => { + const { ctx, parent } = await setup([ + textResponse('here is my answer in prose'), + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 3 }), + ]) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const result = await run.result + expect(result.structured).toEqual({ answer: 3 }) + expect(result.stopReason).toBe('completed') + // The nudge is a real user-visible message in the child's log. + const child = ctx.agents.get(run.id)! + const users = child.session.events.filter(e => e.type === 'user/message') + expect(users.length).toBe(2) + await run.dispose() + }) + + it('settles error when the nudges run out without a capture', async () => { + const { ctx, parent, adapter } = await setup([ + textResponse('prose only'), + textResponse('still prose'), + ], { nudges: 1 }) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const result = await run.result + expect(result.stopReason).toBe('error') + expect(result.structured).toBeUndefined() + expect(adapter.requests.length).toBe(2) + await run.dispose() + }) + + it('zero nudge retries fails immediately after the first clean prose finish', async () => { + const { ctx, parent, adapter } = await setup([textResponse('prose')], { nudges: 0 }) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const result = await run.result + expect(result.stopReason).toBe('error') + expect(adapter.requests.length).toBe(1) + await run.dispose() + }) + + it('a child that errored is NOT nudged (its failure is the honest result)', async () => { + // Script exhaustion on the first call → the child turn errors. + const { ctx, parent, adapter } = await setup([], { nudges: 3 }) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const result = await run.result + expect(result.stopReason).toBe('error') + expect(adapter.requests.length).toBe(1) + await run.dispose() + }) + + it('rejects a schema outside the subset loud, before any child exists', async () => { + const { ctx, parent } = await setup([]) + expect(() => ctx.subagents.start('spawn', structuredRequest(parent, { + outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema, + }))).toThrow(/unsupported output schema/) + expect(ctx.agents.get(AgentId('parent'))).toBeDefined() + }) + + it('appends the structured instruction to the child system prompt (caller prompt preserved)', async () => { + const { ctx, parent } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })]) + const run = ctx.subagents.start('spawn', structuredRequest(parent, { + agentOptions: { systemPrompt: 'You are a counter.' }, + })) + await run.result + const child = ctx.agents.get(run.id)! + expect(child.options.systemPrompt).toBe(`You are a counter.\n\n${STRUCTURED_OUTPUT_INSTRUCTION}`) + await run.dispose() + }) + + it('a structured child WITHOUT a caller prompt gets exactly the instruction', async () => { + const { ctx, parent } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })]) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + await run.result + const child = ctx.agents.get(run.id)! + expect(child.options.systemPrompt).toBe(STRUCTURED_OUTPUT_INSTRUCTION) + await run.dispose() + }) + + describe('final-request enforcement (the prepend agent/request listener)', () => { + it('a structured child sees structured_output with ITS schema; a plain agent never sees the tool', async () => { + const { ctx, parent, adapter } = await setup([ + // Parent turn (a plain agent): must NOT see the tool. + textResponse('parent answer'), + // Child turn: must see it, with the run's schema. + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }), + ]) + parent.send([{ type: 'text', text: 'hello' }]) + await parent.whenIdle() + expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL) + + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + await run.result + const childRequest = adapter.requests[1]! + expect(toolNames(childRequest)).toContain(STRUCTURED_OUTPUT_TOOL) + const entry = childRequest.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)! + expect(entry.parameters).toEqual(SCHEMA) + await run.dispose() + }) + + it('two concurrent structured children each see their OWN schema', async () => { + const otherSchema: StructuredOutputSchema = { + type: 'object', + properties: { verdict: { type: 'string', enum: ['real', 'bogus'] } }, + required: ['verdict'], + } + const { ctx, parent, adapter } = await setup([ + (options: GenerateOptions) => { + // Answer with whatever schema this child was given — proves each + // request carried the right one regardless of scheduling order. + const entry = options.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)! + const args = 'verdict' in (entry.parameters.properties as Record) + ? { verdict: 'real' } + : { answer: 1 } + return toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, args) + }, + (options: GenerateOptions) => { + const entry = options.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)! + const args = 'verdict' in (entry.parameters.properties as Record) + ? { verdict: 'real' } + : { answer: 1 } + return toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, args) + }, + ]) + const runA = ctx.subagents.start('spawn', structuredRequest(parent)) + const runB = ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: otherSchema })) + const [a, b] = await Promise.all([runA.result, runB.result]) + expect(a.structured).toEqual({ answer: 1 }) + expect(b.structured).toEqual({ verdict: 'real' }) + const schemas = adapter.requests.map(request => + request.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!.parameters) + expect(schemas).toContainEqual(SCHEMA) + expect(schemas).toContainEqual(otherSchema) + await runA.dispose() + await runB.dispose() + }) + + it('wins against a downstream listener that REPLACES the request object', async () => { + const { ctx, parent, adapter } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }), + ]) + // A downstream (non-prepend) listener that returns a brand-new request — + // the composition caveat that erases cooperative mutations. Registered + // AFTER the runtime's prepend listener, so it runs INSIDE it. + ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => { + const replaced = await next() + return { ...replaced, tools: [...(replaced.tools ?? [])] } + }) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const result = await run.result + expect(result.structured).toEqual({ answer: 5 }) + const entry = adapter.requests[0]!.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL) + expect(entry).toBeDefined() + expect(entry!.parameters).toEqual(SCHEMA) + await run.dispose() + }) + + it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => { + const { parent, adapter } = await setup([ + // The registry contributes the placeholder via prompt assembly, so + // tools is an array in the raw request — but after stripping the + // placeholder (its ONLY entry), the field must not be re-added as a + // different shape. + textResponse('plain'), + ]) + parent.send([{ type: 'text', text: 'q' }]) + await parent.whenIdle() + const request = adapter.requests[0]! + expect(toolNames(request)).not.toContain(STRUCTURED_OUTPUT_TOOL) + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + it('handles a request with NO tools field at all, for plain and structured agents alike', async () => { + // Drive the waterfall directly with a toolless request — the enforcement + // listener must tolerate `tools: undefined` on both branches: leave it + // absent for a plain agent, and create the array for a structured child. + const { ctx, parent } = await setup([]) + const bare: GenerateOptions = { model: 'mock', messages: [] } + const plain = await ctx.waterfall('agent/request', parent, 1, 1, bare, () => Promise.resolve(bare)) + expect(plain.tools).toBeUndefined() + + const acquisition = acquireStructuredRuntime(ctx) + acquisition.attach(parent, SCHEMA) + const bare2: GenerateOptions = { model: 'mock', messages: [] } + const shaped = await ctx.waterfall('agent/request', parent, 1, 1, bare2, () => Promise.resolve(bare2)) + expect(shaped.tools!.map(tool => tool.name)).toEqual([STRUCTURED_OUTPUT_TOOL]) + acquisition.detach(parent) + acquisition.release() + }) + }) + + describe('runtime lifetime (refcount: backends + live runs)', () => { + it('registers the capture tool while a backend is loaded and unregisters when the last unloads', async () => { + const { ctx, fiber, forkFiber } = await setup([], { withFork: true }) + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined() + await fiber.dispose() + // fork still holds a reference. + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined() + await forkFiber!.dispose() + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() + }) + + it('a live run-level acquisition keeps the runtime registered after EVERY backend unloads', async () => { + // Simulates the run-holder half of the two-level lifetime: a structured + // run acquires at start and releases at settle, so registration ordering + // is settle-then-unregister even if all backends unload first. (A real + // in-process child dies WITH its backend's fiber — the acquisition's + // observable job is this ordering, which a manual holder pins directly.) + const { ctx, fiber, forkFiber } = await setup([], { withFork: true }) + const runHolder = acquireStructuredRuntime(ctx) + await fiber.dispose() + await forkFiber!.dispose() + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined() + runHolder.release() + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() + }) + + it('a structured run releases its acquisition when it settles (backend unload mid-run)', async () => { + const { ctx, parent, fiber } = await setup(['hang']) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + // Let the child's step start streaming, then unload the backend. The + // backend owns the child agent, so the unload tears the child down and + // the run settles — releasing its own acquisition on the way out. + await new Promise(resolve => setTimeout(resolve, 30)) + await fiber.dispose() + const result = await run.result + expect(result.stopReason).toBe('error') + // Both holders (backend + run) released — nothing keeps the runtime now. + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() + await run.dispose() + }) + + it('fork children capture structured output through the same runtime', async () => { + const { ctx, parent } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }), + ], { withFork: true }) + const run = ctx.subagents.start('fork', structuredRequest(parent)) + const result = await run.result + expect(result.structured).toEqual({ answer: 9 }) + await run.dispose() + }) + + it('acquisition release is idempotent (double release cannot underflow the refcount)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + const first = acquireStructuredRuntime(ctx) + const second = acquireStructuredRuntime(ctx) + first.release() + first.release() + // The second holder still keeps the tool registered. + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined() + second.release() + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() + }) + + it('attach/captured/detach manage per-agent state through the acquisition surface', async () => { + const { ctx, parent } = await setup([]) + const acquisition = acquireStructuredRuntime(ctx) + expect(acquisition.captured(parent)).toBeUndefined() + acquisition.attach(parent, SCHEMA) + expect(acquisition.captured(parent)).toBeUndefined() + acquisition.detach(parent) + acquisition.detach(parent) + acquisition.release() + // The backend still holds its own reference from setup(). + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined() + }) + }) + + it('a direct structured_output call from an agent WITHOUT a structured run is an isError', async () => { + const { ctx, parent } = await setup([]) + const result = await ctx.tools.execute({ + callId: 'x' as never, + name: STRUCTURED_OUTPUT_TOOL, + arguments: { answer: 1 }, + agent: parent, + }) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ type: 'text' }) + }) + + it('a structured_output call with NO calling agent at all is an isError', async () => { + const { ctx } = await setup([]) + const result = await ctx.tools.execute({ + callId: 'x' as never, + name: STRUCTURED_OUTPUT_TOOL, + arguments: { answer: 1 }, + }) + expect(result.isError).toBe(true) + }) +}) diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 7219e03988..d3870ae51a 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -51,7 +51,7 @@ describe('depthOf', () => { describe('startInProcessRun', () => { it('drives a fresh child (no seed) to completion and returns its output', async () => { const { ctx, parent } = await setup([textResponse('driver child answer')]) - const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn' }) + const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn', structuredNudgeRetries: 1 }) const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('driver child answer') @@ -61,7 +61,7 @@ describe('startInProcessRun', () => { it('throws SubagentDepthError when the child would exceed maxDepth', async () => { const { ctx, parent } = await setup([]) - expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn' })) + expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn', structuredNudgeRetries: 1 })) .toThrow(SubagentDepthError) }) @@ -73,7 +73,7 @@ describe('startInProcessRun', () => { parent.send([{ type: 'text', text: 'parent q' }]) await parent.whenIdle() const seed = parent.session.events.slice() - const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', seed }) + const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', structuredNudgeRetries: 1, seed }) const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('seeded child reply') diff --git a/packages/subagent/subagent-inprocess/tsconfig.json b/packages/subagent/subagent-inprocess/tsconfig.json index 4cb435d4fb..a52684ee6e 100644 --- a/packages/subagent/subagent-inprocess/tsconfig.json +++ b/packages/subagent/subagent-inprocess/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../subagent" + }, + { + "path": "../../core/tools" } ] } diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index 97dfae9304..059c996215 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -6,14 +6,15 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../ ## What it does -`start(request)` delegates to `startInProcessRun(ctx, request, { providerName })` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). +`start(request)` delegates to `startInProcessRun(ctx, request, { providerName, structuredNudgeRetries })` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). ## Capabilities -`{ outputSchema: false, depthLimit: true, toolFilter: false }`. It constructs the child, so it enforces a recursion cap; structured output and tool-scoping are deferred (the service rejects a request needing either before `start` runs). +`{ outputSchema: true, depthLimit: true, toolFilter: false }`. It constructs the child, so it enforces a recursion cap, and it supports structured output via the driver's shared [structured runtime](../subagent-inprocess/README.md) (the backend acquires it for its plugin lifetime; each structured run holds its own acquisition until it settles). Tool-scoping is deferred (the service rejects a request needing it before `start` runs). ## Config | Key | Meaning | |---|---| | `providerName` | Registry name on `ctx.subagents` (default `spawn`). | +| `structuredNudgeRetries` | How many times a structured run re-prompts a child that finished cleanly without calling `structured_output` (default 1). | diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 2ea082e20a..fdd57fd62a 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -9,6 +9,11 @@ * ({@link startInProcessRun}); this backend just passes NO seed (a fresh * child). The fork backend is an independent peer over the same driver. * + * Structured output (`outputSchema`) is supported via the driver's shared + * structured runtime: the backend acquires it for its plugin lifetime (so the + * capture tool and request-shaping listeners exist before any run), and each + * structured run holds its own acquisition until it settles. + * * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default. * * @module @deepseek-ai/dsh-subagent-spawn @@ -17,38 +22,61 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' +import { acquireStructuredRuntime, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-spawn' -export const inject = ['subagents', 'agents'] +export const inject = ['subagents', 'agents', 'tools'] -/** Config: the registry name to register the provider under. */ +/** Config: the registry name to register the provider under, plus structured-run tuning. */ export interface Config { /** Provider name on `ctx.subagents` (default `spawn`). */ providerName: string + /** + * How many times a structured run re-prompts a child that finished cleanly + * without calling `structured_output` before giving up (default 1). + */ + structuredNudgeRetries: number } export const Config: z = z.object({ providerName: z.string().default('spawn'), + structuredNudgeRetries: z.natural().default(1), }) /** * The spawn provider. Supports `depthLimit` (it constructs the child, so it can - * enforce a recursion cap) but NOT `outputSchema` or `toolFilter` in this cut — - * a request that needs either is rejected by the service before `start` runs. + * enforce a recursion cap) and `outputSchema` (via the shared in-process + * structured runtime); NOT `toolFilter` in this cut — a request that needs it + * is rejected by the service before `start` runs. */ class SpawnProvider implements SubagentProvider { - readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false } + readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: false } - constructor(readonly name: string, private readonly ctx: Context) {} + constructor( + readonly name: string, + private readonly ctx: Context, + private readonly structuredNudgeRetries: number, + ) {} start(request: SubagentStartRequest) { // Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/ - // depth, drives the one-shot, and maps the result. - return startInProcessRun(this.ctx, request, { providerName: this.name }) + // depth, drives the one-shot (including the structured capture/nudge loop + // when the request carries an outputSchema), and maps the result. + return startInProcessRun(this.ctx, request, { + providerName: this.name, + structuredNudgeRetries: this.structuredNudgeRetries, + }) } } export function apply(ctx: Context, config: Config): void { - ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx)) + // Hold the structured runtime for the plugin's lifetime, so the capture tool + // and its request-shaping listeners are registered before the first + // structured run and torn down when the last backend unloads (live runs hold + // their own acquisitions, so an unload mid-run cannot strand a child). + ctx.effect(() => { + const acquisition = acquireStructuredRuntime(ctx) + return () => { acquisition.release() } + }, 'subagent-spawn structured runtime') + ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx, config.structuredNudgeRetries)) } diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index ff551cfc3f..cb66a326a6 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -31,7 +31,7 @@ export async function spawnHarness(workdir: string): Promise { await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(SubagentService) - await ctx.plugin(Spawn, { providerName: 'spawn' }) + await ctx.plugin(Spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) // The model-facing subagent tool, bound to the spawn backend. await ctx.plugin(ToolSubagent, { provider: 'spawn' }) return ctx diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index ccfd6492f4..eaddeae201 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -34,7 +34,7 @@ async function setup(script: Script) { await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - await ctx.plugin(spawn, { providerName: 'spawn' }) + await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) return { ctx, parent, adapter } @@ -241,17 +241,21 @@ describe('dsh-subagent-spawn', () => { await parentHandle.dispose() }) - it('advertises depthLimit but not outputSchema/toolFilter', async () => { + it('advertises depthLimit and outputSchema but not toolFilter', async () => { const { ctx } = await setup([]) const provider = ctx.subagents.getProvider('spawn')! - expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false }) + expect(provider.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: false }) }) it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) await ctx.plugin(AgentRegistry) - const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) + // The backend injects 'tools' for the structured runtime, so the registry + // (and its systemPrompt dependency) must be live for the fiber to activate. + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + const fiber = await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) expect(ctx.subagents.list()).toEqual(['spawn']) await fiber.dispose() expect(ctx.subagents.list()).toEqual([]) @@ -260,12 +264,12 @@ describe('dsh-subagent-spawn', () => { it('has the namespace-plugin export shape (no stray default)', () => { expect('default' in spawn).toBe(false) expect(spawn.name).toBe('subagent-spawn') - expect(spawn.inject).toEqual(['subagents', 'agents']) + expect(spawn.inject).toEqual(['subagents', 'agents', 'tools']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(spawn) as Record expect(unwrapped).toBe(spawn) expect(unwrapped.name).toBe('subagent-spawn') - expect(unwrapped.inject).toEqual(['subagents', 'agents']) + expect(unwrapped.inject).toEqual(['subagents', 'agents', 'tools']) expect(typeof unwrapped.apply).toBe('function') }) }) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index fb60d5667c..16e6cd5237 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -8,7 +8,7 @@ import type { Agent, AgentId, AgentOptions } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { SchemaSpec } from '@deepseek-ai/dsh-tools' +import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools' /** * Which START-TIME features a provider supports. Checked by the service @@ -56,12 +56,16 @@ export interface SubagentStartRequest { /** Per-child agent options (model, system prompt). */ agentOptions?: AgentOptions /** - * Optional structured-output schema. When set AND the provider's - * {@link SubagentCapabilities.outputSchema} is `true`, the child's final - * answer is shaped to this schema and surfaced as {@link SubagentResult.structured}. + * Optional structured-output schema — an object-rooted JSON Schema within the + * enforced subset (see `assertSupportedOutputSchema` in dsh-tools; a schema + * outside the subset is rejected loud at start). When set AND the provider's + * {@link SubagentCapabilities.outputSchema} is `true`, the child is driven to + * report a value matching this schema, surfaced as + * {@link SubagentResult.structured}. The schema must be plain host-realm JSON + * data — a caller holding foreign-realm data materializes it first. * Requesting it against a provider that lacks the capability is rejected at start. */ - outputSchema?: SchemaSpec + outputSchema?: StructuredOutputSchema /** * Optional recursion cap (max delegation depth below this child). Requires * {@link SubagentCapabilities.depthLimit}; rejected at start otherwise. diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 3a8807ad0d..4e1c5b1bfd 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -124,7 +124,7 @@ describe('SubagentService', () => { describe('start-time capability validation (fail loud, before any child)', () => { it.each([ - { field: 'outputSchema', request: baseRequest({ outputSchema: { x: { type: 'string' } } }) }, + { field: 'outputSchema', request: baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } } }) }, { field: 'maxDepth', request: baseRequest({ maxDepth: 2 }) }, { field: 'toolFilter', request: baseRequest({ toolFilter: { deny: ['bash'] } }) }, ])('rejects $field against a provider that lacks the capability — before start() runs', ({ request }) => { @@ -149,7 +149,7 @@ describe('SubagentService', () => { await ctx.plugin(SubagentService) const provider = new StubProvider('strong', ALL_CAPS) ctx.subagents.registerProvider(provider) - ctx.subagents.start('strong', baseRequest({ outputSchema: { x: { type: 'string' } }, maxDepth: 1 })) + ctx.subagents.start('strong', baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } }, maxDepth: 1 })) expect(provider.startCount).toBe(1) }) }) diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts index f35ed884eb..ddd725da4b 100644 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ b/packages/support/subagent-mock/tests/subagent-mock.spec.ts @@ -41,13 +41,13 @@ describe('dsh-subagent-mock', () => { it('surfaces a structured result when the request carries an outputSchema', async () => { const ctx = await mount({ reply: 'r', structured: { answer: 42 } }) - const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { answer: { type: 'number' } } })) + const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } })) await expect(run.result).resolves.toMatchObject({ structured: { answer: 42 } }) }) it('defaults structured output to { reply } when outputSchema is requested but no structured value is configured', async () => { const ctx = await mount({ reply: 'fallback reply' }) - const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { answer: { type: 'number' } } })) + const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } })) await expect(run.result).resolves.toMatchObject({ structured: { reply: 'fallback reply' } }) }) From 1d43ea3cd5c09880dcbf3fbfbe3b90777a00d094 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:29:35 +0800 Subject: [PATCH 02/57] =?UTF-8?q?workflow:=20dynamic=20workflows=20?= =?UTF-8?q?=E2=80=94=20script-driven=20multi-agent=20orchestration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new capability family at packages/workflow/ in the bash seam shape, modeled on Claude Code's dynamic workflows: the model writes a JavaScript orchestration script (export const meta = {...} + plain-JS body), a runtime executes it, and the script — not the conversation — holds the loop, the branching, and the intermediate results. - dsh-workflow (ctx.workflows): abstract WorkflowService + run vocabulary (WorkflowRun whose result NEVER rejects) + observe-only workflow/* events carrying data snapshots (id + meta, never the live run), per-listener contained like subagent/*. - dsh-workflow-vm: in-process node:vm engine. Meta extraction via a string/comment-aware scanner (template interpolation rejected; literal evaluated alone in an empty timed context; statement blanked line- preservingly so stacks keep script line numbers). Hooks: agent(prompt, {label, phase, schema, model}) over ctx.subagents, parallel(), pipeline() (no cross-stage barrier), phase(), log(), args. Fatal-vs-null discipline: hook misuse (unknown/deferred options, bad arguments, unsupported schemas, tripped caps, seam start failures, cancellation) throws fatal WorkflowErrors the combinators RE-THROW — never dissolved into the per-item null reserved for child failures. Realm boundary: inbound values materialized by descriptor walks that never invoke accessors (defineProperty copies, __proto__-safe); outbound values rebuilt in-realm via the context's own JSON.parse. Determinism bans (Date.now/Math.random/argless new Date) kept so future resume support cannot break scripts. Caps and timeouts are validated Config. Every hook promise carries a no-op rejection consumer (app-boot exits on unhandled rejections). - dsh-tool-workflow: the model-facing workflow tool, synchronous like dsh-tool-subagent (start → await → try/finally dispose; abort bridged; non-completed → isError). Generic render card titled by a textual meta.name sniff. The tool description carries the authoring contract. Wired into examples/{coding-agent,acp-agent} with explicit-ask-only guidance. Coverage at every tier: unit (meta scanner, materializer incl. counting-getter and __proto__ regressions, combinator semantics, concurrency ceiling, caps, cancellation, no-unhandled-rejection abandon), integration over the real spawn stack, with-key e2e (real two-phase run + the tool through the registry pipeline), and a recorded ACP snapshot scenario (workflow-run, 1 child session). RFC: docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md (deferred work explicitly listed). AGENTS.md budget 1575 → 1590 for the new group's layout line. --- AGENTS.md | 1 + docs/architecture.md | 1 + docs/cordis-catalog/events.md | 62 ++ docs/cordis-catalog/services.md | 16 + docs/core-data-structures/workflow.md | 68 ++ docs/module-graph.md | 16 + docs/rfc/README.md | 1 + .../feature/2026-07-05-dynamic-workflows.md | 58 ++ docs/tool-catalog/tools.md | 39 ++ examples/acp-agent/cordis.yml | 18 + examples/acp-agent/tests/acp.snapshot.ts | 4 + .../tests/snapshots/workflow-run/input.json | 7 + .../snapshots/workflow-run/session.1.jsonl | 37 ++ .../snapshots/workflow-run/session.jsonl | 152 +++++ .../workflow-run/stdout.golden.jsonl | 75 +++ examples/coding-agent/cordis.yml | 20 +- knip.json | 141 +++- packages/README.md | 1 + .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- .../subagent/subagent-inprocess/package.json | 2 + .../tests/structured.spec.ts | 4 +- packages/workflow/README.md | 13 + packages/workflow/tool-workflow/README.md | 22 + packages/workflow/tool-workflow/package.json | 43 ++ packages/workflow/tool-workflow/src/index.ts | 179 +++++ .../tool-workflow/tests/tool-workflow.spec.ts | 224 +++++++ packages/workflow/tool-workflow/tsconfig.json | 33 + packages/workflow/workflow-vm/README.md | 30 + packages/workflow/workflow-vm/package.json | 50 ++ packages/workflow/workflow-vm/src/index.ts | 166 +++++ packages/workflow/workflow-vm/src/meta.ts | 198 ++++++ packages/workflow/workflow-vm/src/realm.ts | 142 ++++ packages/workflow/workflow-vm/src/runtime.ts | 499 ++++++++++++++ .../workflow-vm/tests/integration.spec.ts | 89 +++ .../workflow/workflow-vm/tests/meta.spec.ts | 143 ++++ .../workflow/workflow-vm/tests/realm.spec.ts | 114 ++++ .../workflow-vm/tests/workflow-vm.spec.ts | 614 ++++++++++++++++++ .../workflow-vm/tests/workflow.e2e.ts | 131 ++++ packages/workflow/workflow-vm/tsconfig.json | 39 ++ packages/workflow/workflow/README.md | 29 + packages/workflow/workflow/package.json | 37 ++ packages/workflow/workflow/src/index.ts | 224 +++++++ packages/workflow/workflow/src/types.ts | 154 +++++ .../workflow/workflow/tests/workflow.spec.ts | 86 +++ packages/workflow/workflow/tsconfig.json | 27 + pnpm-lock.yaml | 95 +++ scripts/doc-budgets.manifest.json | 2 +- scripts/gen-tool-catalog.ts | 16 + scripts/type-equiv.manifest.json | 437 ++++++++++--- tsconfig.base.json | 1 + tsconfig.build.json | 3 + tsconfig.json | 3 + 52 files changed, 4459 insertions(+), 109 deletions(-) create mode 100644 docs/core-data-structures/workflow.md create mode 100644 docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md create mode 100644 examples/acp-agent/tests/snapshots/workflow-run/input.json create mode 100644 examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl create mode 100644 examples/acp-agent/tests/snapshots/workflow-run/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl create mode 100644 packages/workflow/README.md create mode 100644 packages/workflow/tool-workflow/README.md create mode 100644 packages/workflow/tool-workflow/package.json create mode 100644 packages/workflow/tool-workflow/src/index.ts create mode 100644 packages/workflow/tool-workflow/tests/tool-workflow.spec.ts create mode 100644 packages/workflow/tool-workflow/tsconfig.json create mode 100644 packages/workflow/workflow-vm/README.md create mode 100644 packages/workflow/workflow-vm/package.json create mode 100644 packages/workflow/workflow-vm/src/index.ts create mode 100644 packages/workflow/workflow-vm/src/meta.ts create mode 100644 packages/workflow/workflow-vm/src/realm.ts create mode 100644 packages/workflow/workflow-vm/src/runtime.ts create mode 100644 packages/workflow/workflow-vm/tests/integration.spec.ts create mode 100644 packages/workflow/workflow-vm/tests/meta.spec.ts create mode 100644 packages/workflow/workflow-vm/tests/realm.spec.ts create mode 100644 packages/workflow/workflow-vm/tests/workflow-vm.spec.ts create mode 100644 packages/workflow/workflow-vm/tests/workflow.e2e.ts create mode 100644 packages/workflow/workflow-vm/tsconfig.json create mode 100644 packages/workflow/workflow/README.md create mode 100644 packages/workflow/workflow/package.json create mode 100644 packages/workflow/workflow/src/index.ts create mode 100644 packages/workflow/workflow/src/types.ts create mode 100644 packages/workflow/workflow/tests/workflow.spec.ts create mode 100644 packages/workflow/workflow/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index ef3526d6fe..f7b750e430 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ packages/ Harness packages at packages///, all named @deepseek-ai web/ web seam + search/fetch providers + model-facing web tools compact/ compaction seam + basic backend subagent/ subagent seam + spawn/fork/ACP backends + delegation tool + workflow/ workflow seam + node:vm script engine + the workflow tool todo/ the todo_write tool hooks/ Claude Code / Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends diff --git a/docs/architecture.md b/docs/architecture.md index f1f44033a1..47d11d897c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,6 +43,7 @@ Dependency rule: extension plugins depend on interfaces, never on `dsh-agent-loo | `ctx.compact` | dsh-compact | compaction: detect pressure, summarize an older range | | `ctx.web` | dsh-web | search/fetch provider registries + `WebError` taxonomy | | `ctx.subagents` | dsh-subagent | named provider registry for delegating to child agents | +| `ctx.workflows` | dsh-workflow | script-driven multi-agent orchestration: `start()` runs a workflow script | All registrations go through `ctx.effect()` and return disposers, so hot-reload and fiber disposal clean up automatically (full service interfaces: the generated [services catalog](cordis-catalog/services.md)). diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index c85b459069..463c7e06b2 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -313,6 +313,68 @@ Types: [ToolExecution](../core-data-structures/tools.md) Source: [`packages/core/tools/src/index.ts:76`](../../packages/core/tools/src/index.ts) +## `workflow/*` + +### `workflow/agent-end` — emit + +One `agent()` call settled (clean result, child failure, or run cancellation). Paired with Events['workflow/agent-start']. + +```ts cordis-catalog +'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void +``` + +Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/workflow/src/index.ts) + +### `workflow/agent-start` — emit + +One `agent()` call started a child run. Paired with Events['workflow/agent-end'] by `agent.seq`. + +```ts cordis-catalog +'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void +``` + +Source: [`packages/workflow/workflow/src/index.ts:83`](../../packages/workflow/workflow/src/index.ts) + +### `workflow/end` — emit + +A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves. Paired with Events['workflow/start']. + +```ts cordis-catalog +'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void +``` + +Source: [`packages/workflow/workflow/src/index.ts:101`](../../packages/workflow/workflow/src/index.ts) + +### `workflow/log` — emit + +The script emitted a narration line (a `log(message)` call). + +```ts cordis-catalog +'workflow/log'(info: WorkflowRunInfo, message: string): void +``` + +Source: [`packages/workflow/workflow/src/index.ts:75`](../../packages/workflow/workflow/src/index.ts) + +### `workflow/phase` — emit + +The script entered a phase (a `phase(title)` call) — progress grouping for observers; no execution semantics. + +```ts cordis-catalog +'workflow/phase'(info: WorkflowRunInfo, title: string): void +``` + +Source: [`packages/workflow/workflow/src/index.ts:68`](../../packages/workflow/workflow/src/index.ts) + +### `workflow/start` — emit + +A workflow run started — the script's meta block validated, the body about to execute. Paired with Events['workflow/end']. + +```ts cordis-catalog +'workflow/start'(info: WorkflowRunInfo): void +``` + +Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts) + ## Inherited events (cordis core + loader/hmr/timer) The framework events every plugin also sees, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of the event bus, without elevating framework internals to the harness tier's prominence. diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 951e182000..c1f67c0725 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -227,6 +227,22 @@ async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise + cancel(reason?: string): void + dispose(): Promise +} +``` + +## Failure discipline: `WorkflowError.fatal` + +Hook misuse inside a script — bad arguments, unknown/deferred `agent()` options, a schema outside the [structured-output subset](../../packages/core/tools/README.md), a tripped cap, a seam start failure, cancellation — throws a `WorkflowError` with `fatal: true`. The `parallel()`/`pipeline()` combinators RE-THROW fatal errors instead of mapping the item to `null`: a typo'd option must kill the script loudly, never dissolve into something that reads as an ordinary child failure. The per-item `null` is reserved for child-run failures (a non-`completed` stop reason) and ordinary in-stage script errors. + +## Events + +The `workflow/*` events (`workflow/start`, `workflow/phase`, `workflow/log`, `workflow/agent-start`, `workflow/agent-end`, `workflow/end` — see the [events catalog](../cordis-catalog/events.md)) are **observe-only** emits carrying DATA SNAPSHOTS: every payload starts with `WorkflowRunInfo` (id + meta), never the live `WorkflowRun`, so a subscriber cannot gain `cancel`/`dispose`, and `workflow/end` deliberately omits the result value (a listener observing outcomes must not receive a mutable alias of the caller's result). Every emit is per-listener contained — a throwing subscriber is logged, never propagated, and cannot starve the listeners registered after it — mirroring `subagent/start`/`subagent/end`. diff --git a/docs/module-graph.md b/docs/module-graph.md index e8f1edfaa5..cdb51ef1cc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -48,6 +48,9 @@ graph TD tools --> agent tools --> llm tools --> system-prompt + workflow --> agent + workflow --> brand + workflow --> llm acp --> agent acp --> llm acp --> session @@ -83,6 +86,10 @@ graph TD tool-web --> system-prompt tool-web --> tools tool-web --> web + tool-workflow --> agent + tool-workflow --> llm + tool-workflow --> tools + tool-workflow --> workflow agent-core --> agent agent-core --> agent-loop agent-core --> invariants @@ -112,6 +119,12 @@ graph TD tool-subagent --> llm tool-subagent --> subagent tool-subagent --> tools + workflow-vm --> agent + workflow-vm --> brand + workflow-vm --> llm + workflow-vm --> subagent + workflow-vm --> tools + workflow-vm --> workflow acp-agent --> acp acp-agent --> agent-core acp-agent --> app-boot @@ -159,6 +172,7 @@ graph TD | `session-persistence-jsonl` | `session`, `session-persistence` | | `session-persistence-sqlite` | `session`, `session-persistence` | | `tools` | `agent`, `llm`, `system-prompt` | +| `workflow` | `agent`, `brand`, `llm` | | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` | | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | | `hooks-codex` | `agent`, `hook-protocol`, `llm`, `session`, `tools` | @@ -167,12 +181,14 @@ graph TD | `tool-fs` | `fs`, `llm`, `session`, `system-prompt`, `tools` | | `tool-todo` | `agent`, `session`, `tools` | | `tool-web` | `llm`, `system-prompt`, `tools`, `web` | +| `tool-workflow` | `agent`, `llm`, `tools`, `workflow` | | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | | `hooks-claude` | `agent`, `hook-protocol`, `llm`, `session`, `subagent`, `tools` | | `subagent-acp` | `agent`, `llm`, `subagent` | | `subagent-inprocess` | `agent`, `llm`, `session`, `subagent`, `tools` | | `subagent-mock` | `agent`, `llm`, `subagent` | | `tool-subagent` | `agent`, `llm`, `subagent`, `tools` | +| `workflow-vm` | `agent`, `brand`, `llm`, `subagent`, `tools`, `workflow` | | `acp-agent` | `acp`, `agent-core`, `app-boot`, `session-persistence-jsonl` | | `stdio-agent` | `agent`, `agent-core`, `app-boot`, `llm`, `session`, `session-persistence-jsonl` | | `subagent-fork` | `agent`, `session`, `subagent`, `subagent-inprocess` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 54fdd5de70..12dd2715bd 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -95,6 +95,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 | | [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 | | [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 | +| [Dynamic workflows — a script-driven multi-agent orchestration seam](implemented/feature/2026-07-05-dynamic-workflows.md) | 2026-07-05 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md new file mode 100644 index 0000000000..372fce8f9a --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -0,0 +1,58 @@ +# RFC: Dynamic workflows — a script-driven multi-agent orchestration seam + +- **Status**: implemented +- **Class**: feature +- **First proposed**: 2026-07-05 + +## Problem + +The harness can delegate ONE task to ONE child (`dsh-tool-subagent`), but work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — forces the model to orchestrate turn by turn: every intermediate result lands in the parent context, the plan lives nowhere durable, and coordination costs a model round-trip per step. Claude Code ships this capability as [dynamic workflows](https://code.claude.com/docs/en/workflows): the model writes a JavaScript orchestration script, a runtime executes it, and the script — not the conversation — holds the loop, the branching, and the intermediate results. + +## Proposal + +A workflow capability family at `packages/workflow/` in the bash seam shape (interface / implementation / consumer), plus the structured-output foundation it needs on the subagent seam. + +### The script contract (Claude Code-compatible) + +A script is `export const meta = {...}` (a PURE object literal: `name`, `description`, optional `whenToUse`/`phases`) followed by a plain-JS body with top-level `await`, ending in `return `. The body sees exactly: `agent(prompt, {label, phase, schema, model})`, `parallel(thunks)`, `pipeline(items, ...stages)` (NO cross-stage barrier; `(prev, item, index)` callbacks), `phase(title)`, `log(message)`, and `args`. CC semantics are preserved where they matter to script authors: a failed child resolves `null` (scripts `.filter(Boolean)`); an ordinary stage throw nulls the ITEM and skips its remaining stages; `Date.now()`/`Math.random()`/argless `new Date()` throw (kept banned so future resume support cannot break script compatibility). + +One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferred options (`effort`/`isolation`/`agentType`), malformed arguments, schemas outside the supported subset, tripped caps, seam start failures — throws a `WorkflowError` with `fatal: true`, and the combinators RE-THROW fatal errors instead of nulling the item. Without this, a typo'd option dissolves into a `null` indistinguishable from a child failure — the accepted-then-ignored failure mode this repo bans. One addition: the tool's `args` parameter is a JSON OBJECT (a bare list is wrapped as a field) so the wire schema stays honest. + +### The seam (dsh-workflow) + +`ctx.workflows` is an abstract `WorkflowService` in the bash shape — one engine per context, no named-provider registry (engines are deployment swaps, not co-residents). `start(request)` throws synchronously for a script that cannot begin; a returned `WorkflowRun`'s `result` NEVER rejects (failures resolve as `stopReason: 'error' | 'cancelled'`). The `workflow/*` events are observe-only emits carrying DATA SNAPSHOTS (id + meta; `workflow/end` omits the result value), per-listener contained, mirroring `subagent/start`/`subagent/end` — control stays with the run's holder. Vocabulary details: [core-data-structures/workflow.md](../../../core-data-structures/workflow.md). + +### The engine (dsh-workflow-vm): in-process node:vm + +**Why node:vm and not isolated-vm/worker threads**: isolated-vm is in maintenance mode, needs `--no-node-snapshot` on EVERY consumer process (including the published bins) on Node ≥ 20, and falls back to node-gyp source builds; a worker-thread engine turns every hook into RPC and complicates the per-file coverage gate. Scripts are model-written — the same trust level as the model's existing bash access — so genuine sandboxing is not the current requirement. The interface/implementation split exists precisely so a hardened engine can swap in later. Accepted, documented limitations: vm is not a security boundary, and the vm timeout covers only the initial synchronous slice — a pathological synchronous spin after the first await cannot be killed in-process; `dispose()` cancels, waits a bounded grace, then abandons. + +**Meta extraction**: a string/comment-aware brace scanner (template interpolation rejected) finds the literal; it is evaluated ALONE in an empty, timed vm context; the result must materialize to plain JSON data and pass shape validation (unknown fields rejected loud); the statement is blanked line-preservingly so stacks keep script line numbers. + +**Realm boundary**: values entering the host (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a descriptor walk that NEVER invokes accessors (the repo's `isJsonValue` is prototype-strict and getter-invoking, so it cannot run first; it would reject every cross-realm object and let realm code run outside the timed window) and rejects loud everything JSON cannot carry, copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values entering the realm (`args`, `agent()` results) are rebuilt INSIDE the realm via the context's own `JSON.parse`, so the script never holds a live host-prototype object. Realm functions (stages, thunks) are called, never materialized. + +**Containment**: every hook-returned promise carries a no-op rejection consumer, so a script that drops a promise cannot surface an unhandled rejection when cancellation rejects it — `dsh-app-boot` exits the process on unhandled rejections. Caps (`maxConcurrentAgents` auto = `min(16, cores - 2)`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. + +### The consumer (dsh-tool-workflow) + +A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, await, `try/finally` dispose, abort-bridge `exec.signal`, non-`completed` → `isError`. Render intent: a `generic` card titled by a textual `meta.name` sniff (presentation is a pure function of args). The tool description IS the model-facing authoring spec. Examples load it with guidance to use workflows only on explicit user request — the harness has no ultracode-style effort gate. + +### The foundation: structured output on the subagent seam + +`agent({schema})` needs `SubagentStartRequest.outputSchema` to actually work; it was vocabulary without an implementation (`outputSchema: false` everywhere). Implemented in `dsh-subagent-inprocess` for both in-process backends: a globally registered `structured_output` capture tool whose per-child schema is enforced by a `prepend: true` `agent/request` listener doing FINAL-REQUEST enforcement (post-processing `await next()` — cooperative mutation would not survive a downstream listener returning a replacement request), an `agent/turn-continuation` veto after capture (no wasted extra model step), validation-retry in-turn via `ToolArgsError`, and a clean-finish nudge loop (`structuredNudgeRetries`). Lifetime is refcounted by backends (plugin lifetime) AND live runs (start → settle). The seam's `outputSchema` type became the raw JSON-Schema SUBSET (`StructuredOutputSchema` in dsh-tools: single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`; anything unenforced is rejected loud) — the schema travels verbatim to the model as the forced tool's parameters, so the wire format, not the author DSL, is the right vocabulary. + +## What was rejected + +- **Background execution as the default** (CC's shape): deferred; foreground-synchronous matches `dsh-tool-subagent`'s cut, and background semantics should be designed ONCE across bash/subagent/workflow rather than per-tool. +- **Workflow-layer JSON parsing for `agent({schema})`**: duplicating a seam concern at one consumer while the seam's capability flag stayed dishonestly `false`. +- **Meta as tool parameters instead of `export const meta`**: zero parsing, but scripts stop being self-contained artifacts and CC-authored scripts stop being drop-in. +- **`SchemaSpec` as the outputSchema type**: the author-facing DSL cannot express what arrives as data and cannot be validated against without conversion loss. + +## Deferred (documented non-goals of this cut) + +- **Background collection** (start tool → run id → completion notice → collect), designed alongside bash/subagent background unification. +- **Journaling + resume** (`resumeFromRunId`, cached agent() prefixes) — the determinism bans already keep scripts resume-compatible. +- **Saved/bundled workflows** (a `.deepseek/workflows/` registry, slash-command surface) and **script persistence to a run directory** (the tool-call event already records the script durably). +- **Nested `workflow()`**, **token `budget`**, and the `effort`/`isolation`/`agentType` agent options (each rejects loud with a message naming it deferred). +- **Engine hardening**: a worker-thread or isolated-vm engine behind the same seam (kills synchronous spins; adds memory limits). +- **ACP progress UI** over the `workflow/*` events (a `/workflows`-style view); the events exist for it. +- **ACP-backend structured output** and **`toolFilter`** (both still capability-gated `false`). diff --git a/docs/tool-catalog/tools.md b/docs/tool-catalog/tools.md index b1218c3871..9a2d3603f3 100644 --- a/docs/tool-catalog/tools.md +++ b/docs/tool-catalog/tools.md @@ -260,6 +260,45 @@ Record and update a structured task list for the current work. Send the ENTIRE l Source: [`packages/todo/tool-todo/src/index.ts`](../../packages/todo/tool-todo/src/index.ts) +## `@deepseek-ai/dsh-tool-workflow` + +### `workflow` + +Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. + +The script MUST begin with `export const meta = {...}` — a PURE object literal (no variables, calls, or template interpolation) with required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The body after it is plain JavaScript (NOT TypeScript) running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. + +Script-body hooks: +- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. +- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. +- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. +- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. + +Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. + +Constraints: concurrency and total-agent caps apply; `Date.now()`, `Math.random()`, and argless `new Date()` throw (pass timestamps via `args`); no filesystem, network, timers, or Node.js APIs — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. + +```json +{ + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The complete workflow script: `export const meta = {...}` followed by the plain-JS body (top-level await allowed; end with `return `)." + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." + } + }, + "required": [ + "script" + ] +} +``` + +Source: [`packages/workflow/tool-workflow/src/index.ts`](../../packages/workflow/tool-workflow/src/index.ts) + ## `@deepseek-ai/dsh-tool-web` ### `web_fetch` diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 01849bb66e..665a6c2071 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -56,6 +56,12 @@ subagent_fork instead when the subtask needs THIS conversation's context: the child inherits the log so far. + Use the workflow tool ONLY when the user explicitly asks for a + workflow or for large multi-agent orchestration: you write a + JavaScript script (its description documents the exact format) that + fans work out across many subagents with phases and structured + results. For one or two delegations, prefer plain subagent calls. + For multi-step work, use the todo_write tool to track a task list: send the WHOLE list each call (it replaces the previous one), keep at most one task in_progress (exactly one while work remains), and mark a @@ -93,6 +99,18 @@ provider: fork toolName: subagent_fork + +# Dynamic workflows: the node:vm engine (ctx.workflows) over the spawn subagent +# backend above, plus the model-facing `workflow` tool. The model writes a +# JavaScript orchestration script (meta + body); the engine runs it in-process +# and fans agent() calls out as spawn children. +- id: workflow-vm + name: '@deepseek-ai/dsh-workflow-vm' + config: + provider: spawn + +- id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' # The model-facing todo_write tool: whole-list task tracking written to the # session log (todo/write), surfaced to the ACP client as a `plan` update. - id: tool-todo diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 65d2dee9da..5eab642fe4 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -78,6 +78,10 @@ const SCENARIOS: Scenario[] = [ { name: 'subagent-multi', hasModelTurn: true, recorded: true, childSessions: 2 }, { name: 'subagent-fork', hasModelTurn: true, recorded: true, childSessions: 1 }, { name: 'subagent-mixed', hasModelTurn: true, recorded: true, childSessions: 2 }, + // The workflow tool: the model writes a one-child orchestration script; the + // child runs as a spawn subagent inside the vm engine (its session is the + // child fixture), and the tool result carries the script's return value. + { name: 'workflow-run', hasModelTurn: true, recorded: true, childSessions: 1 }, // Hook matrix — one scenario per hook point × its headline Decision outcome, // across BOTH bridges (Claude `hooks.json`, Codex `codex-hooks.json`, seeded in // workspace/). The block scenarios need no model call: a UserPromptSubmit hook diff --git a/examples/acp-agent/tests/snapshots/workflow-run/input.json b/examples/acp-agent/tests/snapshots/workflow-run/input.json new file mode 100644 index 0000000000..2c402dc49e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workflow-run/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the workflow tool exactly once, with args omitted and this EXACT script (copy it verbatim):\nexport const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl new file mode 100644 index 0000000000..de6616fe9b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl @@ -0,0 +1,37 @@ +{"type":"session","version":0,"id":"d6d69d2a-3933-445f-a84d-d8c1b941f5ce","createdAt":1783227490354,"cwd":"/tmp/acp-snap-cwd-I14oAK","parentSession":"fe74cfdb-40b4-45bd-b6fe-efd2b244c415"} +{"type":"turn/start","seq":0,"time":1783227490354,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783227490354,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783227490355,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783227491136,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783227491137,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783227491218,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783227491246,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783227491247,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783227491247,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783227491247,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":10,"time":1783227491247,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":11,"time":1783227491276,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":12,"time":1783227491276,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":13,"time":1783227491276,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":14,"time":1783227491304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":15,"time":1783227491304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} +{"type":"assistant/chunk","seq":16,"time":1783227491304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} +{"type":"assistant/chunk","seq":17,"time":1783227491304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":18,"time":1783227491304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":19,"time":1783227491304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":20,"time":1783227491336,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":21,"time":1783227491336,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":22,"time":1783227491336,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":23,"time":1783227491336,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":24,"time":1783227491336,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":25,"time":1783227491336,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WF"}}} +{"type":"assistant/chunk","seq":26,"time":1783227491366,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_CH"}}} +{"type":"assistant/chunk","seq":27,"time":1783227491366,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ILD"}}} +{"type":"assistant/chunk","seq":28,"time":1783227491366,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} +{"type":"assistant/chunk","seq":29,"time":1783227491366,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"WF_CHILD_OK\" and nothing else."}}}} +{"type":"assistant/chunk","seq":30,"time":1783227491366,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":31,"time":1783227491366,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2707,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":32,"time":1783227491366,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":33,"time":1783227491366,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"usage":{"inputTokens":2707,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"} +{"type":"step/end","seq":34,"time":1783227491367,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":35,"time":1783227491367,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl new file mode 100644 index 0000000000..9509032af2 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl @@ -0,0 +1,152 @@ +{"type":"session","version":0,"id":"fe74cfdb-40b4-45bd-b6fe-efd2b244c415","createdAt":1783227488560,"cwd":"/tmp/acp-snap-cwd-I14oAK"} +{"type":"turn/start","seq":0,"time":1783227488563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783227488564,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted and this EXACT script (copy it verbatim):\nexport const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783227488565,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783227489485,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783227489486,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783227489604,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783227489638,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783227489639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783227489639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783227489639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":10,"time":1783227489665,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":11,"time":1783227489665,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} +{"type":"assistant/chunk","seq":12,"time":1783227489666,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":13,"time":1783227489666,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":14,"time":1783227489692,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":15,"time":1783227489693,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exact"}}} +{"type":"assistant/chunk","seq":16,"time":1783227489693,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" script"}}} +{"type":"assistant/chunk","seq":17,"time":1783227489693,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" provided"}}} +{"type":"assistant/chunk","seq":18,"time":1783227489720,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":19,"time":1783227489720,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" args"}}} +{"type":"assistant/chunk","seq":20,"time":1783227489774,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" omitted"}}} +{"type":"assistant/chunk","seq":21,"time":1783227489775,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":22,"time":1783227489775,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":23,"time":1783227489775,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":24,"time":1783227489781,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":25,"time":1783227489781,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":26,"time":1783227489811,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":27,"time":1783227489811,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WORK"}}} +{"type":"assistant/chunk","seq":28,"time":1783227489811,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}} +{"type":"assistant/chunk","seq":29,"time":1783227489811,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}} +{"type":"assistant/chunk","seq":30,"time":1783227489841,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":31,"time":1783227489841,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":32,"time":1783227489842,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":33,"time":1783227489842,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} +{"type":"assistant/chunk","seq":34,"time":1783227489842,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":35,"time":1783227489869,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":36,"time":1783227489869,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":37,"time":1783227489961,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":38,"time":1783227489961,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":39,"time":1783227489961,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":40,"time":1783227489961,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1783227489991,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"script"}}} +{"type":"assistant/chunk","seq":42,"time":1783227489991,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":43,"time":1783227489991,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":44,"time":1783227489991,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":45,"time":1783227490021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"export"}}} +{"type":"assistant/chunk","seq":46,"time":1783227490021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" const"}}} +{"type":"assistant/chunk","seq":47,"time":1783227490021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" meta"}}} +{"type":"assistant/chunk","seq":48,"time":1783227490021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":49,"time":1783227490021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" {"}}} +{"type":"assistant/chunk","seq":50,"time":1783227490021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" name"}}} +{"type":"assistant/chunk","seq":51,"time":1783227490049,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":52,"time":1783227490049,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" '"}}} +{"type":"assistant/chunk","seq":53,"time":1783227490050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"sn"}}} +{"type":"assistant/chunk","seq":54,"time":1783227490050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"apshot"}}} +{"type":"assistant/chunk","seq":55,"time":1783227490050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"-flow"}}} +{"type":"assistant/chunk","seq":56,"time":1783227490050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"',"}}} +{"type":"assistant/chunk","seq":57,"time":1783227490078,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":58,"time":1783227490078,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":59,"time":1783227490078,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" '"}}} +{"type":"assistant/chunk","seq":60,"time":1783227490078,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"one"}}} +{"type":"assistant/chunk","seq":61,"time":1783227490078,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" child"}}} +{"type":"assistant/chunk","seq":62,"time":1783227490107,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" for"}}} +{"type":"assistant/chunk","seq":63,"time":1783227490108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":64,"time":1783227490108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" snapshot"}}} +{"type":"assistant/chunk","seq":65,"time":1783227490108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"'"}}} +{"type":"assistant/chunk","seq":66,"time":1783227490108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" }\\n"}}} +{"type":"assistant/chunk","seq":67,"time":1783227490108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"phase"}}} +{"type":"assistant/chunk","seq":68,"time":1783227490137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"('"}}} +{"type":"assistant/chunk","seq":69,"time":1783227490137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"Run"}}} +{"type":"assistant/chunk","seq":70,"time":1783227490137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"')\\n"}}} +{"type":"assistant/chunk","seq":71,"time":1783227490137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":72,"time":1783227490137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" reply"}}} +{"type":"assistant/chunk","seq":73,"time":1783227490166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":74,"time":1783227490166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":75,"time":1783227490166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" agent"}}} +{"type":"assistant/chunk","seq":76,"time":1783227490166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"('"}}} +{"type":"assistant/chunk","seq":77,"time":1783227490196,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":78,"time":1783227490196,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":79,"time":1783227490196,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":80,"time":1783227490196,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":81,"time":1783227490224,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":82,"time":1783227490224,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" WF"}}} +{"type":"assistant/chunk","seq":83,"time":1783227490225,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"_CH"}}} +{"type":"assistant/chunk","seq":84,"time":1783227490225,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"ILD"}}} +{"type":"assistant/chunk","seq":85,"time":1783227490225,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":86,"time":1783227490253,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":87,"time":1783227490253,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":88,"time":1783227490253,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":89,"time":1783227490253,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":".')\\n"}}} +{"type":"assistant/chunk","seq":90,"time":1783227490253,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":91,"time":1783227490253,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" {"}}} +{"type":"assistant/chunk","seq":92,"time":1783227490278,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" reply"}}} +{"type":"assistant/chunk","seq":93,"time":1783227490279,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" }"}}} +{"type":"assistant/chunk","seq":94,"time":1783227490279,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":95,"time":1783227490314,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":96,"time":1783227490347,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run the workflow tool with the exact script provided, args omitted, and then reply with \"WORKFLOW_DONE\" after it returns."}}}} +{"type":"assistant/chunk","seq":97,"time":1783227490347,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","arguments":"{\"script\": \"export const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\"}"}}}} +{"type":"assistant/chunk","seq":98,"time":1783227490347,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3124,"outputTokens":125,"cacheReadTokens":0,"reasoningTokens":33}}}} +{"type":"assistant/chunk","seq":99,"time":1783227490347,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":100,"time":1783227490349,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run the workflow tool with the exact script provided, args omitted, and then reply with \"WORKFLOW_DONE\" after it returns."},{"type":"tool-call","id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","arguments":"{\"script\": \"export const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\"}"}],"usage":{"inputTokens":3124,"outputTokens":125,"cacheReadTokens":0,"reasoningTokens":33}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99],"surfaceOp":"append"} +{"type":"tool/call","seq":101,"time":1783227490349,"data":{"turn":1,"step":1,"callId":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","arguments":"{\"script\": \"export const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\"}"}} +{"type":"tool/result","seq":102,"time":1783227491372,"data":{"turn":1,"step":1,"callId":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[101],"surfaceOp":"append"} +{"type":"step/end","seq":103,"time":1783227491373,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":104,"time":1783227491373,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":105,"time":1783227491987,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":106,"time":1783227491988,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":107,"time":1783227492202,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} +{"type":"assistant/chunk","seq":108,"time":1783227492230,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":109,"time":1783227492231,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":110,"time":1783227492231,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":111,"time":1783227492259,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":112,"time":1783227492260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":113,"time":1783227492287,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":114,"time":1783227492288,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} +{"type":"assistant/chunk","seq":115,"time":1783227492288,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} +{"type":"assistant/chunk","seq":116,"time":1783227492288,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":117,"time":1783227492288,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":118,"time":1783227492288,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":119,"time":1783227492317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":120,"time":1783227492317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":121,"time":1783227492317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":122,"time":1783227492317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":123,"time":1783227492317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":124,"time":1783227492345,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":125,"time":1783227492345,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":126,"time":1783227492374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":127,"time":1783227492374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":128,"time":1783227492374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":129,"time":1783227492374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WORK"}}} +{"type":"assistant/chunk","seq":130,"time":1783227492374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}} +{"type":"assistant/chunk","seq":131,"time":1783227492374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}} +{"type":"assistant/chunk","seq":132,"time":1783227492411,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":133,"time":1783227492412,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":134,"time":1783227492412,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":135,"time":1783227492435,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":136,"time":1783227492435,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":137,"time":1783227492435,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":138,"time":1783227492435,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":139,"time":1783227492435,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WORK"}}} +{"type":"assistant/chunk","seq":140,"time":1783227492435,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"FL"}}} +{"type":"assistant/chunk","seq":141,"time":1783227492464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OW"}}} +{"type":"assistant/chunk","seq":142,"time":1783227492464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":143,"time":1783227492464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":144,"time":1783227492465,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with the single word \"WORKFLOW_DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":145,"time":1783227492465,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} +{"type":"assistant/chunk","seq":146,"time":1783227492465,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":87,"outputTokens":38,"cacheReadTokens":3200,"reasoningTokens":32}}}} +{"type":"assistant/chunk","seq":147,"time":1783227492465,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":148,"time":1783227492465,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with the single word \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"usage":{"inputTokens":87,"outputTokens":38,"cacheReadTokens":3200,"reasoningTokens":32}},"sourceEventSeqs":[105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147],"surfaceOp":"append"} +{"type":"step/end","seq":149,"time":1783227492465,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":150,"time":1783227492465,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl new file mode 100644 index 0000000000..6955ea7cc8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl @@ -0,0 +1,75 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" workflow"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exact"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" script"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" provided"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" args"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" omitted"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WORK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OW"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" after"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","title":"workflow: snapshot-flow","kind":"other","status":"in_progress","rawInput":"export const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","status":"completed","content":[{"type":"content","content":{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" workflow"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" successfully"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WF"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_CH"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ILD"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WORK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OW"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"WORK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"FL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OW"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 0c95299fca..f6f56cb57d 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -46,7 +46,7 @@ # under ./.sessions); unset starts a fresh session each run. resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' - welcome: 'agent REPL ready. Give it a coding task (its tools are read, write, edit, bash, subagent, and todo_write).' + welcome: 'agent REPL ready. Give it a coding task (its tools are read, write, edit, bash, subagent, workflow, and todo_write).' systemPrompt: | You are coding-agent, a CLI coding assistant. @@ -64,6 +64,12 @@ subagent_fork instead when the subtask needs THIS conversation's context: the child inherits the log so far. + Use the workflow tool ONLY when the user explicitly asks for a + workflow or for large multi-agent orchestration: you write a + JavaScript script (its description documents the exact format) that + fans work out across many subagents with phases and structured + results. For one or two delegations, prefer plain subagent calls. + Check the [exit code: N] marker on every command; investigate failures before moving on. Verify your work by running the code or tests. Keep answers brief and factual. @@ -120,6 +126,18 @@ provider: fork toolName: subagent_fork + +# Dynamic workflows: the node:vm engine (ctx.workflows) over the spawn subagent +# backend above, plus the model-facing `workflow` tool. The model writes a +# JavaScript orchestration script (meta + body); the engine runs it in-process +# and fans agent() calls out as spawn children. +- id: workflow-vm + name: '@deepseek-ai/dsh-workflow-vm' + config: + provider: spawn + +- id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' # The model-facing todo_write tool: whole-list task tracking written to the # session log (todo/write), rendered as a stdio checklist / ACP plan. - id: tool-todo diff --git a/knip.json b/knip.json index 5e61645458..39fb4c414f 100644 --- a/knip.json +++ b/knip.json @@ -1,7 +1,11 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", - "exclude": ["duplicates"], - "ignoreWorkspaces": ["vendor/*"], + "exclude": [ + "duplicates" + ], + "ignoreWorkspaces": [ + "vendor/*" + ], "workspaces": { ".": { "entry": [ @@ -11,55 +15,138 @@ "examples/acp-agent/tests/**/*.e2e.ts", "examples/acp-agent/tests/**/*.snapshot.ts" ], - "project": ["scripts/**/*.ts", "examples/**/*.ts"] + "project": [ + "scripts/**/*.ts", + "examples/**/*.ts" + ] }, "packages/*/*": { - "entry": ["tests/**/*.spec.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/util/brand": { - "project": ["src/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": [ + "src/**/*.ts" + ], + "ignoreDependencies": [ + "cordis" + ] }, "packages/llm/llm-deepseek": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/llm/llm-pi-ai": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/web/web-search-exa": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/web/web-search-perplexity": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/web/web-search-deepseek": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/ui/acp-agent": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/ui/stdio-agent": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/subagent/subagent-spawn": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/subagent/subagent-acp": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/mock-acp-server.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts", + "tests/mock-acp-server.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/fs/tool-fs": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, + "packages/workflow/workflow-vm": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] } } } diff --git a/packages/README.md b/packages/README.md index f07e0d5a36..3c38eda671 100644 --- a/packages/README.md +++ b/packages/README.md @@ -14,6 +14,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface | | [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface | | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | +| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, the node:vm engine, and the model-facing `workflow` tool | Product — stable surface | | [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | | [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 05236dd322..874f697797 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -35,7 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write']) + expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index 41c46e00d3..9a53dd61f4 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -36,6 +36,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-fork": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 643000de2c..0a92c15fa8 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -10,8 +10,8 @@ import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import * as spawn from '../../subagent-spawn/src/index.ts' -import * as fork from '../../subagent-fork/src/index.ts' +import * as spawn from '@deepseek-ai/dsh-subagent-spawn' +import * as fork from '@deepseek-ai/dsh-subagent-fork' import { acquireStructuredRuntime, STRUCTURED_OUTPUT_INSTRUCTION, diff --git a/packages/workflow/README.md b/packages/workflow/README.md new file mode 100644 index 0000000000..f28a325ea8 --- /dev/null +++ b/packages/workflow/README.md @@ -0,0 +1,13 @@ +# workflow/ — dynamic-workflow capability family + +The workflow seam: a model-written JavaScript orchestration script that fans out subagents at scale (phases, structured per-agent results, concurrency caps), modeled on Claude Code's dynamic workflows. A capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)) in the bash shape: ONE engine implementation per context registers as `ctx.workflows`; the model-facing tool consumes it. + +| Package | Role | ctx key | +|---|---|---| +| `workflow/` | Abstract workflow seam: service base class + run vocabulary + `workflow/*` events | `ctx.workflows` | +| `workflow-vm/` | In-process `node:vm` engine: parses the script, injects the hooks, drives `ctx.subagents` | (provides `ctx.workflows`) | +| `tool-workflow/` | Model-facing `workflow` tool over `ctx.workflows` | (registers on `ctx.tools`) | + +The interface lives at `workflow/workflow/`. The engine's `agent()` hook rides the [subagent seam](../subagent/README.md) (any registered provider; the shipped examples use `spawn`), and `agent({ schema })` rides the structured-output support the in-process backends implement. The seam split exists for engine hardening: `node:vm` is in-process and cannot kill a pathological synchronous spin — a worker-thread or isolated-vm engine swaps in behind the same interface if that ever matters. + +The proposal, decisions, and deferred work: [docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md](../../docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md). diff --git a/packages/workflow/tool-workflow/README.md b/packages/workflow/tool-workflow/README.md new file mode 100644 index 0000000000..4cd86fb43b --- /dev/null +++ b/packages/workflow/tool-workflow/README.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-tool-workflow + +The model-facing **`workflow` tool**: run a JavaScript orchestration script that fans out subagents, and return the script's final value. Pure schema + lifecycle shaping over [`ctx.workflows`](../workflow/README.md) — script parsing, execution, caps, and cancellation live behind the seam, so a hardened engine swaps in without touching what the model sees. + +## What the model sees + +Two parameters: `script` (required — the full `export const meta = {...}` + body text; the tool DESCRIPTION carries the complete authoring contract: hooks, semantics, determinism bans, the supported schema subset) and `args` (optional JSON object exposed to the script as the `args` global; a bare list is wrapped as a field, a deliberate deviation from Claude Code's any-JSON `args` so the wire schema stays honest). + +## Lifecycle + +Collection is SYNCHRONOUS this cut (like [`dsh-tool-subagent`](../../subagent/tool-subagent/README.md)): `execute` starts a run and awaits `run.result` inside a `try/finally` that always disposes the run, so the script and its children reach quiescence on every path. `exec.signal` is bridged to `run.cancel()` (including the already-aborted-before-start case). A non-`completed` stop reason maps to an `isError` result reporting the reason — never partial output as success; a parse/meta failure thrown synchronously by `start()` becomes an `isError` the model can correct from. The completed result renders the meta name, the agent count, and the return value as JSON, truncated at `maxResultChars` with an explicit notice. + +## Render intent + +Decided up front (per the [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md)): a `generic` card titled `workflow: `, the name sniffed TEXTUALLY from `args.script` (presentation must be a pure function of args, so it cannot ask the engine to parse); the script text rides as `rawInput`. The result keeps the generic card. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `toolName` | `workflow` | The model-facing tool name to register. | +| `maxResultChars` | `50000` | Rendered-result ceiling; longer JSON is truncated with a notice. | diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json new file mode 100644 index 0000000000..9c3f819cb5 --- /dev/null +++ b/packages/workflow/tool-workflow/package.json @@ -0,0 +1,43 @@ +{ + "name": "@deepseek-ai/dsh-tool-workflow", + "description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflows", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-workflow": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-workflow": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts new file mode 100644 index 0000000000..eeed5c9dbe --- /dev/null +++ b/packages/workflow/tool-workflow/src/index.ts @@ -0,0 +1,179 @@ +/** + * The model-facing `workflow` tool: run a JavaScript orchestration script that + * fans out subagents, and return the script's final value. Pure schema + + * lifecycle shaping — script parsing, execution, caps, and cancellation live + * behind `ctx.workflows` (`@deepseek-ai/dsh-workflow`), so a hardened engine + * swaps in without touching what the model sees. + * + * Collection is SYNCHRONOUS this cut (like `dsh-tool-subagent`): `execute` + * starts a run and awaits `run.result` inside a `try/finally` that always + * disposes the run, so the script and its children are torn down on every + * path. A non-`completed` stop reason maps to an `isError` tool result (by + * throwing) rather than returning partial output as success. Background + * collection is deferred to the cross-tool background redesign. + * + * Render intent (decided up front, per the render-intent RFC): a `generic` + * card whose title carries the script's `meta.name`, sniffed textually from + * the args — presentation must be a pure function of `args`, so it cannot ask + * the engine to parse. + * + * @module @deepseek-ai/dsh-tool-workflow + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { WorkflowResult, WorkflowRun } from '@deepseek-ai/dsh-workflow' + +export const name = 'tool-workflow' +export const inject = ['tools', 'workflows'] + +/** Config: the model-facing tool name plus result rendering caps. */ +export interface Config { + /** The model-facing tool name to register (default `workflow`). */ + toolName?: string + /** Rendered-result ceiling, in characters: a longer JSON value is truncated with a notice (default 50000). */ + maxResultChars?: number +} + +export const Config: z = z.object({ + toolName: z.string().default('workflow'), + maxResultChars: z.natural().min(1).default(50_000), +}) + +/** + * The script-authoring contract, embedded in the tool description. This IS the + * model-facing spec: the meta block, the hooks and their exact semantics, the + * determinism bans, and the supported schema subset. + */ +const DESCRIPTION = `Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. + +The script MUST begin with \`export const meta = {...}\` — a PURE object literal (no variables, calls, or template interpolation) with required \`name\` (short kebab-case) and \`description\` strings, optional \`whenToUse\` string and \`phases\` array (\`{title, detail?, model?}\`). The body after it is plain JavaScript (NOT TypeScript) running with top-level await; end with \`return \` — the value must be JSON-serializable and is this tool's result. + +Script-body hooks: +- \`agent(prompt, opts?): Promise\` — run one subagent to completion. Without \`opts.schema\` it resolves to the child's final text; with \`opts.schema\` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves \`null\` when the child fails (filter with \`.filter(Boolean)\`). Other opts: \`label\` (display), \`phase\` (progress group), \`model\` (override). Anything else (\`effort\`/\`isolation\`/\`agentType\`) is rejected loudly. +- \`pipeline(items, ...stages): Promise\` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives \`(prev, item, index)\`. An ordinary stage throw drops that ITEM to \`null\` and skips its remaining stages. +- \`parallel(thunks): Promise\` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to \`null\`. +- \`phase(title)\` — start a progress phase; \`log(message)\` — narrate progress; \`args\` — the tool call's \`args\` input, verbatim. + +Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item \`null\`. + +Constraints: concurrency and total-agent caps apply; \`Date.now()\`, \`Math.random()\`, and argless \`new Date()\` throw (pass timestamps via \`args\`); no filesystem, network, timers, or Node.js APIs — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.` + +type WorkflowCallArgs = { script: string; args?: Record } + +/** Best-effort meta.name sniff for presentation (pure textual; no evaluation). */ +function sniffMetaName(script: string): string | undefined { + const match = /export\s+const\s+meta\s*=\s*\{[^{}]*?name\s*:\s*(['"`])([^'"`\n]{1,64})\1/.exec(script) + return match?.[2] +} + +/** The pending-state card: a generic card titled by the script's meta name. */ +function presentWorkflowCall(args: WorkflowCallArgs): ToolCallView { + const name = sniffMetaName(args.script) + return { + card: 'generic', + title: name !== undefined ? `workflow: ${name}` : 'workflow', + rawInput: args.script, + } +} + +/** The completed-state card: keep the pending title; render the result content as-is. */ +function presentWorkflowResult(args: WorkflowCallArgs, result: { content: ContentBlock[]; isError: boolean }): ToolResultView { + void args + void result + return { card: 'generic' } +} + +/** A non-`completed` stop reason means the script did not finish cleanly. */ +function stopReasonError(result: WorkflowResult): string | undefined { + switch (result.stopReason) { + case 'completed': + return undefined + case 'cancelled': + return `workflow run was cancelled${result.error !== undefined ? ` (${result.error})` : ''}` + case 'error': + return `workflow run failed: ${result.error ?? 'unknown error'}` + /* v8 ignore start -- defensive: WorkflowStopReason is a closed union, exhaustive by construction; a future variant fails here loudly */ + default: + return `workflow run ended abnormally (${String(result.stopReason satisfies never)})` + /* v8 ignore stop */ + } +} + +/** Render the run's outcome text: the meta name, agent count, and the JSON value (capped). */ +function renderResult(run: WorkflowRun, result: WorkflowResult, maxChars: number): string { + // The engine returns JSON data (null for a valueless script), so stringify never yields undefined. + const rendered = JSON.stringify(result.value, null, 2) + const clipped = rendered.length > maxChars + ? `${rendered.slice(0, maxChars)}\n… [truncated: ${rendered.length - maxChars} more characters]` + : rendered + return `workflow "${run.meta.name}" completed (${result.agentsStarted} agent${result.agentsStarted === 1 ? '' : 's'}).\nReturn value:\n${clipped}` +} + +export function apply(ctx: Context, config: Config): void { + const maxResultChars = config.maxResultChars ?? 50_000 + ctx.tools.register(defineTool({ + name: config.toolName ?? 'workflow', + description: DESCRIPTION, + parameters: { + script: { + type: 'string', + required: true, + description: 'The complete workflow script: `export const meta = {...}` followed by the plain-JS body (top-level await allowed; end with `return `).', + }, + args: { + type: 'object', + description: 'Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}).', + }, + }, + async execute(args, exec): Promise { + const parent = exec.agent + if (!parent) { + // The loop sets `exec.agent` for every model-driven call; its absence + // means a non-agent caller invoked the tool directly, which has no + // parent to attribute the children to. Fail loud rather than guess. + throw new Error('workflow tool requires a calling agent (exec.agent was undefined)') + } + + // Parse failures (SCRIPT_PARSE/META_INVALID) throw synchronously here + // and become isError results via the registry — the model sees the + // violation list and can correct the script. + const run: WorkflowRun = ctx.workflows.start({ + script: args.script, + ...args.args !== undefined ? { args: args.args } : {}, + parent, + ...exec.signal ? { signal: exec.signal } : {}, + }) + + // Bridge the tool's abort signal to the run: if the parent step is + // aborted while the script is in flight, cancel the whole run. The + // engine also receives `signal` directly, but an explicit bridge keeps + // the tool's contract local (and covers an engine that ignores it). + const onAbort = (): void => { run.cancel('parent step aborted') } + exec.signal?.addEventListener('abort', onAbort, { once: true }) + // `addEventListener` does NOT fire for a signal already aborted before + // this line — cancel explicitly in that case. + if (exec.signal?.aborted) run.cancel('parent step aborted') + + try { + const result = await run.result + const error = stopReasonError(result) + if (error !== undefined) { + // Map a non-clean finish to an isError result (the registry turns a + // throw into an isError). Report the reason, not partial output. + throw new Error(error) + } + return [{ type: 'text', text: renderResult(run, result, maxResultChars) }] + } finally { + exec.signal?.removeEventListener('abort', onAbort) + // Always reach run quiescence — never leak a live script or children. + await run.dispose() + } + }, + presentCall: args => presentWorkflowCall(args), + presentResult: (args, result) => presentWorkflowResult(args, result), + })) +} diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts new file mode 100644 index 0000000000..012fae4961 --- /dev/null +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -0,0 +1,224 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import { AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow' +import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' +import { CallId } from '@deepseek-ai/dsh-llm' +import * as toolWorkflow from '../src/index.ts' + +/** A controllable engine standing in behind ctx.workflows (the tool's only seam). */ +class StubEngine extends WorkflowService { + requests: WorkflowStartRequest[] = [] + cancels: string[] = [] + disposed = 0 + settle!: (result: WorkflowResult) => void + startError: Error | undefined + + start(request: WorkflowStartRequest): WorkflowRun { + if (this.startError) throw this.startError + this.requests.push(request) + const result = new Promise((resolve) => { this.settle = resolve }) + request.signal?.addEventListener('abort', () => { + this.settle({ value: null, stopReason: 'cancelled', error: 'signal', agentsStarted: 0 }) + }, { once: true }) + return { + id: WorkflowRunId('run-1'), + meta: { name: 'stub-flow', description: 'd' }, + result, + cancel: (reason?: string) => { + this.cancels.push(reason ?? 'cancelled') + this.settle({ value: null, stopReason: 'cancelled', ...reason !== undefined ? { error: reason } : {}, agentsStarted: 0 }) + }, + dispose: () => { + this.disposed += 1 + return Promise.resolve() + }, + } + } +} + +async function setup(config?: { toolName?: string; maxResultChars?: number }) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(StubEngine) + await ctx.plugin(toolWorkflow, config ?? {}) + const engine = ctx.workflows as StubEngine + const parent = { id: AgentId('caller'), options: {} } as unknown as Agent + return { ctx, engine, parent } +} + +const SCRIPT = "export const meta = { name: 'audit', description: 'd' }\nreturn 1" + +function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise { + return ctx.tools.execute({ + callId: CallId('call-1'), + name: 'workflow', + arguments: args, + ...extra?.agent ? { agent: extra.agent } : {}, + ...extra?.signal ? { signal: extra.signal } : {}, + }) +} + +describe('dsh-tool-workflow', () => { + it('starts a run with the script/args/parent/signal and renders the completed value', async () => { + const { ctx, engine, parent } = await setup() + const controller = new AbortController() + const pending = execute(ctx, { script: SCRIPT, args: { files: ['a.ts'] } }, { agent: parent, signal: controller.signal }) + await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) + expect(engine.requests[0]).toMatchObject({ script: SCRIPT, args: { files: ['a.ts'] }, parent }) + expect(engine.requests[0]!.signal).toBe(controller.signal) + engine.settle({ value: { findings: [1, 2] }, stopReason: 'completed', agentsStarted: 7 }) + const result = await pending + expect(result.isError).toBe(false) + const rendered = (result.content[0] as { text: string }).text + expect(rendered).toContain('workflow "stub-flow" completed (7 agents)') + expect(rendered).toContain('"findings"') + expect(engine.disposed).toBe(1) + }) + + it('maps a non-completed stop reason to an isError result (and still disposes)', async () => { + const { ctx, engine, parent } = await setup() + const pending = execute(ctx, { script: SCRIPT }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) + engine.settle({ value: null, stopReason: 'error', error: 'script threw: boom', agentsStarted: 2 }) + const result = await pending + expect(result.isError).toBe(true) + expect((result.content[0] as { text: string }).text).toContain('workflow run failed: script threw: boom') + expect(engine.disposed).toBe(1) + }) + + it('reports a cancelled run distinctly (with and without a reason)', async () => { + const { ctx, engine, parent } = await setup() + const pending = execute(ctx, { script: SCRIPT }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) + engine.settle({ value: null, stopReason: 'cancelled', error: 'user', agentsStarted: 0 }) + const result = await pending + expect(result.isError).toBe(true) + expect((result.content[0] as { text: string }).text).toContain('workflow run was cancelled (user)') + + const bare = execute(ctx, { script: SCRIPT }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests.length).toBe(2) }) + engine.settle({ value: null, stopReason: 'cancelled', agentsStarted: 0 }) + expect(((await bare).content[0] as { text: string }).text.trim().endsWith('cancelled')).toBe(true) + }) + + it('an error result without a message renders the unknown-error fallback', async () => { + const { ctx, engine, parent } = await setup() + const pending = execute(ctx, { script: SCRIPT }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) + engine.settle({ value: null, stopReason: 'error', agentsStarted: 0 }) + expect(((await pending).content[0] as { text: string }).text).toContain('unknown error') + }) + + it('cancels the run when exec.signal aborts MID-FLIGHT (the abort bridge)', async () => { + const { ctx, engine, parent } = await setup() + const controller = new AbortController() + const pending = execute(ctx, { script: SCRIPT }, { agent: parent, signal: controller.signal }) + await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) + controller.abort() + const result = await pending + expect(result.isError).toBe(true) + expect(engine.cancels).toContain('parent step aborted') + expect(engine.disposed).toBe(1) + }) + + it('applies raw-config fallbacks when loaded without schemastery defaults (direct apply)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(StubEngine) + // Direct apply with an empty RAW config: the `??` fallbacks resolve the + // tool name and render cap without schemastery having filled them. + toolWorkflow.apply(ctx, {}) + expect(ctx.tools.get('workflow')).toBeDefined() + }) + + it('a synchronous engine start throw (parse/meta failure) becomes an isError result', async () => { + const { ctx, engine, parent } = await setup() + engine.startError = new Error('script must begin with `export const meta = {...}`') + const result = await execute(ctx, { script: 'nope' }, { agent: parent }) + expect(result.isError).toBe(true) + expect((result.content[0] as { text: string }).text).toContain('must begin with') + }) + + it('requires a calling agent (fails loud without exec.agent)', async () => { + const { ctx, engine } = await setup() + const result = await execute(ctx, { script: SCRIPT }) + expect(result.isError).toBe(true) + expect((result.content[0] as { text: string }).text).toContain('requires a calling agent') + expect(engine.requests.length).toBe(0) + }) + + it('validates its own arguments via the schema DSL (missing script)', async () => { + const { ctx, parent } = await setup() + const result = await execute(ctx, {}, { agent: parent }) + expect(result.isError).toBe(true) + expect(result.error?.code).toBe('INVALID_ARGS') + }) + + it('cancels the run when exec.signal is ALREADY aborted at call time', async () => { + const { ctx, engine, parent } = await setup() + const controller = new AbortController() + controller.abort() + const result = await execute(ctx, { script: SCRIPT }, { agent: parent, signal: controller.signal }) + expect(result.isError).toBe(true) + expect(engine.cancels).toContain('parent step aborted') + expect(engine.disposed).toBe(1) + }) + + it('truncates an oversized rendered value with a notice (maxResultChars)', async () => { + const { ctx, engine, parent } = await setup({ maxResultChars: 40 }) + const pending = execute(ctx, { script: SCRIPT }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) + engine.settle({ value: { blob: 'x'.repeat(500) }, stopReason: 'completed', agentsStarted: 1 }) + const rendered = ((await pending).content[0] as { text: string }).text + expect(rendered).toContain('[truncated:') + expect(rendered.length).toBeLessThan(400) + }) + + it('registers under a configured toolName and unregisters on fiber dispose (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(StubEngine) + const fiber = await ctx.plugin(toolWorkflow, { toolName: 'orchestrate' }) + expect(ctx.tools.get('orchestrate')).toBeDefined() + expect(ctx.tools.get('workflow')).toBeUndefined() + await fiber.dispose() + expect(ctx.tools.get('orchestrate')).toBeUndefined() + }) + + it('presents a generic pending card titled by the sniffed meta name, with the script as rawInput', async () => { + const { ctx } = await setup() + const tool = ctx.tools.get('workflow')! + const view = tool.presentCall!({ script: SCRIPT }) + expect(view).toMatchObject({ card: 'generic', title: 'workflow: audit', rawInput: SCRIPT }) + const anonymous = tool.presentCall!({ script: 'export const meta = {}\nreturn 1' }) + expect(anonymous).toMatchObject({ card: 'generic', title: 'workflow' }) + }) + + it('presentResult keeps the generic card; presentation is pure and replay-safe on malformed args', async () => { + const { ctx } = await setup() + const tool = ctx.tools.get('workflow')! + expect(tool.presentResult!({ script: SCRIPT }, { content: [], isError: false })).toEqual({ card: 'generic' }) + // defineTool soft-validates presentation args: a malformed logged shape + // falls back to undefined instead of throwing mid-replay. + expect(tool.presentCall!({ not: 'the schema' })).toBeUndefined() + }) + + it('has the namespace-plugin export shape (no stray default)', () => { + expect('default' in toolWorkflow).toBe(false) + expect(toolWorkflow.name).toBe('tool-workflow') + expect(toolWorkflow.inject).toEqual(['tools', 'workflows']) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(toolWorkflow) as Record + expect(unwrapped).toBe(toolWorkflow) + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/workflow/tool-workflow/tsconfig.json b/packages/workflow/tool-workflow/tsconfig.json new file mode 100644 index 0000000000..25f4d989f2 --- /dev/null +++ b/packages/workflow/tool-workflow/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../workflow" + } + ] +} diff --git a/packages/workflow/workflow-vm/README.md b/packages/workflow/workflow-vm/README.md new file mode 100644 index 0000000000..1cbc76c250 --- /dev/null +++ b/packages/workflow/workflow-vm/README.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-workflow-vm + +The first [`WorkflowService`](../workflow/README.md) implementation: an in-process **`node:vm` engine**. It parses the Claude Code-format script (`export const meta = {...}` + plain-JS body), runs the body in a fresh vm context with the workflow hooks injected, and fans `agent()` calls out to [`ctx.subagents`](../../subagent/README.md). + +## The script contract it executes + +- **Meta extraction** (`extractMeta`): a string/comment-aware brace scanner finds the leading `export const meta` literal (template interpolation rejected — the literal must be pure), evaluates it ALONE in an empty timed vm context, materializes the result to plain JSON data, validates the shape (`name`/`description` required; unknown fields rejected loud), and blanks the statement line-preservingly so error stacks keep the script's own line numbers. +- **Hooks**: `agent(prompt, {label, phase, schema, model})` (schema = the [structured-output subset](../../core/tools/README.md), forwarded as `outputSchema`; result = validated object, or final text without a schema; a failed child resolves `null`), `parallel(thunks)`, `pipeline(items, ...stages)` with NO cross-stage barrier and `(prev, item, index)` stage callbacks, `phase(title)`, `log(message)`, and the `args` global. Anything else — `effort`/`isolation`/`agentType`, unknown options, malformed arguments, schemas outside the subset — throws a FATAL `WorkflowError` that `parallel`/`pipeline` re-throw rather than nulling (see the seam README's failure discipline). +- **Determinism bans**: `Date.now()`, `Math.random()`, and argless `new Date()` throw (kept even though resume is deferred, so scripts stay resume-compatible); no timers, filesystem, or Node APIs exist in the context. + +## Realm discipline + +Values ENTERING the host (the meta literal, hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a descriptor walk that never invokes accessors and rejects loud everything JSON cannot carry (accessors, exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying into host containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values ENTERING the realm (`args`, `agent()` results) are rebuilt INSIDE the realm through the context's own `JSON.parse`, so the script never holds an object whose prototype chain reaches host intrinsics. + +## Limits, cancellation, disposal + +Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` aborts every child (a shared `AbortSignal`), rejects waiting `agent()` slots, and makes every future hook call throw `CANCELLED` — the script dies at its next await and the run settles `cancelled`. Once a run settles, stray children a script fired without awaiting are aborted too. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection (the app boot layer exits the process on those). + +**Documented limitations** (the accepted cost of the in-process mechanism; the seam exists so a worker-thread/isolated-vm engine can swap in): vm is NOT a security boundary — scripts are model-written, the same trust level as the model's bash access — and the vm `timeout` covers only the initial synchronous slice, so a pathological synchronous spin after the first await cannot be killed; `dispose()` waits `disposeGraceMs` then ABANDONS such a script (its settlement stays contained, but an abandoned spin would still occupy the event loop). + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `provider` | `spawn` | The `ctx.subagents` provider children run on. | +| `maxConcurrentAgents` | `0` (auto) | Concurrent `agent()` ceiling; `0` resolves to `min(16, max(1, cores - 2))`. | +| `maxTotalAgents` | `1000` | Total `agent()` calls one run may start (runaway-loop backstop). | +| `maxItemsPerCall` | `4096` | Items accepted by one `parallel()`/`pipeline()` call. | +| `syncTimeoutMs` | `5000` | vm timeout for the initial synchronous slice and the meta evaluation. | +| `disposeGraceMs` | `5000` | How long `dispose()` waits for a cancelled script before abandoning it. | diff --git a/packages/workflow/workflow-vm/package.json b/packages/workflow/workflow-vm/package.json new file mode 100644 index 0000000000..8b1217acc0 --- /dev/null +++ b/packages/workflow/workflow-vm/package.json @@ -0,0 +1,50 @@ +{ + "name": "@deepseek-ai/dsh-workflow-vm", + "description": "node:vm workflow engine: executes model-written orchestration scripts over ctx.subagents", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-workflow": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-workflow": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/workflow/workflow-vm/src/index.ts b/packages/workflow/workflow-vm/src/index.ts new file mode 100644 index 0000000000..914ebb3ba6 --- /dev/null +++ b/packages/workflow/workflow-vm/src/index.ts @@ -0,0 +1,166 @@ +/** + * The `node:vm` workflow engine: the first {@link WorkflowService} + * implementation. Parses the Claude Code-format script (meta + body), runs the + * body in a fresh in-process vm context with the workflow hooks injected, and + * fans `agent()` calls out to `ctx.subagents`. + * + * Engine limitations, documented as the accepted cost of the in-process + * mechanism (the interface/implementation seam exists precisely so a + * worker-thread or isolated-vm engine can swap in if these ever matter): + * + * - vm is NOT a security boundary. Scripts are model-written — the same trust + * level as the model's bash access — and the realm-boundary materialization + * is correctness containment, not a sandbox. + * - The vm `timeout` covers only the initial SYNCHRONOUS slice of the script; + * a pathological synchronous spin after the first await cannot be killed + * in-process. `dispose()` therefore waits a bounded grace and then ABANDONS + * a stuck script: its pending hook promises are already rejected and its + * settlement is contained (no unhandled rejection), but an abandoned + * synchronous spin would still occupy the event loop. + * + * Plugin export shape: a default-exported {@link WorkflowService} subclass + * (the class-based service form, like `dsh-bash-local`). + * + * @module @deepseek-ai/dsh-workflow-vm + */ + +import { randomUUID } from 'node:crypto' +import { availableParallelism } from 'node:os' +import type { Context } from 'cordis' +import z from 'schemastery' +import WorkflowService, { WorkflowRunId } from '@deepseek-ai/dsh-workflow' +import type { WorkflowResult, WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' +import { extractMeta } from './meta.ts' +import { WorkflowExecution, type ExecutionLimits } from './runtime.ts' + +export { extractMeta, type ExtractedScript } from './meta.ts' +export { materializeFromRealm, MaterializeError } from './realm.ts' +export { WorkflowExecution, type ExecutionLimits, type ExecutionObserver } from './runtime.ts' + +/** Plugin config (all optional — `static Config` supplies the defaults). */ +export interface Config { + /** The `ctx.subagents` provider children run on (default `spawn`). */ + provider?: string + /** Concurrent `agent()` ceiling; `0` (the default) auto-resolves to `min(16, max(1, cores - 2))`. */ + maxConcurrentAgents?: number + /** Total `agent()` calls one run may start — the runaway-loop backstop (default 1000). */ + maxTotalAgents?: number + /** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */ + maxItemsPerCall?: number + /** vm timeout for the script's initial synchronous slice AND the meta-literal evaluation (default 5000 ms). */ + syncTimeoutMs?: number + /** How long `dispose()` waits for a cancelled script to settle before abandoning it (default 5000 ms). */ + disposeGraceMs?: number +} + +type ResolvedConfig = Required + +/** + * The vm engine service. `start()` validates the script up front (meta + + * body compile) and returns a {@link WorkflowRun} whose `result` never + * rejects; the `workflow/*` events fire around the run per the seam contract. + */ +export class VmWorkflowEngine extends WorkflowService { + static inject = ['subagents'] + + static Config: z = z.object({ + provider: z.string().default('spawn'), + maxConcurrentAgents: z.natural().default(0), + maxTotalAgents: z.natural().min(1).default(1000), + maxItemsPerCall: z.natural().min(1).default(4096), + syncTimeoutMs: z.natural().min(1).default(5000), + disposeGraceMs: z.natural().default(5000), + }) + + private readonly config: ResolvedConfig + + constructor(ctx: Context, config: Config) { + super(ctx) + // schemastery (static Config) has already filled the defaulted fields; + // the assertion records that resolution, not a hidden fallback. + this.config = config as ResolvedConfig + } + + /** + * Parse and execute a workflow script. Throws {@link WorkflowError} + * synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot + * begin; once a run is returned, every failure resolves through + * `result.stopReason` instead. + * @param request - the script, its `args`, the parent agent, and an + * optional cancel signal. + * @returns the live run (its `result` resolves when the script settles). + */ + start(request: WorkflowStartRequest): WorkflowRun { + const { meta, body } = extractMeta(request.script, this.config.syncTimeoutMs) + const id = WorkflowRunId(randomUUID()) + // The event payloads and the run handle get SEPARATE meta clones: a + // listener mutating its snapshot must not corrupt the holder's view. + const info: WorkflowRunInfo = { id, meta: structuredClone(meta) } + const limits: ExecutionLimits = { + provider: this.config.provider, + maxConcurrentAgents: this.config.maxConcurrentAgents === 0 + ? Math.min(16, Math.max(1, availableParallelism() - 2)) + : this.config.maxConcurrentAgents, + maxTotalAgents: this.config.maxTotalAgents, + maxItemsPerCall: this.config.maxItemsPerCall, + syncTimeoutMs: this.config.syncTimeoutMs, + } + const execution = new WorkflowExecution( + this.ctx, + meta, + body, + request.parent, + request.args, + request.signal, + limits, + { + phase: (title) => { this.emitWorkflowEvent('workflow/phase', info, title) }, + log: (message) => { this.emitWorkflowEvent('workflow/log', info, message) }, + agentStart: (agent) => { this.emitWorkflowEvent('workflow/agent-start', info, agent) }, + agentEnd: (agent) => { this.emitWorkflowEvent('workflow/agent-end', info, agent) }, + }, + ) + + this.emitWorkflowEvent('workflow/start', info) + const result: Promise = execution.drive() + // `workflow/end` fires as the (never-rejecting) result settles, with the + // outcome DATA only — the value stays with the run's holder. + void result.then((settled) => { + this.emitWorkflowEvent('workflow/end', info, { + stopReason: settled.stopReason, + ...settled.error !== undefined ? { error: settled.error } : {}, + agentsStarted: settled.agentsStarted, + }) + }) + + let disposed: Promise | undefined + return { + id, + meta: structuredClone(meta), + result, + cancel(reason?: string): void { + execution.cancel(reason) + }, + dispose: (): Promise => { + // Idempotent: cancel, then wait min(settle, grace). `result` never + // rejects, so the race needs no rejection handling; an unsettled + // script past the grace is abandoned per the module contract. + disposed ??= (async () => { + execution.cancel('workflow disposed') + await Promise.race([result, sleep(this.config.disposeGraceMs)]) + })() + return disposed + }, + } + } +} + +/** A plain timer sleep (the dispose grace); unref'd so it never holds the process open. */ +function sleep(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) + timer.unref() + }) +} + +export default VmWorkflowEngine diff --git a/packages/workflow/workflow-vm/src/meta.ts b/packages/workflow/workflow-vm/src/meta.ts new file mode 100644 index 0000000000..bb97ad7d39 --- /dev/null +++ b/packages/workflow/workflow-vm/src/meta.ts @@ -0,0 +1,198 @@ +/** + * Meta-block extraction: turn a Claude Code-format workflow script — + * `export const meta = {...}` followed by a plain-JS body — into a validated + * {@link WorkflowMeta} plus the body with the meta statement blanked + * line-preservingly (error stacks keep the script's own line numbers). + * + * The scanner is a small string/comment-aware brace matcher, not a JS parser: + * it only has to find the END of the meta object literal, and the literal is + * contractually PURE (no interpolation, no computed values). Template strings + * are tolerated as plain quotes but `${` inside one is rejected up front — + * interpolation is where "literal" stops being checkable by evaluation. The + * extracted text is then evaluated ALONE in an empty, timed vm context (a + * non-literal reference throws there; an expression can still RUN, so the + * result — not the source — is the contract: it must materialize to plain + * JSON data and pass the shape validation). + * + * @module @deepseek-ai/dsh-workflow-vm/meta + */ + +import * as vm from 'node:vm' +import { WorkflowError } from '@deepseek-ai/dsh-workflow' +import type { WorkflowMeta, WorkflowPhase } from '@deepseek-ai/dsh-workflow' +import { materializeFromRealm, MaterializeError } from './realm.ts' + +/** The result of {@link extractMeta}: the validated meta and the runnable body. */ +export interface ExtractedScript { + meta: WorkflowMeta + /** The script with the meta statement blanked (newlines preserved). */ + body: string +} + +const META_PREFIX = /^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*|\s+)*export\s+const\s+meta\s*=\s*/ + +/** + * Scan `source` from `start` (an opening `{`) to its matching `}`, aware of + * string literals (`'`/`"`/backtick, with escapes) and comments. Returns the + * index AFTER the closing brace. Throws `SCRIPT_PARSE` on template + * interpolation (`${` inside a backtick string) or an unterminated literal. + */ +function scanObjectLiteral(source: string, start: number): number { + let depth = 0 + let index = start + while (index < source.length) { + const ch = source.charAt(index) + if (ch === '/' && source[index + 1] === '/') { + const end = source.indexOf('\n', index) + index = end === -1 ? source.length : end + 1 + continue + } + if (ch === '/' && source[index + 1] === '*') { + const end = source.indexOf('*/', index + 2) + if (end === -1) throw new WorkflowError('meta block has an unterminated comment', 'SCRIPT_PARSE') + index = end + 2 + continue + } + if (ch === '\'' || ch === '"' || ch === '`') { + index = scanString(source, index, ch) + continue + } + if (ch === '{' || ch === '[') depth += 1 + if (ch === '}' || ch === ']') { + depth -= 1 + if (depth === 0) return index + 1 + } + index += 1 + } + throw new WorkflowError('meta block is not a balanced object literal', 'SCRIPT_PARSE') +} + +/** Scan past one string literal starting at `start` (the quote char); returns the index after the closing quote. */ +function scanString(source: string, start: number, quote: string): number { + let index = start + 1 + while (index < source.length) { + const ch = source.charAt(index) + if (ch === '\\') { + index += 2 + continue + } + if (quote === '`' && ch === '$' && source[index + 1] === '{') { + throw new WorkflowError('template interpolation (`${...}`) is not allowed in the meta block — meta must be a pure literal', 'SCRIPT_PARSE') + } + if (ch === quote) return index + 1 + index += 1 + } + throw new WorkflowError('meta block has an unterminated string literal', 'SCRIPT_PARSE') +} + +/** Replace `[from, to)` of `source` with whitespace, preserving every newline (line numbers survive). */ +function blankSpan(source: string, from: number, to: number): string { + const blanked = source.slice(from, to).replace(/[^\n]/g, ' ') + return source.slice(0, from) + blanked + source.slice(to) +} + +/** Collect shape violations for an evaluated meta value (already materialized to host JSON data). */ +function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: string[] } { + const violations: string[] = [] + /* v8 ignore next 3 -- defensive: the scanner only extracts a brace-delimited literal, which always evaluates to a plain object */ + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) { + return { violations: ['meta must be an object literal'] } + } + const record = meta as Record + const known = new Set(['name', 'description', 'whenToUse', 'phases']) + for (const key of Object.keys(record)) { + if (!known.has(key)) violations.push(`meta.${key} is not a recognized field (name/description/whenToUse/phases)`) + } + if (typeof record.name !== 'string' || record.name.length === 0) violations.push('meta.name must be a non-empty string') + if (typeof record.description !== 'string' || record.description.length === 0) violations.push('meta.description must be a non-empty string') + if (record.whenToUse !== undefined && typeof record.whenToUse !== 'string') violations.push('meta.whenToUse must be a string') + const phases: WorkflowPhase[] = [] + if (record.phases !== undefined) { + if (!Array.isArray(record.phases)) { + violations.push('meta.phases must be an array') + } else { + record.phases.forEach((phase, index) => { + if (typeof phase !== 'object' || phase === null || Array.isArray(phase)) { + violations.push(`meta.phases[${index}] must be an object`) + return + } + const entry = phase as Record + for (const key of Object.keys(entry)) { + if (!['title', 'detail', 'model'].includes(key)) violations.push(`meta.phases[${index}].${key} is not a recognized field`) + } + if (typeof entry.title !== 'string' || entry.title.length === 0) violations.push(`meta.phases[${index}].title must be a non-empty string`) + if (entry.detail !== undefined && typeof entry.detail !== 'string') violations.push(`meta.phases[${index}].detail must be a string`) + if (entry.model !== undefined && typeof entry.model !== 'string') violations.push(`meta.phases[${index}].model must be a string`) + if (violations.length === 0) { + phases.push({ + title: entry.title as string, + ...entry.detail !== undefined ? { detail: entry.detail as string } : {}, + ...entry.model !== undefined ? { model: entry.model as string } : {}, + }) + } + }) + } + } + if (violations.length > 0) return { violations } + return { + violations, + meta: { + name: record.name as string, + description: record.description as string, + ...record.whenToUse !== undefined ? { whenToUse: record.whenToUse as string } : {}, + ...record.phases !== undefined ? { phases } : {}, + }, + } +} + +/** + * Extract and validate the leading `export const meta = {...}` statement. + * Throws {@link WorkflowError} — `SCRIPT_PARSE` when the statement is missing + * or unscannable, `META_INVALID` when the literal evaluates to something + * outside the meta contract (non-JSON data, wrong shape, unknown fields). + * @param script - the full script text. + * @param evalTimeoutMs - the vm timeout for evaluating the extracted literal. + * @returns the validated meta and the line-preservingly blanked body. + */ +export function extractMeta(script: string, evalTimeoutMs: number): ExtractedScript { + const match = META_PREFIX.exec(script) + if (!match) { + throw new WorkflowError('script must begin with `export const meta = {...}` (leading comments allowed)', 'SCRIPT_PARSE') + } + const literalStart = match[0].length + if (script[literalStart] !== '{') { + throw new WorkflowError('`export const meta =` must be followed by an object literal', 'SCRIPT_PARSE') + } + const literalEnd = scanObjectLiteral(script, literalStart) + const literal = script.slice(literalStart, literalEnd) + + let evaluated: unknown + try { + // An EMPTY context: any non-literal reference (a variable, a call) throws + // here. The result — data only — is what the contract checks; a getter or + // IIFE can still run, which is why the timeout and the materialization + // below are part of the same boundary. + evaluated = vm.runInNewContext(`(${literal})`, undefined, { timeout: evalTimeoutMs }) + } catch (error: unknown) { + throw new WorkflowError(`meta block failed to evaluate as a pure literal: ${String(error)}`, 'META_INVALID', { cause: error }) + } + let data: unknown + try { + data = materializeFromRealm(evaluated, 'meta') + } catch (error: unknown) { + /* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */ + if (!(error instanceof MaterializeError)) throw error + throw new WorkflowError(`meta block is not pure JSON data — ${error.message}`, 'META_INVALID', { cause: error }) + } + const { meta, violations } = validateMetaShape(data) + if (meta === undefined) { + throw new WorkflowError(`invalid meta block: ${violations.join('; ')}`, 'META_INVALID') + } + + // Blank the whole statement (including a trailing semicolon, if any) so the + // body compiles standalone with its original line numbers. + let statementEnd = literalEnd + while (statementEnd < script.length && (script[statementEnd] === ' ' || script[statementEnd] === '\t')) statementEnd += 1 + if (script[statementEnd] === ';') statementEnd += 1 + return { meta, body: blankSpan(script, 0, statementEnd) } +} diff --git a/packages/workflow/workflow-vm/src/realm.ts b/packages/workflow/workflow-vm/src/realm.ts new file mode 100644 index 0000000000..70745cb4f6 --- /dev/null +++ b/packages/workflow/workflow-vm/src/realm.ts @@ -0,0 +1,142 @@ +/** + * Realm-boundary materialization for the vm engine. + * + * Values produced INSIDE the script realm (the meta literal, hook arguments, + * the script's return value) must become plain host-realm JSON data before the + * host touches them. The repo's `isJsonValue` guard cannot run first: it is + * prototype-strict (any cross-realm object fails it) and it INVOKES getters + * (letting realm code run outside the vm's timed window). So this module walks + * own-property DESCRIPTORS — never invoking accessors — and copies data into + * host containers, rejecting loud everything JSON cannot carry: + * accessor properties, non-plain prototypes, functions, symbols (keys or + * values), bigints, non-finite numbers, `undefined` values, cycles, sparse + * arrays, and arrays with non-index own properties. + * + * Host objects are built with `Object.defineProperty` into a fresh `{}` — + * never plain `target[key] =` assignment, which a `"__proto__"` key would turn + * into prototype mutation instead of a data property. + * + * The host→realm direction deliberately does NOT live here: a host object + * handed into the realm would expose host intrinsics through its prototype + * chain, so the engine rebuilds inbound values INSIDE the realm via the + * context's own `JSON.parse` (see the runtime). + * + * @module @deepseek-ai/dsh-workflow-vm/realm + */ + +/** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */ +export class MaterializeError extends Error { + constructor(public readonly path: string, public readonly reason: string) { + super(`${path}: ${reason}`) + this.name = 'MaterializeError' + } +} + +/** + * Whether an object's prototype chain is data-shaped: `null`, or a prototype + * whose own prototype is `null` (the realm's `Object.prototype` — which we + * cannot compare by identity across realms). A `Date`/`Map`/class instance + * has a longer chain and is rejected. + */ +function hasPlainPrototype(value: object): boolean { + const proto: unknown = Object.getPrototypeOf(value) + if (proto === null) return true + return Object.getPrototypeOf(proto) === null +} + +/** + * Copy `value` (typically from the vm realm) into plain host JSON data. + * Throws {@link MaterializeError} naming the offending path for anything JSON + * cannot carry losslessly. Accessors are detected via descriptors and NEVER + * invoked. `undefined` is accepted only at the ROOT (a script with no + * `return` value) — the caller decides what it means; an `undefined` nested + * INSIDE a container is a violation. + * @param value - the realm value to materialize. + * @param root - the path label for the root value (error messages). + * @returns the host-realm copy (plain objects/arrays/scalars only). + */ +export function materializeFromRealm(value: unknown, root = 'value'): unknown { + if (value === undefined) return undefined + return materialize(value, root, new Set()) +} + +function materialize(value: unknown, path: string, seen: Set): unknown { + switch (typeof value) { + case 'boolean': + case 'string': + return value + case 'number': { + if (!Number.isFinite(value)) throw new MaterializeError(path, 'non-finite numbers are not JSON data') + return value + } + case 'bigint': + throw new MaterializeError(path, 'bigints are not JSON data') + case 'function': + throw new MaterializeError(path, 'functions cannot cross the workflow realm boundary') + case 'symbol': + throw new MaterializeError(path, 'symbols cannot cross the workflow realm boundary') + case 'undefined': + throw new MaterializeError(path, 'undefined is not JSON data') + case 'object': + break + } + if (value === null) return null + const objectValue: object = value + if (seen.has(objectValue)) throw new MaterializeError(path, 'circular references are not JSON data') + seen.add(objectValue) + try { + if (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen) + return materializeObject(objectValue, path, seen) + } finally { + seen.delete(objectValue) + } +} + +function materializeArray(value: unknown[], path: string, seen: Set): unknown[] { + const out: unknown[] = [] + for (let index = 0; index < value.length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(value, index) + if (descriptor === undefined) throw new MaterializeError(`${path}[${index}]`, 'sparse arrays are not JSON data') + if (!('value' in descriptor)) throw new MaterializeError(`${path}[${index}]`, 'accessor properties cannot cross the workflow realm boundary') + out.push(materialize(descriptor.value, `${path}[${index}]`, seen)) + } + // Own enumerable props beyond the indices (e.g. `arr.total = 3`) would be + // silently dropped by JSON — reject them instead. + for (const key of Object.keys(value)) { + const index = Number(key) + if (!Number.isInteger(index) || index < 0 || index >= value.length) { + throw new MaterializeError(`${path}.${key}`, 'arrays with non-index properties are not JSON data') + } + } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow realm boundary') + } + return out +} + +function materializeObject(value: object, path: string, seen: Set): Record { + if (!hasPlainPrototype(value)) { + throw new MaterializeError(path, 'only plain objects and arrays are JSON data (exotic prototype)') + } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow realm boundary') + } + const out: Record = {} + for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) { + // Non-enumerable own props never reach JSON output — skip them, matching + // JSON.stringify's contract exactly (documented in the module doc). + if (!descriptor.enumerable) continue + if (!('value' in descriptor)) { + throw new MaterializeError(`${path}.${key}`, 'accessor properties cannot cross the workflow realm boundary') + } + // defineProperty, never assignment: a "__proto__" key must become an OWN + // data property of the copy, not a prototype mutation. + Object.defineProperty(out, key, { + value: materialize(descriptor.value, `${path}.${key}`, seen), + enumerable: true, + writable: true, + configurable: true, + }) + } + return out +} diff --git a/packages/workflow/workflow-vm/src/runtime.ts b/packages/workflow/workflow-vm/src/runtime.ts new file mode 100644 index 0000000000..04176a11ac --- /dev/null +++ b/packages/workflow/workflow-vm/src/runtime.ts @@ -0,0 +1,499 @@ +/** + * Per-run execution state for the vm workflow engine: the script context and + * its injected hooks (`agent`/`parallel`/`pipeline`/`phase`/`log`/`args`), the + * concurrency semaphore and caps, cancellation, and the drive loop that turns + * a script settlement into a {@link WorkflowResult}. + * + * Realm discipline (see also ./realm.ts): values ENTERING the host from the + * script (hook options, schemas, the return value) are materialized via + * descriptor walks; values ENTERING the realm from the host (`args`, agent() + * results) are rebuilt INSIDE the realm through the context's own + * `JSON.parse`, so the script never holds an object whose prototype chain + * reaches host intrinsics. Realm functions (pipeline stages, parallel thunks) + * are called, not materialized — their values stay realm-side. + * + * Failure discipline: fatal {@link WorkflowError}s (bad hook arguments, + * unsupported options/schemas, tripped caps, seam start failures, + * cancellation) ALWAYS propagate through `parallel`/`pipeline`; the per-item + * `null` is reserved for child-run failures and ordinary in-stage script + * errors. Every hook-returned promise gets a no-op rejection consumer + * attached, so a script that drops a promise (fires an `agent()` without + * awaiting it) cannot surface an unhandled rejection when cancellation + * rejects it — the app boot layer exits the process on unhandled rejections. + * + * @module @deepseek-ai/dsh-workflow-vm/runtime + */ + +import * as vm from 'node:vm' +import type { Context } from 'cordis' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-subagent' +import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools' +import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools' +import { WorkflowError, isFatalWorkflowError } from '@deepseek-ai/dsh-workflow' +import type { + WorkflowAgentEndInfo, + WorkflowAgentInfo, + WorkflowMeta, + WorkflowResult, +} from '@deepseek-ai/dsh-workflow' +import { materializeFromRealm, MaterializeError } from './realm.ts' + +/** The per-run knobs the engine resolves from its Config. */ +export interface ExecutionLimits { + /** The `ctx.subagents` provider name to start children on. */ + provider: string + /** Concurrent `agent()` ceiling (already auto-resolved; ≥ 1). */ + maxConcurrentAgents: number + /** Total `agent()` calls per run (the runaway-loop backstop). */ + maxTotalAgents: number + /** Items accepted by one `parallel()`/`pipeline()` call. */ + maxItemsPerCall: number + /** vm timeout for the script's initial synchronous slice. */ + syncTimeoutMs: number +} + +/** The engine-side observers the execution reports progress through. */ +export interface ExecutionObserver { + phase(title: string): void + log(message: string): void + agentStart(info: WorkflowAgentInfo): void + agentEnd(info: WorkflowAgentEndInfo): void +} + +/** The `agent()` options the script may pass; everything else rejects loud. */ +const SUPPORTED_AGENT_OPTIONS = new Set(['label', 'phase', 'schema', 'model']) +/** Deferred Claude Code options we name explicitly in the rejection message. */ +const DEFERRED_AGENT_OPTIONS = new Set(['effort', 'isolation', 'agentType']) + +/** The in-context prelude that bans the nondeterminism sources (kept even though resume is deferred, so scripts stay resume-compatible). */ +const DETERMINISM_PRELUDE = ` +{ + const banned = (name) => () => { + throw new Error(name + ' is not available in workflow scripts (runs must stay deterministic for future resume support; pass timestamps in via args)') + } + Math.random = banned('Math.random()') + Date.now = banned('Date.now()') + const RealDate = Date + globalThis.Date = new Proxy(RealDate, { + construct(target, args, newTarget) { + if (args.length === 0) banned('argless new Date()')() + return Reflect.construct(target, args, newTarget) + }, + apply: banned('Date()'), + }) +} +` + +/** Flatten a child's final output blocks to text (the non-schema `agent()` result). */ +function outputText(blocks: ContentBlock[]): string { + return blocks + .filter((block): block is Extract => block.type === 'text') + .map(block => block.text) + .join('') +} + +/** + * Render a script failure for the result: prefer the stack (it carries the + * script's own line numbers via the compile lineOffset), then the message. + * STRUCTURAL detection, not `instanceof Error` — a realm-thrown Error is not + * an instance of the host Error class. + */ +function errorText(error: unknown): string { + if (typeof error === 'object' && error !== null) { + const maybe = error as { stack?: unknown; message?: unknown } + if (typeof maybe.stack === 'string' && maybe.stack.length > 0) return maybe.stack + if (typeof maybe.message === 'string') return maybe.message + } + return String(error) +} + +/** A short display label derived from the prompt when the script passes none. */ +function defaultLabel(prompt: string): string { + const newline = prompt.indexOf('\n') + const line = newline === -1 ? prompt : prompt.slice(0, newline) + return line.length <= 48 ? line : `${line.slice(0, 47)}…` +} + +/** + * One live script execution. Constructed per run by the engine; `drive()` is + * called exactly once and NEVER rejects — every failure becomes a + * {@link WorkflowResult} with a non-`completed` stop reason. + */ +export class WorkflowExecution { + /** 1-based count of `agent()` calls started (the `agentsStarted` result field). */ + private started = 0 + private activeSlots = 0 + private readonly slotWaiters: { resolve(): void; reject(error: unknown): void }[] = [] + private cancelReason: string | undefined + private cancelError: WorkflowError | undefined + private readonly controller = new AbortController() + private currentPhase: string | undefined + private readonly context: vm.Context + private readonly realmJsonParse: (text: string) => unknown + private readonly compiled: vm.Script + + constructor( + private readonly ctx: Context, + meta: WorkflowMeta, + body: string, + private readonly parent: Agent, + args: unknown, + signal: AbortSignal | undefined, + private readonly limits: ExecutionLimits, + private readonly observer: ExecutionObserver, + ) { + // Compile FIRST: a body syntax error must throw out of the constructor + // (the engine maps it to SCRIPT_PARSE) before any realm state exists. + // lineOffset compensates for the wrapper line, so stack traces carry the + // script's own line numbers (the meta statement was blanked, not removed). + try { + this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, { + filename: `workflow:${meta.name}`, + lineOffset: -1, + }) + } catch (error: unknown) { + throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error }) + } + + this.context = vm.createContext({}, { name: `workflow:${meta.name}` }) + vm.runInContext(DETERMINISM_PRELUDE, this.context) + // The realm's own JSON.parse — the host→realm rebuild channel. + const realmJson = vm.runInContext('JSON', this.context) as { parse(text: string): unknown } + this.realmJsonParse = (text: string) => realmJson.parse(text) + + const globals: Record = { + agent: (prompt: unknown, opts?: unknown) => this.contain(this.agent(prompt, opts)), + parallel: (thunks: unknown) => this.contain(this.parallel(thunks)), + pipeline: (items: unknown, ...stages: unknown[]) => this.contain(this.pipeline(items, stages)), + phase: (title: unknown) => { this.phase(title) }, + log: (message: unknown) => { this.log(message) }, + args: this.toRealm(args), + } + for (const [key, value] of Object.entries(globals)) { + // Data properties on the contextified global; frozen shape not required — + // a script overwriting its own hooks only sabotages itself. + ;(this.context as Record)[key] = typeof value === 'function' ? Object.freeze(value) : value + } + + if (signal?.aborted) { + this.cancel('workflow start signal already aborted') + } else { + signal?.addEventListener('abort', () => { this.cancel('workflow signal aborted') }, { once: true }) + } + } + + /** + * Whether the run has been cancelled. A METHOD, not an inline property + * read: `cancel()` mutates `cancelReason` concurrently (a signal listener, + * a raced dispose), and an inline read after an `await` gets narrowed by + * control flow into an always-false comparison. + */ + private isCancelled(): boolean { + return this.cancelReason !== undefined + } + + /** + * Cancel the run: children abort (the shared signal), waiting `agent()` + * slots reject, and every future hook call throws `CANCELLED` — the script + * dies at its next await. Idempotent; the first reason wins. + */ + cancel(reason?: string): void { + if (this.cancelReason !== undefined) return + this.cancelReason = reason ?? 'workflow cancelled' + this.cancelError = new WorkflowError(`workflow run cancelled: ${this.cancelReason}`, 'CANCELLED') + this.controller.abort(this.cancelReason) + for (const waiter of this.slotWaiters.splice(0)) waiter.reject(this.cancelledError()) + } + + /** + * Run the script to settlement. Resolves — never rejects — with the run's + * {@link WorkflowResult}: the materialized return value on `completed`, the + * failure message on `error`, and `cancelled` when the script died of + * cancellation. After settlement, any stray children a script fired without + * awaiting are aborted (their `agent()` wrappers dispose them). + */ + async drive(): Promise { + try { + const scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs }) as Promise + const raw: unknown = await this.contain(Promise.resolve(scriptPromise)) + const value = raw === undefined ? null : this.materializeResult(raw) + return { value, stopReason: 'completed', agentsStarted: this.started } + } catch (error: unknown) { + if (error instanceof WorkflowError && error.code === 'CANCELLED') { + return { value: null, stopReason: 'cancelled', error: error.message, agentsStarted: this.started } + } + return { value: null, stopReason: 'error', error: errorText(error), agentsStarted: this.started } + } finally { + // Reap strays: a script that fired agent() calls without awaiting them + // leaves live children behind after settlement — abort them all. (The + // per-call wrappers dispose each child; the contain() consumer keeps + // their rejections from going unhandled.) + if (this.cancelReason === undefined) this.cancel('workflow settled') + } + } + + /** + * Attach a no-op rejection consumer WITHOUT changing what the caller + * receives: if the script drops the promise (no await), cancellation cannot + * become an unhandled rejection (the app boot layer exits the process on + * those); if the script does await it, it still observes the rejection. + */ + private contain(promise: Promise): Promise { + promise.catch(() => { /* consumed: see method contract — a dropped hook promise must not surface an unhandled rejection */ }) + return promise + } + + private cancelledError(): WorkflowError { + // cancel() arms cancelError before any caller can observe isCancelled() + // === true; the fallback guards the type, not a reachable path. + /* v8 ignore next */ + return this.cancelError ?? new WorkflowError('workflow run cancelled', 'CANCELLED') + } + + /** Rebuild a host value inside the script realm (via the realm's own JSON.parse). */ + private toRealm(value: unknown): unknown { + if (value === undefined) return undefined + if (value === null) return null + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value + return this.realmJsonParse(JSON.stringify(value)) + } + + /** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */ + private materializeResult(raw: unknown): unknown { + try { + return materializeFromRealm(raw, 'workflow result') + } catch (error: unknown) { + /* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */ + if (!(error instanceof MaterializeError)) throw error + throw new WorkflowError( + `the workflow's return value is not plain JSON data — ${error.message}. Return only JSON-serializable objects/arrays/scalars.`, + 'RESULT_UNSERIALIZABLE', + { cause: error }, + ) + } + } + + /** + * Acquire one concurrency slot (FIFO). Cancellation rejects QUEUED waiters + * (see {@link cancel}); the callers guard their own entry and post-acquire + * windows, so no cancelled-precheck is duplicated here. + */ + private acquireSlot(): Promise { + if (this.activeSlots < this.limits.maxConcurrentAgents) { + this.activeSlots += 1 + return Promise.resolve() + } + return new Promise((resolve, reject) => { + this.slotWaiters.push({ + resolve: () => { + this.activeSlots += 1 + resolve() + }, + reject, + }) + }) + } + + private releaseSlot(): void { + this.activeSlots -= 1 + const next = this.slotWaiters.shift() + if (next) next.resolve() + } + + /** The `agent(prompt, opts)` hook. */ + private async agent(rawPrompt: unknown, rawOpts: unknown): Promise { + if (this.isCancelled()) throw this.cancelledError() + if (typeof rawPrompt !== 'string' || rawPrompt.length === 0) { + throw new WorkflowError('agent() requires a non-empty prompt string', 'INVALID_ARGUMENT') + } + const opts = this.readAgentOptions(rawOpts) + if (this.started >= this.limits.maxTotalAgents) { + throw new WorkflowError( + `this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise maxTotalAgents in the engine config if the scale is intentional`, + 'AGENT_CAP', + ) + } + this.started += 1 + const seq = this.started + const label = opts.label ?? defaultLabel(rawPrompt) + const phase = opts.phase ?? this.currentPhase + + await this.acquireSlot() + try { + // No cancelled re-check here: a cancel cannot interleave between a + // waiter's resolution and this continuation (single-threaded, no await + // between them), and a child started moments after a cancel still dies + // via the shared abort signal — the CANCELLED mapping below covers it. + let run + try { + run = this.ctx.subagents.start(this.limits.provider, { + prompt: [{ type: 'text', text: rawPrompt }], + parent: this.parent, + signal: this.controller.signal, + ...opts.schema !== undefined ? { outputSchema: opts.schema } : {}, + ...opts.model !== undefined ? { agentOptions: { model: opts.model } } : {}, + }) + } catch (error: unknown) { + throw new WorkflowError(`agent() could not start a child on provider "${this.limits.provider}": ${String(error)}`, 'AGENT_START', { cause: error }) + } + const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: run.id } + this.observer.agentStart(info) + try { + const result = await run.result + if (result.stopReason === 'completed') { + if (opts.schema !== undefined) { + // The provider honored outputSchema (capability-gated at start), so + // a completed run without a structured value is a child failure. + if (result.structured === undefined) { + this.observer.agentEnd({ ...info, outcome: 'failed' }) + return null + } + this.observer.agentEnd({ ...info, outcome: 'completed' }) + return this.toRealm(result.structured) + } + this.observer.agentEnd({ ...info, outcome: 'completed' }) + return outputText(result.output) + } + // A cancelled RUN kills the script; a child that failed for its own + // reasons resolves null (scripts .filter(Boolean) per the CC contract). + if (this.isCancelled()) { + this.observer.agentEnd({ ...info, outcome: 'cancelled' }) + throw this.cancelledError() + } + this.observer.agentEnd({ ...info, outcome: 'failed' }) + return null + } finally { + await run.dispose() + } + } finally { + this.releaseSlot() + } + } + + /** Materialize + validate the `agent()` options bag from the realm. */ + private readAgentOptions(rawOpts: unknown): { label?: string; phase?: string; model?: string; schema?: StructuredOutputSchema } { + if (rawOpts === undefined) return {} + let opts: unknown + try { + opts = materializeFromRealm(rawOpts, 'agent() options') + } catch (error: unknown) { + /* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */ + if (!(error instanceof MaterializeError)) throw error + throw new WorkflowError(`agent() options must be plain JSON data — ${error.message}`, 'INVALID_ARGUMENT', { cause: error }) + } + if (typeof opts !== 'object' || opts === null || Array.isArray(opts)) { + throw new WorkflowError('agent() options must be an object', 'INVALID_ARGUMENT') + } + const record = opts as Record + for (const key of Object.keys(record)) { + if (SUPPORTED_AGENT_OPTIONS.has(key)) continue + if (DEFERRED_AGENT_OPTIONS.has(key)) { + throw new WorkflowError(`agent() option "${key}" is deferred and not supported by this engine (supported: label, phase, schema, model)`, 'UNSUPPORTED_OPTION') + } + throw new WorkflowError(`agent() option "${key}" is not recognized (supported: label, phase, schema, model)`, 'UNSUPPORTED_OPTION') + } + for (const key of ['label', 'phase', 'model'] as const) { + if (record[key] !== undefined && typeof record[key] !== 'string') { + throw new WorkflowError(`agent() option "${key}" must be a string`, 'INVALID_ARGUMENT') + } + } + let schema: StructuredOutputSchema | undefined + if (record.schema !== undefined) { + try { + assertSupportedOutputSchema(record.schema) + schema = record.schema + } catch (error: unknown) { + /* v8 ignore next -- defensive rethrow arm: assertSupportedOutputSchema only throws OutputSchemaError */ + if (!(error instanceof OutputSchemaError)) throw error + throw new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, 'UNSUPPORTED_SCHEMA', { cause: error }) + } + } + return { + ...record.label !== undefined ? { label: record.label as string } : {}, + ...record.phase !== undefined ? { phase: record.phase as string } : {}, + ...record.model !== undefined ? { model: record.model as string } : {}, + ...schema !== undefined ? { schema } : {}, + } + } + + /** The `parallel(thunks)` hook: each thunk caught → `null`; fatal errors propagate. */ + private async parallel(rawThunks: unknown): Promise { + if (!Array.isArray(rawThunks)) { + throw new WorkflowError('parallel() requires an array of zero-argument functions', 'INVALID_ARGUMENT') + } + this.assertItemCap(rawThunks.length, 'parallel()') + const thunks = rawThunks.map((thunk, index) => { + if (typeof thunk !== 'function') { + throw new WorkflowError(`parallel() item ${index} is not a function`, 'INVALID_ARGUMENT') + } + return thunk as () => unknown + }) + return Promise.all(thunks.map(async (thunk) => { + try { + return await thunk() + } catch (error: unknown) { + if (isFatalWorkflowError(error)) throw error + return null + } + })) + } + + /** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */ + private async pipeline(rawItems: unknown, rawStages: unknown[]): Promise { + if (!Array.isArray(rawItems)) { + throw new WorkflowError('pipeline() requires an items array', 'INVALID_ARGUMENT') + } + this.assertItemCap(rawItems.length, 'pipeline()') + if (rawStages.length === 0) { + throw new WorkflowError('pipeline() requires at least one stage function', 'INVALID_ARGUMENT') + } + const stages = rawStages.map((stage, index) => { + if (typeof stage !== 'function') { + throw new WorkflowError(`pipeline() stage ${index} is not a function`, 'INVALID_ARGUMENT') + } + return stage as (previous: unknown, item: unknown, index: number) => unknown + }) + return Promise.all(rawItems.map(async (item: unknown, index) => { + let value: unknown = item + try { + for (const stage of stages) { + value = await stage(value, item, index) + } + return value + } catch (error: unknown) { + // An ordinary stage throw drops the ITEM to null and skips its + // remaining stages; a fatal error kills the whole script. + if (isFatalWorkflowError(error)) throw error + return null + } + })) + } + + private assertItemCap(length: number, hook: string): void { + if (length > this.limits.maxItemsPerCall) { + throw new WorkflowError( + `${hook} received ${length} items — over the per-call cap (${this.limits.maxItemsPerCall}); split the work or raise maxItemsPerCall in the engine config`, + 'ITEM_CAP', + ) + } + } + + /** The `phase(title)` hook: sets the current label for subsequent `agent()` calls and notifies observers. */ + private phase(title: unknown): void { + if (typeof title !== 'string' || title.length === 0) { + throw new WorkflowError('phase() requires a non-empty title string', 'INVALID_ARGUMENT') + } + this.currentPhase = title + this.observer.phase(title) + } + + /** The `log(message)` hook: narration to observers. */ + private log(message: unknown): void { + if (typeof message !== 'string') { + throw new WorkflowError('log() requires a message string', 'INVALID_ARGUMENT') + } + this.observer.log(message) + } +} diff --git a/packages/workflow/workflow-vm/tests/integration.spec.ts b/packages/workflow/workflow-vm/tests/integration.spec.ts new file mode 100644 index 0000000000..48b308a986 --- /dev/null +++ b/packages/workflow/workflow-vm/tests/integration.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import * as Invariants from '@deepseek-ai/dsh-invariants' +import SubagentService from '@deepseek-ai/dsh-subagent' +import * as spawn from '@deepseek-ai/dsh-subagent-spawn' +import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import VmWorkflowEngine from '../src/index.ts' + +type Script = ConstructorParameters[0] + +/** + * The whole in-process stack, keyless: the vm engine drives the REAL spawn + * backend (with its structured runtime) on a real agent loop; the scripted + * mock MODEL is the only mocked boundary. This is the integration guard the + * per-hook unit tests (which stub the subagent seam) structurally cannot give. + */ +async function setup(script: Script) { + const ctx = new Context() + const adapter = new MockAdapter(script) + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) + await ctx.plugin(VmWorkflowEngine, {}) + ctx.llm.registerAdapter(['mock'], adapter) + const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + return { ctx, parent, adapter } +} + +describe('dsh-workflow-vm over the real in-process stack', () => { + it('runs a two-stage workflow: a plain child, then a schema child through the structured runtime', async () => { + const { ctx, parent } = await setup([ + textResponse('the file list is a.ts'), + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { verdict: 'real', confidence: 0.9 }), + ]) + const childIds: string[] = [] + ctx.on('workflow/agent-start', (_info, agent) => { childIds.push(agent.childId) }) + const run = ctx.workflows.start({ + script: `export const meta = { name: 'integration', description: 'plain + structured children' } +phase('Read') +const prose = await agent('read the repo') +phase('Judge') +const judged = await agent('judge: ' + prose, { + schema: { type: 'object', properties: { verdict: { type: 'string', enum: ['real', 'bogus'] }, confidence: { type: 'number' } }, required: ['verdict'] }, +}) +return { prose, verdict: judged.verdict, confidence: judged.confidence }`, + parent, + }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(result.value).toEqual({ prose: 'the file list is a.ts', verdict: 'real', confidence: 0.9 }) + expect(result.agentsStarted).toBe(2) + await run.dispose() + // Both children were disposed to quiescence — no live child agents remain. + expect(childIds.length).toBe(2) + for (const childId of childIds) { + expect(ctx.agents.get(AgentId(childId))).toBeUndefined() + } + }) + + it('a child that fails against its schema (nudges exhausted) reaches the script as null', async () => { + const { ctx, parent } = await setup([ + textResponse('prose only'), + textResponse('still prose after the nudge'), + ]) + const run = ctx.workflows.start({ + script: `export const meta = { name: 'null-path', description: 'schema failure maps to null' } +const judged = await agent('judge it', { schema: { type: 'object', properties: { v: { type: 'string' } } } }) +return { got: judged === null ? 'null' : 'value' }`, + parent, + }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(result.value).toEqual({ got: 'null' }) + await run.dispose() + }) +}) diff --git a/packages/workflow/workflow-vm/tests/meta.spec.ts b/packages/workflow/workflow-vm/tests/meta.spec.ts new file mode 100644 index 0000000000..4768da49ff --- /dev/null +++ b/packages/workflow/workflow-vm/tests/meta.spec.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest' +import { WorkflowError } from '@deepseek-ai/dsh-workflow' +import { extractMeta } from '../src/meta.ts' + +const TIMEOUT = 1000 + +/** Extract and expect success. */ +function ok(script: string) { + return extractMeta(script, TIMEOUT) +} + +/** The WorkflowError a bad script produces (throws if it extracts cleanly). */ +function bad(script: string): WorkflowError { + try { + extractMeta(script, TIMEOUT) + } catch (error: unknown) { + if (error instanceof WorkflowError) return error + throw error + } + throw new Error('expected extraction to fail') +} + +describe('extractMeta', () => { + it('extracts a full meta block and blanks the statement line-preservingly', () => { + const script = `export const meta = { + name: 'audit-routes', + description: 'Audit every route', + whenToUse: 'when auditing', + phases: [{ title: 'Scan', detail: 'find files' }, { title: 'Fix', model: 'deepseek-v4-pro' }], +} +const x = 1 +return x` + const { meta, body } = ok(script) + expect(meta).toEqual({ + name: 'audit-routes', + description: 'Audit every route', + whenToUse: 'when auditing', + phases: [{ title: 'Scan', detail: 'find files' }, { title: 'Fix', model: 'deepseek-v4-pro' }], + }) + // Same line count; the statement's characters blanked; the body intact. + expect(body.split('\n').length).toBe(script.split('\n').length) + expect(body.split('\n')[6]).toBe('const x = 1') + expect(body).not.toContain('export') + }) + + it('allows leading line and block comments before the meta statement', () => { + const script = `// a workflow +/* multi + line */ +export const meta = { name: 'x', description: 'y' } +return 1` + expect(ok(script).meta.name).toBe('x') + }) + + it('handles braces inside strings and comments while scanning', () => { + const script = `export const meta = { + name: 'tricky', // } not a close { + /* } also not } */ + description: "has { braces } and 'quotes'", +} +return 2` + expect(ok(script).meta.description).toBe("has { braces } and 'quotes'") + }) + + it('tolerates template-quoted strings WITHOUT interpolation, escapes included', () => { + const script = 'export const meta = { name: `plain`, description: `esc \\` tick` }\nreturn 1' + expect(ok(script).meta.name).toBe('plain') + }) + + it('consumes a trailing semicolon after the literal, spaces included', () => { + const { body } = ok("export const meta = { name: 'x', description: 'y' };\nreturn 1") + expect(body).not.toContain(';') + expect(body.split('\n')[1]).toBe('return 1') + const spaced = ok("export const meta = { name: 'x', description: 'y' } ;\nreturn 1") + expect(spaced.body).not.toContain(';') + }) + + it('rejects a script that does not begin with the meta statement (SCRIPT_PARSE)', () => { + expect(bad('const a = 1').code).toBe('SCRIPT_PARSE') + expect(bad('').code).toBe('SCRIPT_PARSE') + expect(bad('export const meta = [1]').code).toBe('SCRIPT_PARSE') + }) + + it('rejects template interpolation in the meta block as impure (SCRIPT_PARSE)', () => { + const error = bad('export const meta = { name: `w-${1}`, description: "d" }\nreturn 1') + expect(error.code).toBe('SCRIPT_PARSE') + expect(error.message).toContain('pure literal') + }) + + it('rejects unbalanced literals, unterminated strings, and unterminated comments (SCRIPT_PARSE)', () => { + expect(bad('export const meta = { name: "x", description: "y"').code).toBe('SCRIPT_PARSE') + expect(bad('export const meta = { name: "x').code).toBe('SCRIPT_PARSE') + expect(bad('export const meta = { /* open').code).toBe('SCRIPT_PARSE') + // A line comment running to EOF (no newline) leaves the literal unbalanced. + expect(bad('export const meta = { name: "x" // eof comment').code).toBe('SCRIPT_PARSE') + }) + + it('rejects a literal referencing variables or calls (META_INVALID via the empty realm)', () => { + const error = bad('export const meta = { name: someVariable, description: "d" }\nreturn 1') + expect(error.code).toBe('META_INVALID') + expect(error.message).toContain('pure literal') + expect(bad('export const meta = { name: compute(), description: "d" }').code).toBe('META_INVALID') + }) + + it('rejects a literal evaluating to non-JSON data (META_INVALID via materialization)', () => { + const error = bad('export const meta = { name: "x", description: "d", phases: [{ get title() { return "t" } }] }') + expect(error.code).toBe('META_INVALID') + expect(error.message).toContain('JSON data') + }) + + it('rejects shape violations with EVERY violation listed (META_INVALID)', () => { + const error = bad('export const meta = { description: 7, bogus: 1 }\nreturn 1') + expect(error.code).toBe('META_INVALID') + expect(error.message).toContain('meta.name must be a non-empty string') + expect(error.message).toContain('meta.description must be a non-empty string') + expect(error.message).toContain('meta.bogus is not a recognized field') + }) + + it('rejects malformed whenToUse and phases shapes precisely', () => { + expect(bad('export const meta = { name: "x", description: "d", whenToUse: 3 }').message) + .toContain('meta.whenToUse must be a string') + expect(bad('export const meta = { name: "x", description: "d", phases: "no" }').message) + .toContain('meta.phases must be an array') + expect(bad('export const meta = { name: "x", description: "d", phases: [3] }').message) + .toContain('meta.phases[0] must be an object') + expect(bad('export const meta = { name: "x", description: "d", phases: [{}] }').message) + .toContain('meta.phases[0].title must be a non-empty string') + expect(bad('export const meta = { name: "x", description: "d", phases: [{ title: "t", extra: 1 }] }').message) + .toContain('meta.phases[0].extra is not a recognized field') + expect(bad('export const meta = { name: "x", description: "d", phases: [{ title: "t", detail: 1 }] }').message) + .toContain('meta.phases[0].detail must be a string') + expect(bad('export const meta = { name: "x", description: "d", phases: [{ title: "t", model: 1 }] }').message) + .toContain('meta.phases[0].model must be a string') + }) + + it('stops scanning at the balanced literal — trailing expression text stays in the body', () => { + // The scanner extracts exactly `{ valueOf: null }`; the ` && 3` is body + // text (which would fail compilation later, but extraction sees only the + // literal and reports its unknown field). + expect(bad('export const meta = { valueOf: null } && 3').message) + .toContain('meta.valueOf is not a recognized field') + }) +}) diff --git a/packages/workflow/workflow-vm/tests/realm.spec.ts b/packages/workflow/workflow-vm/tests/realm.spec.ts new file mode 100644 index 0000000000..f374706dba --- /dev/null +++ b/packages/workflow/workflow-vm/tests/realm.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import * as vm from 'node:vm' +import { materializeFromRealm, MaterializeError } from '../src/realm.ts' + +/** Evaluate an expression inside a fresh vm realm and hand back the raw realm value. */ +function inRealm(expression: string): unknown { + return vm.runInNewContext(`(${expression})`) +} + +/** The MaterializeError message for a value that must be rejected (throws if accepted). */ +function rejection(value: unknown): string { + try { + materializeFromRealm(value) + } catch (error: unknown) { + if (error instanceof MaterializeError) return error.message + throw error + } + throw new Error('expected the value to be rejected') +} + +describe('materializeFromRealm', () => { + it('copies realm objects/arrays/scalars into host plain data', () => { + const value = inRealm("{ a: 1, b: 'x', c: true, d: null, list: [1, [2, { deep: 'y' }]] }") + const out = materializeFromRealm(value) as Record + expect(out).toEqual({ a: 1, b: 'x', c: true, d: null, list: [1, [2, { deep: 'y' }]] }) + // The copy is HOST data: prototypes are the host intrinsics. + expect(Object.getPrototypeOf(out)).toBe(Object.prototype) + expect(Array.isArray(out.list)).toBe(true) + // And it round-trips through JSON byte-identically (the whole point). + expect(JSON.parse(JSON.stringify(out))).toEqual(out) + }) + + it('accepts undefined ONLY at the root (a valueless script return)', () => { + expect(materializeFromRealm(undefined)).toBeUndefined() + expect(rejection(inRealm('{ a: undefined }'))).toContain('value.a') + }) + + it('never invokes accessors: a counting getter is rejected, not read', () => { + const counter = inRealm(` + (() => { + globalThis.reads = 0 + return { get x() { globalThis.reads += 1; return 1 } } + })() + `) + expect(rejection(counter)).toContain('accessor properties cannot cross') + // The getter body never ran — descriptor inspection only. + expect((counter as { x?: unknown }).x).toBe(1) // sanity: reading DOES run it… + expect(rejection(counter)).toContain('accessor') // …but materialization still never did + }) + + it('a "__proto__" key becomes an OWN data property of the copy, never a prototype mutation', () => { + const value: unknown = vm.runInNewContext('JSON.parse(\'{"__proto__": {"polluted": 1}, "ok": 2}\')') + const out = materializeFromRealm(value) as Record + expect(Object.getPrototypeOf(out)).toBe(Object.prototype) + expect(Object.prototype.hasOwnProperty.call(out, '__proto__')).toBe(true) + expect(out.ok).toBe(2) + // The host Object.prototype was NOT touched. + expect(({} as Record).polluted).toBeUndefined() + }) + + it('rejects functions, symbols (keys and values), and bigints with path-qualified messages', () => { + expect(rejection(inRealm('{ fn: () => 1 }'))).toContain('value.fn') + expect(rejection(inRealm("{ [Symbol('k')]: 1 }"))).toContain('symbol-keyed') + expect(rejection(inRealm("{ s: Symbol('v') }"))).toContain('value.s') + expect(rejection(inRealm('{ big: 1n }'))).toContain('value.big') + expect(rejection(inRealm("[Symbol('x')]"))).toContain('value[0]') + const taggedArray = inRealm("(() => { const a = [1]; a[Symbol('t')] = 1; return a })()") + expect(rejection(taggedArray)).toContain('symbol-keyed') + }) + + it('rejects non-finite numbers and undefined values inside containers', () => { + expect(rejection(inRealm('{ n: NaN }'))).toContain('non-finite') + expect(rejection(inRealm('[Infinity]'))).toContain('non-finite') + }) + + it('rejects exotic prototypes (Date, Map, class instances) but accepts null-prototype data', () => { + expect(rejection(inRealm('{ d: new Date(0) }'))).toContain('exotic prototype') + expect(rejection(inRealm('new Map()'))).toContain('exotic prototype') + expect(rejection(inRealm('(() => { class C { constructor() { this.x = 1 } } return new C() })()'))) + .toContain('exotic prototype') + expect(materializeFromRealm(inRealm('Object.assign(Object.create(null), { a: 1 })'))).toEqual({ a: 1 }) + }) + + it('rejects cycles and accepts the same object reused as a sibling (a DAG)', () => { + expect(rejection(inRealm('(() => { const o = {}; o.self = o; return o })()'))).toContain('circular') + const dag = inRealm('(() => { const leaf = { v: 1 }; return { a: leaf, b: leaf } })()') + expect(materializeFromRealm(dag)).toEqual({ a: { v: 1 }, b: { v: 1 } }) + }) + + it('rejects sparse arrays, accessor elements, and non-index array properties', () => { + expect(rejection(inRealm('[1, , 3]'))).toContain('sparse') + expect(rejection(inRealm('(() => { const a = [1]; Object.defineProperty(a, 0, { get: () => 1 }); return a })()'))) + .toContain('accessor') + expect(rejection(inRealm('(() => { const a = [1]; a.total = 3; return a })()'))) + .toContain('non-index') + }) + + it('skips non-enumerable own properties (matching JSON.stringify exactly)', () => { + const value = inRealm(`(() => { + const o = { visible: 1 } + Object.defineProperty(o, 'hidden', { value: () => 1, enumerable: false }) + return o + })()`) + expect(materializeFromRealm(value)).toEqual({ visible: 1 }) + }) + + it('works on plain host values too (the boundary is realm-agnostic)', () => { + expect(materializeFromRealm({ a: [1, 'x'] })).toEqual({ a: [1, 'x'] }) + expect(materializeFromRealm('str')).toBe('str') + expect(materializeFromRealm(3)).toBe(3) + expect(materializeFromRealm(false)).toBe(false) + expect(materializeFromRealm(null)).toBeNull() + }) +}) diff --git a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts new file mode 100644 index 0000000000..9403dc912e --- /dev/null +++ b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts @@ -0,0 +1,614 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import type { WorkflowResult, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' +import * as vmEngineModule from '../src/index.ts' +import VmWorkflowEngine, { type Config } from '../src/index.ts' + +/** A minimal parent stand-in: the engine only threads it through to the provider. */ +function fakeParent(): Agent { + return { id: AgentId('workflow-parent'), options: {} } as unknown as Agent +} + +/** One controllable child run: the test (or auto mode) settles it. */ +interface ControlledRun { + request: SubagentStartRequest + settle(result: SubagentResult): void + cancelled: string | undefined + disposed: boolean +} + +/** + * A scripted in-test provider over the REAL SubagentService registry: `auto` + * settles each run via the reply function on a microtask; `manual` piles runs + * up in `runs` for the test to settle (concurrency/cancellation tests). A run + * aborts (settles `aborted`) when the request signal fires, like the real + * in-process backends. + */ +class StubProvider implements SubagentProvider { + readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true } + readonly runs: ControlledRun[] = [] + + constructor( + readonly name: string, + private readonly reply?: (request: SubagentStartRequest, index: number) => SubagentResult, + ) {} + + start(request: SubagentStartRequest): SubagentRun { + let settle!: (result: SubagentResult) => void + const result = new Promise((resolve) => { settle = resolve }) + const controlled: ControlledRun = { request, settle, cancelled: undefined, disposed: false } + this.runs.push(controlled) + const index = this.runs.length - 1 + request.signal?.addEventListener('abort', () => { settle({ output: [], stopReason: 'aborted' }) }, { once: true }) + if (this.reply) { + const reply = this.reply + queueMicrotask(() => { settle(reply(request, index)) }) + } + return { + id: AgentId(`stub-child-${index}`), + result, + cancel: (reason?: string) => { + controlled.cancelled = reason ?? 'cancelled' + settle({ output: [], stopReason: 'aborted' }) + }, + dispose: () => { + controlled.disposed = true + return Promise.resolve() + }, + } + } +} + +/** Text-reply helper for auto providers. */ +function text(reply: string): SubagentResult { + return { output: [{ type: 'text', text: reply }], stopReason: 'completed' } +} + +interface SetupOptions { + config?: Config + reply?: (request: SubagentStartRequest, index: number) => SubagentResult + manual?: boolean +} + +async function setup(options?: SetupOptions) { + const ctx = new Context() + await ctx.plugin(SubagentService) + const provider = new StubProvider('stub', options?.manual ? undefined : options?.reply ?? (() => text('stub reply'))) + ctx.subagents.registerProvider(provider) + await ctx.plugin(VmWorkflowEngine, { provider: 'stub', ...options?.config }) + return { ctx, provider, parent: fakeParent() } +} + +/** Wrap a body in the minimal valid meta header. */ +function script(body: string, metaExtra = ''): string { + return `export const meta = { name: 'test-flow', description: 'a test workflow'${metaExtra} }\n${body}` +} + +/** Start + await one run, disposing on the way out. */ +async function run(ctx: Context, parent: Agent, source: string, args?: unknown): Promise { + const handle = ctx.workflows.start({ script: source, parent, ...args !== undefined ? { args } : {} }) + try { + return await handle.result + } finally { + await handle.dispose() + } +} + +describe('dsh-workflow-vm', () => { + describe('script execution', () => { + it('runs a script end-to-end: agent() text results, phases, log, args, return value', async () => { + const { ctx, parent, provider } = await setup({ reply: (_request, index) => text(`answer-${index}`) }) + const events: [string, unknown[]][] = [] + for (const name of ['workflow/start', 'workflow/phase', 'workflow/log', 'workflow/agent-start', 'workflow/agent-end', 'workflow/end'] as const) { + ctx.on(name, (...payload: unknown[]) => { events.push([name, payload]) }) + } + const result = await run(ctx, parent, script(` + phase('Scan') + log('starting with ' + args.files.length + ' files') + const answers = await pipeline(args.files, (prev, item) => agent('read ' + item)) + phase('Report') + return { answers, count: args.files.length } + `, ", phases: [{ title: 'Scan' }, { title: 'Report' }]"), { files: ['a.ts', 'b.ts'] }) + + expect(result.stopReason).toBe('completed') + expect(result.agentsStarted).toBe(2) + expect(result.value).toEqual({ answers: ['answer-0', 'answer-1'], count: 2 }) + expect(provider.runs.every(r => r.disposed)).toBe(true) + + const names = events.map(([name]) => name) + expect(names[0]).toBe('workflow/start') + expect(names).toContain('workflow/phase') + expect(names).toContain('workflow/log') + expect(names.at(-1)).toBe('workflow/end') + const info = events[0]![1][0] as WorkflowRunInfo + expect(info.meta.name).toBe('test-flow') + const end = events.at(-1)![1][1] as Record + expect(end).toEqual({ stopReason: 'completed', agentsStarted: 2 }) + expect('value' in end).toBe(false) + }) + + it('agent-start/end events carry seq, label (defaulted from the prompt), phase, and outcome', async () => { + const { ctx, parent } = await setup() + const starts: unknown[] = [] + const ends: unknown[] = [] + ctx.on('workflow/agent-start', (_info, agent) => starts.push(agent)) + ctx.on('workflow/agent-end', (_info, agent) => ends.push(agent)) + await run(ctx, parent, script(` + phase('Find') + await agent('a prompt that is quite long and will surely get truncated down to a display label\\n' + + 'with a second line the label must not include') + await agent('short', { label: 'named', phase: 'Custom' }) + return null + `)) + expect(starts[0]).toMatchObject({ seq: 1, phase: 'Find', childId: 'stub-child-0' }) + expect((starts[0] as { label: string }).label.length).toBeLessThanOrEqual(48) + expect((starts[0] as { label: string }).label).not.toContain('second line') + expect(starts[1]).toMatchObject({ seq: 2, label: 'named', phase: 'Custom' }) + expect(ends[0]).toMatchObject({ seq: 1, outcome: 'completed' }) + }) + + it('agent({schema}) forwards outputSchema to the provider and returns the structured value into the realm', async () => { + const { ctx, parent, provider } = await setup({ + reply: () => ({ output: [], structured: { files: ['x.ts', 'y.ts'] }, stopReason: 'completed' }), + }) + const result = await run(ctx, parent, script(` + const found = await agent('list files', { schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } }, required: ['files'] } }) + return { first: found.files[0], count: found.files.length } + `)) + expect(result.value).toEqual({ first: 'x.ts', count: 2 }) + expect(provider.runs[0]!.request.outputSchema).toEqual({ + type: 'object', + properties: { files: { type: 'array', items: { type: 'string' } } }, + required: ['files'], + }) + }) + + it('model option maps to agentOptions.model on the start request', async () => { + const { ctx, parent, provider } = await setup() + await run(ctx, parent, script("return await agent('p', { model: 'deepseek-v4-pro' })")) + expect(provider.runs[0]!.request.agentOptions).toEqual({ model: 'deepseek-v4-pro' }) + }) + + it('a failed child resolves null (scripts filter), never throwing into the script', async () => { + const { ctx, parent } = await setup({ + reply: (_request, index) => index === 0 ? { output: [], stopReason: 'error' } : text('ok'), + }) + const result = await run(ctx, parent, script(` + const results = await parallel([() => agent('one'), () => agent('two')]) + return results + `)) + expect(result.value).toEqual([null, 'ok']) + }) + + it('a schema run that completes WITHOUT a structured value is a child failure (null + failed outcome)', async () => { + const { ctx, parent } = await setup({ reply: () => text('prose, no structure') }) + const ends: unknown[] = [] + ctx.on('workflow/agent-end', (_info, agent) => ends.push(agent)) + const result = await run(ctx, parent, script(` + return await agent('p', { schema: { type: 'object' } }) + `)) + expect(result.value).toBeNull() + expect(ends[0]).toMatchObject({ outcome: 'failed' }) + }) + + it('a script with no return value resolves value: null', async () => { + const { ctx, parent } = await setup() + const result = await run(ctx, parent, script("await agent('p')")) + expect(result.stopReason).toBe('completed') + expect(result.value).toBeNull() + }) + }) + + describe('combinator semantics', () => { + it('pipeline has NO cross-stage barrier: a fast item finishes stage 2 while a slow item holds stage 1', async () => { + const { ctx, parent, provider } = await setup({ manual: true }) + const handle = ctx.workflows.start({ + script: script(` + const out = await pipeline(['slow', 'fast'], + (prev, item) => agent('s1 ' + item), + (prev, item) => agent('s2 ' + item + ' after ' + prev), + ) + return out + `), + parent: fakeParent(), + }) + // Both items enter stage 1 concurrently. + await vi.waitFor(() => { expect(provider.runs.length).toBe(2) }) + // Settle only the FAST item's stage 1 → its stage 2 starts with no barrier. + provider.runs[1]!.settle(text('fast-1')) + await vi.waitFor(() => { expect(provider.runs.length).toBe(3) }) + expect((provider.runs[2]!.request.prompt[0] as { text: string }).text).toBe('s2 fast after fast-1') + // The slow item is still sitting in stage 1. + provider.runs[2]!.settle(text('fast-2')) + provider.runs[0]!.settle(text('slow-1')) + await vi.waitFor(() => { expect(provider.runs.length).toBe(4) }) + provider.runs[3]!.settle(text('slow-2')) + const result = await handle.result + expect(result.value).toEqual(['slow-2', 'fast-2']) + await handle.dispose() + void parent + }) + + it('pipeline stage callbacks receive (prev, item, index); an ordinary stage throw nulls the ITEM and skips its remaining stages', async () => { + const { ctx, parent, provider } = await setup({ reply: request => text(`ok:${(request.prompt[0] as { text: string }).text}`) }) + const result = await run(ctx, parent, script(` + const out = await pipeline([10, 20], + (prev, item, index) => { + if (item === 10) throw new Error('ordinary failure') + return agent('stage1-' + item + '-' + index) + }, + (prev) => agent('stage2 saw ' + prev), + ) + return out + `)) + expect(result.stopReason).toBe('completed') + const prompts = provider.runs.map(r => (r.request.prompt[0] as { text: string }).text) + // Item 10 never reached stage 1's agent nor stage 2. + expect(prompts).toEqual(['stage1-20-1', 'stage2 saw ok:stage1-20-1']) + expect(result.value).toEqual([null, 'ok:stage2 saw ok:stage1-20-1']) + }) + + it('parallel maps a throwing thunk to null and never rejects for ordinary errors', async () => { + const { ctx, parent } = await setup() + const result = await run(ctx, parent, script(` + return await parallel([ + () => { throw new Error('boom') }, + () => agent('fine'), + () => 'plain value', + ]) + `)) + expect(result.value).toEqual([null, 'stub reply', 'plain value']) + }) + + it('FATAL errors propagate through parallel AND pipeline instead of dissolving into null', async () => { + const { ctx, parent } = await setup() + const viaParallel = await run(ctx, parent, script(` + return await parallel([() => agent('x', { isolation: 'worktree' })]) + `)) + expect(viaParallel.stopReason).toBe('error') + expect(viaParallel.error).toContain('"isolation" is deferred') + + const viaPipeline = await run(ctx, parent, script(` + return await pipeline([1], () => agent('x', { bogus: true })) + `)) + expect(viaPipeline.stopReason).toBe('error') + expect(viaPipeline.error).toContain('"bogus" is not recognized') + }) + + it('validates combinator arguments loudly (non-array, non-function, missing stages)', async () => { + const { ctx, parent } = await setup() + expect((await run(ctx, parent, script("return await parallel('no')"))).error).toContain('parallel() requires an array') + expect((await run(ctx, parent, script('return await parallel([3])'))).error).toContain('item 0 is not a function') + expect((await run(ctx, parent, script("return await pipeline('no', () => 1)"))).error).toContain('pipeline() requires an items array') + expect((await run(ctx, parent, script('return await pipeline([1])'))).error).toContain('at least one stage') + expect((await run(ctx, parent, script("return await pipeline([1], 'x')"))).error).toContain('stage 0 is not a function') + }) + }) + + describe('caps and option validation', () => { + it('trips the total-agent cap with a message naming the config knob', async () => { + const { ctx, parent } = await setup({ config: { provider: 'stub', maxTotalAgents: 2 } }) + const result = await run(ctx, parent, script(` + await agent('1'); await agent('2'); await agent('3') + return 'unreachable' + `)) + expect(result.stopReason).toBe('error') + expect(result.error).toContain('total agent cap (2)') + expect(result.error).toContain('maxTotalAgents') + expect(result.agentsStarted).toBe(2) + }) + + it('trips the per-call item cap for parallel and pipeline', async () => { + const { ctx, parent } = await setup({ config: { provider: 'stub', maxItemsPerCall: 2 } }) + expect((await run(ctx, parent, script('return await parallel([() => 1, () => 2, () => 3])'))).error) + .toContain('over the per-call cap (2)') + expect((await run(ctx, parent, script('return await pipeline([1, 2, 3], (x) => x)'))).error) + .toContain('maxItemsPerCall') + }) + + it('enforces the concurrency ceiling: never more than maxConcurrentAgents children in flight', async () => { + const { ctx, parent, provider } = await setup({ manual: true, config: { provider: 'stub', maxConcurrentAgents: 2 } }) + const handle = ctx.workflows.start({ + script: script("return await parallel([1, 2, 3, 4, 5].map((n) => () => agent('job ' + n)))"), + parent, + }) + // Only 2 children may exist until one settles. + await vi.waitFor(() => { expect(provider.runs.length).toBe(2) }) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(provider.runs.length).toBe(2) + // Settle children in arrival order; after each settle at most ONE more + // child may enter — the window never exceeds the ceiling. + for (let index = 0; index < 5; index++) { + await vi.waitFor(() => { expect(provider.runs.length).toBeGreaterThan(index) }) + expect(provider.runs.length).toBeLessThanOrEqual(Math.min(index + 2, 5)) + provider.runs[index]!.settle(text(`r${index}`)) + } + const result = await handle.result + expect(result.stopReason).toBe('completed') + expect(result.agentsStarted).toBe(5) + expect(result.value).toEqual(['r0', 'r1', 'r2', 'r3', 'r4']) + await handle.dispose() + }) + + it('rejects malformed agent() arguments and option types loudly', async () => { + const { ctx, parent } = await setup() + expect((await run(ctx, parent, script('return await agent(42)'))).error).toContain('non-empty prompt string') + expect((await run(ctx, parent, script("return await agent('')"))).error).toContain('non-empty prompt string') + expect((await run(ctx, parent, script("return await agent('p', 'opts')"))).error).toContain('options must be an object') + expect((await run(ctx, parent, script("return await agent('p', { label: 3 })"))).error).toContain('"label" must be a string') + expect((await run(ctx, parent, script("return await agent('p', { effort: 'high' })"))).error).toContain('"effort" is deferred') + }) + + it('rejects options that are not plain JSON data (an accessor smuggled into opts)', async () => { + const { ctx, parent } = await setup() + const result = await run(ctx, parent, script("return await agent('p', { get label() { return 'x' } })")) + expect(result.stopReason).toBe('error') + expect(result.error).toContain('options must be plain JSON data') + }) + + it('validates phase() and log() arguments loudly', async () => { + const { ctx, parent } = await setup() + expect((await run(ctx, parent, script('phase(3)'))).error).toContain('phase() requires a non-empty title string') + expect((await run(ctx, parent, script("phase('')"))).error).toContain('phase() requires a non-empty title string') + expect((await run(ctx, parent, script('log(3)'))).error).toContain('log() requires a message string') + }) + + it('rejects an unsupported schema via the shared subset assertion (UNSUPPORTED_SCHEMA)', async () => { + const { ctx, parent } = await setup() + const result = await run(ctx, parent, script("return await agent('p', { schema: { type: 'object', oneOf: [] } })")) + expect(result.stopReason).toBe('error') + expect(result.error).toContain('outside the supported subset') + expect(result.error).toContain('oneOf') + }) + + it('wraps a provider start failure as a fatal AGENT_START error (a missing provider cannot dissolve into null)', async () => { + const { ctx, parent } = await setup({ config: { provider: 'nonexistent' } }) + const result = await run(ctx, parent, script("return await pipeline([1], () => agent('p'))")) + expect(result.stopReason).toBe('error') + expect(result.error).toContain('could not start a child on provider "nonexistent"') + }) + }) + + describe('determinism bans and realm isolation', () => { + it('Date.now, Math.random, and argless new Date throw; parameterized Date stays usable', async () => { + const { ctx, parent } = await setup() + expect((await run(ctx, parent, script('return Date.now()'))).error).toContain('Date.now() is not available') + expect((await run(ctx, parent, script('return Math.random()'))).error).toContain('Math.random() is not available') + expect((await run(ctx, parent, script('return new Date().toISOString()'))).error).toContain('argless new Date()') + const ok = await run(ctx, parent, script('return new Date(0).getTime()')) + expect(ok.value).toBe(0) + }) + + it('args cross into the realm as data: mutating them (or their prototype chain) cannot reach host intrinsics', async () => { + const { ctx, parent } = await setup() + const hostArgs = { files: ['a.ts'], nested: { deep: [1, 2] } } + const result = await run(ctx, parent, script(` + args.files.push('b.ts') + Object.getPrototypeOf(args).polluted = 'realm-only' + return { count: args.files.length, deep: args.nested.deep[1] } + `), hostArgs) + expect(result.value).toEqual({ count: 2, deep: 2 }) + // The host copy is untouched, and the HOST Object.prototype was never reachable. + expect(hostArgs.files).toEqual(['a.ts']) + expect(({} as Record).polluted).toBeUndefined() + }) + + it('scalar/null args pass through directly; absent args leave the global undefined', async () => { + const { ctx, parent } = await setup() + expect((await run(ctx, parent, script('return args * 2'), 21)).value).toBe(42) + expect((await run(ctx, parent, script('return args === null'), null)).value).toBe(true) + expect((await run(ctx, parent, script('return typeof args'))).value).toBe('undefined') + }) + + it('a non-JSON return value fails loud as RESULT_UNSERIALIZABLE', async () => { + const { ctx, parent } = await setup() + const withDate = await run(ctx, parent, script('return { when: new Date(0) }')) + expect(withDate.stopReason).toBe('error') + expect(withDate.error).toContain('not plain JSON data') + const withFn = await run(ctx, parent, script('return { fn: () => 1 }')) + expect(withFn.error).toContain('not plain JSON data') + }) + + it('kills a synchronous spin in the initial slice via the vm timeout', async () => { + const { ctx, parent } = await setup({ config: { provider: 'stub', syncTimeoutMs: 50 } }) + const result = await run(ctx, parent, script('while (true) {}')) + expect(result.stopReason).toBe('error') + expect(result.error?.toLowerCase()).toContain('timed out') + }) + }) + + describe('lifecycle: parse errors, cancellation, disposal', () => { + it('start() throws synchronously for an unparseable script or invalid meta', async () => { + const { ctx, parent } = await setup() + expect(() => ctx.workflows.start({ script: 'const x = 1', parent })).toThrow(/must begin with/) + expect(() => ctx.workflows.start({ script: script('return ((('), parent })).toThrow(/does not parse/) + }) + + it('cancel() aborts in-flight children and settles the run cancelled', async () => { + const { ctx, parent, provider } = await setup({ manual: true }) + const handle = ctx.workflows.start({ script: script("return await agent('long job')"), parent }) + await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) + handle.cancel('user stopped it') + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + expect(result.error).toContain('user stopped it') + expect(provider.runs[0]!.disposed).toBe(true) + await handle.dispose() + }) + + it('an already-aborted request signal cancels before any child starts', async () => { + const { ctx, parent, provider } = await setup({ manual: true }) + const controller = new AbortController() + controller.abort() + const handle = ctx.workflows.start({ script: script("return await agent('never')"), parent, signal: controller.signal }) + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + expect(provider.runs.length).toBe(0) + await handle.dispose() + }) + + it('the signal aborting mid-run cancels like cancel()', async () => { + const { ctx, parent, provider } = await setup({ manual: true }) + const controller = new AbortController() + const handle = ctx.workflows.start({ script: script("return await agent('job')"), parent, signal: controller.signal }) + await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) + controller.abort() + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + await handle.dispose() + }) + + it('reports a non-Error script throw (a thrown string) faithfully', async () => { + const { ctx, parent } = await setup() + const result = await run(ctx, parent, script("throw 'plain string failure'")) + expect(result.stopReason).toBe('error') + expect(result.error).toContain('plain string failure') + }) + + it('a script Error surfaces its stack, carrying the script line numbers (lineOffset)', async () => { + const { ctx, parent } = await setup() + const result = await run(ctx, parent, script("throw new Error('with stack')")) + expect(result.stopReason).toBe('error') + // Line 1 is the blanked meta statement; the throw sits on line 2. + expect(result.error).toContain('workflow:test-flow:2') + }) + + it('an object throw with neither stack nor message stringifies', async () => { + const { ctx, parent } = await setup() + const result = await run(ctx, parent, script('throw { code: 42 }')) + expect(result.stopReason).toBe('error') + expect(result.error).toBe('[object Object]') + }) + + it('falls back to the message for an Error whose stack was stripped', async () => { + const { ctx, parent } = await setup() + const result = await run(ctx, parent, script(` + const e = new Error('stackless failure') + e.stack = undefined + throw e + `)) + expect(result.stopReason).toBe('error') + expect(result.error).toBe('stackless failure') + }) + + it('a waiter resumed by a release RACING a cancel still dies at the post-acquire check', async () => { + const { ctx, parent, provider } = await setup({ manual: true, config: { provider: 'stub', maxConcurrentAgents: 1 } }) + const handle = ctx.workflows.start({ + script: script("return await parallel([() => agent('a'), () => agent('b')])"), + parent, + }) + await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) + // Same synchronous block: the release resolves b's waiter, then the + // cancel lands BEFORE b's continuation runs — b must not start a child. + provider.runs[0]!.settle(text('a-done')) + handle.cancel('raced') + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + expect(provider.runs.length).toBe(1) + await handle.dispose() + }) + + it('a dropped agent() promise cannot become an unhandled rejection when cancellation lands', async () => { + const unhandled: unknown[] = [] + const onUnhandled = (reason: unknown): void => { unhandled.push(reason) } + process.on('unhandledRejection', onUnhandled) + try { + const { ctx, parent, provider } = await setup({ manual: true }) + const handle = ctx.workflows.start({ + script: script(` + agent('dropped, never awaited') + return await agent('awaited') + `), + parent, + }) + await vi.waitFor(() => { expect(provider.runs.length).toBe(2) }) + handle.cancel() + await handle.result + await handle.dispose() + // Let any stray rejection reach the process hook before asserting. + await new Promise(resolve => setTimeout(resolve, 20)) + expect(unhandled).toEqual([]) + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + + it('dispose() abandons a stuck script after the grace instead of hanging (result stays pending)', async () => { + const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } }) + const handle = ctx.workflows.start({ + // No hooks involved: an unsettleable await the engine cannot reject. + script: script("await new Promise(() => {})\nreturn 'unreachable'"), + parent, + }) + const before = Date.now() + await handle.dispose() + expect(Date.now() - before).toBeLessThan(1000) + const settled = await Promise.race([handle.result.then(() => 'settled'), Promise.resolve('pending')]) + expect(settled).toBe('pending') + }) + + it('dispose() is idempotent and settles cleanly after a completed run', async () => { + const { ctx, parent } = await setup() + const handle = ctx.workflows.start({ script: script('return 1'), parent }) + await handle.result + await handle.dispose() + await handle.dispose() + }) + + it('strays: children fired without await are aborted once the script settles', async () => { + const { ctx, parent, provider } = await setup({ manual: true }) + const handle = ctx.workflows.start({ + script: script(` + agent('stray') + return 'done without awaiting' + `), + parent, + }) + const result = await handle.result + expect(result.stopReason).toBe('completed') + await vi.waitFor(() => { + expect(provider.runs.length).toBe(1) + expect(provider.runs[0]!.disposed).toBe(true) + }) + await handle.dispose() + }) + }) + + describe('service surface', () => { + it('run ids are unique per start; the run handle and event payloads hold SEPARATE meta clones', async () => { + const { ctx, parent } = await setup() + let eventMeta: WorkflowRunInfo | undefined + ctx.on('workflow/start', (info) => { eventMeta = info }) + const first = ctx.workflows.start({ script: script('return 1'), parent }) + const second = ctx.workflows.start({ script: script('return 2'), parent }) + expect(first.id).not.toBe(second.id) + // Mutating a listener's snapshot cannot corrupt the holder's view. + eventMeta!.meta.name = 'corrupted' + expect(second.meta.name).toBe('test-flow') + await Promise.all([first.result, second.result]) + await first.dispose() + await second.dispose() + }) + + it('unregisters ctx.workflows when the engine fiber is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const fiber = await ctx.plugin(VmWorkflowEngine, {}) + expect(ctx.get('workflows')).toBeDefined() + await fiber.dispose() + expect(ctx.get('workflows')).toBeUndefined() + }) + + it('has the class-plugin export shape (default = the engine service class)', () => { + expect(vmEngineModule.default).toBe(VmWorkflowEngine) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped: unknown = loader.unwrapExports(vmEngineModule) + expect(unwrapped).toBe(VmWorkflowEngine) + }) + }) +}) diff --git a/packages/workflow/workflow-vm/tests/workflow.e2e.ts b/packages/workflow/workflow-vm/tests/workflow.e2e.ts new file mode 100644 index 0000000000..6ddfdb7251 --- /dev/null +++ b/packages/workflow/workflow-vm/tests/workflow.e2e.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import SubagentService from '@deepseek-ai/dsh-subagent' +import * as Spawn from '@deepseek-ai/dsh-subagent-spawn' +import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' +import { CallId } from '@deepseek-ai/dsh-llm' +import VmWorkflowEngine from '../src/index.ts' + +/** + * With-key e2e for the workflow engine: a REAL script drives REAL spawn + * children against the live DeepSeek API — one plain child and one schema'd + * child through the real structured-output runtime — and the run's value, + * events, and child sessions are asserted from the outside (never the + * script's self-report alone). Key-gated (self-skips without + * DEEPSEEK_API_KEY). + */ + +let ctx: Context | undefined + +afterEach(async () => { + await ctx?.fiber.dispose() + ctx = undefined +}) + +async function harness(): Promise { + const built = new Context() + await built.plugin(LlmService) + await built.plugin(SessionStore) + await built.plugin(SystemPrompt) + await built.plugin(ToolRegistry) + await built.plugin(AgentRegistry) + await built.plugin(AgentLoop, { agents: [] }) + await built.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) + await built.plugin(SubagentService) + await built.plugin(Spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) + await built.plugin(VmWorkflowEngine, { provider: 'spawn' }) + await built.plugin(ToolWorkflow, {}) + return built +} + +const SCRIPT = `export const meta = { + name: 'e2e-arithmetic', + description: 'two real children: one prose, one structured', + phases: [{ title: 'Ask' }, { title: 'Judge' }], +} +phase('Ask') +log('asking the prose child') +const prose = await agent('Reply with exactly one short sentence: what is 2 + 2?') +phase('Judge') +const judged = await agent( + 'Here is an answer to the question "what is 2+2": ' + prose + + ' — report whether it contains the number 4 and your confidence between 0 and 1.', + { schema: { type: 'object', properties: { containsFour: { type: 'boolean' }, confidence: { type: 'number' } }, required: ['containsFour'] } }, +) +return { prose, containsFour: judged === null ? null : judged.containsFour }` + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workflow engine with-key e2e', () => { + it('runs a two-phase script over real children, one through the structured runtime', async () => { + ctx = await harness() + const parentHandle = ctx.agents.create({ + agentId: AgentId('wf-e2e-parent'), + sessionId: 'wf-e2e-session' as never, + agentOptions: { model: 'deepseek-v4-flash' }, + }) + + const events: string[] = [] + const childIds: string[] = [] + for (const name of ['workflow/start', 'workflow/phase', 'workflow/log', 'workflow/agent-start', 'workflow/agent-end', 'workflow/end'] as const) { + ctx.on(name, (...payload: unknown[]) => { + events.push(name) + if (name === 'workflow/agent-start') childIds.push((payload[1] as { childId: string }).childId) + }) + } + + const run = ctx.workflows.start({ script: SCRIPT, parent: parentHandle.agent }) + const result = await run.result + await run.dispose() + + expect(result.stopReason).toBe('completed') + expect(result.agentsStarted).toBe(2) + const value = result.value as { prose: string; containsFour: boolean | null } + // World checks: the prose child really answered (a real completion), and + // the structured child judged it against the REAL schema-forced tool. + expect(value.prose.length).toBeGreaterThan(0) + expect(value.containsFour).toBe(true) + + expect(events[0]).toBe('workflow/start') + expect(events.at(-1)).toBe('workflow/end') + expect(events.filter(name => name === 'workflow/phase').length).toBe(2) + expect(events.filter(name => name === 'workflow/agent-start').length).toBe(2) + expect(childIds.length).toBe(2) + // The children were disposed to quiescence after collection. + for (const childId of childIds) { + expect(ctx.agents.get(AgentId(childId))).toBeUndefined() + } + await parentHandle.dispose() + }, 240_000) + + it('the workflow TOOL runs the same path through the real registry pipeline', async () => { + ctx = await harness() + const parentHandle = ctx.agents.create({ + agentId: AgentId('wf-e2e-tool-parent'), + sessionId: 'wf-e2e-tool-session' as never, + agentOptions: { model: 'deepseek-v4-flash' }, + }) + + const result = await ctx.tools.execute({ + callId: CallId('wf-e2e-call'), + name: 'workflow', + arguments: { + script: `export const meta = { name: 'e2e-tool', description: 'one real child via the tool' } +const answer = await agent('Reply with exactly one word: the capital of France.') +return { answer }`, + }, + agent: parentHandle.agent, + }) + + expect(result.isError).toBe(false) + const text = (result.content[0] as { text: string }).text + expect(text).toContain('workflow "e2e-tool" completed (1 agent)') + expect(text.toLowerCase()).toContain('paris') + await parentHandle.dispose() + }, 240_000) +}) diff --git a/packages/workflow/workflow-vm/tsconfig.json b/packages/workflow/workflow-vm/tsconfig.json new file mode 100644 index 0000000000..385651c192 --- /dev/null +++ b/packages/workflow/workflow-vm/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../subagent/subagent" + }, + { + "path": "../../core/tools" + }, + { + "path": "../workflow" + } + ] +} diff --git a/packages/workflow/workflow/README.md b/packages/workflow/workflow/README.md new file mode 100644 index 0000000000..1dece68d38 --- /dev/null +++ b/packages/workflow/workflow/README.md @@ -0,0 +1,29 @@ +# @deepseek-ai/dsh-workflow + +The **workflow seam** (`ctx.workflows`): an abstract service defining WHAT a workflow engine does — execute a model-written orchestration script that fans out subagents — without saying HOW. The bash-shaped third of the [workflow family](../README.md): implementations subclass `WorkflowService` and register as the `workflows` service (one per context); [`dsh-workflow-vm`](../workflow-vm/README.md) is the first, and [`dsh-tool-workflow`](../tool-workflow/README.md) is the model-facing consumer. + +## Service: `WorkflowService` (abstract) + +`start(request: WorkflowStartRequest): WorkflowRun` — parse and execute a script. Throws synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot begin; once a run is returned, its `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`). `dispose()` must reach quiescence within a bounded grace (cancel → wait → abandon), never hanging its caller. + +The protected `emitWorkflowEvent` helper dispatches the `workflow/*` events with PER-LISTENER containment (a throwing subscriber is logged, never propagated, and cannot starve later listeners) — the same guarantee as the subagent seam's lifecycle emits. + +## Vocabulary + +- `WorkflowStartRequest` — `{ script, args?, parent: Agent, signal? }`. `parent` is REQUIRED: every child the script spawns is attributed to it. `args` must be plain host-realm JSON data. +- `WorkflowMeta` / `WorkflowPhase` — the script's validated `export const meta` block (Claude Code format: required `name`/`description`, optional `whenToUse`/`phases`). +- `WorkflowRun` — `{ id, meta, result, cancel(reason?), dispose() }`; the consumer awaits `result` and MUST `dispose` on every path. +- `WorkflowResult` — `{ value, stopReason: 'completed'|'cancelled'|'error', error?, agentsStarted }`; `value` is the script's materialized return (plain JSON data; `null` for no return). +- `WorkflowError` — `HarnessError` with a `WorkflowErrorCode` and a `fatal` flag driving the combinator discipline: a fatal error (bad hook arguments, unsupported options/schemas, tripped caps, seam start failures, cancellation) always propagates through `parallel()`/`pipeline()` instead of dissolving into a per-item `null`. `isFatalWorkflowError(error)` is the catch-site predicate. + +## Events + +All observe-only emits carrying DATA SNAPSHOTS (`WorkflowRunInfo` = id + meta) — never the live `WorkflowRun`, so a listener cannot gain `cancel`/`dispose`; control stays with the `start()` caller: + +- `workflow/start`(info) / `workflow/end`(info, resultInfo) — run lifecycle; `resultInfo` deliberately omits the value. +- `workflow/phase`(info, title) / `workflow/log`(info, message) — script narration. +- `workflow/agent-start`(info, agent) / `workflow/agent-end`(info, agent + outcome) — one pair per `agent()` call, correlated by `seq`. + +## Non-goals (this cut) + +Background collection, journaling/resume, saved workflows, nested `workflow()`, token budgets — see the [RFC's deferred section](../../../docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md). diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json new file mode 100644 index 0000000000..a6c004d6d0 --- /dev/null +++ b/packages/workflow/workflow/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-workflow", + "description": "Workflow capability seam: ctx.workflows service, run vocabulary, and workflow/* events", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts new file mode 100644 index 0000000000..ab5e056b37 --- /dev/null +++ b/packages/workflow/workflow/src/index.ts @@ -0,0 +1,224 @@ +/** + * The workflow capability seam (`ctx.workflows`): an abstract service defining + * WHAT a workflow engine does — execute a model-written orchestration script + * that fans out subagents — without saying HOW. Implementations subclass + * {@link WorkflowService} and register as the `workflows` service (one + * implementation per context, cordis' standard duplicate-service behavior); + * `@deepseek-ai/dsh-workflow-vm` (an in-process `node:vm` engine) is the + * first. Future engines (a worker-thread or isolated-vm sandbox) swap in + * without touching the model-facing tool that consumes them + * (`@deepseek-ai/dsh-tool-workflow`). + * + * The `workflow/*` lifecycle events are OBSERVE-ONLY data snapshots: they + * carry {@link WorkflowRunInfo} (id + meta), never the live {@link WorkflowRun} + * — a listener must not gain `cancel`/`dispose`; control stays with the + * `start()` caller holding the run. Every emit is per-listener contained (a + * throwing subscriber is logged, never propagated), so one bad observer can + * neither strand a live run nor starve later listeners. + * + * @module @deepseek-ai/dsh-workflow + */ + +import { Context, Service } from 'cordis' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { + WorkflowAgentEndInfo, + WorkflowAgentInfo, + WorkflowResultInfo, + WorkflowRun, + WorkflowRunInfo, + WorkflowStartRequest, +} from './types.ts' + +export { WorkflowRunId } from './types.ts' +export type { + WorkflowAgentEndInfo, + WorkflowAgentInfo, + WorkflowAgentOutcome, + WorkflowMeta, + WorkflowPhase, + WorkflowResult, + WorkflowResultInfo, + WorkflowRun, + WorkflowRunInfo, + WorkflowStartRequest, + WorkflowStopReason, +} from './types.ts' + +declare module 'cordis' { + interface Context { + workflows: WorkflowService + } + + interface Events { + /** + * A workflow run started — the script's meta block validated, the body + * about to execute. Paired with {@link Events['workflow/end']}. + * @param info - the run's identity snapshot (id + meta). + * @mode emit + */ + 'workflow/start'(info: WorkflowRunInfo): void + /** + * The script entered a phase (a `phase(title)` call) — progress grouping + * for observers; no execution semantics. + * @param info - the run's identity snapshot. + * @param title - the phase title, verbatim. + * @mode emit + */ + 'workflow/phase'(info: WorkflowRunInfo, title: string): void + /** + * The script emitted a narration line (a `log(message)` call). + * @param info - the run's identity snapshot. + * @param message - the logged message, verbatim. + * @mode emit + */ + 'workflow/log'(info: WorkflowRunInfo, message: string): void + /** + * One `agent()` call started a child run. Paired with + * {@link Events['workflow/agent-end']} by `agent.seq`. + * @param info - the run's identity snapshot. + * @param agent - the call's sequence number, label, phase, and child id. + * @mode emit + */ + 'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void + /** + * One `agent()` call settled (clean result, child failure, or run + * cancellation). Paired with {@link Events['workflow/agent-start']}. + * @param info - the run's identity snapshot. + * @param agent - the call identity plus its outcome. + * @mode emit + */ + 'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void + /** + * A workflow run settled (any stop reason). Fired when + * {@link WorkflowRun.result} resolves. Paired with + * {@link Events['workflow/start']}. + * @param info - the run's identity snapshot. + * @param result - the outcome data (stop reason, error, agent count) — + * deliberately WITHOUT the result value (see {@link WorkflowResultInfo}). + * @mode emit + */ + 'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void + } +} + +/** The full set of `workflow/*` event names {@link WorkflowService.emitWorkflowEvent} dispatches. */ +export type WorkflowEventName = + | 'workflow/start' + | 'workflow/phase' + | 'workflow/log' + | 'workflow/agent-start' + | 'workflow/agent-end' + | 'workflow/end' + +/** + * The workflow-seam error codes. Every one of these is FATAL when it reaches + * a script (see {@link WorkflowError.fatal}): the combinators re-throw it + * instead of dissolving it into an ordinary per-item `null`. + * + * - `SCRIPT_PARSE` — the script (or its meta statement) does not parse. + * - `META_INVALID` — the meta block evaluated but fails the shape contract. + * - `INVALID_ARGUMENT` — a hook was called with malformed arguments. + * - `UNSUPPORTED_OPTION` — an `agent()` option this engine does not support + * (deferred: `effort`/`isolation`/`agentType`) or does not know. + * - `UNSUPPORTED_SCHEMA` — an `agent()` schema outside the structured-output + * subset (see dsh-tools). + * - `AGENT_CAP` / `ITEM_CAP` — the run/agent caps tripped. + * - `AGENT_START` — the subagent seam refused to start a child. + * - `RESULT_UNSERIALIZABLE` — a value crossing the realm boundary is not + * plain JSON data. + * - `CANCELLED` — the run was cancelled; pending and future hooks reject + * with this (the script-kill mechanism). + */ +export type WorkflowErrorCode = + | 'SCRIPT_PARSE' + | 'META_INVALID' + | 'INVALID_ARGUMENT' + | 'UNSUPPORTED_OPTION' + | 'UNSUPPORTED_SCHEMA' + | 'AGENT_CAP' + | 'ITEM_CAP' + | 'AGENT_START' + | 'RESULT_UNSERIALIZABLE' + | 'CANCELLED' + +/** + * Typed error for workflow-seam failures. Extends {@link HarnessError}, so the + * `code` is machine-routable taxonomy. `fatal` drives the combinator + * discipline: `parallel()`/`pipeline()` re-throw a fatal error (a typo'd + * option or a tripped cap must kill the script loudly), and reserve the + * per-item `null` for child-run failures and ordinary in-stage script errors. + * Every {@link WorkflowErrorCode} is fatal in this cut; the flag exists so the + * distinction is explicit at every catch site rather than implied. + */ +export class WorkflowError extends HarnessError { + /** Whether combinators must propagate this error instead of nulling the item. */ + readonly fatal: boolean + + constructor(message: string, code: WorkflowErrorCode, options?: ErrorOptions & { fatal?: boolean }) { + super(message, code, options) + this.name = 'WorkflowError' + this.fatal = options?.fatal ?? true + } +} + +/** Whether combinators must re-throw `error` instead of mapping the item to `null`. */ +export function isFatalWorkflowError(error: unknown): boolean { + return error instanceof WorkflowError && error.fatal +} + +/** + * Abstract workflow execution service. Subclass, implement {@link start}, and + * load the subclass as a plugin — it registers as `ctx.workflows` (one + * implementation per context; loading a second throws, cordis' standard + * duplicate-service behavior). + * + * Semantics every implementation must honor: + * - {@link start} throws synchronously for a request that cannot begin (an + * unparseable script, an invalid meta block). Once it returns a + * {@link WorkflowRun}, `result` NEVER rejects — every failure resolves with + * `stopReason: 'error'` (or `'cancelled'`). + * - The `workflow/*` events fire through {@link emitWorkflowEvent} (data + * snapshots, per-listener containment); `workflow/end` fires exactly once + * per started run, after `result` is settled or as it settles. + * - `dispose()` reaches quiescence within a bounded grace: it cancels, waits + * for the script to settle, and abandons a stuck script rather than + * hanging its caller (the engine documents what abandonment leaves behind). + */ +export abstract class WorkflowService extends Service { + constructor(ctx: Context) { + super(ctx, 'workflows') + } + + /** + * Parse and execute a workflow script. + * @param request - the script, its `args`, the parent agent, and an + * optional cancel signal. + * @returns the live run; its `result` resolves when the script settles. + */ + abstract start(request: WorkflowStartRequest): WorkflowRun + + /** + * Emit one `workflow/*` lifecycle event with PER-LISTENER containment: + * dispatch each subscriber individually and log (never propagate) a thrown + * one, so one bad subscriber can neither fail the engine mid-run, surface as + * an unhandled rejection on a detached settle hook, nor starve the listeners + * registered after it (cordis `emit` halts on the first throw — same + * guarantee as the subagent seam's lifecycle emits). + * @param name - the `workflow/*` event to dispatch. + * @param args - the event's payload, matching its declared signature. + */ + protected emitWorkflowEvent(name: WorkflowEventName, ...args: unknown[]): void { + for (const callback of this.ctx.events.dispatch('emit', [name, ...args])) { + try { + // The declared workflow/* signatures are all void-returning emits; the + // dispatch callback applies the payload tuple. + ;(callback as (...payload: unknown[]) => void)(...args) + } catch (error: unknown) { + this.ctx.logger.warn(`workflow: ${name} listener threw: ${String(error)}`) + } + } + } +} + +export default WorkflowService diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts new file mode 100644 index 0000000000..32377ee46b --- /dev/null +++ b/packages/workflow/workflow/src/types.ts @@ -0,0 +1,154 @@ +/** + * Workflow seam vocabulary: the request/run/result types a workflow engine + * consumes and produces, plus the payload shapes of the `workflow/*` events. + * Types only (plus the id-brand factory), per the package convention. + * + * @module @deepseek-ai/dsh-workflow/types + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' + +/** Identifies one workflow run. */ +export type WorkflowRunId = Branded<'WorkflowRunId'> + +/** Brand a string as a {@link WorkflowRunId}. */ +export function WorkflowRunId(id: string): WorkflowRunId { + return id as WorkflowRunId +} + +/** + * One phase declared in a script's `meta.phases` (progress vocabulary only — + * phases group agents in observers/UIs; they impose no execution structure). + */ +export interface WorkflowPhase { + /** The phase title; `phase()` calls match against it by exact string. */ + title: string + /** Optional one-line description of what the phase does. */ + detail?: string + /** Optional model override this phase is expected to use (informational). */ + model?: string +} + +/** + * The script's `export const meta` block, validated by the engine before the + * body runs. `name`/`description` are required; the rest is optional + * annotation. Matches the Claude Code dynamic-workflows script format. + */ +export interface WorkflowMeta { + /** Short kebab-case workflow name (display + persistence key). */ + name: string + /** One-line description of what the workflow does. */ + description: string + /** Optional guidance on when this workflow applies (shown in listings). */ + whenToUse?: string + /** Optional phase declarations matched by `phase()` calls. */ + phases?: WorkflowPhase[] +} + +/** + * What a caller asks for when starting a workflow run. `parent` is REQUIRED — + * every `agent()` the script spawns is attributed to it (cwd, lineage, depth + * flow through the subagent seam). `args` must be plain host-realm JSON data; + * the engine exposes it to the script as the `args` global. + */ +export interface WorkflowStartRequest { + /** The full script text: `export const meta = {...}` + a plain-JS body. */ + script: string + /** Optional input exposed verbatim to the script as the `args` global. */ + args?: unknown + /** The agent on whose behalf the run executes (parent of every child). */ + parent: Agent + /** Cancels the run when aborted (the tool's `exec.signal`). */ + signal?: AbortSignal +} + +/** + * Why a run settled. CLOSED union (engine-owned, consumers may exhaust): + * `completed` = the script ran to its final `return`; `cancelled` = the run + * was cancelled (caller `cancel()`/signal); `error` = the script threw, a + * fatal `WorkflowError` propagated, or the result failed materialization. + */ +export type WorkflowStopReason = 'completed' | 'cancelled' | 'error' + +/** + * The outcome of one run, resolved by {@link WorkflowRun.result}. `value` is + * the script's materialized return value (plain host-realm JSON data; `null` + * when the script returned `undefined`) — meaningful only for `completed`. + * A non-`completed` reason carries the failure in `error`; the consumer maps + * it to an `isError` tool result rather than reporting partial output. + */ +export interface WorkflowResult { + /** The script's return value (host JSON data; `null` for no return). */ + value: unknown + /** Why the run settled. */ + stopReason: WorkflowStopReason + /** The failure message (present iff `stopReason` is not `completed`). */ + error?: string + /** How many `agent()` calls the run started (across its whole lifetime). */ + agentsStarted: number +} + +/** + * The handle the consumer holds while a script executes. The consumer awaits + * `result`, may `cancel` mid-flight, and MUST `dispose` on every path. + * `result` does NOT reject — a script failure resolves with `stopReason: + * 'error'` — so the consumer maps a non-`completed` reason to an `isError` + * result. `dispose()` cancels, then waits a bounded grace for the script to + * settle before abandoning it (the engine documents the abandonment + * semantics); it never hangs on a stuck script. + */ +export interface WorkflowRun { + readonly id: WorkflowRunId + /** The validated meta block (available before the body runs). */ + readonly meta: WorkflowMeta + readonly result: Promise + /** Cancel the run: children abort, pending hooks reject, the script dies at its next await. */ + cancel(reason?: string): void + /** Cancel + bounded-grace settle; safe to call on every path (idempotent). */ + dispose(): Promise +} + +/** Identifying detail for a run, carried by every `workflow/*` event (a data snapshot, never the live run). */ +export interface WorkflowRunInfo { + /** The run's id. */ + id: WorkflowRunId + /** The run's validated meta block. */ + meta: WorkflowMeta +} + +/** One `agent()` call's identity within a run (the `workflow/agent-start` payload). */ +export interface WorkflowAgentInfo { + /** 1-based sequence number of this `agent()` call within the run. */ + seq: number + /** The display label (the `label` option, or a prompt snippet). */ + label: string + /** The phase this agent belongs to (the `phase` option, else the current `phase()` title). */ + phase?: string + /** The child agent's id on the subagent seam. */ + childId: AgentId +} + +/** How one `agent()` call settled: clean result, child failure (script sees `null`), or run cancellation. */ +export type WorkflowAgentOutcome = 'completed' | 'failed' | 'cancelled' + +/** One `agent()` call's settlement (the `workflow/agent-end` payload). */ +export interface WorkflowAgentEndInfo extends WorkflowAgentInfo { + /** How the call settled. */ + outcome: WorkflowAgentOutcome +} + +/** + * A settled run's outcome as event data (the `workflow/end` payload): the + * {@link WorkflowResult} minus `value` (a listener observing outcomes must not + * receive a mutable alias of the caller's result value; a consumer that needs + * the value holds the run and awaits `result`). + */ +export interface WorkflowResultInfo { + /** Why the run settled. */ + stopReason: WorkflowStopReason + /** The failure message (present iff `stopReason` is not `completed`). */ + error?: string + /** How many `agent()` calls the run started. */ + agentsStarted: number +} diff --git a/packages/workflow/workflow/tests/workflow.spec.ts b/packages/workflow/workflow/tests/workflow.spec.ts new file mode 100644 index 0000000000..a5bd8cd1ed --- /dev/null +++ b/packages/workflow/workflow/tests/workflow.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import WorkflowServiceDefault, { + isFatalWorkflowError, + WorkflowError, + WorkflowRunId, + WorkflowService, +} from '../src/index.ts' +import type { WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '../src/index.ts' + +/** A minimal concrete subclass exposing the protected emit helper for tests. */ +class StubEngine extends WorkflowService { + start(request: WorkflowStartRequest): WorkflowRun { + void request + throw new Error('not under test') + } + + emit(name: Parameters[0], ...args: unknown[]): void { + this.emitWorkflowEvent(name, ...args) + } +} + +const INFO: WorkflowRunInfo = { id: WorkflowRunId('run-1'), meta: { name: 'w', description: 'd' } } + +describe('dsh-workflow (interface)', () => { + it('WorkflowRunId brands a string (identity at runtime)', () => { + expect(WorkflowRunId('abc')).toBe('abc') + }) + + it('WorkflowError carries code + fatal (default true) and reads as a HarnessError', () => { + const error = new WorkflowError('cap hit', 'AGENT_CAP') + expect(error.code).toBe('AGENT_CAP') + expect(error.fatal).toBe(true) + expect(error.name).toBe('WorkflowError') + const soft = new WorkflowError('advisory', 'ITEM_CAP', { fatal: false }) + expect(soft.fatal).toBe(false) + }) + + it('isFatalWorkflowError: true only for a fatal WorkflowError', () => { + expect(isFatalWorkflowError(new WorkflowError('x', 'CANCELLED'))).toBe(true) + expect(isFatalWorkflowError(new WorkflowError('x', 'CANCELLED', { fatal: false }))).toBe(false) + expect(isFatalWorkflowError(new Error('plain'))).toBe(false) + expect(isFatalWorkflowError('string')).toBe(false) + }) + + it('registers as ctx.workflows and unregisters when its fiber is disposed (HMR safety)', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(StubEngine) + expect(ctx.get('workflows')).toBeInstanceOf(StubEngine) + await fiber.dispose() + expect(ctx.get('workflows')).toBeUndefined() + }) + + it('emitWorkflowEvent dispatches to every listener with the payload tuple', async () => { + const ctx = new Context() + await ctx.plugin(StubEngine) + const seen: unknown[][] = [] + ctx.on('workflow/log', (info, message) => { seen.push([info, message]) }) + ctx.on('workflow/agent-start', (info, agent) => { seen.push([info, agent]) }) + const engine = ctx.workflows as StubEngine + engine.emit('workflow/log', INFO, 'hello') + engine.emit('workflow/agent-start', INFO, { seq: 1, label: 'l', childId: 'c' }) + expect(seen).toEqual([ + [INFO, 'hello'], + [INFO, { seq: 1, label: 'l', childId: 'c' }], + ]) + }) + + it('contains a throwing listener PER LISTENER: later listeners still run, nothing propagates', async () => { + const ctx = new Context() + await ctx.plugin(StubEngine) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger) + const reached: string[] = [] + ctx.on('workflow/phase', () => { throw new Error('bad listener') }) + ctx.on('workflow/phase', (_info, title) => { reached.push(title) }) + const engine = ctx.workflows as StubEngine + expect(() => { engine.emit('workflow/phase', INFO, 'Scan') }).not.toThrow() + expect(reached).toEqual(['Scan']) + expect(warn).toHaveBeenCalledOnce() + expect(String(warn.mock.calls[0]![0])).toContain('workflow/phase listener threw') + }) + + it('has the expected export surface (default = the abstract service class)', () => { + expect(WorkflowServiceDefault).toBe(WorkflowService) + }) +}) diff --git a/packages/workflow/workflow/tsconfig.json b/packages/workflow/workflow/tsconfig.json new file mode 100644 index 0000000000..6ec42e0bfe --- /dev/null +++ b/packages/workflow/workflow/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06189862f2..7877dc9fad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -635,6 +635,12 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent + '@deepseek-ai/dsh-subagent-fork': + specifier: workspace:^ + version: link:../subagent-fork + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../subagent-spawn '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -1039,6 +1045,95 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/workflow/tool-workflow: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-workflow': + specifier: workspace:^ + version: link:../workflow + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/workflow/workflow: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/workflow/workflow-vm: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../../subagent/subagent-spawn + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-workflow': + specifier: workspace:^ + version: link:../workflow + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + vendor/cordis: dependencies: '@cordisjs/plugin-include': diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 26db66b995..9058427020 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1575, + "AGENTS.md": 1590, "docs/AGENTS.md": 1315, "docs/architecture.md": 1890, "docs/defensive-patterns.md": 550, diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 9dd8e0a6e6..ce1fee586a 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -51,6 +51,8 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' import * as ToolWeb from '@deepseek-ai/dsh-tool-web' +import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-vm' +import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog/tools.md' @@ -138,6 +140,20 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolTodo) }, }, + { + pkg: '@deepseek-ai/dsh-tool-workflow', + dir: 'tool-workflow', + source: 'packages/workflow/tool-workflow/src/index.ts', + async mount(ctx) { + // The tool injects `workflows`; boot the vm engine over a scripted + // subagent provider to satisfy it. The schema does not depend on which + // provider backs the engine. + await ctx.plugin(SubagentService) + await ctx.plugin(SubagentMock, { name: 'mock' }) + await ctx.plugin(VmWorkflowEngine, { provider: 'mock' }) + await ctx.plugin(ToolWorkflow) + }, + }, { pkg: '@deepseek-ai/dsh-tool-web', dir: 'tool-web', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index b1c3163782..18927fc884 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1,82 +1,365 @@ { "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source symbol it must match verbatim. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", "entries": [ - { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" }, - - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" }, - - { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "TodoItem", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "TurnTriggerMap", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "TurnEndReasonMap", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceEventType", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" }, - - { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, - - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" }, - - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" }, - - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsDirEntry", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditRequest", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditOutcome", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsPolicyExec", "source": "packages/fs/fs-policy/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/read-render.ts" }, - - { "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" }, - - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResult", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStopReasonMap", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentRun", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" }, - - { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchRequest", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchResult", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchSource", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/src/types.ts" } + { + "doc": "docs/core-data-structures/core.md", + "symbol": "Branded", + "source": "packages/util/brand/src/index.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContentBlockMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "Message", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "MessageSourceMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "FinishReasonMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "GenerateOptions", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ToolSchema", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "SessionEvent", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "Agent", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "HookContext", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "PromptDecision", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContinuationDecision", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "SessionStartSource", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.md", + "symbol": "StreamChunk", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.md", + "symbol": "TokenUsage", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.md", + "symbol": "ContentBlockMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.md", + "symbol": "AppIdentity", + "source": "packages/llm/llm/src/attribution.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SessionEventMap", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "TodoItem", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SessionEvent", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "TurnTriggerMap", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "TurnEndReasonMap", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceEventType", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceOp", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceIntent", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceNode", + "source": "packages/core/session/src/surface.ts" + }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "SessionHeader", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "CreateSessionOptions", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolDefinition", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "SchemaProp", + "source": "packages/core/tools/src/schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "SchemaSpec", + "source": "packages/core/tools/src/schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "InferArgs", + "source": "packages/core/tools/src/schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolExecution", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolExecutionResult", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "PreToolDecision", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "PostToolDecision", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashExecRequest", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashExecSpec", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashRunResult", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "CollectedOutput", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashTask", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashTaskRead", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsTarget", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsTargetKey", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsVersion", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsInfo", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsDirEntry", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsWriteIntent", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsWriteOutcome", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsEditRequest", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsEditOutcome", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsErrorCode", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsPolicyExec", + "source": "packages/fs/fs-policy/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FileReadOutcome", + "source": "packages/fs/tool-fs/src/read-render.ts" + }, + { + "doc": "docs/core-data-structures/compaction.md", + "symbol": "CompactionResult", + "source": "packages/compact/compact/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentCapabilities", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentStartRequest", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentResult", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentStopReasonMap", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentRun", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentProvider", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebSearchRequest", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebSearchResult", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebSearchSource", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebFetchRequest", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebFetchResult", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebFetchBody", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebProviderStatus", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/workflow.md", + "symbol": "WorkflowStartRequest", + "source": "packages/workflow/workflow/src/types.ts" + }, + { + "doc": "docs/core-data-structures/workflow.md", + "symbol": "WorkflowMeta", + "source": "packages/workflow/workflow/src/types.ts" + }, + { + "doc": "docs/core-data-structures/workflow.md", + "symbol": "WorkflowResult", + "source": "packages/workflow/workflow/src/types.ts" + }, + { + "doc": "docs/core-data-structures/workflow.md", + "symbol": "WorkflowRun", + "source": "packages/workflow/workflow/src/types.ts" + } ] } diff --git a/tsconfig.base.json b/tsconfig.base.json index 40e4dbe728..b21ca2b8b5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -46,6 +46,7 @@ "./packages/fs/*/src", "./packages/compact/*/src", "./packages/subagent/*/src", + "./packages/workflow/*/src", "./packages/web/*/src", "./packages/todo/*/src", "./packages/hooks/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index b6ba7901f2..93cce8f4b9 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -51,6 +51,9 @@ { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, + { "path": "./packages/workflow/workflow" }, + { "path": "./packages/workflow/workflow-vm" }, + { "path": "./packages/workflow/tool-workflow" }, { "path": "./packages/todo/tool-todo" }, { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, diff --git a/tsconfig.json b/tsconfig.json index 9cd7aa8a6d..ba5ccbac26 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -62,6 +62,9 @@ { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, + { "path": "./packages/workflow/workflow" }, + { "path": "./packages/workflow/workflow-vm" }, + { "path": "./packages/workflow/tool-workflow" }, { "path": "./packages/todo/tool-todo" }, { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, From e264a106fdfea5d5c1db8be72058b6f1a16edf99 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:04:38 +0800 Subject: [PATCH 03/57] workflow, subagent: fix Codex code-review round-1 blockers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six verified A-findings from the code-stage review, each with a regression test: - parallel()/pipeline() resolved to HOST arrays inside the vm realm, exposing host Array.prototype to scripts; combinator results are now realm-built (in-realm Array.from bound at context setup). - materializeFromRealm ran proxy traps (ownKeys/getOwnPropertyDescriptor/ getPrototypeOf) during the descriptor walk — realm code on the host stack, outside the vm timeout, escaping as raw errors; proxies (root, nested, and in the prototype position) are now rejected trap-free via util.types.isProxy before any inspection. - an already-aborted signal or an immediate cancel() no longer reports 'completed' for a hook-free script: drive() checks cancellation before running the body and again when the script settles. - dispose() now waits (bounded by disposeGraceMs) for stray agent() children to FINISH disposing, not just for the script to settle: every agent() call is tracked and quiesce() drains the in-flight set. - workflow/* event payloads were live mutable aliases shared across emissions; emitWorkflowEvent now hands each listener its own structural clone. - the structured-output turn-continuation veto is now prepend: true, so an earlier-registered force-continue listener cannot short-circuit it. Docs updated in the same change (READMEs, core-data-structures/workflow.md, the dynamic-workflows RFC, regenerated cordis catalogs). --- docs/cordis-catalog/events.md | 12 +- docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/workflow.md | 4 +- .../feature/2026-07-05-dynamic-workflows.md | 6 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent-inprocess/src/structured.ts | 12 +- .../tests/structured.spec.ts | 29 ++++ packages/workflow/workflow-vm/README.md | 6 +- packages/workflow/workflow-vm/src/index.ts | 28 +++- packages/workflow/workflow-vm/src/realm.ts | 19 ++- packages/workflow/workflow-vm/src/runtime.ts | 57 ++++++- .../workflow/workflow-vm/tests/meta.spec.ts | 8 + .../workflow/workflow-vm/tests/realm.spec.ts | 21 +++ .../workflow-vm/tests/workflow-vm.spec.ts | 146 +++++++++++++++++- packages/workflow/workflow/README.md | 4 +- packages/workflow/workflow/src/index.ts | 29 ++-- .../workflow/workflow/tests/workflow.spec.ts | 22 +++ 17 files changed, 358 insertions(+), 51 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 463c7e06b2..1ba45f027e 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -323,7 +323,7 @@ One `agent()` call settled (clean result, child failure, or run cancellation). P 'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:93`](../../packages/workflow/workflow/src/index.ts) ### `workflow/agent-start` — emit @@ -333,7 +333,7 @@ One `agent()` call started a child run. Paired with Events['workflow/agent-end'] 'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:83`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:85`](../../packages/workflow/workflow/src/index.ts) ### `workflow/end` — emit @@ -343,7 +343,7 @@ A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves 'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:101`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:103`](../../packages/workflow/workflow/src/index.ts) ### `workflow/log` — emit @@ -353,7 +353,7 @@ The script emitted a narration line (a `log(message)` call). 'workflow/log'(info: WorkflowRunInfo, message: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:75`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:77`](../../packages/workflow/workflow/src/index.ts) ### `workflow/phase` — emit @@ -363,7 +363,7 @@ The script entered a phase (a `phase(title)` call) — progress grouping for obs 'workflow/phase'(info: WorkflowRunInfo, title: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:68`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/workflow/src/index.ts) ### `workflow/start` — emit @@ -373,7 +373,7 @@ A workflow run started — the script's meta block validated, the body about to 'workflow/start'(info: WorkflowRunInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:62`](../../packages/workflow/workflow/src/index.ts) ## Inherited events (cordis core + loader/hmr/timer) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c1f67c0725..88559d658f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -235,13 +235,13 @@ Semantics every implementation must honor: - start throws synchronously for a request that cannot begin (an unparseable script, an invalid meta block). Once it returns a WorkflowRun, `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`). - The `workflow/*` events fire through emitWorkflowEvent (data snapshots, per-listener containment); `workflow/end` fires exactly once per started run, after `result` is settled or as it settles. -- `dispose()` reaches quiescence within a bounded grace: it cancels, waits for the script to settle, and abandons a stuck script rather than hanging its caller (the engine documents what abandonment leaves behind). +- `dispose()` reaches quiescence within a bounded grace: it cancels, waits for the script to settle AND its started children to finish disposing, and abandons whatever is left rather than hanging its caller (the engine documents what abandonment leaves behind). ```ts cordis-catalog abstract start(request: WorkflowStartRequest): WorkflowRun ``` -Source: [`packages/workflow/workflow/src/index.ts:188`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:191`](../../packages/workflow/workflow/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/docs/core-data-structures/workflow.md b/docs/core-data-structures/workflow.md index ebdf00869a..11f216f39e 100644 --- a/docs/core-data-structures/workflow.md +++ b/docs/core-data-structures/workflow.md @@ -47,7 +47,7 @@ interface WorkflowResult { ## A live run: `WorkflowRun` -The handle the consumer holds while a script executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path. `result` does NOT reject — a script failure resolves with `stopReason: 'error'` — so the consumer maps a non-`completed` reason to an `isError` result. `dispose()` cancels, waits a bounded grace for the script to settle, then abandons it (the engine documents the abandonment semantics); it never hangs on a stuck script. +The handle the consumer holds while a script executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path. `result` does NOT reject — a script failure resolves with `stopReason: 'error'` — so the consumer maps a non-`completed` reason to an `isError` result. `dispose()` cancels, waits a bounded grace for the script to settle AND its children to finish disposing, then abandons whatever is left (the engine documents the abandonment semantics); it never hangs on a stuck script. ```ts type-equiv interface WorkflowRun { @@ -65,4 +65,4 @@ Hook misuse inside a script — bad arguments, unknown/deferred `agent()` option ## Events -The `workflow/*` events (`workflow/start`, `workflow/phase`, `workflow/log`, `workflow/agent-start`, `workflow/agent-end`, `workflow/end` — see the [events catalog](../cordis-catalog/events.md)) are **observe-only** emits carrying DATA SNAPSHOTS: every payload starts with `WorkflowRunInfo` (id + meta), never the live `WorkflowRun`, so a subscriber cannot gain `cancel`/`dispose`, and `workflow/end` deliberately omits the result value (a listener observing outcomes must not receive a mutable alias of the caller's result). Every emit is per-listener contained — a throwing subscriber is logged, never propagated, and cannot starve the listeners registered after it — mirroring `subagent/start`/`subagent/end`. +The `workflow/*` events (`workflow/start`, `workflow/phase`, `workflow/log`, `workflow/agent-start`, `workflow/agent-end`, `workflow/end` — see the [events catalog](../cordis-catalog/events.md)) are **observe-only** emits carrying DATA SNAPSHOTS: every payload starts with `WorkflowRunInfo` (id + meta), never the live `WorkflowRun`, so a subscriber cannot gain `cancel`/`dispose`, and `workflow/end` deliberately omits the result value (a listener observing outcomes must not receive a mutable alias of the caller's result). Every emit is per-listener contained — a throwing subscriber is logged, never propagated, and cannot starve the listeners registered after it — and every listener receives its own payload clone, so mutating it corrupts neither the engine nor other listeners; the containment mirrors `subagent/start`/`subagent/end`. diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index 372fce8f9a..35b65e3fb2 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -24,11 +24,11 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre ### The engine (dsh-workflow-vm): in-process node:vm -**Why node:vm and not isolated-vm/worker threads**: isolated-vm is in maintenance mode, needs `--no-node-snapshot` on EVERY consumer process (including the published bins) on Node ≥ 20, and falls back to node-gyp source builds; a worker-thread engine turns every hook into RPC and complicates the per-file coverage gate. Scripts are model-written — the same trust level as the model's existing bash access — so genuine sandboxing is not the current requirement. The interface/implementation split exists precisely so a hardened engine can swap in later. Accepted, documented limitations: vm is not a security boundary, and the vm timeout covers only the initial synchronous slice — a pathological synchronous spin after the first await cannot be killed in-process; `dispose()` cancels, waits a bounded grace, then abandons. +**Why node:vm and not isolated-vm/worker threads**: isolated-vm is in maintenance mode, needs `--no-node-snapshot` on EVERY consumer process (including the published bins) on Node ≥ 20, and falls back to node-gyp source builds; a worker-thread engine turns every hook into RPC and complicates the per-file coverage gate. Scripts are model-written — the same trust level as the model's existing bash access — so genuine sandboxing is not the current requirement. The interface/implementation split exists precisely so a hardened engine can swap in later. Accepted, documented limitations: vm is not a security boundary, and the vm timeout covers only the initial synchronous slice — a pathological synchronous spin after the first await cannot be killed in-process; `dispose()` cancels, waits a bounded grace for the script to settle and its children to finish disposing, then abandons. **Meta extraction**: a string/comment-aware brace scanner (template interpolation rejected) finds the literal; it is evaluated ALONE in an empty, timed vm context; the result must materialize to plain JSON data and pass shape validation (unknown fields rejected loud); the statement is blanked line-preservingly so stacks keep script line numbers. -**Realm boundary**: values entering the host (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a descriptor walk that NEVER invokes accessors (the repo's `isJsonValue` is prototype-strict and getter-invoking, so it cannot run first; it would reject every cross-realm object and let realm code run outside the timed window) and rejects loud everything JSON cannot carry, copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values entering the realm (`args`, `agent()` results) are rebuilt INSIDE the realm via the context's own `JSON.parse`, so the script never holds a live host-prototype object. Realm functions (stages, thunks) are called, never materialized. +**Realm boundary**: values entering the host (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a descriptor walk that NEVER invokes accessors (the repo's `isJsonValue` is prototype-strict and getter-invoking, so it cannot run first; it would reject every cross-realm object and let realm code run outside the timed window) and rejects loud everything JSON cannot carry, proxies included (the trap-free `util.types.isProxy`, checked before any inspection, so realm-side traps never run on the host stack), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values entering the realm (`args`, `agent()` results) are rebuilt INSIDE the realm via the context's own `JSON.parse`, and `parallel`/`pipeline` resolve to realm-built arrays, so the script never holds a live host-prototype object. Realm functions (stages, thunks) are called, never materialized. **Containment**: every hook-returned promise carries a no-op rejection consumer, so a script that drops a promise cannot surface an unhandled rejection when cancellation rejects it — `dsh-app-boot` exits the process on unhandled rejections. Caps (`maxConcurrentAgents` auto = `min(16, cores - 2)`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. @@ -38,7 +38,7 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai ### The foundation: structured output on the subagent seam -`agent({schema})` needs `SubagentStartRequest.outputSchema` to actually work; it was vocabulary without an implementation (`outputSchema: false` everywhere). Implemented in `dsh-subagent-inprocess` for both in-process backends: a globally registered `structured_output` capture tool whose per-child schema is enforced by a `prepend: true` `agent/request` listener doing FINAL-REQUEST enforcement (post-processing `await next()` — cooperative mutation would not survive a downstream listener returning a replacement request), an `agent/turn-continuation` veto after capture (no wasted extra model step), validation-retry in-turn via `ToolArgsError`, and a clean-finish nudge loop (`structuredNudgeRetries`). Lifetime is refcounted by backends (plugin lifetime) AND live runs (start → settle). The seam's `outputSchema` type became the raw JSON-Schema SUBSET (`StructuredOutputSchema` in dsh-tools: single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`; anything unenforced is rejected loud) — the schema travels verbatim to the model as the forced tool's parameters, so the wire format, not the author DSL, is the right vocabulary. +`agent({schema})` needs `SubagentStartRequest.outputSchema` to actually work; it was vocabulary without an implementation (`outputSchema: false` everywhere). Implemented in `dsh-subagent-inprocess` for both in-process backends: a globally registered `structured_output` capture tool whose per-child schema is enforced by a `prepend: true` `agent/request` listener doing FINAL-REQUEST enforcement (post-processing `await next()` — cooperative mutation would not survive a downstream listener returning a replacement request), a `prepend: true` `agent/turn-continuation` veto after capture (no wasted extra model step, and an earlier-registered force-continue listener cannot short-circuit it), validation-retry in-turn via `ToolArgsError`, and a clean-finish nudge loop (`structuredNudgeRetries`). Lifetime is refcounted by backends (plugin lifetime) AND live runs (start → settle). The seam's `outputSchema` type became the raw JSON-Schema SUBSET (`StructuredOutputSchema` in dsh-tools: single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`; anything unenforced is rejected loud) — the schema travels verbatim to the model as the forced tool's parameters, so the wire format, not the author DSL, is the right vocabulary. ## What was rejected diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 1cbdccaee6..a257582464 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -24,7 +24,7 @@ Runs a child as a child [`Agent`](../../core/agent) on the same cordis context ( The mechanism behind `outputSchema` for in-process children. One globally registered `structured_output` capture tool (its registered parameters are a placeholder) plus two listeners, registered once per root context and shared by every holder: - an `agent/request` waterfall listener registered `prepend: true` that post-processes `await next()` — **final-request enforcement**: the request that hits the wire never carries `structured_output` for an agent without a structured run, and always carries the run's OWN schema (as the tool's `parameters`) for one that has it. Per-agent shaping lives here because the tool registry and prompt assembly are context-global while schemas differ per concurrent child; cooperative mutate-then-`next()` would not survive a downstream listener returning a replacement request. -- an `agent/turn-continuation` listener that stops a child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step. +- an `agent/turn-continuation` listener (also `prepend: true` — an earlier-registered force-continue listener returning without `next()` must not decide the turn before the veto runs) that stops a child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step. The capture tool validates each call against the run's schema (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError result the model retries in-turn; a valid call records the value. diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index da371ac8f4..2a061ed56d 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -18,7 +18,9 @@ * * A companion `agent/turn-continuation` listener stops a child's turn once its * output is captured — without it, the loop's default "had tool calls ⇒ - * continue" buys a wasted extra model step per structured child. + * continue" buys a wasted extra model step per structured child. It is also + * `prepend: true`: the veto must run before any earlier-registered listener + * that could short-circuit the chain into a forced continue. * * Lifetime is refcounted with two kinds of holder: each backend acquires for * its plugin lifetime (so the tool exists before any run), and each structured @@ -183,11 +185,15 @@ function registerRuntime(root: Context, runtime: StructuredRuntime): void { // Stop a structured child's turn once its output is captured: the default // "had tool calls ⇒ continue" would otherwise buy a wasted extra model step - // after every successful capture. + // after every successful capture. `prepend: true` puts the veto OUTERMOST — + // an earlier-registered listener that short-circuits the chain (a goal-style + // force-continue returning without `next()`) would otherwise decide the turn + // before this listener ever ran, and no downstream decision may resurrect a + // structured turn that is already finished. runtime.disposers.push(root.on('agent/turn-continuation', function ( this: unknown, agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise, ): Promise { if (runtime.states.get(agent)?.captured) return Promise.resolve({ action: 'stop' }) return next() - })) + }, { prepend: true })) } diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 0a92c15fa8..cb046cc469 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -5,6 +5,7 @@ import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' @@ -85,6 +86,34 @@ describe('in-process structured output', () => { await run.dispose() }) + it('the captured-turn veto is prepend: an EARLIER force-continue listener cannot short-circuit it', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + // Registered BEFORE the structured runtime exists — without prepend, this + // goal-style listener would decide the turn first (returning WITHOUT + // calling next()) and the veto would never run. + ctx.on('agent/turn-continuation', () => Promise.resolve({ action: 'continue' })) + const acquisition = acquireStructuredRuntime(ctx) + const agent = { id: AgentId('structured-child') } as unknown as Agent + acquisition.attach(agent, SCHEMA) + const captured = await ctx.tools.execute({ + callId: 'call-1' as never, + name: STRUCTURED_OUTPUT_TOOL, + arguments: { answer: 1 }, + agent, + }) + expect(captured.isError).toBeFalsy() + const decision = await ctx.waterfall( + 'agent/turn-continuation', agent, 1, + { action: 'continue' }, + () => Promise.resolve({ action: 'continue' }), + ) + expect(decision).toEqual({ action: 'stop' }) + acquisition.detach(agent) + acquisition.release() + }) + it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => { const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }), diff --git a/packages/workflow/workflow-vm/README.md b/packages/workflow/workflow-vm/README.md index 1cbc76c250..0ba1173f23 100644 --- a/packages/workflow/workflow-vm/README.md +++ b/packages/workflow/workflow-vm/README.md @@ -10,11 +10,11 @@ The first [`WorkflowService`](../workflow/README.md) implementation: an in-proce ## Realm discipline -Values ENTERING the host (the meta literal, hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a descriptor walk that never invokes accessors and rejects loud everything JSON cannot carry (accessors, exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying into host containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values ENTERING the realm (`args`, `agent()` results) are rebuilt INSIDE the realm through the context's own `JSON.parse`, so the script never holds an object whose prototype chain reaches host intrinsics. +Values ENTERING the host (the meta literal, hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a descriptor walk that never invokes accessors and rejects loud everything JSON cannot carry (accessors, exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`, and proxies — rejected via the trap-free `util.types.isProxy` BEFORE any inspection could run a realm-side trap on the host stack), copying into host containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values ENTERING the realm (`args`, `agent()` results) are rebuilt INSIDE the realm through the context's own `JSON.parse`, and the arrays `parallel`/`pipeline` resolve to are realm-built, so the script never holds an object whose prototype chain reaches host intrinsics. ## Limits, cancellation, disposal -Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` aborts every child (a shared `AbortSignal`), rejects waiting `agent()` slots, and makes every future hook call throw `CANCELLED` — the script dies at its next await and the run settles `cancelled`. Once a run settles, stray children a script fired without awaiting are aborted too. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection (the app boot layer exits the process on those). +Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` aborts every child (a shared `AbortSignal`), rejects waiting `agent()` slots, and makes every future hook call throw `CANCELLED` — the script dies at its next await and the run settles `cancelled`; a cancellation that lands before the body runs (or before it settles) reports `cancelled` even if the script itself needed no hooks. Once a run settles, stray children a script fired without awaiting are aborted too, and `dispose()` waits for those children to finish disposing (bounded by the grace) before returning. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection (the app boot layer exits the process on those). **Documented limitations** (the accepted cost of the in-process mechanism; the seam exists so a worker-thread/isolated-vm engine can swap in): vm is NOT a security boundary — scripts are model-written, the same trust level as the model's bash access — and the vm `timeout` covers only the initial synchronous slice, so a pathological synchronous spin after the first await cannot be killed; `dispose()` waits `disposeGraceMs` then ABANDONS such a script (its settlement stays contained, but an abandoned spin would still occupy the event loop). @@ -27,4 +27,4 @@ Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap | `maxTotalAgents` | `1000` | Total `agent()` calls one run may start (runaway-loop backstop). | | `maxItemsPerCall` | `4096` | Items accepted by one `parallel()`/`pipeline()` call. | | `syncTimeoutMs` | `5000` | vm timeout for the initial synchronous slice and the meta evaluation. | -| `disposeGraceMs` | `5000` | How long `dispose()` waits for a cancelled script before abandoning it. | +| `disposeGraceMs` | `5000` | How long `dispose()` waits for a cancelled script and its children before abandoning them. | diff --git a/packages/workflow/workflow-vm/src/index.ts b/packages/workflow/workflow-vm/src/index.ts index 914ebb3ba6..4f6a506329 100644 --- a/packages/workflow/workflow-vm/src/index.ts +++ b/packages/workflow/workflow-vm/src/index.ts @@ -13,10 +13,12 @@ * is correctness containment, not a sandbox. * - The vm `timeout` covers only the initial SYNCHRONOUS slice of the script; * a pathological synchronous spin after the first await cannot be killed - * in-process. `dispose()` therefore waits a bounded grace and then ABANDONS - * a stuck script: its pending hook promises are already rejected and its - * settlement is contained (no unhandled rejection), but an abandoned - * synchronous spin would still occupy the event loop. + * in-process. `dispose()` waits a bounded grace for the script to settle + * AND its children (stray `agent()` calls included) to finish disposing, + * then ABANDONS whatever is left: pending hook promises are already + * rejected and the script's settlement is contained (no unhandled + * rejection), but an abandoned synchronous spin would still occupy the + * event loop. * * Plugin export shape: a default-exported {@link WorkflowService} subclass * (the class-based service form, like `dsh-bash-local`). @@ -142,12 +144,22 @@ export class VmWorkflowEngine extends WorkflowService { execution.cancel(reason) }, dispose: (): Promise => { - // Idempotent: cancel, then wait min(settle, grace). `result` never - // rejects, so the race needs no rejection handling; an unsettled - // script past the grace is abandoned per the module contract. + // Idempotent: cancel, then wait min(settle + child quiescence, grace). + // `result` and `quiesce()` never reject, so the race needs no + // rejection handling; a script or child still unsettled past the grace + // is abandoned per the module contract. disposed ??= (async () => { execution.cancel('workflow disposed') - await Promise.race([result, sleep(this.config.disposeGraceMs)]) + await Promise.race([ + (async () => { + await result + // The result settles with the SCRIPT; stray children a script + // fired without awaiting are still winding down — dispose must + // not return while they hold live resources. + await execution.quiesce() + })(), + sleep(this.config.disposeGraceMs), + ]) })() return disposed }, diff --git a/packages/workflow/workflow-vm/src/realm.ts b/packages/workflow/workflow-vm/src/realm.ts index 70745cb4f6..3bf59f2a1f 100644 --- a/packages/workflow/workflow-vm/src/realm.ts +++ b/packages/workflow/workflow-vm/src/realm.ts @@ -10,7 +10,13 @@ * host containers, rejecting loud everything JSON cannot carry: * accessor properties, non-plain prototypes, functions, symbols (keys or * values), bigints, non-finite numbers, `undefined` values, cycles, sparse - * arrays, and arrays with non-index own properties. + * arrays, arrays with non-index own properties, and proxies. Proxies are + * rejected via the trap-free native `util.types.isProxy` check BEFORE any + * other inspection — a descriptor walk over a proxy would otherwise run its + * realm-side traps (`ownKeys`, `getOwnPropertyDescriptor`, `getPrototypeOf`) + * on the host stack, outside the vm's timed window, and a throwing trap would + * escape as a raw realm error instead of a {@link MaterializeError}. The same + * check guards the PROTOTYPE position (an object whose prototype is a proxy). * * Host objects are built with `Object.defineProperty` into a fresh `{}` — * never plain `target[key] =` assignment, which a `"__proto__"` key would turn @@ -24,6 +30,8 @@ * @module @deepseek-ai/dsh-workflow-vm/realm */ +import { types } from 'node:util' + /** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */ export class MaterializeError extends Error { constructor(public readonly path: string, public readonly reason: string) { @@ -36,11 +44,13 @@ export class MaterializeError extends Error { * Whether an object's prototype chain is data-shaped: `null`, or a prototype * whose own prototype is `null` (the realm's `Object.prototype` — which we * cannot compare by identity across realms). A `Date`/`Map`/class instance - * has a longer chain and is rejected. + * has a longer chain and is rejected, as is a proxy sitting in the prototype + * position (checked trap-free BEFORE its own prototype is dereferenced). */ function hasPlainPrototype(value: object): boolean { const proto: unknown = Object.getPrototypeOf(value) if (proto === null) return true + if (types.isProxy(proto)) return false return Object.getPrototypeOf(proto) === null } @@ -81,6 +91,11 @@ function materialize(value: unknown, path: string, seen: Set): unknown { break } if (value === null) return null + // BEFORE anything else touches the object: every inspection below — + // Array.isArray aside — can trigger a proxy trap, running realm code on the + // host stack (module doc). isProxy is a native internal-slot check (no + // traps, catches revoked proxies, realm-agnostic). + if (types.isProxy(value)) throw new MaterializeError(path, 'proxies cannot cross the workflow realm boundary') const objectValue: object = value if (seen.has(objectValue)) throw new MaterializeError(path, 'circular references are not JSON data') seen.add(objectValue) diff --git a/packages/workflow/workflow-vm/src/runtime.ts b/packages/workflow/workflow-vm/src/runtime.ts index 04176a11ac..6c357408a8 100644 --- a/packages/workflow/workflow-vm/src/runtime.ts +++ b/packages/workflow/workflow-vm/src/runtime.ts @@ -9,8 +9,11 @@ * descriptor walks; values ENTERING the realm from the host (`args`, agent() * results) are rebuilt INSIDE the realm through the context's own * `JSON.parse`, so the script never holds an object whose prototype chain - * reaches host intrinsics. Realm functions (pipeline stages, parallel thunks) - * are called, not materialized — their values stay realm-side. + * reaches host intrinsics. The arrays `parallel`/`pipeline` resolve to are + * realm-built for the same reason (their ELEMENTS are realm values already — + * only the container needs rebuilding). Realm functions (pipeline stages, + * parallel thunks) are called, not materialized — their values stay + * realm-side. * * Failure discipline: fatal {@link WorkflowError}s (bad hook arguments, * unsupported options/schemas, tripped caps, seam start failures, @@ -132,7 +135,10 @@ export class WorkflowExecution { private currentPhase: string | undefined private readonly context: vm.Context private readonly realmJsonParse: (text: string) => unknown + private readonly realmArrayFrom: (items: unknown[]) => unknown[] private readonly compiled: vm.Script + /** Every live `agent()` call promise — awaited or stray — for {@link quiesce}. */ + private readonly inFlightAgents = new Set>() constructor( private readonly ctx: Context, @@ -162,9 +168,12 @@ export class WorkflowExecution { // The realm's own JSON.parse — the host→realm rebuild channel. const realmJson = vm.runInContext('JSON', this.context) as { parse(text: string): unknown } this.realmJsonParse = (text: string) => realmJson.parse(text) + // The realm's own Array.from, bound NOW so a script reassigning its + // globals later cannot swap it: combinator results must be realm arrays. + this.realmArrayFrom = vm.runInContext('Array.from.bind(Array)', this.context) as (items: unknown[]) => unknown[] const globals: Record = { - agent: (prompt: unknown, opts?: unknown) => this.contain(this.agent(prompt, opts)), + agent: (prompt: unknown, opts?: unknown) => this.contain(this.track(this.agent(prompt, opts))), parallel: (thunks: unknown) => this.contain(this.parallel(thunks)), pipeline: (items: unknown, ...stages: unknown[]) => this.contain(this.pipeline(items, stages)), phase: (title: unknown) => { this.phase(title) }, @@ -216,8 +225,15 @@ export class WorkflowExecution { */ async drive(): Promise { try { + // Cancelled before the body ever ran (an already-aborted start signal): + // the script must not execute at all, let alone report `completed`. + if (this.isCancelled()) throw this.cancelledError() const scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs }) as Promise const raw: unknown = await this.contain(Promise.resolve(scriptPromise)) + // Cancelled while the body ran: a script that settled without touching + // another hook (or without any) must still report `cancelled` — the + // holder asked for cancellation and `completed` would be a lie. + if (this.isCancelled()) throw this.cancelledError() const value = raw === undefined ? null : this.materializeResult(raw) return { value, stopReason: 'completed', agentsStarted: this.started } } catch (error: unknown) { @@ -245,6 +261,31 @@ export class WorkflowExecution { return promise } + /** + * Register one `agent()` call promise for {@link quiesce} tracking; the + * entry drops when the call fully settles (which is AFTER its child's + * `dispose()` — the call wrapper disposes in its `finally`). + */ + private track(promise: Promise): Promise { + this.inFlightAgents.add(promise) + const drop = (): void => { this.inFlightAgents.delete(promise) } + promise.then(drop, drop) + return promise + } + + /** + * Settles once every `agent()` call — awaited or stray — has fully settled, + * INCLUDING each child's `dispose()`. The reap in {@link drive}'s finally + * aborts strays; this is the wait for those aborts to reach quiescence, so + * the engine's `dispose()` cannot return while a child is still winding + * down. Never rejects (the tracked promises' rejections are contained). + */ + async quiesce(): Promise { + while (this.inFlightAgents.size > 0) { + await Promise.allSettled([...this.inFlightAgents]) + } + } + private cancelledError(): WorkflowError { // cancel() arms cancelError before any caller can observe isCancelled() // === true; the fallback guards the type, not a reachable path. @@ -430,7 +471,7 @@ export class WorkflowExecution { } return thunk as () => unknown }) - return Promise.all(thunks.map(async (thunk) => { + const settled = await Promise.all(thunks.map(async (thunk) => { try { return await thunk() } catch (error: unknown) { @@ -438,6 +479,9 @@ export class WorkflowExecution { return null } })) + // The container must be a REALM array (module doc); the elements are + // realm values already. + return this.realmArrayFrom(settled) } /** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */ @@ -455,7 +499,7 @@ export class WorkflowExecution { } return stage as (previous: unknown, item: unknown, index: number) => unknown }) - return Promise.all(rawItems.map(async (item: unknown, index) => { + const settled = await Promise.all(rawItems.map(async (item: unknown, index) => { let value: unknown = item try { for (const stage of stages) { @@ -469,6 +513,9 @@ export class WorkflowExecution { return null } })) + // The container must be a REALM array (module doc); the elements are + // realm values already. + return this.realmArrayFrom(settled) } private assertItemCap(length: number, hook: string): void { diff --git a/packages/workflow/workflow-vm/tests/meta.spec.ts b/packages/workflow/workflow-vm/tests/meta.spec.ts index 4768da49ff..27d18497d0 100644 --- a/packages/workflow/workflow-vm/tests/meta.spec.ts +++ b/packages/workflow/workflow-vm/tests/meta.spec.ts @@ -108,6 +108,14 @@ return 2` expect(error.message).toContain('JSON data') }) + it('rejects a meta literal containing a proxy as META_INVALID — its traps never run', () => { + // bad() rethrows anything that is not a WorkflowError, so a trap firing + // ('trap ran') would fail this test instead of mapping to META_INVALID. + const error = bad('export const meta = { name: "x", description: "d", phases: new Proxy([], { getPrototypeOf() { throw new Error("trap ran") } }) }') + expect(error.code).toBe('META_INVALID') + expect(error.message).toContain('proxies cannot cross') + }) + it('rejects shape violations with EVERY violation listed (META_INVALID)', () => { const error = bad('export const meta = { description: 7, bogus: 1 }\nreturn 1') expect(error.code).toBe('META_INVALID') diff --git a/packages/workflow/workflow-vm/tests/realm.spec.ts b/packages/workflow/workflow-vm/tests/realm.spec.ts index f374706dba..86deb49bd3 100644 --- a/packages/workflow/workflow-vm/tests/realm.spec.ts +++ b/packages/workflow/workflow-vm/tests/realm.spec.ts @@ -81,6 +81,27 @@ describe('materializeFromRealm', () => { expect(materializeFromRealm(inRealm('Object.assign(Object.create(null), { a: 1 })'))).toEqual({ a: 1 }) }) + it('rejects proxies (root, nested, revoked, host-realm) WITHOUT running any trap', () => { + const trapped = inRealm(`new Proxy({ a: 1 }, { + ownKeys() { throw new Error('trap ran') }, + getOwnPropertyDescriptor() { throw new Error('trap ran') }, + getPrototypeOf() { throw new Error('trap ran') }, + })`) + // A trap firing would surface 'trap ran' (a non-MaterializeError) instead. + expect(rejection(trapped)).toContain('proxies cannot cross') + expect(rejection(inRealm('{ nested: new Proxy([], {}) }'))).toContain('value.nested') + const revoked = inRealm('(() => { const r = Proxy.revocable({}, {}); r.revoke(); return r.proxy })()') + expect(rejection(revoked)).toContain('proxies cannot cross') + expect(rejection(new Proxy({}, {}))).toContain('proxies cannot cross') + }) + + it('rejects an object whose PROTOTYPE is a proxy without dereferencing through it', () => { + const value = inRealm(`Object.create(new Proxy({}, { + getPrototypeOf() { throw new Error('trap ran') }, + }))`) + expect(rejection(value)).toContain('exotic prototype') + }) + it('rejects cycles and accepts the same object reused as a sibling (a DAG)', () => { expect(rejection(inRealm('(() => { const o = {}; o.self = o; return o })()'))).toContain('circular') const dag = inRealm('(() => { const leaf = { v: 1 }; return { a: leaf, b: leaf } })()') diff --git a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts index 9403dc912e..30dc0e4dfa 100644 --- a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts +++ b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts @@ -36,6 +36,7 @@ class StubProvider implements SubagentProvider { constructor( readonly name: string, private readonly reply?: (request: SubagentStartRequest, index: number) => SubagentResult, + private readonly disposeDelayMs = 0, ) {} start(request: SubagentStartRequest): SubagentRun { @@ -57,8 +58,17 @@ class StubProvider implements SubagentProvider { settle({ output: [], stopReason: 'aborted' }) }, dispose: () => { - controlled.disposed = true - return Promise.resolve() + if (this.disposeDelayMs === 0) { + controlled.disposed = true + return Promise.resolve() + } + // A slow-winding child (quiescence tests): disposal completes late. + return new Promise((resolve) => { + setTimeout(() => { + controlled.disposed = true + resolve() + }, this.disposeDelayMs) + }) }, } } @@ -73,12 +83,17 @@ interface SetupOptions { config?: Config reply?: (request: SubagentStartRequest, index: number) => SubagentResult manual?: boolean + disposeDelayMs?: number } async function setup(options?: SetupOptions) { const ctx = new Context() await ctx.plugin(SubagentService) - const provider = new StubProvider('stub', options?.manual ? undefined : options?.reply ?? (() => text('stub reply'))) + const provider = new StubProvider( + 'stub', + options?.manual ? undefined : options?.reply ?? (() => text('stub reply')), + options?.disposeDelayMs ?? 0, + ) ctx.subagents.registerProvider(provider) await ctx.plugin(VmWorkflowEngine, { provider: 'stub', ...options?.config }) return { ctx, provider, parent: fakeParent() } @@ -405,6 +420,50 @@ describe('dsh-workflow-vm', () => { expect((await run(ctx, parent, script('return typeof args'))).value).toBe('undefined') }) + it('parallel/pipeline resolve to REALM arrays: instanceof holds in-script, host intrinsics stay unreachable', async () => { + const { ctx, parent } = await setup() + const result = await run(ctx, parent, script(` + const fromParallel = await parallel([() => agent('a'), () => 'plain']) + const fromPipeline = await pipeline([1], (prev) => prev + 1) + Object.getPrototypeOf(fromParallel).polluted = 'realm-only' + return { + parallelIsRealmArray: fromParallel instanceof Array, + pipelineIsRealmArray: fromPipeline instanceof Array, + values: [fromParallel[1], fromPipeline[0]], + } + `)) + expect(result.stopReason).toBe('completed') + expect(result.value).toEqual({ + parallelIsRealmArray: true, + pipelineIsRealmArray: true, + values: ['plain', 2], + }) + // The script's prototype mutation stayed realm-side: the HOST + // Array.prototype was never reachable through a combinator result. + expect(([] as unknown as Record).polluted).toBeUndefined() + }) + + it('a returned proxy is rejected as RESULT_UNSERIALIZABLE without running its traps', async () => { + const { ctx, parent } = await setup() + const result = await run(ctx, parent, script(` + return new Proxy({ a: 1 }, { ownKeys() { throw new Error('trap ran') } }) + `)) + expect(result.stopReason).toBe('error') + expect(result.error).toContain('not plain JSON data') + expect(result.error).toContain('proxies cannot cross') + expect(result.error).not.toContain('trap ran') + }) + + it('agent() options passed as a proxy are rejected loudly, traps never invoked', async () => { + const { ctx, parent } = await setup() + const result = await run(ctx, parent, script(` + return await agent('p', new Proxy({}, { ownKeys() { throw new Error('trap ran') } })) + `)) + expect(result.stopReason).toBe('error') + expect(result.error).toContain('options must be plain JSON data') + expect(result.error).not.toContain('trap ran') + }) + it('a non-JSON return value fails loud as RESULT_UNSERIALIZABLE', async () => { const { ctx, parent } = await setup() const withDate = await run(ctx, parent, script('return { when: new Date(0) }')) @@ -452,6 +511,51 @@ describe('dsh-workflow-vm', () => { await handle.dispose() }) + it('an already-aborted signal cancels a HOOK-FREE script: the body never runs at all', async () => { + const { ctx, parent } = await setup() + const controller = new AbortController() + controller.abort() + const logs: string[] = [] + ctx.on('workflow/log', (_info, message) => { logs.push(message) }) + const handle = ctx.workflows.start({ script: script("log('ran')\nreturn 123"), parent, signal: controller.signal }) + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + expect(result.value).toBeNull() + expect(logs).toEqual([]) + await handle.dispose() + }) + + it('cancel() right after start() reports cancelled even when the script needed no hooks', async () => { + const { ctx, parent } = await setup() + const handle = ctx.workflows.start({ script: script('return 123'), parent }) + handle.cancel('changed my mind') + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + expect(result.value).toBeNull() + expect(result.error).toContain('changed my mind') + await handle.dispose() + }) + + it('an agent() call AFTER a mid-run cancel rejects at entry — no child ever starts', async () => { + const { ctx, parent, provider } = await setup({ manual: true }) + const handle = ctx.workflows.start({ + script: script(` + await agent('first') + return await agent('second') + `), + parent, + }) + await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) + // Same synchronous block: the first child settles completed, then the + // cancel lands BEFORE the script's continuation can call agent() again. + provider.runs[0]!.settle(text('first done')) + handle.cancel('mid-run') + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + expect(provider.runs.length).toBe(1) + await handle.dispose() + }) + it('the signal aborting mid-run cancels like cancel()', async () => { const { ctx, parent, provider } = await setup({ manual: true }) const controller = new AbortController() @@ -577,6 +681,24 @@ describe('dsh-workflow-vm', () => { }) await handle.dispose() }) + + it('dispose() waits for a stray child to FINISH disposing (quiescence), not just the script settle', async () => { + const { ctx, parent, provider } = await setup({ manual: true, disposeDelayMs: 40 }) + const handle = ctx.workflows.start({ + script: script(` + agent('stray') + return 'done without awaiting' + `), + parent, + }) + const result = await handle.result + expect(result.stopReason).toBe('completed') + expect(provider.runs.length).toBe(1) + await handle.dispose() + // Not a waitFor: by the time dispose() returns, the slow child disposal + // must already be complete. + expect(provider.runs[0]!.disposed).toBe(true) + }) }) describe('service surface', () => { @@ -595,6 +717,24 @@ describe('dsh-workflow-vm', () => { await second.dispose() }) + it('a listener mutating one event payload cannot corrupt later events (per-emission snapshots)', async () => { + const { ctx, parent } = await setup() + const ends: unknown[] = [] + let endInfo: WorkflowRunInfo | undefined + ctx.on('workflow/agent-start', (info, agent) => { + agent.seq = 999 + agent.label = 'HACKED' + info.meta.name = 'HACKED' + }) + ctx.on('workflow/agent-end', (info, agent) => { + ends.push(agent) + endInfo = info + }) + await run(ctx, parent, script("return await agent('job', { label: 'honest' })")) + expect(ends[0]).toMatchObject({ seq: 1, label: 'honest', outcome: 'completed' }) + expect(endInfo!.meta.name).toBe('test-flow') + }) + it('unregisters ctx.workflows when the engine fiber is disposed (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) diff --git a/packages/workflow/workflow/README.md b/packages/workflow/workflow/README.md index 1dece68d38..46efa2f2d0 100644 --- a/packages/workflow/workflow/README.md +++ b/packages/workflow/workflow/README.md @@ -4,9 +4,9 @@ The **workflow seam** (`ctx.workflows`): an abstract service defining WHAT a wor ## Service: `WorkflowService` (abstract) -`start(request: WorkflowStartRequest): WorkflowRun` — parse and execute a script. Throws synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot begin; once a run is returned, its `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`). `dispose()` must reach quiescence within a bounded grace (cancel → wait → abandon), never hanging its caller. +`start(request: WorkflowStartRequest): WorkflowRun` — parse and execute a script. Throws synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot begin; once a run is returned, its `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`). `dispose()` must reach quiescence within a bounded grace (cancel → wait for the script to settle and its children to finish disposing → abandon), never hanging its caller. -The protected `emitWorkflowEvent` helper dispatches the `workflow/*` events with PER-LISTENER containment (a throwing subscriber is logged, never propagated, and cannot starve later listeners) — the same guarantee as the subagent seam's lifecycle emits. +The protected `emitWorkflowEvent` helper dispatches the `workflow/*` events with PER-LISTENER containment and PER-LISTENER payload snapshots (a throwing subscriber is logged, never propagated, and cannot starve later listeners; each subscriber gets its own clone of the payload, so mutating it corrupts neither the engine nor other listeners) — the same containment guarantee as the subagent seam's lifecycle emits. ## Vocabulary diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index ab5e056b37..5f340e2f93 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -13,8 +13,10 @@ * carry {@link WorkflowRunInfo} (id + meta), never the live {@link WorkflowRun} * — a listener must not gain `cancel`/`dispose`; control stays with the * `start()` caller holding the run. Every emit is per-listener contained (a - * throwing subscriber is logged, never propagated), so one bad observer can - * neither strand a live run nor starve later listeners. + * throwing subscriber is logged, never propagated) and every listener gets its + * own payload clone (mutating it corrupts nothing), so one bad observer can + * neither strand a live run, starve later listeners, nor poison another + * listener's view. * * @module @deepseek-ai/dsh-workflow */ @@ -182,8 +184,9 @@ export function isFatalWorkflowError(error: unknown): boolean { * snapshots, per-listener containment); `workflow/end` fires exactly once * per started run, after `result` is settled or as it settles. * - `dispose()` reaches quiescence within a bounded grace: it cancels, waits - * for the script to settle, and abandons a stuck script rather than - * hanging its caller (the engine documents what abandonment leaves behind). + * for the script to settle AND its started children to finish disposing, + * and abandons whatever is left rather than hanging its caller (the engine + * documents what abandonment leaves behind). */ export abstract class WorkflowService extends Service { constructor(ctx: Context) { @@ -199,12 +202,16 @@ export abstract class WorkflowService extends Service { abstract start(request: WorkflowStartRequest): WorkflowRun /** - * Emit one `workflow/*` lifecycle event with PER-LISTENER containment: - * dispatch each subscriber individually and log (never propagate) a thrown - * one, so one bad subscriber can neither fail the engine mid-run, surface as - * an unhandled rejection on a detached settle hook, nor starve the listeners - * registered after it (cordis `emit` halts on the first throw — same - * guarantee as the subagent seam's lifecycle emits). + * Emit one `workflow/*` lifecycle event with PER-LISTENER containment and + * PER-LISTENER payload snapshots: each subscriber is dispatched individually + * with its OWN structural clone of the payload (the payloads are plain JSON + * data by the seam contract), so a listener mutating what it received can + * corrupt neither the engine's live state nor any other listener's or later + * event's view; a thrown listener is logged (never propagated), so one bad + * subscriber can neither fail the engine mid-run, surface as an unhandled + * rejection on a detached settle hook, nor starve the listeners registered + * after it (cordis `emit` halts on the first throw — same guarantee as the + * subagent seam's lifecycle emits). * @param name - the `workflow/*` event to dispatch. * @param args - the event's payload, matching its declared signature. */ @@ -213,7 +220,7 @@ export abstract class WorkflowService extends Service { try { // The declared workflow/* signatures are all void-returning emits; the // dispatch callback applies the payload tuple. - ;(callback as (...payload: unknown[]) => void)(...args) + ;(callback as (...payload: unknown[]) => void)(...structuredClone(args)) } catch (error: unknown) { this.ctx.logger.warn(`workflow: ${name} listener threw: ${String(error)}`) } diff --git a/packages/workflow/workflow/tests/workflow.spec.ts b/packages/workflow/workflow/tests/workflow.spec.ts index a5bd8cd1ed..b303c02962 100644 --- a/packages/workflow/workflow/tests/workflow.spec.ts +++ b/packages/workflow/workflow/tests/workflow.spec.ts @@ -66,6 +66,28 @@ describe('dsh-workflow (interface)', () => { ]) }) + it('gives each listener its OWN payload snapshot: mutation corrupts neither peers nor the caller', async () => { + const ctx = new Context() + await ctx.plugin(StubEngine) + const seen: string[] = [] + ctx.on('workflow/agent-start', (info, agent) => { + agent.label = 'HACKED' + info.meta.name = 'HACKED' + seen.push('mutator') + }) + ctx.on('workflow/agent-start', (info, agent) => { + seen.push(`${info.meta.name}/${agent.label}`) + }) + const engine = ctx.workflows as StubEngine + const info: WorkflowRunInfo = { id: WorkflowRunId('run-2'), meta: { name: 'w', description: 'd' } } + const payload = { seq: 1, label: 'original', childId: 'c' } + engine.emit('workflow/agent-start', info, payload) + expect(seen).toEqual(['mutator', 'w/original']) + // The caller's own objects are pristine too — no listener ever saw them. + expect(info.meta.name).toBe('w') + expect(payload.label).toBe('original') + }) + it('contains a throwing listener PER LISTENER: later listeners still run, nothing propagates', async () => { const ctx = new Context() await ctx.plugin(StubEngine) From 57b9910339658b17c86eea0eb8aa2b3406fb563b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:39:19 +0800 Subject: [PATCH 04/57] workflow: total, contained rendering of hostile thrown script values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex code-review round 2: errorText() read .stack/.message as plain property gets and fell back to String(error) — a script throwing a value with a throwing accessor (or toString/Symbol.toPrimitive) ran realm code in drive()'s catch and made WorkflowRun.result REJECT, which the detached workflow/end hook turned into an unhandledRejection (process death under dsh-app-boot). Replaced with describeThrown in dsh-workflow-vm/realm: total (never throws), proxy-labelling before any inspection, own-descriptor reads, String() only on primitives, and a CONTAINED stack-getter invocation — modern V8 (Node >= 22) makes stack an own ACCESSOR on genuine Errors, so refusing all accessors would lose every real stack and the lineOffset line numbers; a hostile getter's throw is swallowed and rendering falls back to message. The meta-literal eval catch had the same String(error) exposure and now uses the same renderer. Regression tests: a hostile-thrown-values table through the real engine (throwing stack/message getters, data stack, setter-only stack, proxy, Symbol.toPrimitive, function, null) asserting result resolves 'error' with the expected rendering and NO unhandledRejection fires; a meta-path hostile throw mapping to META_INVALID. --- .../feature/2026-07-05-dynamic-workflows.md | 2 +- packages/workflow/workflow-vm/README.md | 2 +- packages/workflow/workflow-vm/src/meta.ts | 7 +- packages/workflow/workflow-vm/src/realm.ts | 72 +++++++++++++++++++ packages/workflow/workflow-vm/src/runtime.ts | 22 ++---- .../workflow/workflow-vm/tests/meta.spec.ts | 7 ++ .../workflow-vm/tests/workflow-vm.spec.ts | 33 +++++++++ 7 files changed, 124 insertions(+), 21 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index 35b65e3fb2..c1fbbe7427 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -30,7 +30,7 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre **Realm boundary**: values entering the host (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a descriptor walk that NEVER invokes accessors (the repo's `isJsonValue` is prototype-strict and getter-invoking, so it cannot run first; it would reject every cross-realm object and let realm code run outside the timed window) and rejects loud everything JSON cannot carry, proxies included (the trap-free `util.types.isProxy`, checked before any inspection, so realm-side traps never run on the host stack), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values entering the realm (`args`, `agent()` results) are rebuilt INSIDE the realm via the context's own `JSON.parse`, and `parallel`/`pipeline` resolve to realm-built arrays, so the script never holds a live host-prototype object. Realm functions (stages, thunks) are called, never materialized. -**Containment**: every hook-returned promise carries a no-op rejection consumer, so a script that drops a promise cannot surface an unhandled rejection when cancellation rejects it — `dsh-app-boot` exits the process on unhandled rejections. Caps (`maxConcurrentAgents` auto = `min(16, cores - 2)`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. +**Containment**: every hook-returned promise carries a no-op rejection consumer, so a script that drops a promise cannot surface an unhandled rejection when cancellation rejects it — `dsh-app-boot` exits the process on unhandled rejections. Thrown script values (and meta-evaluation throws) are rendered by the total `describeThrown` — fixed labels for proxies/functions, own-descriptor reads, `String()` only on primitives, and a CONTAINED stack-getter call (modern V8 makes `stack` an own accessor on real Errors; a hostile getter's throw is swallowed) — so a hostile thrown value can neither escape the catch path raw nor make `result` reject. Caps (`maxConcurrentAgents` auto = `min(16, cores - 2)`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. ### The consumer (dsh-tool-workflow) diff --git a/packages/workflow/workflow-vm/README.md b/packages/workflow/workflow-vm/README.md index 0ba1173f23..1efe4eb5af 100644 --- a/packages/workflow/workflow-vm/README.md +++ b/packages/workflow/workflow-vm/README.md @@ -14,7 +14,7 @@ Values ENTERING the host (the meta literal, hook options/schemas, the script's r ## Limits, cancellation, disposal -Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` aborts every child (a shared `AbortSignal`), rejects waiting `agent()` slots, and makes every future hook call throw `CANCELLED` — the script dies at its next await and the run settles `cancelled`; a cancellation that lands before the body runs (or before it settles) reports `cancelled` even if the script itself needed no hooks. Once a run settles, stray children a script fired without awaiting are aborted too, and `dispose()` waits for those children to finish disposing (bounded by the grace) before returning. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection (the app boot layer exits the process on those). +Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` aborts every child (a shared `AbortSignal`), rejects waiting `agent()` slots, and makes every future hook call throw `CANCELLED` — the script dies at its next await and the run settles `cancelled`; a cancellation that lands before the body runs (or before it settles) reports `cancelled` even if the script itself needed no hooks. Once a run settles, stray children a script fired without awaiting are aborted too, and `dispose()` waits for those children to finish disposing (bounded by the grace) before returning. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection (the app boot layer exits the process on those), and thrown script values are rendered by the total `describeThrown` (proxy-labelling, own-descriptor reads, a contained stack-getter call), so a hostile throw (`{ get stack() { throw ... } }`) cannot make `result` reject. **Documented limitations** (the accepted cost of the in-process mechanism; the seam exists so a worker-thread/isolated-vm engine can swap in): vm is NOT a security boundary — scripts are model-written, the same trust level as the model's bash access — and the vm `timeout` covers only the initial synchronous slice, so a pathological synchronous spin after the first await cannot be killed; `dispose()` waits `disposeGraceMs` then ABANDONS such a script (its settlement stays contained, but an abandoned spin would still occupy the event loop). diff --git a/packages/workflow/workflow-vm/src/meta.ts b/packages/workflow/workflow-vm/src/meta.ts index bb97ad7d39..edf95eb22c 100644 --- a/packages/workflow/workflow-vm/src/meta.ts +++ b/packages/workflow/workflow-vm/src/meta.ts @@ -20,7 +20,7 @@ import * as vm from 'node:vm' import { WorkflowError } from '@deepseek-ai/dsh-workflow' import type { WorkflowMeta, WorkflowPhase } from '@deepseek-ai/dsh-workflow' -import { materializeFromRealm, MaterializeError } from './realm.ts' +import { materializeFromRealm, MaterializeError, describeThrown } from './realm.ts' /** The result of {@link extractMeta}: the validated meta and the runnable body. */ export interface ExtractedScript { @@ -174,7 +174,10 @@ export function extractMeta(script: string, evalTimeoutMs: number): ExtractedScr // below are part of the same boundary. evaluated = vm.runInNewContext(`(${literal})`, undefined, { timeout: evalTimeoutMs }) } catch (error: unknown) { - throw new WorkflowError(`meta block failed to evaluate as a pure literal: ${String(error)}`, 'META_INVALID', { cause: error }) + // describeThrown, not String(): an expression in the literal can THROW a + // hostile value (a throwing toString/accessor), and this catch must map + // it to META_INVALID rather than let realm code run or a raw error escape. + throw new WorkflowError(`meta block failed to evaluate as a pure literal: ${describeThrown(error)}`, 'META_INVALID', { cause: error }) } let data: unknown try { diff --git a/packages/workflow/workflow-vm/src/realm.ts b/packages/workflow/workflow-vm/src/realm.ts index 3bf59f2a1f..b1389a999c 100644 --- a/packages/workflow/workflow-vm/src/realm.ts +++ b/packages/workflow/workflow-vm/src/realm.ts @@ -27,6 +27,11 @@ * chain, so the engine rebuilds inbound values INSIDE the realm via the * context's own `JSON.parse` (see the runtime). * + * {@link describeThrown} is the same discipline for the one place realm + * values reach the host WITHOUT materialization: rendering a thrown value + * for a failure report. It never throws; the only realm code it can invoke + * is a stack getter, contained (see its doc). + * * @module @deepseek-ai/dsh-workflow-vm/realm */ @@ -40,6 +45,73 @@ export class MaterializeError extends Error { } } +/** + * Render a value THROWN by realm code (a script failure, a meta-literal + * evaluation failure) as text, without ever throwing itself — the callers sit + * in catch blocks whose totality is a seam contract (`WorkflowRun.result` + * never rejects). Plain property reads and `String(value)` are hostile-value + * hazards (`{ get stack() { throw ... } }`, a throwing + * `toString`/`Symbol.toPrimitive`), so: proxies render as a fixed label + * (trap-free `isProxy`, before any inspection); `message` is read as an OWN + * DATA descriptor only; everything else object-shaped renders as + * `[object Object]` without being touched; only primitives (which cannot + * carry code) reach `String()`. The one exception is the `stack` getter — + * modern V8 makes `stack` an own ACCESSOR on genuine `Error`s, so it is + * invoked (that is how real stacks, with the script's own line numbers via + * the compile lineOffset, are obtained) but CONTAINED: a hostile getter's + * throw is swallowed and rendering falls back to message. Detection is + * structural, not `instanceof` — a realm Error is not an instance of the host + * class. + * @param error - the thrown value, of any shape and any realm. + * @returns human-readable text for the failure report; prefers the stack. + */ +export function describeThrown(error: unknown): string { + switch (typeof error) { + case 'object': + break + case 'function': + return '[thrown function]' + default: + // Primitives (string/number/boolean/bigint/symbol/undefined): String() + // cannot reach user code on these. + return String(error) + } + if (error === null) return 'null' + if (types.isProxy(error)) return '[thrown proxy]' + const stack = readStack(error) + if (typeof stack === 'string' && stack.length > 0) return stack + const message = ownDataProperty(error, 'message') + if (typeof message === 'string') return message + return '[object Object]' +} + +/** + * Read `error.stack`, tolerating both descriptor shapes: an own DATA property + * (older V8, plain objects) and the modern own ACCESSOR pair (the Error Stack + * Accessor proposal). Invoking the getter is the only way to obtain a real + * stack; on a hostile object that getter is user code, so the call is + * contained — a throw yields `undefined` (the caller falls back to message), + * and a synchronous spin is the engine's already-accepted post-await + * limitation (a script can spin directly just the same). + */ +function readStack(error: object): unknown { + const descriptor = Object.getOwnPropertyDescriptor(error, 'stack') + if (descriptor === undefined) return undefined + if ('value' in descriptor) return descriptor.value + if (typeof descriptor.get !== 'function') return undefined + try { + return descriptor.get.call(error) + } catch { + return undefined // a hostile stack getter threw; message/fallback renders instead + } +} + +/** An own DATA property's value (`undefined` for absent or accessor); never invokes user code on a non-proxy object. */ +function ownDataProperty(value: object, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key) + return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined +} + /** * Whether an object's prototype chain is data-shaped: `null`, or a prototype * whose own prototype is `null` (the realm's `Object.prototype` — which we diff --git a/packages/workflow/workflow-vm/src/runtime.ts b/packages/workflow/workflow-vm/src/runtime.ts index 6c357408a8..c108d2cf69 100644 --- a/packages/workflow/workflow-vm/src/runtime.ts +++ b/packages/workflow/workflow-vm/src/runtime.ts @@ -41,7 +41,7 @@ import type { WorkflowMeta, WorkflowResult, } from '@deepseek-ai/dsh-workflow' -import { materializeFromRealm, MaterializeError } from './realm.ts' +import { materializeFromRealm, MaterializeError, describeThrown } from './realm.ts' /** The per-run knobs the engine resolves from its Config. */ export interface ExecutionLimits { @@ -97,21 +97,6 @@ function outputText(blocks: ContentBlock[]): string { .join('') } -/** - * Render a script failure for the result: prefer the stack (it carries the - * script's own line numbers via the compile lineOffset), then the message. - * STRUCTURAL detection, not `instanceof Error` — a realm-thrown Error is not - * an instance of the host Error class. - */ -function errorText(error: unknown): string { - if (typeof error === 'object' && error !== null) { - const maybe = error as { stack?: unknown; message?: unknown } - if (typeof maybe.stack === 'string' && maybe.stack.length > 0) return maybe.stack - if (typeof maybe.message === 'string') return maybe.message - } - return String(error) -} - /** A short display label derived from the prompt when the script passes none. */ function defaultLabel(prompt: string): string { const newline = prompt.indexOf('\n') @@ -240,7 +225,10 @@ export class WorkflowExecution { if (error instanceof WorkflowError && error.code === 'CANCELLED') { return { value: null, stopReason: 'cancelled', error: error.message, agentsStarted: this.started } } - return { value: null, stopReason: 'error', error: errorText(error), agentsStarted: this.started } + // describeThrown is total and trap-free: a hostile thrown value (a + // throwing accessor, a proxy) cannot make this catch throw — drive() + // resolving is the `result` never-rejects seam contract. + return { value: null, stopReason: 'error', error: describeThrown(error), agentsStarted: this.started } } finally { // Reap strays: a script that fired agent() calls without awaiting them // leaves live children behind after settlement — abort them all. (The diff --git a/packages/workflow/workflow-vm/tests/meta.spec.ts b/packages/workflow/workflow-vm/tests/meta.spec.ts index 27d18497d0..7b5a929046 100644 --- a/packages/workflow/workflow-vm/tests/meta.spec.ts +++ b/packages/workflow/workflow-vm/tests/meta.spec.ts @@ -116,6 +116,13 @@ return 2` expect(error.message).toContain('proxies cannot cross') }) + it('a meta expression THROWING a hostile value maps to META_INVALID — rendering runs no realm code', () => { + const error = bad('export const meta = { name: (() => { throw { get stack() { throw new Error("boom") }, toString() { throw new Error("boom") } } })(), description: "d" }\nreturn 1') + expect(error.code).toBe('META_INVALID') + expect(error.message).toContain('pure literal') + expect(error.message).toContain('[object Object]') + }) + it('rejects shape violations with EVERY violation listed (META_INVALID)', () => { const error = bad('export const meta = { description: 7, bogus: 1 }\nreturn 1') expect(error.code).toBe('META_INVALID') diff --git a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts index 30dc0e4dfa..e0518c8192 100644 --- a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts +++ b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts @@ -589,6 +589,39 @@ describe('dsh-workflow-vm', () => { expect(result.error).toBe('[object Object]') }) + it('hostile thrown values render contained: result NEVER rejects, no unhandled rejection', async () => { + const unhandled: unknown[] = [] + const onUnhandled = (reason: unknown): void => { unhandled.push(reason) } + process.on('unhandledRejection', onUnhandled) + try { + const { ctx, parent } = await setup() + // Each thrown value would run realm code (or throw) under a plain + // property read or String(); rendering must stay total — the only + // permitted realm call is the CONTAINED stack getter. + const cases: [string, string][] = [ + ["throw { get stack() { throw new Error('stack getter threw') } }", '[object Object]'], + ["throw { get stack() { throw new Error('x') }, message: 'getter threw, message renders' }", 'getter threw, message renders'], + ["throw { get message() { throw new Error('message getter ran') } }", '[object Object]'], + ["throw { stack: 'custom data stack' }", 'custom data stack'], + ["throw (() => { const o = { message: 'setter-only stack' }; Object.defineProperty(o, 'stack', { set() {} }); return o })()", 'setter-only stack'], + ["throw new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap ran') } })", '[thrown proxy]'], + ["throw { [Symbol.toPrimitive]() { throw new Error('toPrimitive ran') } }", '[object Object]'], + ['throw () => 1', '[thrown function]'], + ['throw null', 'null'], + ] + for (const [body, rendered] of cases) { + const result = await run(ctx, parent, script(body)) + expect(result.stopReason).toBe('error') + expect(result.error).toBe(rendered) + } + // Let any stray rejection reach the process hook before asserting. + await new Promise(resolve => setTimeout(resolve, 20)) + expect(unhandled).toEqual([]) + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + it('falls back to the message for an Error whose stack was stripped', async () => { const { ctx, parent } = await setup() const result = await run(ctx, parent, script(` From fff2e1f33ddf97e45fd868885d990f604476dbe4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:32:35 +0800 Subject: [PATCH 05/57] workflow: render thrown script values inside the realm's execution window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex code-review round 3: the round-2 'contained stack getter' still let a script escape the vm sync-slice timeout — throw { get stack() { while(true){} } } put the spin on the HOST catch path, where no timeout applies (verified: a direct sync-slice spin dies by the timeout; the getter-hidden one hung the process). Identity-trusting the native getter is also insufficient: V8 stack formatting reads script-controllable hooks at format time (Error.prepareStackTrace, a subclass name getter — both empirically confirmed), so ANY host-side formatting of a realm error can run realm code. The fix moves rendering into the realm itself: the compiled body (and the meta literal) is wrapped in a realm-side catch that pre-renders the thrown value to a string (REALM_THROWN_RENDERER_SOURCE) — a hostile accessor/toString now runs as ordinary script code, killed by the sync-slice timeout or falling under the documented post-await spin limitation; host WorkflowErrors pass through for the CANCELLED mapping. The host catch descriptor-reads the pre-rendered string (thrownRendering) or falls back to describeThrown, which invokes no getter whose identity is not the host realm's own native stack getter. Tests: hostile-table expectations updated for realm-side rendering; new regressions for the getter-hidden sync spin dying by the vm timeout (engine + meta paths) and for a hostile thenable rejection that bypasses the realm wrapper (renders host-side, proxy labelled, traps never run); describeThrown/ thrownRendering unit tables including the realm-error identity-mismatch case. --- .../feature/2026-07-05-dynamic-workflows.md | 2 +- packages/workflow/workflow-vm/README.md | 2 +- packages/workflow/workflow-vm/src/meta.ts | 22 ++-- packages/workflow/workflow-vm/src/realm.ts | 108 ++++++++++++------ packages/workflow/workflow-vm/src/runtime.ts | 27 +++-- .../workflow/workflow-vm/tests/meta.spec.ts | 17 ++- .../workflow/workflow-vm/tests/realm.spec.ts | 45 +++++++- .../workflow-vm/tests/workflow-vm.spec.ts | 37 ++++-- 8 files changed, 199 insertions(+), 61 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index c1fbbe7427..6f8678ecea 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -30,7 +30,7 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre **Realm boundary**: values entering the host (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a descriptor walk that NEVER invokes accessors (the repo's `isJsonValue` is prototype-strict and getter-invoking, so it cannot run first; it would reject every cross-realm object and let realm code run outside the timed window) and rejects loud everything JSON cannot carry, proxies included (the trap-free `util.types.isProxy`, checked before any inspection, so realm-side traps never run on the host stack), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values entering the realm (`args`, `agent()` results) are rebuilt INSIDE the realm via the context's own `JSON.parse`, and `parallel`/`pipeline` resolve to realm-built arrays, so the script never holds a live host-prototype object. Realm functions (stages, thunks) are called, never materialized. -**Containment**: every hook-returned promise carries a no-op rejection consumer, so a script that drops a promise cannot surface an unhandled rejection when cancellation rejects it — `dsh-app-boot` exits the process on unhandled rejections. Thrown script values (and meta-evaluation throws) are rendered by the total `describeThrown` — fixed labels for proxies/functions, own-descriptor reads, `String()` only on primitives, and a CONTAINED stack-getter call (modern V8 makes `stack` an own accessor on real Errors; a hostile getter's throw is swallowed) — so a hostile thrown value can neither escape the catch path raw nor make `result` reject. Caps (`maxConcurrentAgents` auto = `min(16, cores - 2)`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. +**Containment**: every hook-returned promise carries a no-op rejection consumer, so a script that drops a promise cannot surface an unhandled rejection when cancellation rejects it — `dsh-app-boot` exits the process on unhandled rejections. Thrown script/meta values are pre-rendered to a string by a realm-side catch compiled into the wrapper (rendering runs inside the realm's own execution window, so a hostile `stack` getter dies by the vm sync-slice timeout like any other script code — host-side formatting of realm errors is unfixable in general, since V8 stack formatting invokes script-controllable `name`/`prepareStackTrace` hooks); the host catch descriptor-reads that string or falls back to `describeThrown` (fixed labels, own-data reads, an identity-verified host-native stack getter), so `result` cannot reject. Caps (`maxConcurrentAgents` auto = `min(16, cores - 2)`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. ### The consumer (dsh-tool-workflow) diff --git a/packages/workflow/workflow-vm/README.md b/packages/workflow/workflow-vm/README.md index 1efe4eb5af..e519ce7c0e 100644 --- a/packages/workflow/workflow-vm/README.md +++ b/packages/workflow/workflow-vm/README.md @@ -14,7 +14,7 @@ Values ENTERING the host (the meta literal, hook options/schemas, the script's r ## Limits, cancellation, disposal -Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` aborts every child (a shared `AbortSignal`), rejects waiting `agent()` slots, and makes every future hook call throw `CANCELLED` — the script dies at its next await and the run settles `cancelled`; a cancellation that lands before the body runs (or before it settles) reports `cancelled` even if the script itself needed no hooks. Once a run settles, stray children a script fired without awaiting are aborted too, and `dispose()` waits for those children to finish disposing (bounded by the grace) before returning. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection (the app boot layer exits the process on those), and thrown script values are rendered by the total `describeThrown` (proxy-labelling, own-descriptor reads, a contained stack-getter call), so a hostile throw (`{ get stack() { throw ... } }`) cannot make `result` reject. +Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` aborts every child (a shared `AbortSignal`), rejects waiting `agent()` slots, and makes every future hook call throw `CANCELLED` — the script dies at its next await and the run settles `cancelled`; a cancellation that lands before the body runs (or before it settles) reports `cancelled` even if the script itself needed no hooks. Once a run settles, stray children a script fired without awaiting are aborted too, and `dispose()` waits for those children to finish disposing (bounded by the grace) before returning. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection (the app boot layer exits the process on those). Thrown script values are pre-rendered to a string INSIDE the realm's execution window (the body is compiled into a realm-side catch), so a hostile `stack` getter is subject to the vm sync-slice timeout like any other script code; the host catch only descriptor-reads that string, falling back to `describeThrown` (fixed labels, own-data reads, an identity-verified host-native stack getter) — `result` cannot reject. **Documented limitations** (the accepted cost of the in-process mechanism; the seam exists so a worker-thread/isolated-vm engine can swap in): vm is NOT a security boundary — scripts are model-written, the same trust level as the model's bash access — and the vm `timeout` covers only the initial synchronous slice, so a pathological synchronous spin after the first await cannot be killed; `dispose()` waits `disposeGraceMs` then ABANDONS such a script (its settlement stays contained, but an abandoned spin would still occupy the event loop). diff --git a/packages/workflow/workflow-vm/src/meta.ts b/packages/workflow/workflow-vm/src/meta.ts index edf95eb22c..bcb891e85e 100644 --- a/packages/workflow/workflow-vm/src/meta.ts +++ b/packages/workflow/workflow-vm/src/meta.ts @@ -20,7 +20,7 @@ import * as vm from 'node:vm' import { WorkflowError } from '@deepseek-ai/dsh-workflow' import type { WorkflowMeta, WorkflowPhase } from '@deepseek-ai/dsh-workflow' -import { materializeFromRealm, MaterializeError, describeThrown } from './realm.ts' +import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering, REALM_THROWN_RENDERER_SOURCE } from './realm.ts' /** The result of {@link extractMeta}: the validated meta and the runnable body. */ export interface ExtractedScript { @@ -171,13 +171,21 @@ export function extractMeta(script: string, evalTimeoutMs: number): ExtractedScr // An EMPTY context: any non-literal reference (a variable, a call) throws // here. The result — data only — is what the contract checks; a getter or // IIFE can still run, which is why the timeout and the materialization - // below are part of the same boundary. - evaluated = vm.runInNewContext(`(${literal})`, undefined, { timeout: evalTimeoutMs }) + // below are part of the same boundary. A thrown value is pre-rendered by + // the realm-side catch INSIDE the timed window, so a hostile + // stack/message/toString can neither run on the host catch path nor + // outlive the timeout. + evaluated = vm.runInNewContext( + `(() => { try { return (${literal}) } catch (e) { throw (${REALM_THROWN_RENDERER_SOURCE})(e) } })()`, + undefined, + { timeout: evalTimeoutMs }, + ) } catch (error: unknown) { - // describeThrown, not String(): an expression in the literal can THROW a - // hostile value (a throwing toString/accessor), and this catch must map - // it to META_INVALID rather than let realm code run or a raw error escape. - throw new WorkflowError(`meta block failed to evaluate as a pure literal: ${describeThrown(error)}`, 'META_INVALID', { cause: error }) + throw new WorkflowError( + `meta block failed to evaluate as a pure literal: ${thrownRendering(error) ?? describeThrown(error)}`, + 'META_INVALID', + { cause: error }, + ) } let data: unknown try { diff --git a/packages/workflow/workflow-vm/src/realm.ts b/packages/workflow/workflow-vm/src/realm.ts index b1389a999c..fca93caac4 100644 --- a/packages/workflow/workflow-vm/src/realm.ts +++ b/packages/workflow/workflow-vm/src/realm.ts @@ -27,10 +27,16 @@ * chain, so the engine rebuilds inbound values INSIDE the realm via the * context's own `JSON.parse` (see the runtime). * - * {@link describeThrown} is the same discipline for the one place realm - * values reach the host WITHOUT materialization: rendering a thrown value - * for a failure report. It never throws; the only realm code it can invoke - * is a stack getter, contained (see its doc). + * {@link REALM_THROWN_RENDERER_SOURCE}, {@link thrownRendering}, and + * {@link describeThrown} are the same discipline for the one place realm + * values reach the host WITHOUT materialization: a thrown value crossing into + * a host catch block. The renderer runs INSIDE the realm's own execution + * window (compiled into the script wrapper), so reading a hostile + * accessor/`toString` there is subject to the vm sync-slice timeout exactly + * like any other script code; the host side only descriptor-reads the + * pre-rendered string, or falls back to {@link describeThrown}, which invokes + * no getter whose function identity is not the host realm's own native stack + * getter. * * @module @deepseek-ai/dsh-workflow-vm/realm */ @@ -46,22 +52,66 @@ export class MaterializeError extends Error { } /** - * Render a value THROWN by realm code (a script failure, a meta-literal - * evaluation failure) as text, without ever throwing itself — the callers sit - * in catch blocks whose totality is a seam contract (`WorkflowRun.result` - * never rejects). Plain property reads and `String(value)` are hostile-value - * hazards (`{ get stack() { throw ... } }`, a throwing - * `toString`/`Symbol.toPrimitive`), so: proxies render as a fixed label - * (trap-free `isProxy`, before any inspection); `message` is read as an OWN - * DATA descriptor only; everything else object-shaped renders as - * `[object Object]` without being touched; only primitives (which cannot - * carry code) reach `String()`. The one exception is the `stack` getter — - * modern V8 makes `stack` an own ACCESSOR on genuine `Error`s, so it is - * invoked (that is how real stacks, with the script's own line numbers via - * the compile lineOffset, are obtained) but CONTAINED: a hostile getter's - * throw is swallowed and rendering falls back to message. Detection is - * structural, not `instanceof` — a realm Error is not an instance of the host - * class. + * Realm-SOURCE text (an arrow-function expression) the engine compiles into + * its script wrappers: `throw (RENDERER)(e)` inside a catch around the whole + * body/literal. It renders the thrown value to a string INSIDE the realm's + * own execution window — a hostile `stack`/`message` accessor or `toString` + * invoked here is subject to the vm sync-slice timeout like any other script + * code (and post-await it is the engine's accepted spin limitation, identical + * to a script reading `e.stack` in its own catch). Host `WorkflowError`s + * thrown by hooks pass through unwrapped (duck-checked by name — a realm + * forgery fails the host's `instanceof` and merely renders data-only); + * everything else becomes `{ __wfThrown: }`, whose only consumer is + * {@link thrownRendering}. Every read is individually contained, so the + * renderer itself never throws. + */ +export const REALM_THROWN_RENDERER_SOURCE = `(e) => { + try { if (e && e.name === 'WorkflowError') return e } catch { /* hostile name getter: fall through to rendering */ } + const rendered = (() => { + try { if (e && typeof e.stack === 'string' && e.stack.length > 0) return e.stack } catch { /* hostile stack getter */ } + try { if (e && typeof e.message === 'string') return e.message } catch { /* hostile message getter */ } + try { return String(e) } catch { /* hostile toString/Symbol.toPrimitive */ } + return '[unrenderable thrown value]' + })() + return { __wfThrown: rendered } +}` + +/** + * The pre-rendered failure text carried by a realm-catch wrapper object + * (`{ __wfThrown: string }` from {@link REALM_THROWN_RENDERER_SOURCE}), or + * `undefined` when `error` is not such a wrapper. Descriptor-read and + * proxy-guarded: never invokes user code. + * @param error - the value a host catch received from script execution. + * @returns the realm-rendered string, or `undefined` to fall back to + * {@link describeThrown}. + */ +export function thrownRendering(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null || types.isProxy(error)) return undefined + const value = ownDataProperty(error, '__wfThrown') + return typeof value === 'string' ? value : undefined +} + +/** + * The host realm's own native `stack` getter (modern V8 makes `stack` an own + * ACCESSOR on Errors); `undefined` where it is a data property. Typed through + * a structural view of the descriptor — it is only ever identity-compared or + * `.call`ed on an explicit receiver, never invoked unbound. + */ +const HOST_STACK_GETTER: unknown = (Object.getOwnPropertyDescriptor(new Error(), 'stack') as { get?: unknown } | undefined)?.get + +/** + * Render a thrown value HOST-SIDE without ever throwing and without running + * any code the host does not own: proxies become a fixed label (trap-free + * `isProxy` before any inspection); `stack` is read as an own data descriptor, + * or through its getter ONLY when that getter's function identity is the host + * realm's own native stack getter (an unforgeable check — realm code cannot + * hold that identity, and the host realm's `prepareStackTrace` is the host's + * own trust domain); `message` is an own-data read; anything else + * object-shaped renders as `[object Object]` untouched; only primitives + * (which cannot carry code) reach `String()`. Used for host-thrown errors + * (vm timeouts, `WorkflowError`s) and as the fallback for adversarial values + * that bypassed the realm-side renderer (e.g. a hostile thenable rejection); + * ordinary script failures arrive pre-rendered via {@link thrownRendering}. * @param error - the thrown value, of any shape and any realm. * @returns human-readable text for the failure report; prefers the stack. */ @@ -86,24 +136,18 @@ export function describeThrown(error: unknown): string { } /** - * Read `error.stack`, tolerating both descriptor shapes: an own DATA property - * (older V8, plain objects) and the modern own ACCESSOR pair (the Error Stack - * Accessor proposal). Invoking the getter is the only way to obtain a real - * stack; on a hostile object that getter is user code, so the call is - * contained — a throw yields `undefined` (the caller falls back to message), - * and a synchronous spin is the engine's already-accepted post-await - * limitation (a script can spin directly just the same). + * Read `error.stack` without running foreign code: an own DATA descriptor is + * read directly; an accessor is invoked only on function identity with + * {@link HOST_STACK_GETTER} (never a realm or user function). The native + * getter returns `undefined` on a non-Error receiver rather than throwing. */ function readStack(error: object): unknown { const descriptor = Object.getOwnPropertyDescriptor(error, 'stack') if (descriptor === undefined) return undefined if ('value' in descriptor) return descriptor.value if (typeof descriptor.get !== 'function') return undefined - try { - return descriptor.get.call(error) - } catch { - return undefined // a hostile stack getter threw; message/fallback renders instead - } + if (descriptor.get !== HOST_STACK_GETTER) return undefined + return descriptor.get.call(error) } /** An own DATA property's value (`undefined` for absent or accessor); never invokes user code on a non-proxy object. */ diff --git a/packages/workflow/workflow-vm/src/runtime.ts b/packages/workflow/workflow-vm/src/runtime.ts index c108d2cf69..892b7c781d 100644 --- a/packages/workflow/workflow-vm/src/runtime.ts +++ b/packages/workflow/workflow-vm/src/runtime.ts @@ -41,7 +41,7 @@ import type { WorkflowMeta, WorkflowResult, } from '@deepseek-ai/dsh-workflow' -import { materializeFromRealm, MaterializeError, describeThrown } from './realm.ts' +import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering, REALM_THROWN_RENDERER_SOURCE } from './realm.ts' /** The per-run knobs the engine resolves from its Config. */ export interface ExecutionLimits { @@ -137,13 +137,19 @@ export class WorkflowExecution { ) { // Compile FIRST: a body syntax error must throw out of the constructor // (the engine maps it to SCRIPT_PARSE) before any realm state exists. + // The body is wrapped in a realm-side catch that pre-renders any thrown + // value to a string (see REALM_THROWN_RENDERER_SOURCE) — rendering happens + // inside the realm's own execution window, never on a host catch path. // lineOffset compensates for the wrapper line, so stack traces carry the // script's own line numbers (the meta statement was blanked, not removed). try { - this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, { - filename: `workflow:${meta.name}`, - lineOffset: -1, - }) + this.compiled = new vm.Script( + `(async () => { try {\n${body}\n} catch (e) { throw (${REALM_THROWN_RENDERER_SOURCE})(e) } })()`, + { + filename: `workflow:${meta.name}`, + lineOffset: -1, + }, + ) } catch (error: unknown) { throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error }) } @@ -225,10 +231,13 @@ export class WorkflowExecution { if (error instanceof WorkflowError && error.code === 'CANCELLED') { return { value: null, stopReason: 'cancelled', error: error.message, agentsStarted: this.started } } - // describeThrown is total and trap-free: a hostile thrown value (a - // throwing accessor, a proxy) cannot make this catch throw — drive() - // resolving is the `result` never-rejects seam contract. - return { value: null, stopReason: 'error', error: describeThrown(error), agentsStarted: this.started } + // Ordinary script failures arrive pre-rendered by the realm-side catch + // (thrownRendering); host-thrown errors (a vm timeout, a WorkflowError) + // and adversarial values that bypassed the wrapper (e.g. a hostile + // thenable rejection) render via the total, host-code-only + // describeThrown. Neither path can throw — drive() resolving is the + // `result` never-rejects seam contract. + return { value: null, stopReason: 'error', error: thrownRendering(error) ?? describeThrown(error), agentsStarted: this.started } } finally { // Reap strays: a script that fired agent() calls without awaiting them // leaves live children behind after settlement — abort them all. (The diff --git a/packages/workflow/workflow-vm/tests/meta.spec.ts b/packages/workflow/workflow-vm/tests/meta.spec.ts index 7b5a929046..a957b0ae05 100644 --- a/packages/workflow/workflow-vm/tests/meta.spec.ts +++ b/packages/workflow/workflow-vm/tests/meta.spec.ts @@ -116,11 +116,24 @@ return 2` expect(error.message).toContain('proxies cannot cross') }) - it('a meta expression THROWING a hostile value maps to META_INVALID — rendering runs no realm code', () => { + it('a meta expression THROWING a hostile value maps to META_INVALID — rendering stays realm-side', () => { + // bad() rethrows anything that is not a WorkflowError, so a hostile value + // escaping the realm-side renderer raw would fail this test. const error = bad('export const meta = { name: (() => { throw { get stack() { throw new Error("boom") }, toString() { throw new Error("boom") } } })(), description: "d" }\nreturn 1') expect(error.code).toBe('META_INVALID') expect(error.message).toContain('pure literal') - expect(error.message).toContain('[object Object]') + expect(error.message).toContain('[unrenderable thrown value]') + }) + + it('a spinning meta expression (even inside a thrown stack getter) dies by the eval timeout', () => { + try { + extractMeta('export const meta = { name: (() => { while (true) {} })(), description: "d" }', 50) + throw new Error('expected the extraction to time out') + } catch (error: unknown) { + expect(error).toBeInstanceOf(WorkflowError) + expect((error as WorkflowError).code).toBe('META_INVALID') + expect((error as WorkflowError).message.toLowerCase()).toContain('timed out') + } }) it('rejects shape violations with EVERY violation listed (META_INVALID)', () => { diff --git a/packages/workflow/workflow-vm/tests/realm.spec.ts b/packages/workflow/workflow-vm/tests/realm.spec.ts index 86deb49bd3..c1bdeb33d6 100644 --- a/packages/workflow/workflow-vm/tests/realm.spec.ts +++ b/packages/workflow/workflow-vm/tests/realm.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import * as vm from 'node:vm' -import { materializeFromRealm, MaterializeError } from '../src/realm.ts' +import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering } from '../src/realm.ts' /** Evaluate an expression inside a fresh vm realm and hand back the raw realm value. */ function inRealm(expression: string): unknown { @@ -133,3 +133,46 @@ describe('materializeFromRealm', () => { expect(materializeFromRealm(null)).toBeNull() }) }) + +describe('describeThrown (host-side thrown-value rendering)', () => { + it('renders a HOST Error via its identity-verified native stack getter', () => { + const error = new Error('host failure') + const rendered = describeThrown(error) + expect(rendered).toContain('host failure') + expect(rendered).toContain('at ') // a real stack, not just the message + }) + + it('never invokes a REALM error stack getter (identity mismatch) — message renders instead', () => { + const realmError: unknown = vm.runInNewContext('(() => { try { throw new Error("realm failure") } catch (e) { return e } })()') + expect(describeThrown(realmError)).toBe('realm failure') + }) + + it('reads a data-property stack directly and falls through a setter-only accessor', () => { + expect(describeThrown({ stack: 'data stack' })).toBe('data stack') + const setterOnly = { message: 'via message' } + Object.defineProperty(setterOnly, 'stack', { set() { /* swallow */ } }) + expect(describeThrown(setterOnly)).toBe('via message') + }) + + it('labels proxies and functions without touching them; primitives stringify', () => { + expect(describeThrown(new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap ran') } }))).toBe('[thrown proxy]') + expect(describeThrown(() => 1)).toBe('[thrown function]') + expect(describeThrown('plain')).toBe('plain') + expect(describeThrown(42)).toBe('42') + expect(describeThrown(undefined)).toBe('undefined') + expect(describeThrown(null)).toBe('null') + expect(describeThrown({ code: 42 })).toBe('[object Object]') + }) +}) + +describe('thrownRendering (the realm-catch wrapper reader)', () => { + it('extracts the pre-rendered string from a wrapper and nothing else', () => { + expect(thrownRendering({ __wfThrown: 'rendered text' })).toBe('rendered text') + expect(thrownRendering({ __wfThrown: 42 })).toBeUndefined() + expect(thrownRendering({ other: 'x' })).toBeUndefined() + expect(thrownRendering(new Error('plain'))).toBeUndefined() + expect(thrownRendering('string')).toBeUndefined() + expect(thrownRendering(null)).toBeUndefined() + expect(thrownRendering(new Proxy({ __wfThrown: 'forged' }, { getOwnPropertyDescriptor() { throw new Error('trap ran') } }))).toBeUndefined() + }) +}) diff --git a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts index e0518c8192..00a9438575 100644 --- a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts +++ b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts @@ -589,24 +589,24 @@ describe('dsh-workflow-vm', () => { expect(result.error).toBe('[object Object]') }) - it('hostile thrown values render contained: result NEVER rejects, no unhandled rejection', async () => { + it('hostile thrown values render realm-side: result NEVER rejects, no unhandled rejection', async () => { const unhandled: unknown[] = [] const onUnhandled = (reason: unknown): void => { unhandled.push(reason) } process.on('unhandledRejection', onUnhandled) try { const { ctx, parent } = await setup() - // Each thrown value would run realm code (or throw) under a plain - // property read or String(); rendering must stay total — the only - // permitted realm call is the CONTAINED stack getter. + // Each thrown value runs code (or throws) when rendered — the realm + // wrapper renders it INSIDE script execution, and the host catch only + // ever descriptor-reads the pre-rendered string. const cases: [string, string][] = [ ["throw { get stack() { throw new Error('stack getter threw') } }", '[object Object]'], ["throw { get stack() { throw new Error('x') }, message: 'getter threw, message renders' }", 'getter threw, message renders'], - ["throw { get message() { throw new Error('message getter ran') } }", '[object Object]'], + ["throw { get message() { throw new Error('message getter threw') } }", '[object Object]'], ["throw { stack: 'custom data stack' }", 'custom data stack'], ["throw (() => { const o = { message: 'setter-only stack' }; Object.defineProperty(o, 'stack', { set() {} }); return o })()", 'setter-only stack'], - ["throw new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap ran') } })", '[thrown proxy]'], - ["throw { [Symbol.toPrimitive]() { throw new Error('toPrimitive ran') } }", '[object Object]'], - ['throw () => 1', '[thrown function]'], + ["throw new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('gopd trap threw') } })", '[object Object]'], + ["throw { [Symbol.toPrimitive]() { throw new Error('toPrimitive threw') } }", '[unrenderable thrown value]'], + ['throw () => 1', '() => 1'], ['throw null', 'null'], ] for (const [body, rendered] of cases) { @@ -622,6 +622,27 @@ describe('dsh-workflow-vm', () => { } }) + it('a synchronous spin hidden in a thrown stack getter dies by the vm timeout, not on the host', async () => { + const { ctx, parent } = await setup({ config: { provider: 'stub', syncTimeoutMs: 50 } }) + // The realm-side renderer reads e.stack INSIDE the timed sync slice, so + // the spin is killed exactly like a plain `while (true) {}` body. + const result = await run(ctx, parent, script('throw { get stack() { while (true) {} } }')) + expect(result.stopReason).toBe('error') + expect(result.error?.toLowerCase()).toContain('timed out') + }) + + it('a hostile thenable rejection that bypasses the realm wrapper renders host-side, data-only', async () => { + const { ctx, parent } = await setup() + // Returning a thenable makes the host unwrap it AFTER the script + // settled — its rejection value skips the realm catch entirely and hits + // drive()'s catch raw. The proxy must be labelled, its traps never run. + const result = await run(ctx, parent, script(` + return { then(_resolve, reject) { reject(new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap ran') } })) } } + `)) + expect(result.stopReason).toBe('error') + expect(result.error).toBe('[thrown proxy]') + }) + it('falls back to the message for an Error whose stack was stripped', async () => { const { ctx, parent } = await setup() const result = await run(ctx, parent, script(` From 95c8c878e1e967b9be6e306593d845e21e6ed30e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:57:51 +0800 Subject: [PATCH 06/57] workflow: pin thenable-return semantics as documented async-JS behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex code-review round 4 flagged the return channel: an async IIFE Promise-assimilates a returned thenable, so its then() runs past the sync slice and the RESOLUTION replaces the raw object. Verified against the real engine and judged behavior, not defect: - Assimilation is standard JavaScript (an async function's returned thenable resolves before the caller sees it) and is load-bearing ergonomics: an un-awaited 'return agent(...)' / 'return parallel(...)' resolves to the intended value precisely because of it. Rejecting callable-then returns would break that; intercepting pre-assimilation is spec-impossible (the Get(v,'then') and job enqueue are internal to promise resolution). - The realm-boundary guard applies to the RESOLUTION (a thenable resolving to non-JSON is still RESULT_UNSERIALIZABLE), so nothing crosses unmaterialized. - A spin inside a returned thenable's then() is the same accepted class as any post-slice spin (it runs on the microtask queue, past the vm timeout's reach); the docs previously said 'after the first await', which was too narrow — reworded to 'past the initial synchronous slice (an await continuation, or a thenable's then invoked by promise resolution)'. Pinned with an engine test (un-awaited return agent(); custom thenable resolution as the return value; thenable resolving to non-JSON rejects), and the limitation wording updated in the module doc, README, and RFC. --- .../feature/2026-07-05-dynamic-workflows.md | 2 +- packages/workflow/workflow-vm/README.md | 2 +- packages/workflow/workflow-vm/src/index.ts | 18 +++++++++++------- .../workflow-vm/tests/workflow-vm.spec.ts | 13 +++++++++++++ 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index 6f8678ecea..f44e1fad89 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -24,7 +24,7 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre ### The engine (dsh-workflow-vm): in-process node:vm -**Why node:vm and not isolated-vm/worker threads**: isolated-vm is in maintenance mode, needs `--no-node-snapshot` on EVERY consumer process (including the published bins) on Node ≥ 20, and falls back to node-gyp source builds; a worker-thread engine turns every hook into RPC and complicates the per-file coverage gate. Scripts are model-written — the same trust level as the model's existing bash access — so genuine sandboxing is not the current requirement. The interface/implementation split exists precisely so a hardened engine can swap in later. Accepted, documented limitations: vm is not a security boundary, and the vm timeout covers only the initial synchronous slice — a pathological synchronous spin after the first await cannot be killed in-process; `dispose()` cancels, waits a bounded grace for the script to settle and its children to finish disposing, then abandons. +**Why node:vm and not isolated-vm/worker threads**: isolated-vm is in maintenance mode, needs `--no-node-snapshot` on EVERY consumer process (including the published bins) on Node ≥ 20, and falls back to node-gyp source builds; a worker-thread engine turns every hook into RPC and complicates the per-file coverage gate. Scripts are model-written — the same trust level as the model's existing bash access — so genuine sandboxing is not the current requirement. The interface/implementation split exists precisely so a hardened engine can swap in later. Accepted, documented limitations: vm is not a security boundary, and the vm timeout covers only the initial synchronous slice — a pathological synchronous spin in realm code past that slice (an await continuation, or a thenable's `then` invoked by promise resolution — a returned thenable resolves per JavaScript semantics, which is what makes an un-awaited `return agent('x')` work) cannot be killed in-process; `dispose()` cancels, waits a bounded grace for the script to settle and its children to finish disposing, then abandons. **Meta extraction**: a string/comment-aware brace scanner (template interpolation rejected) finds the literal; it is evaluated ALONE in an empty, timed vm context; the result must materialize to plain JSON data and pass shape validation (unknown fields rejected loud); the statement is blanked line-preservingly so stacks keep script line numbers. diff --git a/packages/workflow/workflow-vm/README.md b/packages/workflow/workflow-vm/README.md index e519ce7c0e..05a8e252d3 100644 --- a/packages/workflow/workflow-vm/README.md +++ b/packages/workflow/workflow-vm/README.md @@ -16,7 +16,7 @@ Values ENTERING the host (the meta literal, hook options/schemas, the script's r Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` aborts every child (a shared `AbortSignal`), rejects waiting `agent()` slots, and makes every future hook call throw `CANCELLED` — the script dies at its next await and the run settles `cancelled`; a cancellation that lands before the body runs (or before it settles) reports `cancelled` even if the script itself needed no hooks. Once a run settles, stray children a script fired without awaiting are aborted too, and `dispose()` waits for those children to finish disposing (bounded by the grace) before returning. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection (the app boot layer exits the process on those). Thrown script values are pre-rendered to a string INSIDE the realm's execution window (the body is compiled into a realm-side catch), so a hostile `stack` getter is subject to the vm sync-slice timeout like any other script code; the host catch only descriptor-reads that string, falling back to `describeThrown` (fixed labels, own-data reads, an identity-verified host-native stack getter) — `result` cannot reject. -**Documented limitations** (the accepted cost of the in-process mechanism; the seam exists so a worker-thread/isolated-vm engine can swap in): vm is NOT a security boundary — scripts are model-written, the same trust level as the model's bash access — and the vm `timeout` covers only the initial synchronous slice, so a pathological synchronous spin after the first await cannot be killed; `dispose()` waits `disposeGraceMs` then ABANDONS such a script (its settlement stays contained, but an abandoned spin would still occupy the event loop). +**Documented limitations** (the accepted cost of the in-process mechanism; the seam exists so a worker-thread/isolated-vm engine can swap in): vm is NOT a security boundary — scripts are model-written, the same trust level as the model's bash access — and the vm `timeout` covers only the initial synchronous slice, so a pathological synchronous spin in realm code past that slice (an await continuation, or a thenable's `then` invoked by promise resolution) cannot be killed; `dispose()` waits `disposeGraceMs` then ABANDONS such a script (its settlement stays contained, but an abandoned spin would still occupy the event loop). A returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited `return agent('x')` work — and the realm-boundary guard applies to the resolution. ## Config diff --git a/packages/workflow/workflow-vm/src/index.ts b/packages/workflow/workflow-vm/src/index.ts index 4f6a506329..d0c8d8abfc 100644 --- a/packages/workflow/workflow-vm/src/index.ts +++ b/packages/workflow/workflow-vm/src/index.ts @@ -12,13 +12,17 @@ * level as the model's bash access — and the realm-boundary materialization * is correctness containment, not a sandbox. * - The vm `timeout` covers only the initial SYNCHRONOUS slice of the script; - * a pathological synchronous spin after the first await cannot be killed - * in-process. `dispose()` waits a bounded grace for the script to settle - * AND its children (stray `agent()` calls included) to finish disposing, - * then ABANDONS whatever is left: pending hook promises are already - * rejected and the script's settlement is contained (no unhandled - * rejection), but an abandoned synchronous spin would still occupy the - * event loop. + * realm code that runs past that slice — an await continuation, a + * thenable's `then` invoked by promise resolution (including one the script + * RETURNS: a returned thenable resolves per JavaScript semantics before + * materialization, which is what makes an un-awaited `return agent('x')` + * work) — is beyond the timeout, so a pathological synchronous spin there + * cannot be killed in-process. `dispose()` waits a bounded grace for the + * script to settle AND its children (stray `agent()` calls included) to + * finish disposing, then ABANDONS whatever is left: pending hook promises + * are already rejected and the script's settlement is contained (no + * unhandled rejection), but an abandoned synchronous spin would still + * occupy the event loop. * * Plugin export shape: a default-exported {@link WorkflowService} subclass * (the class-based service form, like `dsh-bash-local`). diff --git a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts index 00a9438575..28c67dda14 100644 --- a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts +++ b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts @@ -217,6 +217,19 @@ describe('dsh-workflow-vm', () => { expect(result.stopReason).toBe('completed') expect(result.value).toBeNull() }) + + it('a returned promise/thenable resolves per async-JS semantics before materialization', async () => { + const { ctx, parent } = await setup() + // Load-bearing ergonomics: forgetting await on the final hook call works. + expect((await run(ctx, parent, script("return agent('x')"))).value).toBe('stub reply') + // A hand-built thenable is assimilated by the async return — the + // RESOLUTION is the script's return value (standard JavaScript), and the + // realm-boundary guard applies to that resolution, not the thenable. + expect((await run(ctx, parent, script('return { value: 1, then(resolve) { resolve({ ok: true }) } }'))).value).toEqual({ ok: true }) + const nonJson = await run(ctx, parent, script('return { then(resolve) { resolve({ bad: new Date(0) }) } }')) + expect(nonJson.stopReason).toBe('error') + expect(nonJson.error).toContain('not plain JSON data') + }) }) describe('combinator semantics', () => { From 7234d41b915d0b75e7b69ee1b99e42fa002e7e4b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:24:30 +0800 Subject: [PATCH 07/57] workflow: hook promises and hook failures are realm-built too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex code-review round 5: agent()/parallel()/pipeline() returned HOST Promise objects into the script realm — Object.getPrototypeOf(agent('x')) reached host Promise.prototype, contradicting the realm contract (correctness containment, not the accepted sandbox stance). The rejection channel had the same leak one hop away: a caught hook failure was a host WorkflowError (host Error.prototype chain), and phase()/log() threw host errors synchronously. All three surfaces are realm-built now: - hook promises: the realm's own Promise.resolve (bound at context setup) assimilates the host promise, so the script-visible promise carries realm prototypes; the realm promise gets the same no-op rejection consumer as the host one (a script may drop it). - hook failures: rejections and phase/log sync throws are translated at the boundary into realm-built clones (name/code/message/fatal via an in-realm factory); non-WorkflowError host failures become generic realm Errors carrying their describeThrown rendering. - the combinators recognize FATAL clones structurally (isFatalWorkflowErrorClone: proxy-guarded descriptor reads), preserving the fatal-vs-null discipline across the boundary; a script forging the shape kills only its own run. drive() maps any post-cancel failure to 'cancelled' by run state (a CANCELLED clone deliberately fails the host instanceof). Tests: realm-promise identity for all three hooks + host Promise.prototype pollution unreachable; clone shape (instanceof realm Error, name/code/fatal/ message) with prototype-chain mutation staying realm-side; a rejecting provider result crossing as a generic clone; phase/log sync-throw clones; combinator catch branches (string throw, proxy throw, shape-miss forgery → null; forged fatal → kills own run); existing fatal-propagation, cancellation, and unhandled-rejection tests as canaries. --- .../feature/2026-07-05-dynamic-workflows.md | 2 +- packages/workflow/workflow-vm/README.md | 2 +- packages/workflow/workflow-vm/src/realm.ts | 16 +++ packages/workflow/workflow-vm/src/runtime.ts | 112 ++++++++++++++---- .../workflow-vm/tests/workflow-vm.spec.ts | 96 ++++++++++++++- 5 files changed, 203 insertions(+), 25 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index f44e1fad89..4d7061335e 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -28,7 +28,7 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre **Meta extraction**: a string/comment-aware brace scanner (template interpolation rejected) finds the literal; it is evaluated ALONE in an empty, timed vm context; the result must materialize to plain JSON data and pass shape validation (unknown fields rejected loud); the statement is blanked line-preservingly so stacks keep script line numbers. -**Realm boundary**: values entering the host (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a descriptor walk that NEVER invokes accessors (the repo's `isJsonValue` is prototype-strict and getter-invoking, so it cannot run first; it would reject every cross-realm object and let realm code run outside the timed window) and rejects loud everything JSON cannot carry, proxies included (the trap-free `util.types.isProxy`, checked before any inspection, so realm-side traps never run on the host stack), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values entering the realm (`args`, `agent()` results) are rebuilt INSIDE the realm via the context's own `JSON.parse`, and `parallel`/`pipeline` resolve to realm-built arrays, so the script never holds a live host-prototype object. Realm functions (stages, thunks) are called, never materialized. +**Realm boundary**: values entering the host (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a descriptor walk that NEVER invokes accessors (the repo's `isJsonValue` is prototype-strict and getter-invoking, so it cannot run first; it would reject every cross-realm object and let realm code run outside the timed window) and rejects loud everything JSON cannot carry, proxies included (the trap-free `util.types.isProxy`, checked before any inspection, so realm-side traps never run on the host stack), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values entering the realm are realm-built throughout, so the script never holds a live host-prototype object: `args` and `agent()` results via the context's own `JSON.parse`, combinator result arrays via its `Array.from`, hook promises via its `Promise.resolve`, and hook failures as realm-built clones (name/code/message/fatal — the combinators recognize fatal clones structurally). Realm functions (stages, thunks) are called, never materialized. **Containment**: every hook-returned promise carries a no-op rejection consumer, so a script that drops a promise cannot surface an unhandled rejection when cancellation rejects it — `dsh-app-boot` exits the process on unhandled rejections. Thrown script/meta values are pre-rendered to a string by a realm-side catch compiled into the wrapper (rendering runs inside the realm's own execution window, so a hostile `stack` getter dies by the vm sync-slice timeout like any other script code — host-side formatting of realm errors is unfixable in general, since V8 stack formatting invokes script-controllable `name`/`prepareStackTrace` hooks); the host catch descriptor-reads that string or falls back to `describeThrown` (fixed labels, own-data reads, an identity-verified host-native stack getter), so `result` cannot reject. Caps (`maxConcurrentAgents` auto = `min(16, cores - 2)`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. diff --git a/packages/workflow/workflow-vm/README.md b/packages/workflow/workflow-vm/README.md index 05a8e252d3..b5d8817735 100644 --- a/packages/workflow/workflow-vm/README.md +++ b/packages/workflow/workflow-vm/README.md @@ -10,7 +10,7 @@ The first [`WorkflowService`](../workflow/README.md) implementation: an in-proce ## Realm discipline -Values ENTERING the host (the meta literal, hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a descriptor walk that never invokes accessors and rejects loud everything JSON cannot carry (accessors, exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`, and proxies — rejected via the trap-free `util.types.isProxy` BEFORE any inspection could run a realm-side trap on the host stack), copying into host containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values ENTERING the realm (`args`, `agent()` results) are rebuilt INSIDE the realm through the context's own `JSON.parse`, and the arrays `parallel`/`pipeline` resolve to are realm-built, so the script never holds an object whose prototype chain reaches host intrinsics. +Values ENTERING the host (the meta literal, hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a descriptor walk that never invokes accessors and rejects loud everything JSON cannot carry (accessors, exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`, and proxies — rejected via the trap-free `util.types.isProxy` BEFORE any inspection could run a realm-side trap on the host stack), copying into host containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values ENTERING the realm are realm-built throughout, so the script never holds an object whose prototype chain reaches host intrinsics: `args` and `agent()` results are rebuilt through the context's own `JSON.parse`, combinator result arrays through its `Array.from`, hook promises through its `Promise.resolve`, and a hook failure (rejection or synchronous `phase`/`log` throw) crosses as a realm-built clone carrying name/code/message/fatal — the combinators recognize fatal clones structurally, so the fatal-vs-null discipline survives the boundary. ## Limits, cancellation, disposal diff --git a/packages/workflow/workflow-vm/src/realm.ts b/packages/workflow/workflow-vm/src/realm.ts index fca93caac4..c1f1ff7c5c 100644 --- a/packages/workflow/workflow-vm/src/realm.ts +++ b/packages/workflow/workflow-vm/src/realm.ts @@ -156,6 +156,22 @@ function ownDataProperty(value: object, key: string): unknown { return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined } +/** + * Whether `error` is a FATAL realm-built `WorkflowError` clone — the shape the + * engine's hooks reject with (host errors are translated at the realm boundary + * so the script never holds host prototypes), duck-checked because a realm + * object cannot be an `instanceof` the host class. Proxy-guarded and + * descriptor-read, so a forged object cannot run code here; a script forging + * the shape only kills its own run (self-sabotage). Combinators use this to + * decide re-throw vs per-item `null`. + * @param error - the value a combinator caught from a realm thunk/stage. + * @returns `true` when the error must propagate and kill the script. + */ +export function isFatalWorkflowErrorClone(error: unknown): boolean { + if (typeof error !== 'object' || error === null || types.isProxy(error)) return false + return ownDataProperty(error, 'name') === 'WorkflowError' && ownDataProperty(error, 'fatal') === true +} + /** * Whether an object's prototype chain is data-shaped: `null`, or a prototype * whose own prototype is `null` (the realm's `Object.prototype` — which we diff --git a/packages/workflow/workflow-vm/src/runtime.ts b/packages/workflow/workflow-vm/src/runtime.ts index 892b7c781d..955be94a87 100644 --- a/packages/workflow/workflow-vm/src/runtime.ts +++ b/packages/workflow/workflow-vm/src/runtime.ts @@ -9,17 +9,22 @@ * descriptor walks; values ENTERING the realm from the host (`args`, agent() * results) are rebuilt INSIDE the realm through the context's own * `JSON.parse`, so the script never holds an object whose prototype chain - * reaches host intrinsics. The arrays `parallel`/`pipeline` resolve to are - * realm-built for the same reason (their ELEMENTS are realm values already — - * only the container needs rebuilding). Realm functions (pipeline stages, - * parallel thunks) are called, not materialized — their values stay - * realm-side. + * reaches host intrinsics. The same rule covers every other value a hook + * hands the script: the promises `agent`/`parallel`/`pipeline` return are + * realm promises (the realm's own `Promise.resolve` over the host promise), + * the arrays the combinators resolve to are realm-built (their ELEMENTS are + * realm values already — only the container needs rebuilding), and a hook + * failure — rejection or synchronous `phase`/`log` throw — crosses as a + * realm-built clone carrying name/code/message/fatal. Realm functions + * (pipeline stages, parallel thunks) are called, not materialized — their + * values stay realm-side. * * Failure discipline: fatal {@link WorkflowError}s (bad hook arguments, * unsupported options/schemas, tripped caps, seam start failures, - * cancellation) ALWAYS propagate through `parallel`/`pipeline`; the per-item - * `null` is reserved for child-run failures and ordinary in-stage script - * errors. Every hook-returned promise gets a no-op rejection consumer + * cancellation) ALWAYS propagate through `parallel`/`pipeline` — they cross + * the realm boundary as fatal clones, recognized structurally — and the + * per-item `null` is reserved for child-run failures and ordinary in-stage + * script errors. Every hook-returned promise gets a no-op rejection consumer * attached, so a script that drops a promise (fires an `agent()` without * awaiting it) cannot surface an unhandled rejection when cancellation * rejects it — the app boot layer exits the process on unhandled rejections. @@ -34,14 +39,14 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-subagent' import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools' import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools' -import { WorkflowError, isFatalWorkflowError } from '@deepseek-ai/dsh-workflow' +import { WorkflowError } from '@deepseek-ai/dsh-workflow' import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowMeta, WorkflowResult, } from '@deepseek-ai/dsh-workflow' -import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering, REALM_THROWN_RENDERER_SOURCE } from './realm.ts' +import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering, isFatalWorkflowErrorClone, REALM_THROWN_RENDERER_SOURCE } from './realm.ts' /** The per-run knobs the engine resolves from its Config. */ export interface ExecutionLimits { @@ -121,6 +126,8 @@ export class WorkflowExecution { private readonly context: vm.Context private readonly realmJsonParse: (text: string) => unknown private readonly realmArrayFrom: (items: unknown[]) => unknown[] + private readonly realmPromiseResolve: (value: unknown) => Promise + private readonly realmErrorClone: (name: string, code: string | undefined, message: string, fatal: boolean) => unknown private readonly compiled: vm.Script /** Every live `agent()` call promise — awaited or stray — for {@link quiesce}. */ private readonly inFlightAgents = new Set>() @@ -159,16 +166,38 @@ export class WorkflowExecution { // The realm's own JSON.parse — the host→realm rebuild channel. const realmJson = vm.runInContext('JSON', this.context) as { parse(text: string): unknown } this.realmJsonParse = (text: string) => realmJson.parse(text) - // The realm's own Array.from, bound NOW so a script reassigning its - // globals later cannot swap it: combinator results must be realm arrays. + // The realm's own Array.from / Promise.resolve / an error factory, bound + // NOW so a script reassigning its globals later cannot swap them: + // combinator results must be realm arrays, hook promises realm promises, + // and hook failures realm-built clones. this.realmArrayFrom = vm.runInContext('Array.from.bind(Array)', this.context) as (items: unknown[]) => unknown[] + this.realmPromiseResolve = vm.runInContext('Promise.resolve.bind(Promise)', this.context) as (value: unknown) => Promise + this.realmErrorClone = vm.runInContext(`(name, code, message, fatal) => { + const error = new Error(message) + error.name = name + if (code !== undefined) error.code = code + error.fatal = fatal + return error + }`, this.context) as (name: string, code: string | undefined, message: string, fatal: boolean) => unknown const globals: Record = { - agent: (prompt: unknown, opts?: unknown) => this.contain(this.track(this.agent(prompt, opts))), - parallel: (thunks: unknown) => this.contain(this.parallel(thunks)), - pipeline: (items: unknown, ...stages: unknown[]) => this.contain(this.pipeline(items, stages)), - phase: (title: unknown) => { this.phase(title) }, - log: (message: unknown) => { this.log(message) }, + agent: (prompt: unknown, opts?: unknown) => this.realmFacing(this.track(this.agent(prompt, opts))), + parallel: (thunks: unknown) => this.realmFacing(this.parallel(thunks)), + pipeline: (items: unknown, ...stages: unknown[]) => this.realmFacing(this.pipeline(items, stages)), + phase: (title: unknown) => { + try { + this.phase(title) + } catch (error: unknown) { + throw this.toRealmError(error) + } + }, + log: (message: unknown) => { + try { + this.log(message) + } catch (error: unknown) { + throw this.toRealmError(error) + } + }, args: this.toRealm(args), } for (const [key, value] of Object.entries(globals)) { @@ -228,8 +257,12 @@ export class WorkflowExecution { const value = raw === undefined ? null : this.materializeResult(raw) return { value, stopReason: 'completed', agentsStarted: this.started } } catch (error: unknown) { - if (error instanceof WorkflowError && error.code === 'CANCELLED') { - return { value: null, stopReason: 'cancelled', error: error.message, agentsStarted: this.started } + // Any failure after cancel() reports `cancelled` with the canonical + // reason — the reject path mirrors the resolve path's post-settle + // check, and a hook CANCELLED failure crosses the realm boundary as a + // clone that deliberately fails the host `instanceof`. + if (this.isCancelled()) { + return { value: null, stopReason: 'cancelled', error: this.cancelledError().message, agentsStarted: this.started } } // Ordinary script failures arrive pre-rendered by the realm-side catch // (thrownRendering); host-thrown errors (a vm timeout, a WorkflowError) @@ -258,6 +291,37 @@ export class WorkflowExecution { return promise } + /** + * Hand a hook's host promise to the script as a REALM promise (the realm's + * own `Promise.resolve` assimilates it) whose failure reason is a + * realm-built clone — the script must never hold host prototypes, and both + * the promise object and a caught rejection would otherwise expose them + * (module doc). The realm promise gets the same no-op rejection consumer as + * {@link contain}, since the script may drop it; the intermediate host + * promises are handled by the assimilation chain itself. + */ + private realmFacing(hostPromise: Promise): Promise { + const translated = hostPromise.catch((error: unknown) => { + throw this.toRealmError(error) + }) + const realmPromise = this.realmPromiseResolve(translated) + realmPromise.catch(() => { /* consumed: a script-dropped realm promise must not surface an unhandled rejection (see contain) */ }) + return realmPromise + } + + /** + * Rebuild a host failure as a realm-built error clone: a `WorkflowError` + * keeps its name/code/message/fatal (the combinators recognize the shape + * via {@link isFatalWorkflowErrorClone}); anything else becomes a generic + * realm `Error` carrying its {@link describeThrown} rendering. + */ + private toRealmError(error: unknown): unknown { + if (error instanceof WorkflowError) { + return this.realmErrorClone('WorkflowError', error.code, error.message, error.fatal) + } + return this.realmErrorClone('Error', undefined, describeThrown(error), false) + } + /** * Register one `agent()` call promise for {@link quiesce} tracking; the * entry drops when the call fully settles (which is AFTER its child's @@ -472,7 +536,10 @@ export class WorkflowExecution { try { return await thunk() } catch (error: unknown) { - if (isFatalWorkflowError(error)) throw error + // Hooks translate host errors at the realm boundary, so a fatal error + // reaches a thunk catch only as a realm clone (a script forging the + // shape merely kills its own run). + if (isFatalWorkflowErrorClone(error)) throw error return null } })) @@ -505,8 +572,9 @@ export class WorkflowExecution { return value } catch (error: unknown) { // An ordinary stage throw drops the ITEM to null and skips its - // remaining stages; a fatal error kills the whole script. - if (isFatalWorkflowError(error)) throw error + // remaining stages; a fatal error (a realm clone — see parallel()) + // kills the whole script. + if (isFatalWorkflowErrorClone(error)) throw error return null } })) diff --git a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts index 28c67dda14..d87d571295 100644 --- a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts +++ b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts @@ -288,9 +288,23 @@ describe('dsh-workflow-vm', () => { () => { throw new Error('boom') }, () => agent('fine'), () => 'plain value', + () => { throw 'string throw' }, + () => { throw new Proxy({ name: 'WorkflowError', fatal: true }, {}) }, + () => { throw { name: 'WorkflowError', fatal: 'forged-but-not-true' } }, ]) `)) - expect(result.value).toEqual([null, 'stub reply', 'plain value']) + // The last three probe the fatal-clone recognition: a non-object, a + // proxy (never inspected), and a shape miss are all ordinary nulls. + expect(result.value).toEqual([null, 'stub reply', 'plain value', null, null, null]) + }) + + it('a script forging a fatal clone kills only its own run (self-sabotage, not a bypass)', async () => { + const { ctx, parent } = await setup() + const result = await run(ctx, parent, script(` + return await parallel([() => { throw { name: 'WorkflowError', fatal: true, message: 'forged fatal' } }]) + `)) + expect(result.stopReason).toBe('error') + expect(result.error).toContain('forged fatal') }) it('FATAL errors propagate through parallel AND pipeline instead of dissolving into null', async () => { @@ -433,6 +447,86 @@ describe('dsh-workflow-vm', () => { expect((await run(ctx, parent, script('return typeof args'))).value).toBe('undefined') }) + it('hook promises are REALM promises: instanceof holds in-script, host Promise.prototype stays unreachable', async () => { + const { ctx, parent } = await setup() + const result = await run(ctx, parent, script(` + const p = agent('x') + const par = parallel([() => 'v']) + const pipe = pipeline([1], (n) => n) + Object.getPrototypeOf(p).wfLeakProbe = 'realm-only' + return { + agentIsRealmPromise: p instanceof Promise, + parallelIsRealmPromise: par instanceof Promise, + pipelineIsRealmPromise: pipe instanceof Promise, + value: await p, + } + `)) + expect(result.stopReason).toBe('completed') + expect(result.value).toEqual({ + agentIsRealmPromise: true, + parallelIsRealmPromise: true, + pipelineIsRealmPromise: true, + value: 'stub reply', + }) + expect((Promise.prototype as unknown as Record).wfLeakProbe).toBeUndefined() + delete (Promise.prototype as unknown as Record).wfLeakProbe + }) + + it('hook failures cross the boundary as realm-built WorkflowError clones', async () => { + const { ctx, parent } = await setup() + const result = await run(ctx, parent, script(` + try { + await agent('p', { bogus: true }) + return 'unreachable' + } catch (e) { + Object.getPrototypeOf(Object.getPrototypeOf(e)).wfErrLeakProbe = 'realm-only' + return { isRealmError: e instanceof Error, name: e.name, code: e.code, fatal: e.fatal, message: e.message } + } + `)) + expect(result.stopReason).toBe('completed') + expect(result.value).toMatchObject({ isRealmError: true, name: 'WorkflowError', code: 'UNSUPPORTED_OPTION', fatal: true }) + expect((result.value as { message: string }).message).toContain('"bogus" is not recognized') + // The script mutated its error's prototype CHAIN — host intrinsics untouched. + expect((Object.prototype as unknown as Record).wfErrLeakProbe).toBeUndefined() + expect((Error.prototype as unknown as Record).wfErrLeakProbe).toBeUndefined() + }) + + it('a non-WorkflowError host failure (a rejecting provider result) crosses as a generic realm clone', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const provider: SubagentProvider = { + name: 'rejecting', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true }, + start: () => ({ + id: AgentId('reject-child'), + result: Promise.reject(new Error('backend exploded')), + cancel: () => { /* nothing in flight */ }, + dispose: () => Promise.resolve(), + }), + } + ctx.subagents.registerProvider(provider) + await ctx.plugin(VmWorkflowEngine, { provider: 'rejecting' }) + const result = await run(ctx, fakeParent(), script(` + try { await agent('p'); return 'unreachable' } catch (e) { return { isRealmError: e instanceof Error, name: e.name, message: e.message } } + `)) + expect(result.value).toMatchObject({ isRealmError: true, name: 'Error' }) + expect((result.value as { message: string }).message).toContain('backend exploded') + }) + + it('phase()/log() synchronous throws cross as realm clones too', async () => { + const { ctx, parent } = await setup() + const result = await run(ctx, parent, script(` + try { phase(3) } catch (e) { + if (!(e instanceof Error) || e.name !== 'WorkflowError') throw e + } + try { log(3) } catch (e) { + return { isRealmError: e instanceof Error, name: e.name, message: e.message } + } + `)) + expect(result.value).toMatchObject({ isRealmError: true, name: 'WorkflowError' }) + expect((result.value as { message: string }).message).toContain('log() requires') + }) + it('parallel/pipeline resolve to REALM arrays: instanceof holds in-script, host intrinsics stay unreachable', async () => { const { ctx, parent } = await setup() const result = await run(ctx, parent, script(` From 2accf85714885ae33fef40e58b30b3692f7ad3ec Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:48:49 +0800 Subject: [PATCH 08/57] workflow: simplify to the trust premise; settle result on cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review responses that belong together — the same review argued the engine was defending the wrong threat while a benign-input bug wedged the product. 1) Drop hostile-value containment; state the trust premise. Scripts are model-written — the same trust level as the model's bash access — yet successive pre-push review rounds had ratcheted in defenses that only matter against an adversarial author: trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition. That same author keeps a documented, accepted, unkillable event-loop spin, so containing its error VALUES is cost without a threat model — and the planned hardened engine (worker/isolated-vm) gets value isolation by serialization and deletes all of this machinery anyway. What stays, because benign scripts hit it constantly: result never rejects; dropped hook promises cannot become unhandled rejections; the value boundary rejects LOUD everything JSON cannot carry (now a plain recursive walk — getters are read ordinarily and their result is what crosses; a throwing read fails loud); a "__proto__" key still copies as a data property; the fatal-vs-null combinator discipline (now host instanceof — unforgeable from the realm and simpler than clone-shape recognition). What changes for scripts (documented in the engine README): hooks hand back host values and host errors — in-script `instanceof Error` on a hook failure is false (branch on e.name/e.code) — and args are host-cloned once so a script cannot mutate the caller's object. realm.ts drops 289 → 173 lines; the hostile-value test tables go with it. The premise now leads the engine module doc, the README, and the RFC's engine section, with the removed machinery recorded under What was rejected. 2) result settles within the dispose grace of a cancellation. Review finding (verified through the real registry + tool + engine): a script parked on a promise no hook owns — `await new Promise(() => {})`, `await Promise.race([])`, a returned never-settling thenable — could not be settled by cancel(): hooks reject and children abort, but nothing touches a promise the engine does not own, so `result` stayed pending FOREVER (the previous cut even pinned that as intended). The tool awaits run.result BEFORE its disposing finally, the registry awaits the tool, the loop awaits the registry — one such script wedged the whole agent turn past any abort, unrecoverable in-process; the mock engine in the tool's abort test settles result on cancel, which is exactly the behavior the real engine lacked, so no existing test could see it. The seam contract now says it out loud: once a run is cancelled, result SETTLES within the implementation's bounded grace even if the script never does. The vm engine arms an abandon channel in cancel(); drive() races the script against it, force-settling 'cancelled' at the grace (the abandoned settlement stays contained; a post-slice synchronous spin remains the documented limitation). dispose()'s outer race now exists for child quiescence only, and `workflow/end` again fires exactly once per started run. The old 'result stays pending' pin is FLIPPED to the new contract (the pinned behavior was the bug); new regressions cover cancel-then-settle on a parked script, a never-settling returned thenable, and the full composition through the REAL registry + tool + vm engine (tool-workflow gains workflow-vm/subagent devDeps for it). agentsStarted JSDoc clarified while touching the vocabulary (accepted calls, including ones still queued at cancellation). --- docs/core-data-structures/workflow.md | 2 +- .../feature/2026-07-05-dynamic-workflows.md | 9 +- packages/workflow/tool-workflow/package.json | 2 + .../tool-workflow/tests/tool-workflow.spec.ts | 31 +++ packages/workflow/workflow-vm/README.md | 12 +- packages/workflow/workflow-vm/src/index.ts | 59 +++-- packages/workflow/workflow-vm/src/meta.ts | 15 +- packages/workflow/workflow-vm/src/realm.ts | 246 +++++------------- packages/workflow/workflow-vm/src/runtime.ts | 225 ++++++---------- .../workflow/workflow-vm/tests/meta.spec.ts | 20 +- .../workflow/workflow-vm/tests/realm.spec.ts | 99 +++---- .../workflow-vm/tests/workflow-vm.spec.ts | 208 ++++----------- packages/workflow/workflow/README.md | 2 +- packages/workflow/workflow/src/index.ts | 9 +- packages/workflow/workflow/src/types.ts | 16 +- pnpm-lock.yaml | 6 + 16 files changed, 352 insertions(+), 609 deletions(-) diff --git a/docs/core-data-structures/workflow.md b/docs/core-data-structures/workflow.md index 11f216f39e..8c7916a6c3 100644 --- a/docs/core-data-structures/workflow.md +++ b/docs/core-data-structures/workflow.md @@ -47,7 +47,7 @@ interface WorkflowResult { ## A live run: `WorkflowRun` -The handle the consumer holds while a script executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path. `result` does NOT reject — a script failure resolves with `stopReason: 'error'` — so the consumer maps a non-`completed` reason to an `isError` result. `dispose()` cancels, waits a bounded grace for the script to settle AND its children to finish disposing, then abandons whatever is left (the engine documents the abandonment semantics); it never hangs on a stuck script. +The handle the consumer holds while a script executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path. `result` does NOT reject — a script failure resolves with `stopReason: 'error'` — and once the run is cancelled it SETTLES within the engine's bounded grace even if the script itself never settles (the engine abandons the script and reports `cancelled`), so a consumer awaiting `result` is never wedged past a cancellation. `dispose()` = cancel + that bounded settle + child quiescence (the engine documents what abandonment leaves behind); it never hangs on a stuck script. ```ts type-equiv interface WorkflowRun { diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index 4d7061335e..f39f12f0ed 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -24,13 +24,13 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre ### The engine (dsh-workflow-vm): in-process node:vm -**Why node:vm and not isolated-vm/worker threads**: isolated-vm is in maintenance mode, needs `--no-node-snapshot` on EVERY consumer process (including the published bins) on Node ≥ 20, and falls back to node-gyp source builds; a worker-thread engine turns every hook into RPC and complicates the per-file coverage gate. Scripts are model-written — the same trust level as the model's existing bash access — so genuine sandboxing is not the current requirement. The interface/implementation split exists precisely so a hardened engine can swap in later. Accepted, documented limitations: vm is not a security boundary, and the vm timeout covers only the initial synchronous slice — a pathological synchronous spin in realm code past that slice (an await continuation, or a thenable's `then` invoked by promise resolution — a returned thenable resolves per JavaScript semantics, which is what makes an un-awaited `return agent('x')` work) cannot be killed in-process; `dispose()` cancels, waits a bounded grace for the script to settle and its children to finish disposing, then abandons. +**Trust premise (governs every engine decision below)**: workflow scripts are MODEL-WRITTEN — the same trust level as the model's existing bash access — so the engine defends against BUGGY scripts, never hostile ones. In scope: `result` never rejects, no unhandled rejections from dropped hook promises, loud rejection of values JSON cannot carry, fatal-vs-null hook discipline, cancellation that always frees the caller. Out of scope, deliberately: adversarial values (throwing/spinning accessors, proxies with hostile traps, prototype forgery, `prepareStackTrace` hijack) — host code MAY run script code while reading script values, and that is accepted, because a hostile script can already occupy the event loop forever with a synchronous spin past its first await; containing its error VALUES while conceding it the event loop would be cost without a threat model. Genuine hardening is an engine swap behind the seam (worker/isolated-vm gets value isolation by serialization for free), not incremental host-side defenses. + +**Why node:vm and not isolated-vm/worker threads**: isolated-vm is in maintenance mode, needs `--no-node-snapshot` on EVERY consumer process (including the published bins) on Node ≥ 20, and falls back to node-gyp source builds; a worker-thread engine turns every hook into RPC and complicates the per-file coverage gate. Under the trust premise, in-process is enough. Accepted, documented limitations: `start()` blocks the caller for the script's initial synchronous slice (bounded by the vm timeout); that timeout covers ONLY the initial slice, so a synchronous spin past it (an await continuation, a thenable's `then` invoked by promise resolution — a returned thenable resolves per JavaScript semantics, which is what makes an un-awaited `return agent('x')` work — or script code the host runs while rendering a thrown value) cannot be killed in-process; `dispose()` cancels, waits a bounded grace for the script to settle and its children to finish disposing, then abandons. **Meta extraction**: a string/comment-aware brace scanner (template interpolation rejected) finds the literal; it is evaluated ALONE in an empty, timed vm context; the result must materialize to plain JSON data and pass shape validation (unknown fields rejected loud); the statement is blanked line-preservingly so stacks keep script line numbers. -**Realm boundary**: values entering the host (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a descriptor walk that NEVER invokes accessors (the repo's `isJsonValue` is prototype-strict and getter-invoking, so it cannot run first; it would reject every cross-realm object and let realm code run outside the timed window) and rejects loud everything JSON cannot carry, proxies included (the trap-free `util.types.isProxy`, checked before any inspection, so realm-side traps never run on the host stack), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values entering the realm are realm-built throughout, so the script never holds a live host-prototype object: `args` and `agent()` results via the context's own `JSON.parse`, combinator result arrays via its `Array.from`, hook promises via its `Promise.resolve`, and hook failures as realm-built clones (name/code/message/fatal — the combinators recognize fatal clones structurally). Realm functions (stages, thunks) are called, never materialized. - -**Containment**: every hook-returned promise carries a no-op rejection consumer, so a script that drops a promise cannot surface an unhandled rejection when cancellation rejects it — `dsh-app-boot` exits the process on unhandled rejections. Thrown script/meta values are pre-rendered to a string by a realm-side catch compiled into the wrapper (rendering runs inside the realm's own execution window, so a hostile `stack` getter dies by the vm sync-slice timeout like any other script code — host-side formatting of realm errors is unfixable in general, since V8 stack formatting invokes script-controllable `name`/`prepareStackTrace` hooks); the host catch descriptor-reads that string or falls back to `describeThrown` (fixed labels, own-data reads, an identity-verified host-native stack getter), so `result` cannot reject. Caps (`maxConcurrentAgents` auto = `min(16, cores - 2)`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. +**Value boundary**: values entering the host (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation; getters are read ordinarily and their RESULT crosses (a throwing read fails loud). Values entering the realm (`args`, `agent()` results, hook promises and failures, combinator arrays) are handed over directly as host values — the script is trusted, so host prototypes are not a leak; `args` is host-`structuredClone`d once so a script cannot mutate the caller's object. Hook failures are host `WorkflowError`s: the combinators recognize fatality by host `instanceof` (unforgeable from the realm), and the script-visible consequence — in-script `instanceof Error` is `false` for hook errors; branch on `e.name`/`e.code` — is documented in the engine README. Realm functions (stages, thunks) are called, never materialized. Thrown script values are rendered by a total host-side renderer (stack → message → `String()`, fixed label if rendering throws), so `result` cannot reject. Caps (`maxConcurrentAgents` auto = `min(16, cores - 2)`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. ### The consumer (dsh-tool-workflow) @@ -42,6 +42,7 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai ## What was rejected +- **Hostile-value containment in the host** (trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition): an earlier revision built all of it, and review showed the cost was real while the threat model was not — every one of those defenses guards against an author the premise already trusts, who retains an accepted unkillable event-loop spin regardless. Removed in favor of the plain boundary above; the hardened engine deletes such machinery anyway (serialization by construction). - **Background execution as the default** (CC's shape): deferred; foreground-synchronous matches `dsh-tool-subagent`'s cut, and background semantics should be designed ONCE across bash/subagent/workflow rather than per-tool. - **Workflow-layer JSON parsing for `agent({schema})`**: duplicating a seam concern at one consumer while the seam's capability flag stayed dishonestly `false`. - **Meta as tool parameters instead of `export const meta`**: zero parsing, but scripts stop being self-contained artifacts and CC-authored scripts stop being drop-in. diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 9c3f819cb5..416b6a814f 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -35,9 +35,11 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", + "@deepseek-ai/dsh-workflow-vm": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 012fae4961..677cbbf7a9 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -9,6 +9,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow' import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' import { CallId } from '@deepseek-ai/dsh-llm' +import SubagentService from '@deepseek-ai/dsh-subagent' +import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-vm' import * as toolWorkflow from '../src/index.ts' /** A controllable engine standing in behind ctx.workflows (the tool's only seam). */ @@ -221,4 +223,33 @@ describe('dsh-tool-workflow', () => { expect(unwrapped).toBe(toolWorkflow) expect(typeof unwrapped.apply).toBe('function') }) + + describe('composition with the REAL vm engine (the mock above must stay honest)', () => { + it('an abort releases the tool even when the script parks on a promise no hook owns', async () => { + // Regression for the review-found turn wedge: the tool awaits + // run.result BEFORE its disposing finally, the registry and the loop + // await the tool — so if cancellation could not settle result (a script + // parked on `await new Promise(() => {})`), an aborted turn stayed + // wedged forever. The seam now guarantees result settles within the + // grace of cancel(); this drives that guarantee through the real + // registry + real tool + real engine. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + await ctx.plugin(VmWorkflowEngine, { disposeGraceMs: 30 }) + await ctx.plugin(toolWorkflow, {}) + const parent = { id: AgentId('caller'), options: {} } as unknown as Agent + const controller = new AbortController() + const pending = execute(ctx, { + script: "export const meta = { name: 'stuck', description: 'parks forever' }\nawait new Promise(() => {})\nreturn 1", + }, { agent: parent, signal: controller.signal }) + // Give the run a beat to start (past its synchronous slice), then abort. + await new Promise(resolve => setTimeout(resolve, 20)) + controller.abort('user abort') + const result = await pending + expect(result.isError).toBe(true) + expect((result.content[0] as { text: string }).text).toContain('cancelled') + }) + }) }) diff --git a/packages/workflow/workflow-vm/README.md b/packages/workflow/workflow-vm/README.md index b5d8817735..0ec1116cb7 100644 --- a/packages/workflow/workflow-vm/README.md +++ b/packages/workflow/workflow-vm/README.md @@ -2,21 +2,25 @@ The first [`WorkflowService`](../workflow/README.md) implementation: an in-process **`node:vm` engine**. It parses the Claude Code-format script (`export const meta = {...}` + plain-JS body), runs the body in a fresh vm context with the workflow hooks injected, and fans `agent()` calls out to [`ctx.subagents`](../../subagent/README.md). +## Trust premise + +Workflow scripts are **model-written** — the same trust level as the model's existing bash access — so this engine defends against **buggy** scripts, never hostile ones. vm is NOT a security boundary and no attempt is made to contain adversarial values: property reads on script values may run script code (a getter, a `toString`, a proxy trap) on the host stack, and a script determined to hang the process can simply spin past its first await (see the limitations below). What the engine DOES guarantee, because benign scripts hit these constantly: `result` never rejects, a dropped hook promise never becomes an unhandled rejection (the app boot layer exits the process on those), values that JSON cannot carry are rejected **loud** instead of silently mangled, and hook misuse is fatal instead of dissolving into a per-item `null`. Genuine sandboxing is an engine swap behind the seam (worker-thread/isolated-vm, where the boundary is serialization by construction), not incremental host-side defenses here. + ## The script contract it executes - **Meta extraction** (`extractMeta`): a string/comment-aware brace scanner finds the leading `export const meta` literal (template interpolation rejected — the literal must be pure), evaluates it ALONE in an empty timed vm context, materializes the result to plain JSON data, validates the shape (`name`/`description` required; unknown fields rejected loud), and blanks the statement line-preservingly so error stacks keep the script's own line numbers. - **Hooks**: `agent(prompt, {label, phase, schema, model})` (schema = the [structured-output subset](../../core/tools/README.md), forwarded as `outputSchema`; result = validated object, or final text without a schema; a failed child resolves `null`), `parallel(thunks)`, `pipeline(items, ...stages)` with NO cross-stage barrier and `(prev, item, index)` stage callbacks, `phase(title)`, `log(message)`, and the `args` global. Anything else — `effort`/`isolation`/`agentType`, unknown options, malformed arguments, schemas outside the subset — throws a FATAL `WorkflowError` that `parallel`/`pipeline` re-throw rather than nulling (see the seam README's failure discipline). - **Determinism bans**: `Date.now()`, `Math.random()`, and argless `new Date()` throw (kept even though resume is deferred, so scripts stay resume-compatible); no timers, filesystem, or Node APIs exist in the context. -## Realm discipline +## The value boundary -Values ENTERING the host (the meta literal, hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a descriptor walk that never invokes accessors and rejects loud everything JSON cannot carry (accessors, exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`, and proxies — rejected via the trap-free `util.types.isProxy` BEFORE any inspection could run a realm-side trap on the host stack), copying into host containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values ENTERING the realm are realm-built throughout, so the script never holds an object whose prototype chain reaches host intrinsics: `args` and `agent()` results are rebuilt through the context's own `JSON.parse`, combinator result arrays through its `Array.from`, hook promises through its `Promise.resolve`, and a hook failure (rejection or synchronous `phase`/`log` throw) crosses as a realm-built clone carrying name/code/message/fatal — the combinators recognize fatal clones structurally, so the fatal-vs-null discipline survives the boundary. +Values ENTERING the host (the meta literal, hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying into host containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Getters are read ordinarily — the RESULT is what crosses; a read that throws fails loud. Values ENTERING the realm (`args`, `agent()` results, hook promises and their failures, combinator arrays) are handed over directly as host values — the script is trusted, so host prototypes are not a leak; `args` is `structuredClone`d once at start so a script scribbling on it cannot mutate the caller's object. One script-visible consequence: an error thrown by a hook is a HOST error, so `e instanceof Error` inside the script is `false` — branch on `e.name`/`e.code` instead (the combinators recognize fatality by host `instanceof`, which a script-built object can never pass, so fatal-vs-null cannot be forged or dissolved). ## Limits, cancellation, disposal -Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` aborts every child (a shared `AbortSignal`), rejects waiting `agent()` slots, and makes every future hook call throw `CANCELLED` — the script dies at its next await and the run settles `cancelled`; a cancellation that lands before the body runs (or before it settles) reports `cancelled` even if the script itself needed no hooks. Once a run settles, stray children a script fired without awaiting are aborted too, and `dispose()` waits for those children to finish disposing (bounded by the grace) before returning. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection (the app boot layer exits the process on those). Thrown script values are pre-rendered to a string INSIDE the realm's execution window (the body is compiled into a realm-side catch), so a hostile `stack` getter is subject to the vm sync-slice timeout like any other script code; the host catch only descriptor-reads that string, falling back to `describeThrown` (fixed labels, own-data reads, an identity-verified host-native stack getter) — `result` cannot reject. +Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` aborts every child (a shared `AbortSignal`), rejects waiting `agent()` slots, and makes every future hook call throw `CANCELLED` — the script dies at its next await and the run settles `cancelled`; a cancellation that lands before the body runs (or before it settles) reports `cancelled` even if the script itself needed no hooks, and a script that STILL has not settled `disposeGraceMs` after the cancel (parked on a promise no hook owns, like `await new Promise(() => {})`) is ABANDONED with `result` force-settling `cancelled` — a consumer awaiting `result` is never wedged past a cancellation. Once a run settles, stray children a script fired without awaiting are aborted too, and `dispose()` waits for those children to finish disposing (bounded by the grace) before returning. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection; thrown script values are rendered by a total host-side renderer (stack, then message, then `String()`, with a fixed label if rendering itself throws) — `result` cannot reject. -**Documented limitations** (the accepted cost of the in-process mechanism; the seam exists so a worker-thread/isolated-vm engine can swap in): vm is NOT a security boundary — scripts are model-written, the same trust level as the model's bash access — and the vm `timeout` covers only the initial synchronous slice, so a pathological synchronous spin in realm code past that slice (an await continuation, or a thenable's `then` invoked by promise resolution) cannot be killed; `dispose()` waits `disposeGraceMs` then ABANDONS such a script (its settlement stays contained, but an abandoned spin would still occupy the event loop). A returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited `return agent('x')` work — and the realm-boundary guard applies to the resolution. +**Documented limitations** (the accepted cost of the in-process mechanism; the seam exists so a worker-thread/isolated-vm engine can swap in): `start()` runs the script's initial synchronous slice inline, so the caller blocks until the first await or the vm `timeout`; that `timeout` covers ONLY the initial slice, so a synchronous spin past it (an await continuation, a thenable's `then` invoked by promise resolution, or script code the host runs while rendering a thrown value) cannot be killed; `dispose()` waits `disposeGraceMs` then ABANDONS such a script (its settlement stays contained, but an abandoned spin would still occupy the event loop). A returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited `return agent('x')` work — and the value-boundary guard applies to the resolution. ## Config diff --git a/packages/workflow/workflow-vm/src/index.ts b/packages/workflow/workflow-vm/src/index.ts index d0c8d8abfc..9b8c646aa9 100644 --- a/packages/workflow/workflow-vm/src/index.ts +++ b/packages/workflow/workflow-vm/src/index.ts @@ -4,25 +4,33 @@ * body in a fresh in-process vm context with the workflow hooks injected, and * fans `agent()` calls out to `ctx.subagents`. * - * Engine limitations, documented as the accepted cost of the in-process - * mechanism (the interface/implementation seam exists precisely so a - * worker-thread or isolated-vm engine can swap in if these ever matter): + * TRUST PREMISE: scripts are MODEL-WRITTEN — the same trust level as the + * model's existing bash access — so this engine defends against BUGGY + * scripts, never hostile ones. vm is NOT a security boundary and no attempt + * is made to contain adversarial values (see ./realm.ts); genuine sandboxing + * is an engine swap behind the seam (worker-thread/isolated-vm), not + * incremental host-side defenses here. * - * - vm is NOT a security boundary. Scripts are model-written — the same trust - * level as the model's bash access — and the realm-boundary materialization - * is correctness containment, not a sandbox. - * - The vm `timeout` covers only the initial SYNCHRONOUS slice of the script; - * realm code that runs past that slice — an await continuation, a - * thenable's `then` invoked by promise resolution (including one the script - * RETURNS: a returned thenable resolves per JavaScript semantics before - * materialization, which is what makes an un-awaited `return agent('x')` - * work) — is beyond the timeout, so a pathological synchronous spin there - * cannot be killed in-process. `dispose()` waits a bounded grace for the - * script to settle AND its children (stray `agent()` calls included) to - * finish disposing, then ABANDONS whatever is left: pending hook promises - * are already rejected and the script's settlement is contained (no - * unhandled rejection), but an abandoned synchronous spin would still - * occupy the event loop. + * Engine limitations, documented as the accepted cost of the in-process + * mechanism: + * + * - `start()` runs the script's initial SYNCHRONOUS slice inline, so the + * CALLER blocks on the host event loop until the script's first await (or + * the vm `timeout` kills the slice); the meta-literal evaluation has its + * own timeout budget on the same call. + * - The vm `timeout` covers only that initial slice; realm code running past + * it — an await continuation, a thenable's `then` invoked by promise + * resolution (including one the script RETURNS: a returned thenable + * resolves per JavaScript semantics before materialization, which is what + * makes an un-awaited `return agent('x')` work) — is beyond the timeout, so + * a synchronous spin there cannot be killed in-process, and neither can + * script code the host invokes while rendering a failure (a getter on a + * thrown value). `dispose()` waits a bounded grace for the script to settle + * AND its children (stray `agent()` calls included) to finish disposing, + * then ABANDONS whatever is left: pending hook promises are already + * rejected and the script's settlement is contained (no unhandled + * rejection), but an abandoned synchronous spin would still occupy the + * event loop. * * Plugin export shape: a default-exported {@link WorkflowService} subclass * (the class-based service form, like `dsh-bash-local`). @@ -55,7 +63,11 @@ export interface Config { maxItemsPerCall?: number /** vm timeout for the script's initial synchronous slice AND the meta-literal evaluation (default 5000 ms). */ syncTimeoutMs?: number - /** How long `dispose()` waits for a cancelled script to settle before abandoning it (default 5000 ms). */ + /** + * How long after a cancellation an unsettled script may keep running before + * it is abandoned and `result` force-settles `cancelled` (default 5000 ms); + * also bounds `dispose()`. + */ disposeGraceMs?: number } @@ -110,6 +122,7 @@ export class VmWorkflowEngine extends WorkflowService { maxTotalAgents: this.config.maxTotalAgents, maxItemsPerCall: this.config.maxItemsPerCall, syncTimeoutMs: this.config.syncTimeoutMs, + disposeGraceMs: this.config.disposeGraceMs, } const execution = new WorkflowExecution( this.ctx, @@ -149,9 +162,11 @@ export class VmWorkflowEngine extends WorkflowService { }, dispose: (): Promise => { // Idempotent: cancel, then wait min(settle + child quiescence, grace). - // `result` and `quiesce()` never reject, so the race needs no - // rejection handling; a script or child still unsettled past the grace - // is abandoned per the module contract. + // The cancel itself bounds `result` (the execution abandons a script + // still unsettled `disposeGraceMs` later), so this outer race exists + // for CHILD quiescence: a slow-disposing child must not hold dispose + // past the grace. `result` and `quiesce()` never reject, so the race + // needs no rejection handling. disposed ??= (async () => { execution.cancel('workflow disposed') await Promise.race([ diff --git a/packages/workflow/workflow-vm/src/meta.ts b/packages/workflow/workflow-vm/src/meta.ts index bcb891e85e..cfe3e4d381 100644 --- a/packages/workflow/workflow-vm/src/meta.ts +++ b/packages/workflow/workflow-vm/src/meta.ts @@ -20,7 +20,7 @@ import * as vm from 'node:vm' import { WorkflowError } from '@deepseek-ai/dsh-workflow' import type { WorkflowMeta, WorkflowPhase } from '@deepseek-ai/dsh-workflow' -import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering, REALM_THROWN_RENDERER_SOURCE } from './realm.ts' +import { materializeFromRealm, MaterializeError, renderThrown } from './realm.ts' /** The result of {@link extractMeta}: the validated meta and the runnable body. */ export interface ExtractedScript { @@ -171,18 +171,11 @@ export function extractMeta(script: string, evalTimeoutMs: number): ExtractedScr // An EMPTY context: any non-literal reference (a variable, a call) throws // here. The result — data only — is what the contract checks; a getter or // IIFE can still run, which is why the timeout and the materialization - // below are part of the same boundary. A thrown value is pre-rendered by - // the realm-side catch INSIDE the timed window, so a hostile - // stack/message/toString can neither run on the host catch path nor - // outlive the timeout. - evaluated = vm.runInNewContext( - `(() => { try { return (${literal}) } catch (e) { throw (${REALM_THROWN_RENDERER_SOURCE})(e) } })()`, - undefined, - { timeout: evalTimeoutMs }, - ) + // below are part of the same boundary. + evaluated = vm.runInNewContext(`(${literal})`, undefined, { timeout: evalTimeoutMs }) } catch (error: unknown) { throw new WorkflowError( - `meta block failed to evaluate as a pure literal: ${thrownRendering(error) ?? describeThrown(error)}`, + `meta block failed to evaluate as a pure literal: ${renderThrown(error)}`, 'META_INVALID', { cause: error }, ) diff --git a/packages/workflow/workflow-vm/src/realm.ts b/packages/workflow/workflow-vm/src/realm.ts index c1f1ff7c5c..f5a2665626 100644 --- a/packages/workflow/workflow-vm/src/realm.ts +++ b/packages/workflow/workflow-vm/src/realm.ts @@ -1,48 +1,34 @@ /** - * Realm-boundary materialization for the vm engine. + * The vm engine's value boundary: copy script-realm values into plain host + * JSON data — loud about everything JSON cannot carry — and render thrown + * script values to failure text. * - * Values produced INSIDE the script realm (the meta literal, hook arguments, - * the script's return value) must become plain host-realm JSON data before the - * host touches them. The repo's `isJsonValue` guard cannot run first: it is - * prototype-strict (any cross-realm object fails it) and it INVOKES getters - * (letting realm code run outside the vm's timed window). So this module walks - * own-property DESCRIPTORS — never invoking accessors — and copies data into - * host containers, rejecting loud everything JSON cannot carry: - * accessor properties, non-plain prototypes, functions, symbols (keys or - * values), bigints, non-finite numbers, `undefined` values, cycles, sparse - * arrays, arrays with non-index own properties, and proxies. Proxies are - * rejected via the trap-free native `util.types.isProxy` check BEFORE any - * other inspection — a descriptor walk over a proxy would otherwise run its - * realm-side traps (`ownKeys`, `getOwnPropertyDescriptor`, `getPrototypeOf`) - * on the host stack, outside the vm's timed window, and a throwing trap would - * escape as a raw realm error instead of a {@link MaterializeError}. The same - * check guards the PROTOTYPE position (an object whose prototype is a proxy). + * TRUST PREMISE (everything in this module hangs on it): workflow scripts are + * MODEL-WRITTEN, the same trust level as the model's existing bash access, so + * this boundary guards against BUGGY scripts, not hostile ones. It rejects + * loud what JSON would silently mangle — functions, symbols, bigints, + * non-finite numbers, nested `undefined`, cycles, sparse arrays, exotic + * prototypes — because accepted-then-ignored is this repo's banned failure + * mode. It does NOT defend against adversarial values: the walk reads + * properties ordinarily (a getter runs, and whatever it returns is what + * crosses), {@link renderThrown} reads `stack`/`message`/`String()` directly, + * and a proxy is walked through its traps. A hostile script gains nothing + * worth defending here — it can already occupy the event loop forever with a + * synchronous spin past the first await (the engine's documented, accepted + * limitation) — so host-side hostile-value containment would be cost without + * a threat model; genuine hardening is an ENGINE SWAP (worker/isolated-vm, + * where the boundary is serialization by construction), not incremental + * defenses here. * - * Host objects are built with `Object.defineProperty` into a fresh `{}` — - * never plain `target[key] =` assignment, which a `"__proto__"` key would turn - * into prototype mutation instead of a data property. - * - * The host→realm direction deliberately does NOT live here: a host object - * handed into the realm would expose host intrinsics through its prototype - * chain, so the engine rebuilds inbound values INSIDE the realm via the - * context's own `JSON.parse` (see the runtime). - * - * {@link REALM_THROWN_RENDERER_SOURCE}, {@link thrownRendering}, and - * {@link describeThrown} are the same discipline for the one place realm - * values reach the host WITHOUT materialization: a thrown value crossing into - * a host catch block. The renderer runs INSIDE the realm's own execution - * window (compiled into the script wrapper), so reading a hostile - * accessor/`toString` there is subject to the vm sync-slice timeout exactly - * like any other script code; the host side only descriptor-reads the - * pre-rendered string, or falls back to {@link describeThrown}, which invokes - * no getter whose function identity is not the host realm's own native stack - * getter. + * The host→realm direction needs no machinery at all: hooks hand the script + * plain host values, host prototypes included — the script is trusted. One + * consequence is documented in the engine README: an error thrown by a hook + * is a HOST error, so an in-script `instanceof Error` check is false; read + * `name`/`code`/`message` instead. * * @module @deepseek-ai/dsh-workflow-vm/realm */ -import { types } from 'node:util' - /** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */ export class MaterializeError extends Error { constructor(public readonly path: string, public readonly reason: string) { @@ -52,154 +38,63 @@ export class MaterializeError extends Error { } /** - * Realm-SOURCE text (an arrow-function expression) the engine compiles into - * its script wrappers: `throw (RENDERER)(e)` inside a catch around the whole - * body/literal. It renders the thrown value to a string INSIDE the realm's - * own execution window — a hostile `stack`/`message` accessor or `toString` - * invoked here is subject to the vm sync-slice timeout like any other script - * code (and post-await it is the engine's accepted spin limitation, identical - * to a script reading `e.stack` in its own catch). Host `WorkflowError`s - * thrown by hooks pass through unwrapped (duck-checked by name — a realm - * forgery fails the host's `instanceof` and merely renders data-only); - * everything else becomes `{ __wfThrown: }`, whose only consumer is - * {@link thrownRendering}. Every read is individually contained, so the - * renderer itself never throws. - */ -export const REALM_THROWN_RENDERER_SOURCE = `(e) => { - try { if (e && e.name === 'WorkflowError') return e } catch { /* hostile name getter: fall through to rendering */ } - const rendered = (() => { - try { if (e && typeof e.stack === 'string' && e.stack.length > 0) return e.stack } catch { /* hostile stack getter */ } - try { if (e && typeof e.message === 'string') return e.message } catch { /* hostile message getter */ } - try { return String(e) } catch { /* hostile toString/Symbol.toPrimitive */ } - return '[unrenderable thrown value]' - })() - return { __wfThrown: rendered } -}` - -/** - * The pre-rendered failure text carried by a realm-catch wrapper object - * (`{ __wfThrown: string }` from {@link REALM_THROWN_RENDERER_SOURCE}), or - * `undefined` when `error` is not such a wrapper. Descriptor-read and - * proxy-guarded: never invokes user code. - * @param error - the value a host catch received from script execution. - * @returns the realm-rendered string, or `undefined` to fall back to - * {@link describeThrown}. - */ -export function thrownRendering(error: unknown): string | undefined { - if (typeof error !== 'object' || error === null || types.isProxy(error)) return undefined - const value = ownDataProperty(error, '__wfThrown') - return typeof value === 'string' ? value : undefined -} - -/** - * The host realm's own native `stack` getter (modern V8 makes `stack` an own - * ACCESSOR on Errors); `undefined` where it is a data property. Typed through - * a structural view of the descriptor — it is only ever identity-compared or - * `.call`ed on an explicit receiver, never invoked unbound. - */ -const HOST_STACK_GETTER: unknown = (Object.getOwnPropertyDescriptor(new Error(), 'stack') as { get?: unknown } | undefined)?.get - -/** - * Render a thrown value HOST-SIDE without ever throwing and without running - * any code the host does not own: proxies become a fixed label (trap-free - * `isProxy` before any inspection); `stack` is read as an own data descriptor, - * or through its getter ONLY when that getter's function identity is the host - * realm's own native stack getter (an unforgeable check — realm code cannot - * hold that identity, and the host realm's `prepareStackTrace` is the host's - * own trust domain); `message` is an own-data read; anything else - * object-shaped renders as `[object Object]` untouched; only primitives - * (which cannot carry code) reach `String()`. Used for host-thrown errors - * (vm timeouts, `WorkflowError`s) and as the fallback for adversarial values - * that bypassed the realm-side renderer (e.g. a hostile thenable rejection); - * ordinary script failures arrive pre-rendered via {@link thrownRendering}. + * Render a thrown value to failure text without ever throwing: prefer the + * `stack` (host or realm — a realm error's `stack` is a plain string read), + * fall back to `message`, then `String()`. Reading those properties MAY run + * script code (a getter, `toString`) — accepted under the module's trust + * premise; if that code itself throws, a fixed label is returned instead. * @param error - the thrown value, of any shape and any realm. * @returns human-readable text for the failure report; prefers the stack. */ -export function describeThrown(error: unknown): string { - switch (typeof error) { - case 'object': - break - case 'function': - return '[thrown function]' - default: - // Primitives (string/number/boolean/bigint/symbol/undefined): String() - // cannot reach user code on these. - return String(error) +export function renderThrown(error: unknown): string { + try { + const stack = (error as { stack?: unknown } | null | undefined)?.stack + if (typeof stack === 'string' && stack.length > 0) return stack + const message = (error as { message?: unknown } | null | undefined)?.message + if (typeof message === 'string' && message.length > 0) return message + return String(error) + } catch { + // A throwing accessor/toString on the thrown value — rendering must be + // total (drive()'s never-reject contract), so fall back to a fixed label. + return '[unrenderable thrown value]' } - if (error === null) return 'null' - if (types.isProxy(error)) return '[thrown proxy]' - const stack = readStack(error) - if (typeof stack === 'string' && stack.length > 0) return stack - const message = ownDataProperty(error, 'message') - if (typeof message === 'string') return message - return '[object Object]' -} - -/** - * Read `error.stack` without running foreign code: an own DATA descriptor is - * read directly; an accessor is invoked only on function identity with - * {@link HOST_STACK_GETTER} (never a realm or user function). The native - * getter returns `undefined` on a non-Error receiver rather than throwing. - */ -function readStack(error: object): unknown { - const descriptor = Object.getOwnPropertyDescriptor(error, 'stack') - if (descriptor === undefined) return undefined - if ('value' in descriptor) return descriptor.value - if (typeof descriptor.get !== 'function') return undefined - if (descriptor.get !== HOST_STACK_GETTER) return undefined - return descriptor.get.call(error) -} - -/** An own DATA property's value (`undefined` for absent or accessor); never invokes user code on a non-proxy object. */ -function ownDataProperty(value: object, key: string): unknown { - const descriptor = Object.getOwnPropertyDescriptor(value, key) - return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined -} - -/** - * Whether `error` is a FATAL realm-built `WorkflowError` clone — the shape the - * engine's hooks reject with (host errors are translated at the realm boundary - * so the script never holds host prototypes), duck-checked because a realm - * object cannot be an `instanceof` the host class. Proxy-guarded and - * descriptor-read, so a forged object cannot run code here; a script forging - * the shape only kills its own run (self-sabotage). Combinators use this to - * decide re-throw vs per-item `null`. - * @param error - the value a combinator caught from a realm thunk/stage. - * @returns `true` when the error must propagate and kill the script. - */ -export function isFatalWorkflowErrorClone(error: unknown): boolean { - if (typeof error !== 'object' || error === null || types.isProxy(error)) return false - return ownDataProperty(error, 'name') === 'WorkflowError' && ownDataProperty(error, 'fatal') === true } /** * Whether an object's prototype chain is data-shaped: `null`, or a prototype * whose own prototype is `null` (the realm's `Object.prototype` — which we * cannot compare by identity across realms). A `Date`/`Map`/class instance - * has a longer chain and is rejected, as is a proxy sitting in the prototype - * position (checked trap-free BEFORE its own prototype is dereferenced). + * has a longer chain and is rejected. */ function hasPlainPrototype(value: object): boolean { const proto: unknown = Object.getPrototypeOf(value) if (proto === null) return true - if (types.isProxy(proto)) return false return Object.getPrototypeOf(proto) === null } /** * Copy `value` (typically from the vm realm) into plain host JSON data. * Throws {@link MaterializeError} naming the offending path for anything JSON - * cannot carry losslessly. Accessors are detected via descriptors and NEVER - * invoked. `undefined` is accepted only at the ROOT (a script with no - * `return` value) — the caller decides what it means; an `undefined` nested - * INSIDE a container is a violation. + * cannot carry losslessly. Properties are read ordinarily — a getter runs and + * its RESULT is materialized; a read that throws surfaces as a + * {@link MaterializeError} carrying the rendered failure. `undefined` is + * accepted only at the ROOT (a script with no `return` value) — the caller + * decides what it means; an `undefined` nested INSIDE a container is a + * violation. * @param value - the realm value to materialize. * @param root - the path label for the root value (error messages). * @returns the host-realm copy (plain objects/arrays/scalars only). */ export function materializeFromRealm(value: unknown, root = 'value'): unknown { if (value === undefined) return undefined - return materialize(value, root, new Set()) + try { + return materialize(value, root, new Set()) + } catch (error: unknown) { + if (error instanceof MaterializeError) throw error + // A property read ran script code that threw; total-ize it so callers can + // keep the narrow MaterializeError contract. + throw new MaterializeError(root, `reading the value threw: ${renderThrown(error)}`) + } } function materialize(value: unknown, path: string, seen: Set): unknown { @@ -214,20 +109,15 @@ function materialize(value: unknown, path: string, seen: Set): unknown { case 'bigint': throw new MaterializeError(path, 'bigints are not JSON data') case 'function': - throw new MaterializeError(path, 'functions cannot cross the workflow realm boundary') + throw new MaterializeError(path, 'functions cannot cross the workflow value boundary') case 'symbol': - throw new MaterializeError(path, 'symbols cannot cross the workflow realm boundary') + throw new MaterializeError(path, 'symbols cannot cross the workflow value boundary') case 'undefined': throw new MaterializeError(path, 'undefined is not JSON data') case 'object': break } if (value === null) return null - // BEFORE anything else touches the object: every inspection below — - // Array.isArray aside — can trigger a proxy trap, running realm code on the - // host stack (module doc). isProxy is a native internal-slot check (no - // traps, catches revoked proxies, realm-agnostic). - if (types.isProxy(value)) throw new MaterializeError(path, 'proxies cannot cross the workflow realm boundary') const objectValue: object = value if (seen.has(objectValue)) throw new MaterializeError(path, 'circular references are not JSON data') seen.add(objectValue) @@ -242,10 +132,8 @@ function materialize(value: unknown, path: string, seen: Set): unknown { function materializeArray(value: unknown[], path: string, seen: Set): unknown[] { const out: unknown[] = [] for (let index = 0; index < value.length; index++) { - const descriptor = Object.getOwnPropertyDescriptor(value, index) - if (descriptor === undefined) throw new MaterializeError(`${path}[${index}]`, 'sparse arrays are not JSON data') - if (!('value' in descriptor)) throw new MaterializeError(`${path}[${index}]`, 'accessor properties cannot cross the workflow realm boundary') - out.push(materialize(descriptor.value, `${path}[${index}]`, seen)) + if (!(index in value)) throw new MaterializeError(`${path}[${index}]`, 'sparse arrays are not JSON data') + out.push(materialize(value[index], `${path}[${index}]`, seen)) } // Own enumerable props beyond the indices (e.g. `arr.total = 3`) would be // silently dropped by JSON — reject them instead. @@ -256,7 +144,7 @@ function materializeArray(value: unknown[], path: string, seen: Set): un } } if (Object.getOwnPropertySymbols(value).length > 0) { - throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow realm boundary') + throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow value boundary') } return out } @@ -266,20 +154,16 @@ function materializeObject(value: object, path: string, seen: Set): Reco throw new MaterializeError(path, 'only plain objects and arrays are JSON data (exotic prototype)') } if (Object.getOwnPropertySymbols(value).length > 0) { - throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow realm boundary') + throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow value boundary') } const out: Record = {} - for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) { - // Non-enumerable own props never reach JSON output — skip them, matching - // JSON.stringify's contract exactly (documented in the module doc). - if (!descriptor.enumerable) continue - if (!('value' in descriptor)) { - throw new MaterializeError(`${path}.${key}`, 'accessor properties cannot cross the workflow realm boundary') - } + // Object.keys = own enumerable string keys, matching JSON.stringify's + // property selection exactly (non-enumerable props never reach JSON output). + for (const key of Object.keys(value)) { // defineProperty, never assignment: a "__proto__" key must become an OWN // data property of the copy, not a prototype mutation. Object.defineProperty(out, key, { - value: materialize(descriptor.value, `${path}.${key}`, seen), + value: materialize((value as Record)[key], `${path}.${key}`, seen), enumerable: true, writable: true, configurable: true, diff --git a/packages/workflow/workflow-vm/src/runtime.ts b/packages/workflow/workflow-vm/src/runtime.ts index 955be94a87..7748cb2f2e 100644 --- a/packages/workflow/workflow-vm/src/runtime.ts +++ b/packages/workflow/workflow-vm/src/runtime.ts @@ -4,30 +4,27 @@ * concurrency semaphore and caps, cancellation, and the drive loop that turns * a script settlement into a {@link WorkflowResult}. * - * Realm discipline (see also ./realm.ts): values ENTERING the host from the - * script (hook options, schemas, the return value) are materialized via - * descriptor walks; values ENTERING the realm from the host (`args`, agent() - * results) are rebuilt INSIDE the realm through the context's own - * `JSON.parse`, so the script never holds an object whose prototype chain - * reaches host intrinsics. The same rule covers every other value a hook - * hands the script: the promises `agent`/`parallel`/`pipeline` return are - * realm promises (the realm's own `Promise.resolve` over the host promise), - * the arrays the combinators resolve to are realm-built (their ELEMENTS are - * realm values already — only the container needs rebuilding), and a hook - * failure — rejection or synchronous `phase`/`log` throw — crosses as a - * realm-built clone carrying name/code/message/fatal. Realm functions - * (pipeline stages, parallel thunks) are called, not materialized — their - * values stay realm-side. + * Value boundary (the trust premise lives in ./realm.ts): values ENTERING the + * host from the script (hook options, schemas, the return value) are + * materialized by `materializeFromRealm` — a plain walk that rejects loud + * everything JSON cannot carry. Values ENTERING the realm (`args`, `agent()` + * results, hook promises and their failures, combinator arrays) are handed + * over DIRECTLY as host values: the script is model-written and trusted, so + * host prototypes are not a leak. `args` is host-side `structuredClone`d once + * at start so a script scribbling on it cannot mutate the caller's object — + * that is a benign-bug guard, not isolation. Realm functions (pipeline + * stages, parallel thunks) are called, not materialized — their values stay + * realm-side until they cross through a hook or the final return. * * Failure discipline: fatal {@link WorkflowError}s (bad hook arguments, * unsupported options/schemas, tripped caps, seam start failures, - * cancellation) ALWAYS propagate through `parallel`/`pipeline` — they cross - * the realm boundary as fatal clones, recognized structurally — and the - * per-item `null` is reserved for child-run failures and ordinary in-stage - * script errors. Every hook-returned promise gets a no-op rejection consumer - * attached, so a script that drops a promise (fires an `agent()` without - * awaiting it) cannot surface an unhandled rejection when cancellation - * rejects it — the app boot layer exits the process on unhandled rejections. + * cancellation) ALWAYS propagate through `parallel`/`pipeline` — recognized + * by host `instanceof`, which a script cannot forge — and the per-item `null` + * is reserved for child-run failures and ordinary in-stage script errors. + * Every hook-returned promise gets a no-op rejection consumer attached, so a + * script that drops a promise (fires an `agent()` without awaiting it) cannot + * surface an unhandled rejection when cancellation rejects it — the app boot + * layer exits the process on unhandled rejections. * * @module @deepseek-ai/dsh-workflow-vm/runtime */ @@ -39,14 +36,14 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-subagent' import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools' import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools' -import { WorkflowError } from '@deepseek-ai/dsh-workflow' +import { isFatalWorkflowError, WorkflowError } from '@deepseek-ai/dsh-workflow' import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowMeta, WorkflowResult, } from '@deepseek-ai/dsh-workflow' -import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering, isFatalWorkflowErrorClone, REALM_THROWN_RENDERER_SOURCE } from './realm.ts' +import { materializeFromRealm, MaterializeError, renderThrown } from './realm.ts' /** The per-run knobs the engine resolves from its Config. */ export interface ExecutionLimits { @@ -60,6 +57,8 @@ export interface ExecutionLimits { maxItemsPerCall: number /** vm timeout for the script's initial synchronous slice. */ syncTimeoutMs: number + /** How long after `cancel()` a still-unsettled script is abandoned (result force-settles `cancelled`). */ + disposeGraceMs: number } /** The engine-side observers the execution reports progress through. */ @@ -124,13 +123,24 @@ export class WorkflowExecution { private readonly controller = new AbortController() private currentPhase: string | undefined private readonly context: vm.Context - private readonly realmJsonParse: (text: string) => unknown - private readonly realmArrayFrom: (items: unknown[]) => unknown[] - private readonly realmPromiseResolve: (value: unknown) => Promise - private readonly realmErrorClone: (name: string, code: string | undefined, message: string, fatal: boolean) => unknown private readonly compiled: vm.Script /** Every live `agent()` call promise — awaited or stray — for {@link quiesce}. */ private readonly inFlightAgents = new Set>() + /** Fires {@link abandoned}; assigned by the promise executor at field initialization. */ + private declareAbandoned!: () => void + private abandonTimer: NodeJS.Timeout | undefined + /** + * Rejects `disposeGraceMs` after {@link cancel} if the script has not + * settled by then. `drive()` races the script against it, so `result` + * ALWAYS settles within the grace of a cancellation — even when the script + * is parked on a promise no hook owns (`await new Promise(() => {})`), which + * cancellation cannot reject. Without this, a consumer awaiting `result` + * before disposing (the tool's shape) would hang forever on such a script, + * wedging its caller past any abort. + */ + private readonly abandoned = new Promise((_, reject) => { + this.declareAbandoned = () => { reject(new WorkflowError('workflow script abandoned after the cancellation grace', 'CANCELLED')) } + }) constructor( private readonly ctx: Context, @@ -144,61 +154,34 @@ export class WorkflowExecution { ) { // Compile FIRST: a body syntax error must throw out of the constructor // (the engine maps it to SCRIPT_PARSE) before any realm state exists. - // The body is wrapped in a realm-side catch that pre-renders any thrown - // value to a string (see REALM_THROWN_RENDERER_SOURCE) — rendering happens - // inside the realm's own execution window, never on a host catch path. // lineOffset compensates for the wrapper line, so stack traces carry the // script's own line numbers (the meta statement was blanked, not removed). try { - this.compiled = new vm.Script( - `(async () => { try {\n${body}\n} catch (e) { throw (${REALM_THROWN_RENDERER_SOURCE})(e) } })()`, - { - filename: `workflow:${meta.name}`, - lineOffset: -1, - }, - ) + this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, { + filename: `workflow:${meta.name}`, + lineOffset: -1, + }) } catch (error: unknown) { throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error }) } this.context = vm.createContext({}, { name: `workflow:${meta.name}` }) vm.runInContext(DETERMINISM_PRELUDE, this.context) - // The realm's own JSON.parse — the host→realm rebuild channel. - const realmJson = vm.runInContext('JSON', this.context) as { parse(text: string): unknown } - this.realmJsonParse = (text: string) => realmJson.parse(text) - // The realm's own Array.from / Promise.resolve / an error factory, bound - // NOW so a script reassigning its globals later cannot swap them: - // combinator results must be realm arrays, hook promises realm promises, - // and hook failures realm-built clones. - this.realmArrayFrom = vm.runInContext('Array.from.bind(Array)', this.context) as (items: unknown[]) => unknown[] - this.realmPromiseResolve = vm.runInContext('Promise.resolve.bind(Promise)', this.context) as (value: unknown) => Promise - this.realmErrorClone = vm.runInContext(`(name, code, message, fatal) => { - const error = new Error(message) - error.name = name - if (code !== undefined) error.code = code - error.fatal = fatal - return error - }`, this.context) as (name: string, code: string | undefined, message: string, fatal: boolean) => unknown + // A run that settles without ever being abandoned leaves `abandoned` + // permanently pending or rejecting into the void — consume it so a late + // grace timer cannot surface an unhandled rejection. + void this.contain(this.abandoned) const globals: Record = { - agent: (prompt: unknown, opts?: unknown) => this.realmFacing(this.track(this.agent(prompt, opts))), - parallel: (thunks: unknown) => this.realmFacing(this.parallel(thunks)), - pipeline: (items: unknown, ...stages: unknown[]) => this.realmFacing(this.pipeline(items, stages)), - phase: (title: unknown) => { - try { - this.phase(title) - } catch (error: unknown) { - throw this.toRealmError(error) - } - }, - log: (message: unknown) => { - try { - this.log(message) - } catch (error: unknown) { - throw this.toRealmError(error) - } - }, - args: this.toRealm(args), + agent: (prompt: unknown, opts?: unknown) => this.contain(this.track(this.agent(prompt, opts))), + parallel: (thunks: unknown) => this.contain(this.parallel(thunks)), + pipeline: (items: unknown, ...stages: unknown[]) => this.contain(this.pipeline(items, stages)), + phase: (title: unknown) => { this.phase(title) }, + log: (message: unknown) => { this.log(message) }, + // Host-side clone: a script scribbling on args must not mutate the + // caller's object (a benign-bug guard; args is plain JSON by the seam + // contract, so structuredClone is total here and throws loud otherwise). + args: args === undefined ? undefined : structuredClone(args), } for (const [key, value] of Object.entries(globals)) { // Data properties on the contextified global; frozen shape not required — @@ -226,7 +209,10 @@ export class WorkflowExecution { /** * Cancel the run: children abort (the shared signal), waiting `agent()` * slots reject, and every future hook call throws `CANCELLED` — the script - * dies at its next await. Idempotent; the first reason wins. + * dies at its next await. A script that STILL has not settled after + * `disposeGraceMs` (parked on a promise no hook owns) is abandoned so + * `result` settles regardless (see {@link abandoned}). Idempotent; the + * first reason wins. */ cancel(reason?: string): void { if (this.cancelReason !== undefined) return @@ -234,14 +220,18 @@ export class WorkflowExecution { this.cancelError = new WorkflowError(`workflow run cancelled: ${this.cancelReason}`, 'CANCELLED') this.controller.abort(this.cancelReason) for (const waiter of this.slotWaiters.splice(0)) waiter.reject(this.cancelledError()) + this.abandonTimer = setTimeout(() => { this.declareAbandoned() }, this.limits.disposeGraceMs) + // unref'd: an armed grace timer must never hold the process open. + this.abandonTimer.unref() } /** * Run the script to settlement. Resolves — never rejects — with the run's * {@link WorkflowResult}: the materialized return value on `completed`, the * failure message on `error`, and `cancelled` when the script died of - * cancellation. After settlement, any stray children a script fired without - * awaiting are aborted (their `agent()` wrappers dispose them). + * cancellation (or outlived its post-cancel grace and was abandoned — see + * {@link abandoned}). After settlement, any stray children a script fired + * without awaiting are aborted (their `agent()` wrappers dispose them). */ async drive(): Promise { try { @@ -249,7 +239,9 @@ export class WorkflowExecution { // the script must not execute at all, let alone report `completed`. if (this.isCancelled()) throw this.cancelledError() const scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs }) as Promise - const raw: unknown = await this.contain(Promise.resolve(scriptPromise)) + // The race is the result-settles-after-cancel guarantee: a parked + // script loses to the abandon channel once the grace expires. + const raw: unknown = await Promise.race([this.contain(Promise.resolve(scriptPromise)), this.abandoned]) // Cancelled while the body ran: a script that settled without touching // another hook (or without any) must still report `cancelled` — the // holder asked for cancellation and `completed` would be a lie. @@ -258,25 +250,23 @@ export class WorkflowExecution { return { value, stopReason: 'completed', agentsStarted: this.started } } catch (error: unknown) { // Any failure after cancel() reports `cancelled` with the canonical - // reason — the reject path mirrors the resolve path's post-settle - // check, and a hook CANCELLED failure crosses the realm boundary as a - // clone that deliberately fails the host `instanceof`. + // reason — the reject path mirrors the resolve path's post-settle check. if (this.isCancelled()) { return { value: null, stopReason: 'cancelled', error: this.cancelledError().message, agentsStarted: this.started } } - // Ordinary script failures arrive pre-rendered by the realm-side catch - // (thrownRendering); host-thrown errors (a vm timeout, a WorkflowError) - // and adversarial values that bypassed the wrapper (e.g. a hostile - // thenable rejection) render via the total, host-code-only - // describeThrown. Neither path can throw — drive() resolving is the - // `result` never-rejects seam contract. - return { value: null, stopReason: 'error', error: thrownRendering(error) ?? describeThrown(error), agentsStarted: this.started } + // renderThrown is total (host- and realm-thrown values alike), so this + // arm cannot throw — drive() resolving is the `result` never-rejects + // seam contract. + return { value: null, stopReason: 'error', error: renderThrown(error), agentsStarted: this.started } } finally { // Reap strays: a script that fired agent() calls without awaiting them // leaves live children behind after settlement — abort them all. (The // per-call wrappers dispose each child; the contain() consumer keeps // their rejections from going unhandled.) if (this.cancelReason === undefined) this.cancel('workflow settled') + // drive() settling means nothing is left to abandon — including the + // timer the self-cancel above just armed. + if (this.abandonTimer !== undefined) clearTimeout(this.abandonTimer) } } @@ -291,37 +281,6 @@ export class WorkflowExecution { return promise } - /** - * Hand a hook's host promise to the script as a REALM promise (the realm's - * own `Promise.resolve` assimilates it) whose failure reason is a - * realm-built clone — the script must never hold host prototypes, and both - * the promise object and a caught rejection would otherwise expose them - * (module doc). The realm promise gets the same no-op rejection consumer as - * {@link contain}, since the script may drop it; the intermediate host - * promises are handled by the assimilation chain itself. - */ - private realmFacing(hostPromise: Promise): Promise { - const translated = hostPromise.catch((error: unknown) => { - throw this.toRealmError(error) - }) - const realmPromise = this.realmPromiseResolve(translated) - realmPromise.catch(() => { /* consumed: a script-dropped realm promise must not surface an unhandled rejection (see contain) */ }) - return realmPromise - } - - /** - * Rebuild a host failure as a realm-built error clone: a `WorkflowError` - * keeps its name/code/message/fatal (the combinators recognize the shape - * via {@link isFatalWorkflowErrorClone}); anything else becomes a generic - * realm `Error` carrying its {@link describeThrown} rendering. - */ - private toRealmError(error: unknown): unknown { - if (error instanceof WorkflowError) { - return this.realmErrorClone('WorkflowError', error.code, error.message, error.fatal) - } - return this.realmErrorClone('Error', undefined, describeThrown(error), false) - } - /** * Register one `agent()` call promise for {@link quiesce} tracking; the * entry drops when the call fully settles (which is AFTER its child's @@ -354,14 +313,6 @@ export class WorkflowExecution { return this.cancelError ?? new WorkflowError('workflow run cancelled', 'CANCELLED') } - /** Rebuild a host value inside the script realm (via the realm's own JSON.parse). */ - private toRealm(value: unknown): unknown { - if (value === undefined) return undefined - if (value === null) return null - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value - return this.realmJsonParse(JSON.stringify(value)) - } - /** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */ private materializeResult(raw: unknown): unknown { try { @@ -453,7 +404,7 @@ export class WorkflowExecution { return null } this.observer.agentEnd({ ...info, outcome: 'completed' }) - return this.toRealm(result.structured) + return result.structured } this.observer.agentEnd({ ...info, outcome: 'completed' }) return outputText(result.output) @@ -532,20 +483,17 @@ export class WorkflowExecution { } return thunk as () => unknown }) - const settled = await Promise.all(thunks.map(async (thunk) => { + return Promise.all(thunks.map(async (thunk) => { try { return await thunk() } catch (error: unknown) { - // Hooks translate host errors at the realm boundary, so a fatal error - // reaches a thunk catch only as a realm clone (a script forging the - // shape merely kills its own run). - if (isFatalWorkflowErrorClone(error)) throw error + // Hook failures are host WorkflowErrors; a fatal one is recognized by + // host `instanceof` — a script-built object can never pass it, so + // fatality cannot be forged (nor accidentally dissolved). + if (isFatalWorkflowError(error)) throw error return null } })) - // The container must be a REALM array (module doc); the elements are - // realm values already. - return this.realmArrayFrom(settled) } /** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */ @@ -563,7 +511,7 @@ export class WorkflowExecution { } return stage as (previous: unknown, item: unknown, index: number) => unknown }) - const settled = await Promise.all(rawItems.map(async (item: unknown, index) => { + return Promise.all(rawItems.map(async (item: unknown, index) => { let value: unknown = item try { for (const stage of stages) { @@ -572,15 +520,12 @@ export class WorkflowExecution { return value } catch (error: unknown) { // An ordinary stage throw drops the ITEM to null and skips its - // remaining stages; a fatal error (a realm clone — see parallel()) - // kills the whole script. - if (isFatalWorkflowErrorClone(error)) throw error + // remaining stages; a fatal host WorkflowError (see parallel()) kills + // the whole script. + if (isFatalWorkflowError(error)) throw error return null } })) - // The container must be a REALM array (module doc); the elements are - // realm values already. - return this.realmArrayFrom(settled) } private assertItemCap(length: number, hook: string): void { diff --git a/packages/workflow/workflow-vm/tests/meta.spec.ts b/packages/workflow/workflow-vm/tests/meta.spec.ts index a957b0ae05..02daa02940 100644 --- a/packages/workflow/workflow-vm/tests/meta.spec.ts +++ b/packages/workflow/workflow-vm/tests/meta.spec.ts @@ -103,29 +103,19 @@ return 2` }) it('rejects a literal evaluating to non-JSON data (META_INVALID via materialization)', () => { - const error = bad('export const meta = { name: "x", description: "d", phases: [{ get title() { return "t" } }] }') + const error = bad('export const meta = { name: "x", description: "d", whenToUse: () => 1 }') expect(error.code).toBe('META_INVALID') expect(error.message).toContain('JSON data') }) - it('rejects a meta literal containing a proxy as META_INVALID — its traps never run', () => { - // bad() rethrows anything that is not a WorkflowError, so a trap firing - // ('trap ran') would fail this test instead of mapping to META_INVALID. - const error = bad('export const meta = { name: "x", description: "d", phases: new Proxy([], { getPrototypeOf() { throw new Error("trap ran") } }) }') - expect(error.code).toBe('META_INVALID') - expect(error.message).toContain('proxies cannot cross') - }) - - it('a meta expression THROWING a hostile value maps to META_INVALID — rendering stays realm-side', () => { - // bad() rethrows anything that is not a WorkflowError, so a hostile value - // escaping the realm-side renderer raw would fail this test. - const error = bad('export const meta = { name: (() => { throw { get stack() { throw new Error("boom") }, toString() { throw new Error("boom") } } })(), description: "d" }\nreturn 1') + it('a meta expression that THROWS maps to META_INVALID carrying the rendered value', () => { + const error = bad('export const meta = { name: (() => { throw "nope" })(), description: "d" }\nreturn 1') expect(error.code).toBe('META_INVALID') expect(error.message).toContain('pure literal') - expect(error.message).toContain('[unrenderable thrown value]') + expect(error.message).toContain('nope') }) - it('a spinning meta expression (even inside a thrown stack getter) dies by the eval timeout', () => { + it('a spinning meta expression dies by the eval timeout', () => { try { extractMeta('export const meta = { name: (() => { while (true) {} })(), description: "d" }', 50) throw new Error('expected the extraction to time out') diff --git a/packages/workflow/workflow-vm/tests/realm.spec.ts b/packages/workflow/workflow-vm/tests/realm.spec.ts index c1bdeb33d6..769a19aa71 100644 --- a/packages/workflow/workflow-vm/tests/realm.spec.ts +++ b/packages/workflow/workflow-vm/tests/realm.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import * as vm from 'node:vm' -import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering } from '../src/realm.ts' +import { materializeFromRealm, MaterializeError, renderThrown } from '../src/realm.ts' /** Evaluate an expression inside a fresh vm realm and hand back the raw realm value. */ function inRealm(expression: string): unknown { @@ -35,17 +35,21 @@ describe('materializeFromRealm', () => { expect(rejection(inRealm('{ a: undefined }'))).toContain('value.a') }) - it('never invokes accessors: a counting getter is rejected, not read', () => { + it('invokes getters ordinarily — the getter RESULT is what crosses (trust premise)', () => { const counter = inRealm(` (() => { globalThis.reads = 0 - return { get x() { globalThis.reads += 1; return 1 } } + return { get x() { globalThis.reads += 1; return globalThis.reads } } })() `) - expect(rejection(counter)).toContain('accessor properties cannot cross') - // The getter body never ran — descriptor inspection only. - expect((counter as { x?: unknown }).x).toBe(1) // sanity: reading DOES run it… - expect(rejection(counter)).toContain('accessor') // …but materialization still never did + expect(materializeFromRealm(counter)).toEqual({ x: 1 }) + }) + + it('a getter that THROWS surfaces as a MaterializeError carrying the rendered failure', () => { + const hostile = inRealm("{ get x() { throw new Error('read failed') } }") + const message = rejection(hostile) + expect(message).toContain('reading the value threw') + expect(message).toContain('read failed') }) it('a "__proto__" key becomes an OWN data property of the copy, never a prototype mutation', () => { @@ -81,39 +85,18 @@ describe('materializeFromRealm', () => { expect(materializeFromRealm(inRealm('Object.assign(Object.create(null), { a: 1 })'))).toEqual({ a: 1 }) }) - it('rejects proxies (root, nested, revoked, host-realm) WITHOUT running any trap', () => { - const trapped = inRealm(`new Proxy({ a: 1 }, { - ownKeys() { throw new Error('trap ran') }, - getOwnPropertyDescriptor() { throw new Error('trap ran') }, - getPrototypeOf() { throw new Error('trap ran') }, - })`) - // A trap firing would surface 'trap ran' (a non-MaterializeError) instead. - expect(rejection(trapped)).toContain('proxies cannot cross') - expect(rejection(inRealm('{ nested: new Proxy([], {}) }'))).toContain('value.nested') - const revoked = inRealm('(() => { const r = Proxy.revocable({}, {}); r.revoke(); return r.proxy })()') - expect(rejection(revoked)).toContain('proxies cannot cross') - expect(rejection(new Proxy({}, {}))).toContain('proxies cannot cross') - }) - - it('rejects an object whose PROTOTYPE is a proxy without dereferencing through it', () => { - const value = inRealm(`Object.create(new Proxy({}, { - getPrototypeOf() { throw new Error('trap ran') }, - }))`) - expect(rejection(value)).toContain('exotic prototype') - }) - it('rejects cycles and accepts the same object reused as a sibling (a DAG)', () => { expect(rejection(inRealm('(() => { const o = {}; o.self = o; return o })()'))).toContain('circular') const dag = inRealm('(() => { const leaf = { v: 1 }; return { a: leaf, b: leaf } })()') expect(materializeFromRealm(dag)).toEqual({ a: { v: 1 }, b: { v: 1 } }) }) - it('rejects sparse arrays, accessor elements, and non-index array properties', () => { + it('rejects sparse arrays and non-index array properties; an array getter element materializes its value', () => { expect(rejection(inRealm('[1, , 3]'))).toContain('sparse') - expect(rejection(inRealm('(() => { const a = [1]; Object.defineProperty(a, 0, { get: () => 1 }); return a })()'))) - .toContain('accessor') expect(rejection(inRealm('(() => { const a = [1]; a.total = 3; return a })()'))) .toContain('non-index') + expect(materializeFromRealm(inRealm('(() => { const a = [1]; Object.defineProperty(a, 0, { get: () => 7, enumerable: true }); return a })()'))) + .toEqual([7]) }) it('skips non-enumerable own properties (matching JSON.stringify exactly)', () => { @@ -134,45 +117,29 @@ describe('materializeFromRealm', () => { }) }) -describe('describeThrown (host-side thrown-value rendering)', () => { - it('renders a HOST Error via its identity-verified native stack getter', () => { - const error = new Error('host failure') - const rendered = describeThrown(error) - expect(rendered).toContain('host failure') - expect(rendered).toContain('at ') // a real stack, not just the message - }) - - it('never invokes a REALM error stack getter (identity mismatch) — message renders instead', () => { +describe('renderThrown', () => { + it('prefers the stack, for host and realm errors alike', () => { + const host = renderThrown(new Error('host failure')) + expect(host).toContain('host failure') + expect(host).toContain('at ') // a real stack, not just the message const realmError: unknown = vm.runInNewContext('(() => { try { throw new Error("realm failure") } catch (e) { return e } })()') - expect(describeThrown(realmError)).toBe('realm failure') + expect(renderThrown(realmError)).toContain('realm failure') }) - it('reads a data-property stack directly and falls through a setter-only accessor', () => { - expect(describeThrown({ stack: 'data stack' })).toBe('data stack') - const setterOnly = { message: 'via message' } - Object.defineProperty(setterOnly, 'stack', { set() { /* swallow */ } }) - expect(describeThrown(setterOnly)).toBe('via message') + it('falls back from stack to message to String()', () => { + expect(renderThrown({ stack: 'custom data stack' })).toBe('custom data stack') + const stackless = new Error('stackless failure') + delete stackless.stack + expect(renderThrown(stackless)).toBe('stackless failure') + expect(renderThrown({ code: 42 })).toBe('[object Object]') + expect(renderThrown('plain')).toBe('plain') + expect(renderThrown(42)).toBe('42') + expect(renderThrown(undefined)).toBe('undefined') + expect(renderThrown(null)).toBe('null') }) - it('labels proxies and functions without touching them; primitives stringify', () => { - expect(describeThrown(new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap ran') } }))).toBe('[thrown proxy]') - expect(describeThrown(() => 1)).toBe('[thrown function]') - expect(describeThrown('plain')).toBe('plain') - expect(describeThrown(42)).toBe('42') - expect(describeThrown(undefined)).toBe('undefined') - expect(describeThrown(null)).toBe('null') - expect(describeThrown({ code: 42 })).toBe('[object Object]') - }) -}) - -describe('thrownRendering (the realm-catch wrapper reader)', () => { - it('extracts the pre-rendered string from a wrapper and nothing else', () => { - expect(thrownRendering({ __wfThrown: 'rendered text' })).toBe('rendered text') - expect(thrownRendering({ __wfThrown: 42 })).toBeUndefined() - expect(thrownRendering({ other: 'x' })).toBeUndefined() - expect(thrownRendering(new Error('plain'))).toBeUndefined() - expect(thrownRendering('string')).toBeUndefined() - expect(thrownRendering(null)).toBeUndefined() - expect(thrownRendering(new Proxy({ __wfThrown: 'forged' }, { getOwnPropertyDescriptor() { throw new Error('trap ran') } }))).toBeUndefined() + it('is total: a value whose accessors/toString throw renders as a fixed label', () => { + expect(renderThrown({ get stack() { throw new Error('nope') } })).toBe('[unrenderable thrown value]') + expect(renderThrown({ [Symbol.toPrimitive]() { throw new Error('nope') } })).toBe('[unrenderable thrown value]') }) }) diff --git a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts index d87d571295..e362ed6ef0 100644 --- a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts +++ b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts @@ -289,22 +289,13 @@ describe('dsh-workflow-vm', () => { () => agent('fine'), () => 'plain value', () => { throw 'string throw' }, - () => { throw new Proxy({ name: 'WorkflowError', fatal: true }, {}) }, - () => { throw { name: 'WorkflowError', fatal: 'forged-but-not-true' } }, + () => { throw { name: 'WorkflowError', fatal: true, message: 'forged fatal' } }, ]) `)) - // The last three probe the fatal-clone recognition: a non-object, a - // proxy (never inspected), and a shape miss are all ordinary nulls. - expect(result.value).toEqual([null, 'stub reply', 'plain value', null, null, null]) - }) - - it('a script forging a fatal clone kills only its own run (self-sabotage, not a bypass)', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script(` - return await parallel([() => { throw { name: 'WorkflowError', fatal: true, message: 'forged fatal' } }]) - `)) - expect(result.stopReason).toBe('error') - expect(result.error).toContain('forged fatal') + // The last entry probes fatality: it is recognized by host instanceof, + // which a script-built object can never pass — a WorkflowError-SHAPED + // throw is an ordinary null, and real fatality cannot be forged. + expect(result.value).toEqual([null, 'stub reply', 'plain value', null, null]) }) it('FATAL errors propagate through parallel AND pipeline instead of dissolving into null', async () => { @@ -386,11 +377,12 @@ describe('dsh-workflow-vm', () => { expect((await run(ctx, parent, script("return await agent('p', { effort: 'high' })"))).error).toContain('"effort" is deferred') }) - it('rejects options that are not plain JSON data (an accessor smuggled into opts)', async () => { + it('rejects options whose property reads throw (materialization is loud, not silent)', async () => { const { ctx, parent } = await setup() - const result = await run(ctx, parent, script("return await agent('p', { get label() { return 'x' } })")) + const result = await run(ctx, parent, script("return await agent('p', { get label() { throw new Error('read failed') } })")) expect(result.stopReason).toBe('error') expect(result.error).toContain('options must be plain JSON data') + expect(result.error).toContain('read failed') }) it('validates phase() and log() arguments loudly', async () => { @@ -416,7 +408,7 @@ describe('dsh-workflow-vm', () => { }) }) - describe('determinism bans and realm isolation', () => { + describe('determinism bans and the value boundary', () => { it('Date.now, Math.random, and argless new Date throw; parameterized Date stays usable', async () => { const { ctx, parent } = await setup() expect((await run(ctx, parent, script('return Date.now()'))).error).toContain('Date.now() is not available') @@ -426,18 +418,16 @@ describe('dsh-workflow-vm', () => { expect(ok.value).toBe(0) }) - it('args cross into the realm as data: mutating them (or their prototype chain) cannot reach host intrinsics', async () => { + it('args are cloned at start: a script scribbling on them cannot mutate the caller\'s object', async () => { const { ctx, parent } = await setup() const hostArgs = { files: ['a.ts'], nested: { deep: [1, 2] } } const result = await run(ctx, parent, script(` args.files.push('b.ts') - Object.getPrototypeOf(args).polluted = 'realm-only' return { count: args.files.length, deep: args.nested.deep[1] } `), hostArgs) expect(result.value).toEqual({ count: 2, deep: 2 }) - // The host copy is untouched, and the HOST Object.prototype was never reachable. + // The caller's object is untouched (the engine cloned args host-side). expect(hostArgs.files).toEqual(['a.ts']) - expect(({} as Record).polluted).toBeUndefined() }) it('scalar/null args pass through directly; absent args leave the global undefined', async () => { @@ -447,51 +437,24 @@ describe('dsh-workflow-vm', () => { expect((await run(ctx, parent, script('return typeof args'))).value).toBe('undefined') }) - it('hook promises are REALM promises: instanceof holds in-script, host Promise.prototype stays unreachable', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script(` - const p = agent('x') - const par = parallel([() => 'v']) - const pipe = pipeline([1], (n) => n) - Object.getPrototypeOf(p).wfLeakProbe = 'realm-only' - return { - agentIsRealmPromise: p instanceof Promise, - parallelIsRealmPromise: par instanceof Promise, - pipelineIsRealmPromise: pipe instanceof Promise, - value: await p, - } - `)) - expect(result.stopReason).toBe('completed') - expect(result.value).toEqual({ - agentIsRealmPromise: true, - parallelIsRealmPromise: true, - pipelineIsRealmPromise: true, - value: 'stub reply', - }) - expect((Promise.prototype as unknown as Record).wfLeakProbe).toBeUndefined() - delete (Promise.prototype as unknown as Record).wfLeakProbe - }) - - it('hook failures cross the boundary as realm-built WorkflowError clones', async () => { + it('hook failures reach the script as HOST WorkflowErrors: fields readable, in-realm instanceof Error is false', async () => { const { ctx, parent } = await setup() const result = await run(ctx, parent, script(` try { await agent('p', { bogus: true }) return 'unreachable' } catch (e) { - Object.getPrototypeOf(Object.getPrototypeOf(e)).wfErrLeakProbe = 'realm-only' + // The documented consequence of the trust premise: hook errors are + // host objects, so realm instanceof is false — read the fields. return { isRealmError: e instanceof Error, name: e.name, code: e.code, fatal: e.fatal, message: e.message } } `)) expect(result.stopReason).toBe('completed') - expect(result.value).toMatchObject({ isRealmError: true, name: 'WorkflowError', code: 'UNSUPPORTED_OPTION', fatal: true }) + expect(result.value).toMatchObject({ isRealmError: false, name: 'WorkflowError', code: 'UNSUPPORTED_OPTION', fatal: true }) expect((result.value as { message: string }).message).toContain('"bogus" is not recognized') - // The script mutated its error's prototype CHAIN — host intrinsics untouched. - expect((Object.prototype as unknown as Record).wfErrLeakProbe).toBeUndefined() - expect((Error.prototype as unknown as Record).wfErrLeakProbe).toBeUndefined() }) - it('a non-WorkflowError host failure (a rejecting provider result) crosses as a generic realm clone', async () => { + it('a non-WorkflowError host failure (a rejecting provider result) reaches the script raw', async () => { const ctx = new Context() await ctx.plugin(SubagentService) const provider: SubagentProvider = { @@ -507,68 +470,34 @@ describe('dsh-workflow-vm', () => { ctx.subagents.registerProvider(provider) await ctx.plugin(VmWorkflowEngine, { provider: 'rejecting' }) const result = await run(ctx, fakeParent(), script(` - try { await agent('p'); return 'unreachable' } catch (e) { return { isRealmError: e instanceof Error, name: e.name, message: e.message } } + try { await agent('p'); return 'unreachable' } catch (e) { return { name: e.name, message: e.message } } `)) - expect(result.value).toMatchObject({ isRealmError: true, name: 'Error' }) + expect(result.value).toMatchObject({ name: 'Error' }) expect((result.value as { message: string }).message).toContain('backend exploded') }) - it('phase()/log() synchronous throws cross as realm clones too', async () => { + it('phase()/log() throw host WorkflowErrors synchronously on misuse', async () => { const { ctx, parent } = await setup() const result = await run(ctx, parent, script(` try { phase(3) } catch (e) { - if (!(e instanceof Error) || e.name !== 'WorkflowError') throw e + if (e.name !== 'WorkflowError') throw e } try { log(3) } catch (e) { - return { isRealmError: e instanceof Error, name: e.name, message: e.message } + return { name: e.name, message: e.message } } `)) - expect(result.value).toMatchObject({ isRealmError: true, name: 'WorkflowError' }) + expect(result.value).toMatchObject({ name: 'WorkflowError' }) expect((result.value as { message: string }).message).toContain('log() requires') }) - it('parallel/pipeline resolve to REALM arrays: instanceof holds in-script, host intrinsics stay unreachable', async () => { + it('a returned value whose property reads throw fails loud as RESULT_UNSERIALIZABLE', async () => { const { ctx, parent } = await setup() const result = await run(ctx, parent, script(` - const fromParallel = await parallel([() => agent('a'), () => 'plain']) - const fromPipeline = await pipeline([1], (prev) => prev + 1) - Object.getPrototypeOf(fromParallel).polluted = 'realm-only' - return { - parallelIsRealmArray: fromParallel instanceof Array, - pipelineIsRealmArray: fromPipeline instanceof Array, - values: [fromParallel[1], fromPipeline[0]], - } - `)) - expect(result.stopReason).toBe('completed') - expect(result.value).toEqual({ - parallelIsRealmArray: true, - pipelineIsRealmArray: true, - values: ['plain', 2], - }) - // The script's prototype mutation stayed realm-side: the HOST - // Array.prototype was never reachable through a combinator result. - expect(([] as unknown as Record).polluted).toBeUndefined() - }) - - it('a returned proxy is rejected as RESULT_UNSERIALIZABLE without running its traps', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script(` - return new Proxy({ a: 1 }, { ownKeys() { throw new Error('trap ran') } }) + return { get a() { throw new Error('read failed') } } `)) expect(result.stopReason).toBe('error') expect(result.error).toContain('not plain JSON data') - expect(result.error).toContain('proxies cannot cross') - expect(result.error).not.toContain('trap ran') - }) - - it('agent() options passed as a proxy are rejected loudly, traps never invoked', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script(` - return await agent('p', new Proxy({}, { ownKeys() { throw new Error('trap ran') } })) - `)) - expect(result.stopReason).toBe('error') - expect(result.error).toContain('options must be plain JSON data') - expect(result.error).not.toContain('trap ran') + expect(result.error).toContain('read failed') }) it('a non-JSON return value fails loud as RESULT_UNSERIALIZABLE', async () => { @@ -696,60 +625,6 @@ describe('dsh-workflow-vm', () => { expect(result.error).toBe('[object Object]') }) - it('hostile thrown values render realm-side: result NEVER rejects, no unhandled rejection', async () => { - const unhandled: unknown[] = [] - const onUnhandled = (reason: unknown): void => { unhandled.push(reason) } - process.on('unhandledRejection', onUnhandled) - try { - const { ctx, parent } = await setup() - // Each thrown value runs code (or throws) when rendered — the realm - // wrapper renders it INSIDE script execution, and the host catch only - // ever descriptor-reads the pre-rendered string. - const cases: [string, string][] = [ - ["throw { get stack() { throw new Error('stack getter threw') } }", '[object Object]'], - ["throw { get stack() { throw new Error('x') }, message: 'getter threw, message renders' }", 'getter threw, message renders'], - ["throw { get message() { throw new Error('message getter threw') } }", '[object Object]'], - ["throw { stack: 'custom data stack' }", 'custom data stack'], - ["throw (() => { const o = { message: 'setter-only stack' }; Object.defineProperty(o, 'stack', { set() {} }); return o })()", 'setter-only stack'], - ["throw new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('gopd trap threw') } })", '[object Object]'], - ["throw { [Symbol.toPrimitive]() { throw new Error('toPrimitive threw') } }", '[unrenderable thrown value]'], - ['throw () => 1', '() => 1'], - ['throw null', 'null'], - ] - for (const [body, rendered] of cases) { - const result = await run(ctx, parent, script(body)) - expect(result.stopReason).toBe('error') - expect(result.error).toBe(rendered) - } - // Let any stray rejection reach the process hook before asserting. - await new Promise(resolve => setTimeout(resolve, 20)) - expect(unhandled).toEqual([]) - } finally { - process.off('unhandledRejection', onUnhandled) - } - }) - - it('a synchronous spin hidden in a thrown stack getter dies by the vm timeout, not on the host', async () => { - const { ctx, parent } = await setup({ config: { provider: 'stub', syncTimeoutMs: 50 } }) - // The realm-side renderer reads e.stack INSIDE the timed sync slice, so - // the spin is killed exactly like a plain `while (true) {}` body. - const result = await run(ctx, parent, script('throw { get stack() { while (true) {} } }')) - expect(result.stopReason).toBe('error') - expect(result.error?.toLowerCase()).toContain('timed out') - }) - - it('a hostile thenable rejection that bypasses the realm wrapper renders host-side, data-only', async () => { - const { ctx, parent } = await setup() - // Returning a thenable makes the host unwrap it AFTER the script - // settled — its rejection value skips the realm catch entirely and hits - // drive()'s catch raw. The proxy must be labelled, its traps never run. - const result = await run(ctx, parent, script(` - return { then(_resolve, reject) { reject(new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap ran') } })) } } - `)) - expect(result.stopReason).toBe('error') - expect(result.error).toBe('[thrown proxy]') - }) - it('falls back to the message for an Error whose stack was stripped', async () => { const { ctx, parent } = await setup() const result = await run(ctx, parent, script(` @@ -803,18 +678,43 @@ describe('dsh-workflow-vm', () => { } }) - it('dispose() abandons a stuck script after the grace instead of hanging (result stays pending)', async () => { + it('cancel() force-settles the result of a script parked on a promise no hook owns', async () => { + const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } }) + const handle = ctx.workflows.start({ + // No hooks involved: an unsettleable await cancellation cannot reject + // — the abandon grace is the only thing that can settle this run. + script: script("await new Promise(() => {})\nreturn 'unreachable'"), + parent, + }) + handle.cancel('user aborted') + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + expect(result.error).toContain('user aborted') + await handle.dispose() + }) + + it('a never-settling returned thenable is abandoned the same way', async () => { + const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } }) + const handle = ctx.workflows.start({ script: script('return { then() {} }'), parent }) + handle.cancel() + expect((await handle.result).stopReason).toBe('cancelled') + await handle.dispose() + }) + + it('dispose() abandons a stuck script after the grace instead of hanging (result settles cancelled)', async () => { const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } }) const handle = ctx.workflows.start({ - // No hooks involved: an unsettleable await the engine cannot reject. script: script("await new Promise(() => {})\nreturn 'unreachable'"), parent, }) const before = Date.now() await handle.dispose() expect(Date.now() - before).toBeLessThan(1000) - const settled = await Promise.race([handle.result.then(() => 'settled'), Promise.resolve('pending')]) - expect(settled).toBe('pending') + // The abandon that freed dispose() also settled result — a consumer + // still awaiting it (the tool does, before its disposing finally) is + // released rather than wedged forever. + const result = await handle.result + expect(result.stopReason).toBe('cancelled') }) it('dispose() is idempotent and settles cleanly after a completed run', async () => { diff --git a/packages/workflow/workflow/README.md b/packages/workflow/workflow/README.md index 46efa2f2d0..076f5443ac 100644 --- a/packages/workflow/workflow/README.md +++ b/packages/workflow/workflow/README.md @@ -4,7 +4,7 @@ The **workflow seam** (`ctx.workflows`): an abstract service defining WHAT a wor ## Service: `WorkflowService` (abstract) -`start(request: WorkflowStartRequest): WorkflowRun` — parse and execute a script. Throws synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot begin; once a run is returned, its `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`). `dispose()` must reach quiescence within a bounded grace (cancel → wait for the script to settle and its children to finish disposing → abandon), never hanging its caller. +`start(request: WorkflowStartRequest): WorkflowRun` — parse and execute a script. Throws synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot begin; once a run is returned, its `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`) — and once the run is cancelled, `result` settles within the implementation's bounded grace even if the script itself never settles (a consumer awaiting `result` must never be wedged past a cancellation). `dispose()` must reach quiescence within a bounded grace (cancel → wait for the script to settle and its children to finish disposing → abandon), never hanging its caller. The protected `emitWorkflowEvent` helper dispatches the `workflow/*` events with PER-LISTENER containment and PER-LISTENER payload snapshots (a throwing subscriber is logged, never propagated, and cannot starve later listeners; each subscriber gets its own clone of the payload, so mutating it corrupts neither the engine nor other listeners) — the same containment guarantee as the subagent seam's lifecycle emits. diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index 5f340e2f93..83b1560563 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -127,8 +127,8 @@ export type WorkflowEventName = * subset (see dsh-tools). * - `AGENT_CAP` / `ITEM_CAP` — the run/agent caps tripped. * - `AGENT_START` — the subagent seam refused to start a child. - * - `RESULT_UNSERIALIZABLE` — a value crossing the realm boundary is not - * plain JSON data. + * - `RESULT_UNSERIALIZABLE` — a value crossing the script/host value boundary + * is not plain JSON data. * - `CANCELLED` — the run was cancelled; pending and future hooks reject * with this (the script-kill mechanism). */ @@ -179,7 +179,10 @@ export function isFatalWorkflowError(error: unknown): boolean { * - {@link start} throws synchronously for a request that cannot begin (an * unparseable script, an invalid meta block). Once it returns a * {@link WorkflowRun}, `result` NEVER rejects — every failure resolves with - * `stopReason: 'error'` (or `'cancelled'`). + * `stopReason: 'error'` (or `'cancelled'`) — and once the run is cancelled, + * `result` SETTLES within the implementation's bounded grace even if the + * script itself never settles (a consumer awaiting `result` must never be + * wedged past a cancellation). * - The `workflow/*` events fire through {@link emitWorkflowEvent} (data * snapshots, per-listener containment); `workflow/end` fires exactly once * per started run, after `result` is settled or as it settles. diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index 32377ee46b..01e5d08bf0 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -85,7 +85,7 @@ export interface WorkflowResult { stopReason: WorkflowStopReason /** The failure message (present iff `stopReason` is not `completed`). */ error?: string - /** How many `agent()` calls the run started (across its whole lifetime). */ + /** How many `agent()` calls the run accepted (whole lifetime, including calls still queued for a slot when the run was cancelled). */ agentsStarted: number } @@ -93,17 +93,19 @@ export interface WorkflowResult { * The handle the consumer holds while a script executes. The consumer awaits * `result`, may `cancel` mid-flight, and MUST `dispose` on every path. * `result` does NOT reject — a script failure resolves with `stopReason: - * 'error'` — so the consumer maps a non-`completed` reason to an `isError` - * result. `dispose()` cancels, then waits a bounded grace for the script to - * settle before abandoning it (the engine documents the abandonment - * semantics); it never hangs on a stuck script. + * 'error'` — and once the run is cancelled it SETTLES within the engine's + * bounded grace even if the script itself never settles (the engine abandons + * the script and reports `cancelled`), so a consumer awaiting `result` is + * never wedged past a cancellation. `dispose()` = cancel + that bounded + * settle + child quiescence; it never hangs on a stuck script and is safe to + * call on every path (idempotent). */ export interface WorkflowRun { readonly id: WorkflowRunId /** The validated meta block (available before the body runs). */ readonly meta: WorkflowMeta readonly result: Promise - /** Cancel the run: children abort, pending hooks reject, the script dies at its next await. */ + /** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is abandoned at the grace). */ cancel(reason?: string): void /** Cancel + bounded-grace settle; safe to call on every path (idempotent). */ dispose(): Promise @@ -149,6 +151,6 @@ export interface WorkflowResultInfo { stopReason: WorkflowStopReason /** The failure message (present iff `stopReason` is not `completed`). */ error?: string - /** How many `agent()` calls the run started. */ + /** How many `agent()` calls the run accepted (see {@link WorkflowResult.agentsStarted}). */ agentsStarted: number } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7877dc9fad..093e982930 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1060,6 +1060,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -1069,6 +1072,9 @@ importers: '@deepseek-ai/dsh-workflow': specifier: workspace:^ version: link:../workflow + '@deepseek-ai/dsh-workflow-vm': + specifier: workspace:^ + version: link:../workflow-vm cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) From ed3972a9c6dad73404100e204fcc191957503341 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:51:04 +0800 Subject: [PATCH 09/57] workflow: linear meta-prefix scan (the regex backtracked exponentially) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding, measured: the leading-trivia prefix regex (`^\s*(?:comment|comment|\s+)*export …`) partitions a whitespace run ambiguously between its outer `\s*` and the starred `\s+` alternative, so a script that ultimately FAILS the match backtracks exponentially — ~19 ms at 35 leading whitespace characters, ~174 ms at 38, ×2.2 per character; a realistic near-miss (a comment header, blank indented lines, then `const meta` missing its `export`) did not finish in 10 seconds. The regex ran on the HOST stack inside the synchronous `start()`, where no vm timeout applies and no abort can interleave — a benign one-token typo, exactly what SCRIPT_PARSE exists to bounce back to the model, hung the whole process instead of reaching that designed recovery. Replaced with a hand-rolled linear trivia scan (whitespace + `//` and `/* */` comments — the module already scans characters for the literal) followed by an anchored `^export\s+const\s+meta\s*=\s*` on the remainder, whose quantifiers cannot backtrack ambiguously. An unterminated block comment before the statement now gets its own SCRIPT_PARSE message. Regressions: the near-miss shape must reject in under a second (the old regex would trip the suite timeout), plus the unterminated-leading-comment and comment-to-EOF edges. --- packages/workflow/workflow-vm/src/meta.ts | 45 +++++++++++++++++-- .../workflow/workflow-vm/tests/meta.spec.ts | 21 +++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/workflow/workflow-vm/src/meta.ts b/packages/workflow/workflow-vm/src/meta.ts index cfe3e4d381..75958ba6f1 100644 --- a/packages/workflow/workflow-vm/src/meta.ts +++ b/packages/workflow/workflow-vm/src/meta.ts @@ -29,8 +29,6 @@ export interface ExtractedScript { body: string } -const META_PREFIX = /^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*|\s+)*export\s+const\s+meta\s*=\s*/ - /** * Scan `source` from `start` (an opening `{`) to its matching `}`, aware of * string literals (`'`/`"`/backtick, with escapes) and comments. Returns the @@ -145,6 +143,44 @@ function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: st } } +/** `export const meta =`, anchored AFTER {@link skipLeadingTrivia} — its quantifiers cannot backtrack ambiguously. */ +const META_HEAD = /^export\s+const\s+meta\s*=\s*/ + +/** + * Index just past the leading trivia: whitespace and `//` / `/*`-style + * comments. A hand-rolled character scan, NOT a prefix regex — an + * all-alternation prefix (`\s*(?:comment|\s+)*`) partitions a whitespace run + * ambiguously and backtracks EXPONENTIALLY when the match ultimately fails, + * so a near-miss script (a comment header, then a forgotten `export`) would + * spin the host synchronously inside `start()`, where no vm timeout applies. + * The near-miss must fail fast into `SCRIPT_PARSE` instead — that error is + * the model's retry signal. + */ +function skipLeadingTrivia(source: string): number { + let index = 0 + while (index < source.length) { + const ch = source.charAt(index) + if (/\s/.test(ch)) { + index += 1 + continue + } + if (ch === '/' && source[index + 1] === '/') { + const end = source.indexOf('\n', index) + if (end === -1) return source.length + index = end + 1 + continue + } + if (ch === '/' && source[index + 1] === '*') { + const end = source.indexOf('*/', index + 2) + if (end === -1) throw new WorkflowError('script has an unterminated comment before the meta block', 'SCRIPT_PARSE') + index = end + 2 + continue + } + break + } + return index +} + /** * Extract and validate the leading `export const meta = {...}` statement. * Throws {@link WorkflowError} — `SCRIPT_PARSE` when the statement is missing @@ -155,11 +191,12 @@ function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: st * @returns the validated meta and the line-preservingly blanked body. */ export function extractMeta(script: string, evalTimeoutMs: number): ExtractedScript { - const match = META_PREFIX.exec(script) + const triviaEnd = skipLeadingTrivia(script) + const match = META_HEAD.exec(script.slice(triviaEnd)) if (!match) { throw new WorkflowError('script must begin with `export const meta = {...}` (leading comments allowed)', 'SCRIPT_PARSE') } - const literalStart = match[0].length + const literalStart = triviaEnd + match[0].length if (script[literalStart] !== '{') { throw new WorkflowError('`export const meta =` must be followed by an object literal', 'SCRIPT_PARSE') } diff --git a/packages/workflow/workflow-vm/tests/meta.spec.ts b/packages/workflow/workflow-vm/tests/meta.spec.ts index 02daa02940..a1c622e3fc 100644 --- a/packages/workflow/workflow-vm/tests/meta.spec.ts +++ b/packages/workflow/workflow-vm/tests/meta.spec.ts @@ -81,6 +81,27 @@ return 2` expect(bad('export const meta = [1]').code).toBe('SCRIPT_PARSE') }) + it('a near-miss prefix (comment header + whitespace, then no `export`) fails FAST as SCRIPT_PARSE', () => { + // Regression: the previous all-alternation prefix regex backtracked + // exponentially on exactly this shape (~×2 per extra whitespace char once + // the match fails), spinning the host synchronously inside start(). The + // linear trivia scan must reject it in effectively zero time. + const nearMiss = `// deep-audit workflow: reviews every route handler\n${' \n'.repeat(40)}/* second header block */\n${' '.repeat(200)}\nconst meta = { name: 'x', description: 'y' }\n` + const started = Date.now() + expect(bad(nearMiss).code).toBe('SCRIPT_PARSE') + expect(Date.now() - started).toBeLessThan(1000) + }) + + it('an unterminated block comment BEFORE the meta statement is SCRIPT_PARSE', () => { + const error = bad('/* never closed\nexport const meta = { name: "x", description: "y" }') + expect(error.code).toBe('SCRIPT_PARSE') + expect(error.message).toContain('unterminated comment') + }) + + it('a line comment running to EOF leaves no meta statement (SCRIPT_PARSE)', () => { + expect(bad('// only a comment, no newline').code).toBe('SCRIPT_PARSE') + }) + it('rejects template interpolation in the meta block as impure (SCRIPT_PARSE)', () => { const error = bad('export const meta = { name: `w-${1}`, description: "d" }\nreturn 1') expect(error.code).toBe('SCRIPT_PARSE') From 8cb5e1554400fd1103ec900cb9531bc339e09266 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:53:17 +0800 Subject: [PATCH 10/57] chore: undo the knip.json reformat noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review nit: the feature commit exploded every single-line array in knip.json to multi-line, burying the one semantic change (the workflow-vm workspace entry) under ~110 lines of mechanical reformat. Restore the file to master's formatting with only that entry added — the diff against master is now the 4 lines that mean something. --- knip.json | 165 ++++++++++++++---------------------------------------- 1 file changed, 41 insertions(+), 124 deletions(-) diff --git a/knip.json b/knip.json index 39fb4c414f..938164d37d 100644 --- a/knip.json +++ b/knip.json @@ -1,11 +1,7 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", - "exclude": [ - "duplicates" - ], - "ignoreWorkspaces": [ - "vendor/*" - ], + "exclude": ["duplicates"], + "ignoreWorkspaces": ["vendor/*"], "workspaces": { ".": { "entry": [ @@ -15,138 +11,59 @@ "examples/acp-agent/tests/**/*.e2e.ts", "examples/acp-agent/tests/**/*.snapshot.ts" ], - "project": [ - "scripts/**/*.ts", - "examples/**/*.ts" - ] + "project": ["scripts/**/*.ts", "examples/**/*.ts"] }, "packages/*/*": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] + "entry": ["tests/**/*.spec.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/util/brand": { - "project": [ - "src/**/*.ts" - ], - "ignoreDependencies": [ - "cordis" - ] + "project": ["src/**/*.ts"], + "ignoreDependencies": ["cordis"] }, "packages/llm/llm-deepseek": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/llm/llm-pi-ai": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/web/web-search-exa": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/web/web-search-perplexity": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/web/web-search-deepseek": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/ui/acp-agent": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/ui/stdio-agent": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/subagent/subagent-spawn": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/subagent/subagent-acp": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts", - "tests/mock-acp-server.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/fs/tool-fs": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/workflow/workflow-vm": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/web/web-search-deepseek": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/ui/acp-agent": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/ui/stdio-agent": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/subagent/subagent-spawn": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/subagent/subagent-acp": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/mock-acp-server.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/fs/tool-fs": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] } } } From 2ba4964aba52c99446d32c019e8b3bb7b97278f8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:54:21 +0800 Subject: [PATCH 11/57] docs: regenerate the services catalog for the seam-contract JSDoc --- docs/cordis-catalog/services.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 88559d658f..0c219b51b5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -233,7 +233,7 @@ Abstract workflow execution service. Subclass, implement start, and load the sub Semantics every implementation must honor: -- start throws synchronously for a request that cannot begin (an unparseable script, an invalid meta block). Once it returns a WorkflowRun, `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`). +- start throws synchronously for a request that cannot begin (an unparseable script, an invalid meta block). Once it returns a WorkflowRun, `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`) — and once the run is cancelled, `result` SETTLES within the implementation's bounded grace even if the script itself never settles (a consumer awaiting `result` must never be wedged past a cancellation). - The `workflow/*` events fire through emitWorkflowEvent (data snapshots, per-listener containment); `workflow/end` fires exactly once per started run, after `result` is settled or as it settles. - `dispose()` reaches quiescence within a bounded grace: it cancels, waits for the script to settle AND its started children to finish disposing, and abandons whatever is left rather than hanging its caller (the engine documents what abandonment leaves behind). @@ -241,7 +241,7 @@ Semantics every implementation must honor: abstract start(request: WorkflowStartRequest): WorkflowRun ``` -Source: [`packages/workflow/workflow/src/index.ts:191`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:194`](../../packages/workflow/workflow/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) From 706691a7dff8262f640683f74dbd68d6b9ccdeeb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:39:01 +0800 Subject: [PATCH 12/57] workflow: re-check cancellation after the slot acquire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex convergence round 1 on the review-response commits: agent()'s post-acquire window was real and unguarded. `await acquireSlot()` yields at least one microtask tick even when a slot is free (and a queued waiter resumes a tick after its release), so a cancel() landing in that tick let the continuation start a child carrying an ALREADY-aborted signal — the in-code comment claimed the window could not exist. A provider that subscribes only to future abort events (the test stub; the seam does not promise pre-aborted-signal handling) would never settle such a child, leaking it until the dispose grace abandoned the run, and a backend that misses the pre-aborted signal would burn a full model turn after the user cancelled. agent() now re-checks isCancelled() immediately after the acquire (inside the slot-owning try, so the finally still releases), making every post-cancel path reject before subagents.start. New deterministic regression: cancel() in the same synchronous frame as start() lands in the free-slot await tick — the run settles cancelled with ZERO children started (previously: one leaked child and a grace-delayed settle). The raced-release test's comment now states what it actually pins (the queued-waiter rejection path). Also aligns the RFC's auto-concurrency formula with the code (min(16, max(1, availableParallelism() - 2))). --- .../feature/2026-07-05-dynamic-workflows.md | 2 +- packages/workflow/workflow-vm/src/runtime.ts | 10 +++++---- .../workflow-vm/tests/workflow-vm.spec.ts | 22 +++++++++++++++++-- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index f39f12f0ed..c7004f5d19 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -30,7 +30,7 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre **Meta extraction**: a string/comment-aware brace scanner (template interpolation rejected) finds the literal; it is evaluated ALONE in an empty, timed vm context; the result must materialize to plain JSON data and pass shape validation (unknown fields rejected loud); the statement is blanked line-preservingly so stacks keep script line numbers. -**Value boundary**: values entering the host (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation; getters are read ordinarily and their RESULT crosses (a throwing read fails loud). Values entering the realm (`args`, `agent()` results, hook promises and failures, combinator arrays) are handed over directly as host values — the script is trusted, so host prototypes are not a leak; `args` is host-`structuredClone`d once so a script cannot mutate the caller's object. Hook failures are host `WorkflowError`s: the combinators recognize fatality by host `instanceof` (unforgeable from the realm), and the script-visible consequence — in-script `instanceof Error` is `false` for hook errors; branch on `e.name`/`e.code` — is documented in the engine README. Realm functions (stages, thunks) are called, never materialized. Thrown script values are rendered by a total host-side renderer (stack → message → `String()`, fixed label if rendering throws), so `result` cannot reject. Caps (`maxConcurrentAgents` auto = `min(16, cores - 2)`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. +**Value boundary**: values entering the host (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation; getters are read ordinarily and their RESULT crosses (a throwing read fails loud). Values entering the realm (`args`, `agent()` results, hook promises and failures, combinator arrays) are handed over directly as host values — the script is trusted, so host prototypes are not a leak; `args` is host-`structuredClone`d once so a script cannot mutate the caller's object. Hook failures are host `WorkflowError`s: the combinators recognize fatality by host `instanceof` (unforgeable from the realm), and the script-visible consequence — in-script `instanceof Error` is `false` for hook errors; branch on `e.name`/`e.code` — is documented in the engine README. Realm functions (stages, thunks) are called, never materialized. Thrown script values are rendered by a total host-side renderer (stack → message → `String()`, fixed label if rendering throws), so `result` cannot reject. Caps (`maxConcurrentAgents` auto = `min(16, max(1, availableParallelism() - 2))`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. ### The consumer (dsh-tool-workflow) diff --git a/packages/workflow/workflow-vm/src/runtime.ts b/packages/workflow/workflow-vm/src/runtime.ts index 7748cb2f2e..af8a30927e 100644 --- a/packages/workflow/workflow-vm/src/runtime.ts +++ b/packages/workflow/workflow-vm/src/runtime.ts @@ -375,10 +375,12 @@ export class WorkflowExecution { await this.acquireSlot() try { - // No cancelled re-check here: a cancel cannot interleave between a - // waiter's resolution and this continuation (single-threaded, no await - // between them), and a child started moments after a cancel still dies - // via the shared abort signal — the CANCELLED mapping below covers it. + // Re-check after the acquire: the await yields at least one microtask + // tick even when a slot is free, and a queued waiter resumes a tick + // after its release — a cancel() landing in either window must not + // start a child (it would carry an ALREADY-aborted signal, which a + // provider subscribing only to future abort events would never see). + if (this.isCancelled()) throw this.cancelledError() let run try { run = this.ctx.subagents.start(this.limits.provider, { diff --git a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts index e362ed6ef0..7c56d245ae 100644 --- a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts +++ b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts @@ -636,6 +636,22 @@ describe('dsh-workflow-vm', () => { expect(result.error).toBe('stackless failure') }) + it('cancel() in the same frame as start(): the awaited slot tick cannot start a child', async () => { + const { ctx, parent, provider } = await setup({ manual: true }) + // agent() enters during start()'s synchronous slice and suspends on the + // acquireSlot await (one microtask tick even with a free slot); the + // synchronous cancel below lands in that tick. Without the post-acquire + // re-check the continuation would start a child carrying an ALREADY- + // aborted signal — which the stub provider (subscribing only to future + // abort events, like a real backend) would never settle, leaking it. + const handle = ctx.workflows.start({ script: script("return await agent('never')"), parent }) + handle.cancel('immediately after start') + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + expect(provider.runs.length).toBe(0) + await handle.dispose() + }) + it('a waiter resumed by a release RACING a cancel still dies at the post-acquire check', async () => { const { ctx, parent, provider } = await setup({ manual: true, config: { provider: 'stub', maxConcurrentAgents: 1 } }) const handle = ctx.workflows.start({ @@ -643,8 +659,10 @@ describe('dsh-workflow-vm', () => { parent, }) await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) - // Same synchronous block: the release resolves b's waiter, then the - // cancel lands BEFORE b's continuation runs — b must not start a child. + // Same synchronous block: b is still a QUEUED waiter when the cancel + // lands, so cancel() rejects it outright; together with the immediate- + // cancel test above (the resumed-waiter tick), no post-cancel path can + // reach subagents.start. provider.runs[0]!.settle(text('a-done')) handle.cancel('raced') const result = await handle.result From 80250a8f2e4fd7c03bcb7ea95f5c2efa929b9c7f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:53:44 +0800 Subject: [PATCH 13/57] workflow: drop the dead abandon-timer guard in drive()'s finally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-file branch gate caught it: drive()'s finally always cancels first, and every first cancel() arms the abandon timer, so the `!== undefined` guard's false arm was unreachable. clearTimeout tolerates undefined by contract — call it unguarded. --- packages/workflow/workflow-vm/src/runtime.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/workflow/workflow-vm/src/runtime.ts b/packages/workflow/workflow-vm/src/runtime.ts index af8a30927e..b2a117fdcc 100644 --- a/packages/workflow/workflow-vm/src/runtime.ts +++ b/packages/workflow/workflow-vm/src/runtime.ts @@ -265,8 +265,9 @@ export class WorkflowExecution { // their rejections from going unhandled.) if (this.cancelReason === undefined) this.cancel('workflow settled') // drive() settling means nothing is left to abandon — including the - // timer the self-cancel above just armed. - if (this.abandonTimer !== undefined) clearTimeout(this.abandonTimer) + // timer the self-cancel above just armed (cancel() always arms it, so + // it is never undefined here; clearTimeout tolerates undefined anyway). + clearTimeout(this.abandonTimer) } } From e70227d5810a94af2ec084de35c91a6ac86bcf32 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:59:47 +0800 Subject: [PATCH 14/57] =?UTF-8?q?workflow:=20pin=20the=20spec=20engine's?= =?UTF-8?q?=20concurrency=20=E2=80=94=20the=20auto=20default=20is=20one=20?= =?UTF-8?q?slot=20on=20small=20CI=20runners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two tests that wait for two children IN FLIGHT (the pipeline no-barrier test and the dropped-promise cancellation test) inherited the engine's auto-resolved maxConcurrentAgents, which is min(16, max(1, cores - 2)) — exactly 1 on the 2-core CI runner, so the second child never started and vi.waitFor timed out. Reproduced locally under taskset -c 0,1; the full spec passes there with the helper pinning a fixed ceiling. Tests about the ceiling itself keep their explicit overrides, and the auto-resolve arm stays covered by the default-config provider tests. --- packages/workflow/workflow-vm/tests/workflow-vm.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts index a4058303ea..ac037bdb04 100644 --- a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts +++ b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts @@ -97,7 +97,10 @@ async function setup(options?: SetupOptions) { options?.disposeDelayMs ?? 0, ) ctx.subagents.registerProvider(provider) - await ctx.plugin(VmWorkflowEngine, { provider: 'stub', ...options?.config }) + // A fixed concurrency ceiling: the auto-resolved default is machine-derived + // (cores - 2, floored at 1), so tests that expect N children in flight + // would wedge on small CI runners. Tests about the ceiling override it. + await ctx.plugin(VmWorkflowEngine, { provider: 'stub', maxConcurrentAgents: 8, ...options?.config }) return { ctx, provider, parent: fakeParent() } } From db4a39b02454f404c620e8efab06a9e469d4036f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:02:41 +0800 Subject: [PATCH 15/57] workflow: close the review-found cancellation and child-lifecycle gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on the engine's child seam, one mechanism each: - Cancellation now bridges to run.cancel() on every in-flight child, not just the shared request signal — the subagent seam leaves a provider free to honor either channel, so the consumer drives both (listener removed in the child finally). - A child result REJECTION (an infrastructure fault the seam allows) now emits the paired workflow/agent-end before propagating, and propagates as a fatal WorkflowError with the new AGENT_RESULT code — previously it skipped agent-end (permanently open child for seq-matching observers) and dissolved to a per-item null inside parallel()/pipeline(), letting a broken provider read as an ordinary failed child. A rejection landing after cancel stays a cancellation (cancelled outcome + CANCELLED). - Every hook now guards its entry with a shared throwIfCancelled(): phase()/log() no longer emit observer events after a script caught an earlier cancelled rejection, and parallel()/pipeline() refuse entry — cancellation is the next HOOK boundary, not just the next agent(). --- docs/cordis-catalog/services.md | 2 +- packages/workflow/workflow-vm/src/runtime.ts | 53 +++++++-- .../workflow-vm/tests/workflow-vm.spec.ts | 102 +++++++++++++++++- packages/workflow/workflow/src/index.ts | 4 + 4 files changed, 148 insertions(+), 13 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f1a61e2ecd..772a70f035 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -242,7 +242,7 @@ Semantics every implementation must honor: abstract start(request: WorkflowStartRequest): WorkflowRun ``` -Source: [`packages/workflow/workflow/src/index.ts:194`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:198`](../../packages/workflow/workflow/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/packages/workflow/workflow-vm/src/runtime.ts b/packages/workflow/workflow-vm/src/runtime.ts index b2a117fdcc..f1901ffaf7 100644 --- a/packages/workflow/workflow-vm/src/runtime.ts +++ b/packages/workflow/workflow-vm/src/runtime.ts @@ -17,10 +17,11 @@ * realm-side until they cross through a hook or the final return. * * Failure discipline: fatal {@link WorkflowError}s (bad hook arguments, - * unsupported options/schemas, tripped caps, seam start failures, - * cancellation) ALWAYS propagate through `parallel`/`pipeline` — recognized - * by host `instanceof`, which a script cannot forge — and the per-item `null` - * is reserved for child-run failures and ordinary in-stage script errors. + * unsupported options/schemas, tripped caps, seam start failures and result + * rejections, cancellation) ALWAYS propagate through `parallel`/`pipeline` — + * recognized by host `instanceof`, which a script cannot forge — and the + * per-item `null` is reserved for child-run failures and ordinary in-stage + * script errors. * Every hook-returned promise gets a no-op rejection consumer attached, so a * script that drops a promise (fires an `agent()` without awaiting it) cannot * surface an unhandled rejection when cancellation rejects it — the app boot @@ -206,6 +207,17 @@ export class WorkflowExecution { return this.cancelReason !== undefined } + /** + * Shared hook entry guard: after {@link cancel}, EVERY hook throws + * `CANCELLED` at its next call — cancellation is the next HOOK boundary, + * not just the next `agent()`, so a script that caught one cancelled + * rejection cannot keep emitting progress through `phase`/`log` or enter a + * combinator. + */ + private throwIfCancelled(): void { + if (this.isCancelled()) throw this.cancelledError() + } + /** * Cancel the run: children abort (the shared signal), waiting `agent()` * slots reject, and every future hook call throws `CANCELLED` — the script @@ -358,7 +370,7 @@ export class WorkflowExecution { /** The `agent(prompt, opts)` hook. */ private async agent(rawPrompt: unknown, rawOpts: unknown): Promise { - if (this.isCancelled()) throw this.cancelledError() + this.throwIfCancelled() if (typeof rawPrompt !== 'string' || rawPrompt.length === 0) { throw new WorkflowError('agent() requires a non-empty prompt string', 'INVALID_ARGUMENT') } @@ -381,7 +393,7 @@ export class WorkflowExecution { // after its release — a cancel() landing in either window must not // start a child (it would carry an ALREADY-aborted signal, which a // provider subscribing only to future abort events would never see). - if (this.isCancelled()) throw this.cancelledError() + this.throwIfCancelled() let run try { run = this.ctx.subagents.start(this.limits.provider, { @@ -396,8 +408,30 @@ export class WorkflowExecution { } const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: run.id } this.observer.agentStart(info) + // Cancellation bridges to run.cancel() as well as the request signal: + // the seam leaves a provider free to honor either channel, so the + // consumer must drive both. The signal cannot be aborted yet (the block + // since the post-acquire check is synchronous), so the listener always + // arms; `once` plus the finally removal keep it leak-free. + const onAbort = (): void => { run.cancel(this.cancelReason) } + this.controller.signal.addEventListener('abort', onAbort, { once: true }) try { - const result = await run.result + let result + try { + result = await run.result + } catch (error: unknown) { + // The seam allows `result` to reject for an INFRASTRUCTURE fault — + // distinct from a child that failed and resolved. Pair the + // lifecycle before propagating, and propagate FATAL: an ordinary + // throw would dissolve to a per-item null inside the combinators, + // and a broken provider must not read as a failed child. + if (this.isCancelled()) { + this.observer.agentEnd({ ...info, outcome: 'cancelled' }) + throw this.cancelledError() + } + this.observer.agentEnd({ ...info, outcome: 'failed' }) + throw new WorkflowError(`child agent run failed: ${renderThrown(error)}`, 'AGENT_RESULT', { cause: error }) + } if (result.stopReason === 'completed') { if (opts.schema !== undefined) { // The provider honored outputSchema (capability-gated at start), so @@ -421,6 +455,7 @@ export class WorkflowExecution { this.observer.agentEnd({ ...info, outcome: 'failed' }) return null } finally { + this.controller.signal.removeEventListener('abort', onAbort) await run.dispose() } } finally { @@ -476,6 +511,7 @@ export class WorkflowExecution { /** The `parallel(thunks)` hook: each thunk caught → `null`; fatal errors propagate. */ private async parallel(rawThunks: unknown): Promise { + this.throwIfCancelled() if (!Array.isArray(rawThunks)) { throw new WorkflowError('parallel() requires an array of zero-argument functions', 'INVALID_ARGUMENT') } @@ -501,6 +537,7 @@ export class WorkflowExecution { /** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */ private async pipeline(rawItems: unknown, rawStages: unknown[]): Promise { + this.throwIfCancelled() if (!Array.isArray(rawItems)) { throw new WorkflowError('pipeline() requires an items array', 'INVALID_ARGUMENT') } @@ -542,6 +579,7 @@ export class WorkflowExecution { /** The `phase(title)` hook: sets the current label for subsequent `agent()` calls and notifies observers. */ private phase(title: unknown): void { + this.throwIfCancelled() if (typeof title !== 'string' || title.length === 0) { throw new WorkflowError('phase() requires a non-empty title string', 'INVALID_ARGUMENT') } @@ -551,6 +589,7 @@ export class WorkflowExecution { /** The `log(message)` hook: narration to observers. */ private log(message: unknown): void { + this.throwIfCancelled() if (typeof message !== 'string') { throw new WorkflowError('log() requires a message string', 'INVALID_ARGUMENT') } diff --git a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts index ac037bdb04..1dc4833426 100644 --- a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts +++ b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts @@ -459,7 +459,7 @@ describe('dsh-workflow-vm', () => { expect((result.value as { message: string }).message).toContain('"bogus" is not recognized') }) - it('a non-WorkflowError host failure (a rejecting provider result) reaches the script raw', async () => { + it('a rejecting provider result is an infrastructure fault: fatal AGENT_RESULT, agent-end paired, no combinator dissolve', async () => { const ctx = new Context() await ctx.plugin(SubagentService) const provider: SubagentProvider = { @@ -475,11 +475,22 @@ describe('dsh-workflow-vm', () => { } ctx.subagents.registerProvider(provider) await ctx.plugin(VmWorkflowEngine, { provider: 'rejecting' }) - const result = await run(ctx, fakeParent(), script(` - try { await agent('p'); return 'unreachable' } catch (e) { return { name: e.name, message: e.message } } + const ends: unknown[] = [] + ctx.on('workflow/agent-end', (_info, agent) => { ends.push(agent) }) + // Direct await: the script reads the typed fields (a host object, so + // realm instanceof is false — same as every hook failure). + const direct = await run(ctx, fakeParent(), script(` + try { await agent('p'); return 'unreachable' } catch (e) { return { name: e.name, code: e.code, fatal: e.fatal, message: e.message } } `)) - expect(result.value).toMatchObject({ name: 'Error' }) - expect((result.value as { message: string }).message).toContain('backend exploded') + expect(direct.value).toMatchObject({ name: 'WorkflowError', code: 'AGENT_RESULT', fatal: true }) + expect((direct.value as { message: string }).message).toContain('backend exploded') + // The child's lifecycle stays paired even though result never resolved. + expect(ends).toEqual([expect.objectContaining({ seq: 1, outcome: 'failed' })]) + // Through a combinator the fault PROPAGATES (fatal) — a broken provider + // must not dissolve into the per-item null and read as a failed child. + const throughParallel = await run(ctx, fakeParent(), script("return await parallel([() => agent('p')])")) + expect(throughParallel.stopReason).toBe('error') + expect(throughParallel.error).toContain('backend exploded') }) it('phase()/log() throw host WorkflowErrors synchronously on misuse', async () => { @@ -542,6 +553,87 @@ describe('dsh-workflow-vm', () => { await handle.dispose() }) + it('cancellation bridges to run.cancel() on every in-flight child, not just the request signal', async () => { + const { ctx, parent, provider } = await setup({ manual: true }) + const handle = ctx.workflows.start({ + script: script("return await parallel([() => agent('a'), () => agent('b')])"), + parent, + }) + await vi.waitFor(() => { expect(provider.runs.length).toBe(2) }) + handle.cancel('bridged') + expect((await handle.result).stopReason).toBe('cancelled') + // The seam leaves a provider free to honor run.cancel() rather than the + // request signal, so the engine must drive BOTH channels per child. + expect(provider.runs.map(r => r.cancelled)).toEqual(['bridged', 'bridged']) + await handle.dispose() + }) + + it('a provider whose result REJECTS on abort still gets a paired cancelled agent-end, and the run reports cancelled', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + // The seam allows result to reject for infrastructure faults; a backend + // that tears down uncleanly on abort exercises the rejection path WHILE + // the run is cancelled — which must stay a cancellation, not AGENT_RESULT. + const provider: SubagentProvider = { + name: 'reject-on-abort', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true }, + inheritsParentContext: false, + start: request => ({ + id: AgentId('crashing-child'), + result: new Promise((_, reject) => { + request.signal?.addEventListener('abort', () => { reject(new Error('backend crashed on abort')) }, { once: true }) + }), + cancel: () => { /* the signal listener above is the teardown */ }, + dispose: () => Promise.resolve(), + }), + } + ctx.subagents.registerProvider(provider) + await ctx.plugin(VmWorkflowEngine, { provider: 'reject-on-abort' }) + const starts: unknown[] = [] + const ends: unknown[] = [] + ctx.on('workflow/agent-start', (_info, agent) => { starts.push(agent) }) + ctx.on('workflow/agent-end', (_info, agent) => { ends.push(agent) }) + const handle = ctx.workflows.start({ script: script("return await agent('doomed')"), parent: fakeParent() }) + await vi.waitFor(() => { expect(starts.length).toBe(1) }) + handle.cancel('user aborted') + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + expect(result.error).toContain('user aborted') + expect(ends).toEqual([expect.objectContaining({ seq: 1, outcome: 'cancelled' })]) + await handle.dispose() + }) + + it('after cancellation EVERY hook throws at entry — phase/log/parallel/pipeline, not just agent()', async () => { + const { ctx, parent, provider } = await setup({ manual: true }) + let cancelled = false + const postCancel: string[] = [] + ctx.on('workflow/phase', (_info, title) => { if (cancelled) postCancel.push(`phase:${title}`) }) + ctx.on('workflow/log', (_info, message) => { if (cancelled) postCancel.push(`log:${message}`) }) + const handle = ctx.workflows.start({ + // The script survives each throw by catching, so every guarded hook is + // actually ATTEMPTED after the cancel; the run still reports cancelled. + script: script(` + phase('before') + try { await agent('x') } catch (e) {} + try { phase('after') } catch (e) {} + try { log('after') } catch (e) {} + try { await parallel([() => 'ran']) } catch (e) {} + try { await pipeline(['item'], p => p) } catch (e) {} + return 'survived by catching' + `), + parent, + }) + await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) + cancelled = true + handle.cancel('stop everything') + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + // No post-cancel progress ever reached observers, and no child started. + expect(postCancel).toEqual([]) + expect(provider.runs.length).toBe(1) + await handle.dispose() + }) + it('an already-aborted request signal cancels before any child starts', async () => { const { ctx, parent, provider } = await setup({ manual: true }) const controller = new AbortController() diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index 83b1560563..31b9eed038 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -127,6 +127,9 @@ export type WorkflowEventName = * subset (see dsh-tools). * - `AGENT_CAP` / `ITEM_CAP` — the run/agent caps tripped. * - `AGENT_START` — the subagent seam refused to start a child. + * - `AGENT_RESULT` — a child's `result` REJECTED: an infrastructure fault at + * the subagent seam, distinct from a child that failed and resolved (which + * is the per-item `null`, never an error). * - `RESULT_UNSERIALIZABLE` — a value crossing the script/host value boundary * is not plain JSON data. * - `CANCELLED` — the run was cancelled; pending and future hooks reject @@ -141,6 +144,7 @@ export type WorkflowErrorCode = | 'AGENT_CAP' | 'ITEM_CAP' | 'AGENT_START' + | 'AGENT_RESULT' | 'RESULT_UNSERIALIZABLE' | 'CANCELLED' From 3987425547eb302e176d86821cf8bc6d3ed5e094 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:03:40 +0800 Subject: [PATCH 16/57] subagent-inprocess: stop the structured nudge loop once cancellation lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cancel that lands AFTER a child's clean turn end but before the nudge continuation runs clears nothing — child.cancel() only kills queued or running work — so the loop's turn-state check alone let a later child.send() spend a fresh post-cancellation turn (and even capture a structured result the caller had already abandoned). The loop condition now also reads the run's own cancelled flag, re-evaluated after every whenIdle(), through an accessor because closure assignments are invisible to control-flow narrowing (an inline read lints always-true). readResult keeps the outcome honest on this path: a completed-but- uncaptured turn maps to aborted, not error, when a cancel is why the nudging stopped — the cancel contract outranks the schema shortfall. --- .../subagent/subagent-inprocess/src/index.ts | 24 ++++++++++++++----- .../tests/structured.spec.ts | 19 +++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 93983e420c..381f94acba 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -171,6 +171,10 @@ export function startInProcessRun( // `turn/end` is logged — settles as `aborted` (honoring the cancel contract) // rather than falling through to the no-turn `error` mapping. let cancelled = false + // An accessor, not an inline read: `cancelled` mutates from closures (the + // abort listener, run.cancel), which control-flow narrowing cannot see — an + // inline `!cancelled` in the nudge condition reads as always-true. + const isCancelled = (): boolean => cancelled const requestCancel = (reason: string): void => { cancelled = true child.cancel(reason) @@ -191,12 +195,17 @@ export function startInProcessRun( // Nudge loop: a child that finished a turn CLEANLY without calling // structured_output gets re-prompted, up to the backend-configured // retry count. An errored/aborted turn is not nudged — its failure is - // the honest result. (This also covers a cancel: a cancelled turn ends - // `aborted`, and a pre-turn cancel leaves no `turn/end` at all, so - // neither reads `completed`.) + // the honest result (a cancelled turn ends `aborted`, and a pre-turn + // cancel leaves no `turn/end` at all, so neither reads `completed`). + // `!cancelled` closes the remaining window: a cancel landing AFTER a + // clean turn end clears nothing — `child.cancel()` only kills + // queued/running work — so without it the next `send` would spend a + // fresh post-cancellation turn; the condition re-evaluates after + // every `whenIdle()`, so a mid-nudge cancel stops the loop at the + // next boundary too. let nudges = options.structuredNudgeRetries while ( - structured.captured(child) === undefined && nudges > 0 + !isCancelled() && structured.captured(child) === undefined && nudges > 0 && lastOwnTurnEnd(child, seedLength)?.data.reason.kind === 'completed' ) { nudges -= 1 @@ -204,7 +213,7 @@ export function startInProcessRun( await child.whenIdle() } } - return readResult(child, seedLength, cancelled, structured ? { captured: structured.captured(child) } : undefined) + return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured(child) } : undefined) } finally { request.signal?.removeEventListener('abort', onAbort) if (structured) { @@ -266,7 +275,10 @@ function readResult( : toStopReason(lastEnd?.data.reason) if (structured) { if (structured.captured) return { output, structured: structured.captured.value, stopReason } - if (stopReason === 'completed') return { output, stopReason: 'error' } + // No capture on a cleanly-completed turn: an ERROR when the run was left + // to finish (the nudges ran out), but ABORTED when a cancel is why the + // nudging stopped — the cancel contract outranks the schema shortfall. + if (stopReason === 'completed') return { output, stopReason: cancelled ? 'aborted' : 'error' } } return { output, stopReason } } diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index be231f63e4..c8ca489451 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -179,6 +179,25 @@ describe('in-process structured output', () => { await run.dispose() }) + it('a cancel landing after a clean turn end stops the nudge loop: no post-cancellation turn is spent', async () => { + const { ctx, parent, adapter } = await setup([textResponse('prose, no capture')], { nudges: 3 }) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const child = ctx.agents.get(run.id)! + // Cancel synchronously inside the first turn's end recording — after the + // turn reads `completed`, before the nudge continuation resumes. The turn + // state alone cannot see this cancel (`child.cancel()` only clears + // queued/running work), so without the loop's own cancelled check the + // next send would spend a fresh child turn after the caller cancelled. + ctx.on('session/event', (session, event) => { + if (session === child.session && event.type === 'turn/end') run.cancel('cancelled between turn end and nudge') + }) + const result = await run.result + expect(result.stopReason).toBe('aborted') + // Exactly one model request: the nudge turn never ran. + expect(adapter.requests.length).toBe(1) + await run.dispose() + }) + it('rejects a schema outside the subset loud, before any child exists', async () => { const { ctx, parent } = await setup([]) expect(() => ctx.subagents.start('spawn', structuredRequest(parent, { From 9688870da31c74e77d0b34295e10bf468a9d4c6a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:04:20 +0800 Subject: [PATCH 17/57] =?UTF-8?q?docs(workflow):=20admit=20the=20node:vm?= =?UTF-8?q?=20escape=20concretely=20=E2=80=94=20absent=20globals=20are=20s?= =?UTF-8?q?urface,=20not=20containment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review's critical finding: the engine README and the tool description read as if the missing filesystem/network/Node globals were enforced, but a script can reach the host Function constructor via globalThis.constructor.constructor and from it process and every Node builtin. Per the trust premise this is ACCEPTED (model-written scripts, bash-equivalent trust; genuine sandboxing is the deferred engine swap already listed) — but the docs must say so instead of implying a wall. The trust-premise sections (engine README, module doc, RFC) now name the escape and its acceptance; the model-facing tool description says the APIs are not PROVIDED rather than implying they are prevented. --- .../implemented/feature/2026-07-05-dynamic-workflows.md | 2 +- docs/tool-catalog/tools.md | 2 +- packages/workflow/tool-workflow/src/index.ts | 2 +- packages/workflow/workflow-vm/README.md | 4 ++-- packages/workflow/workflow-vm/src/index.ts | 9 ++++++--- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index 7d44ec91db..17c26760be 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -22,7 +22,7 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre ### The engine (dsh-workflow-vm): in-process node:vm -**Trust premise (governs every engine decision below)**: workflow scripts are MODEL-WRITTEN — the same trust level as the model's existing bash access — so the engine defends against BUGGY scripts, never hostile ones. In scope: `result` never rejects, no unhandled rejections from dropped hook promises, loud rejection of values JSON cannot carry, fatal-vs-null hook discipline, cancellation that always frees the caller. Out of scope, deliberately: adversarial values (throwing/spinning accessors, proxies with hostile traps, prototype forgery, `prepareStackTrace` hijack) — host code MAY run script code while reading script values, and that is accepted, because a hostile script can already occupy the event loop forever with a synchronous spin past its first await; containing its error VALUES while conceding it the event loop would be cost without a threat model. Genuine hardening is an engine swap behind the seam (worker/isolated-vm gets value isolation by serialization for free), not incremental host-side defenses. +**Trust premise (governs every engine decision below)**: workflow scripts are MODEL-WRITTEN — the same trust level as the model's existing bash access — so the engine defends against BUGGY scripts, never hostile ones. In scope: `result` never rejects, no unhandled rejections from dropped hook promises, loud rejection of values JSON cannot carry, fatal-vs-null hook discipline, cancellation that always frees the caller. Out of scope, deliberately: adversarial values (throwing/spinning accessors, proxies with hostile traps, prototype forgery, `prepareStackTrace` hijack) AND Node-API escape from the context — the vm context shares object machinery with the host, so a script can reach the host `Function` constructor (`globalThis.constructor.constructor`) and from it `process` and every Node builtin; the absent globals are API surface, not containment. Host code MAY run script code while reading script values, and that is accepted, because a hostile script can already occupy the event loop forever with a synchronous spin past its first await; containing its error VALUES while conceding it the event loop would be cost without a threat model. Genuine hardening is an engine swap behind the seam (worker/isolated-vm gets value isolation by serialization for free), not incremental host-side defenses. **Why node:vm and not isolated-vm/worker threads**: isolated-vm is in maintenance mode, needs `--no-node-snapshot` on EVERY consumer process (including the published bins) on Node ≥ 20, and falls back to node-gyp source builds; a worker-thread engine turns every hook into RPC and complicates the per-file coverage gate. Under the trust premise, in-process is enough. Accepted, documented limitations: `start()` blocks the caller for the script's initial synchronous slice (bounded by the vm timeout); that timeout covers ONLY the initial slice, so a synchronous spin past it (an await continuation, a thenable's `then` invoked by promise resolution — a returned thenable resolves per JavaScript semantics, which is what makes an un-awaited `return agent('x')` work — or script code the host runs while rendering a thrown value) cannot be killed in-process; `dispose()` cancels, waits a bounded grace for the script to settle and its children to finish disposing, then abandons. diff --git a/docs/tool-catalog/tools.md b/docs/tool-catalog/tools.md index 76176dae86..b32c10e31f 100644 --- a/docs/tool-catalog/tools.md +++ b/docs/tool-catalog/tools.md @@ -293,7 +293,7 @@ Script-body hooks: Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. -Constraints: concurrency and total-agent caps apply; `Date.now()`, `Math.random()`, and argless `new Date()` throw (pass timestamps via `args`); no filesystem, network, timers, or Node.js APIs — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. +Constraints: concurrency and total-agent caps apply; `Date.now()`, `Math.random()`, and argless `new Date()` throw (pass timestamps via `args`); no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. ```json { diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index a116866887..4df4d6aba1 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -66,7 +66,7 @@ Script-body hooks: Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item \`null\`. -Constraints: concurrency and total-agent caps apply; \`Date.now()\`, \`Math.random()\`, and argless \`new Date()\` throw (pass timestamps via \`args\`); no filesystem, network, timers, or Node.js APIs — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.` +Constraints: concurrency and total-agent caps apply; \`Date.now()\`, \`Math.random()\`, and argless \`new Date()\` throw (pass timestamps via \`args\`); no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.` type WorkflowCallArgs = { script: string; args?: Record } diff --git a/packages/workflow/workflow-vm/README.md b/packages/workflow/workflow-vm/README.md index 0ec1116cb7..e81e74dc7e 100644 --- a/packages/workflow/workflow-vm/README.md +++ b/packages/workflow/workflow-vm/README.md @@ -4,13 +4,13 @@ The first [`WorkflowService`](../workflow/README.md) implementation: an in-proce ## Trust premise -Workflow scripts are **model-written** — the same trust level as the model's existing bash access — so this engine defends against **buggy** scripts, never hostile ones. vm is NOT a security boundary and no attempt is made to contain adversarial values: property reads on script values may run script code (a getter, a `toString`, a proxy trap) on the host stack, and a script determined to hang the process can simply spin past its first await (see the limitations below). What the engine DOES guarantee, because benign scripts hit these constantly: `result` never rejects, a dropped hook promise never becomes an unhandled rejection (the app boot layer exits the process on those), values that JSON cannot carry are rejected **loud** instead of silently mangled, and hook misuse is fatal instead of dissolving into a per-item `null`. Genuine sandboxing is an engine swap behind the seam (worker-thread/isolated-vm, where the boundary is serialization by construction), not incremental host-side defenses here. +Workflow scripts are **model-written** — the same trust level as the model's existing bash access — so this engine defends against **buggy** scripts, never hostile ones. vm is NOT a security boundary and no attempt is made to contain adversarial values: property reads on script values may run script code (a getter, a `toString`, a proxy trap) on the host stack, and a script determined to hang the process can simply spin past its first await (see the limitations below). Concretely, the context is **escapable by construction**: `node:vm` shares object machinery with the host, so a script can reach the host `Function` constructor (`globalThis.constructor.constructor`) and from it `process` and every Node builtin — the absent globals and determinism bans are API surface that keeps honest scripts portable and resume-compatible, not walls. What the engine DOES guarantee, because benign scripts hit these constantly: `result` never rejects, a dropped hook promise never becomes an unhandled rejection (the app boot layer exits the process on those), values that JSON cannot carry are rejected **loud** instead of silently mangled, and hook misuse is fatal instead of dissolving into a per-item `null`. Genuine sandboxing is an engine swap behind the seam (worker-thread/isolated-vm, where the boundary is serialization by construction), not incremental host-side defenses here. ## The script contract it executes - **Meta extraction** (`extractMeta`): a string/comment-aware brace scanner finds the leading `export const meta` literal (template interpolation rejected — the literal must be pure), evaluates it ALONE in an empty timed vm context, materializes the result to plain JSON data, validates the shape (`name`/`description` required; unknown fields rejected loud), and blanks the statement line-preservingly so error stacks keep the script's own line numbers. - **Hooks**: `agent(prompt, {label, phase, schema, model})` (schema = the [structured-output subset](../../core/tools/README.md), forwarded as `outputSchema`; result = validated object, or final text without a schema; a failed child resolves `null`), `parallel(thunks)`, `pipeline(items, ...stages)` with NO cross-stage barrier and `(prev, item, index)` stage callbacks, `phase(title)`, `log(message)`, and the `args` global. Anything else — `effort`/`isolation`/`agentType`, unknown options, malformed arguments, schemas outside the subset — throws a FATAL `WorkflowError` that `parallel`/`pipeline` re-throw rather than nulling (see the seam README's failure discipline). -- **Determinism bans**: `Date.now()`, `Math.random()`, and argless `new Date()` throw (kept even though resume is deferred, so scripts stay resume-compatible); no timers, filesystem, or Node APIs exist in the context. +- **Determinism bans**: `Date.now()`, `Math.random()`, and argless `new Date()` throw (kept even though resume is deferred, so scripts stay resume-compatible); no timers, filesystem, or Node APIs are injected into the context (absence is API surface, not containment — see the trust premise). ## The value boundary diff --git a/packages/workflow/workflow-vm/src/index.ts b/packages/workflow/workflow-vm/src/index.ts index 9b8c646aa9..c1300427a2 100644 --- a/packages/workflow/workflow-vm/src/index.ts +++ b/packages/workflow/workflow-vm/src/index.ts @@ -7,9 +7,12 @@ * TRUST PREMISE: scripts are MODEL-WRITTEN — the same trust level as the * model's existing bash access — so this engine defends against BUGGY * scripts, never hostile ones. vm is NOT a security boundary and no attempt - * is made to contain adversarial values (see ./realm.ts); genuine sandboxing - * is an engine swap behind the seam (worker-thread/isolated-vm), not - * incremental host-side defenses here. + * is made to contain adversarial values (see ./realm.ts); the context is + * escapable by construction (the host `Function` constructor is reachable via + * `globalThis.constructor.constructor`, and `process` from there), so the + * absent globals are API surface, not containment. Genuine sandboxing is an + * engine swap behind the seam (worker-thread/isolated-vm), not incremental + * host-side defenses here. * * Engine limitations, documented as the accepted cost of the in-process * mechanism: From 4641f1a851b4579adb3cd7ccd50ba53b4102ce04 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:26:47 +0800 Subject: [PATCH 18/57] chore: absorb the no-nudge seam into the workflow tree The parent branch dropped structuredNudgeRetries; the two workflow-side test setups stop passing it, and the RFC's foundation paragraph now describes the current design (final-ASSEMBLY enforcement logged via request/header, the post-capture pre-execute deny, the start() schema snapshot, no re-prompt) instead of the retired agent/request + nudge shape. --- docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md | 2 +- packages/workflow/workflow-vm/tests/integration.spec.ts | 2 +- packages/workflow/workflow-vm/tests/workflow.e2e.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index 17c26760be..7bf09f742c 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -36,7 +36,7 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai ### The foundation: structured output on the subagent seam -`agent({schema})` needs `SubagentStartRequest.outputSchema` to actually work; it was vocabulary without an implementation (`outputSchema: false` everywhere). Implemented in `dsh-subagent-inprocess` for both in-process backends: a globally registered `structured_output` capture tool whose per-child schema is enforced by a `prepend: true` `agent/request` listener doing FINAL-REQUEST enforcement (post-processing `await next()` — cooperative mutation would not survive a downstream listener returning a replacement request; the listener also appends the calling instruction to the request's `system` text, since `AgentOptions` carries no per-agent prompt field), a `prepend: true` `agent/turn-continuation` veto after capture (no wasted extra model step, and an earlier-registered force-continue listener cannot short-circuit it), validation-retry in-turn via `ToolArgsError`, and a clean-finish nudge loop (`structuredNudgeRetries`). Lifetime is refcounted by backends (plugin lifetime) AND live runs (start → settle). The seam's `outputSchema` type became the raw JSON-Schema SUBSET (`StructuredOutputSchema` in dsh-tools: single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`; anything unenforced is rejected loud) — the schema travels verbatim to the model as the forced tool's parameters, so the wire format, not the author DSL, is the right vocabulary. +`agent({schema})` needs `SubagentStartRequest.outputSchema` to actually work; it was vocabulary without an implementation (`outputSchema: false` everywhere). Implemented in `dsh-subagent-inprocess` for both in-process backends: a globally registered `structured_output` capture tool whose per-child schema is enforced by a `prepend: true` `system-prompt/assemble` listener doing FINAL-ASSEMBLY enforcement (post-processing `await next()` — cooperative mutation would not survive a downstream listener returning a replacement assembly; the calling instruction rides as a trailing prompt section, since `AgentOptions` carries no per-agent prompt field, and the loop logs the result as the step's `request/header`, keeping the injection reconstructable), a `prepend: true` `agent/turn-continuation` veto after capture (no wasted extra model step) plus a `tools/pre-execute` deny for calls arriving after the capture (terminal within the step, not only at its end), and validation-retry in-turn via `ToolArgsError`. The schema is `structuredClone`d at `start()` (caller mutation cannot drift enforcement). Deliberately NO re-prompt: a child that finishes cleanly without calling the tool settles `error` to the parent. Lifetime is refcounted by backends (plugin lifetime) AND live runs (start → settle). The seam's `outputSchema` type became the raw JSON-Schema SUBSET (`StructuredOutputSchema` in dsh-tools: single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`; anything unenforced is rejected loud) — the schema travels verbatim to the model as the forced tool's parameters, so the wire format, not the author DSL, is the right vocabulary. ## Deferred (documented non-goals of this cut) diff --git a/packages/workflow/workflow-vm/tests/integration.spec.ts b/packages/workflow/workflow-vm/tests/integration.spec.ts index 48b308a986..3572131a8a 100644 --- a/packages/workflow/workflow-vm/tests/integration.spec.ts +++ b/packages/workflow/workflow-vm/tests/integration.spec.ts @@ -32,7 +32,7 @@ async function setup(script: Script) { await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) + await ctx.plugin(spawn, { providerName: 'spawn' }) await ctx.plugin(VmWorkflowEngine, {}) ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) diff --git a/packages/workflow/workflow-vm/tests/workflow.e2e.ts b/packages/workflow/workflow-vm/tests/workflow.e2e.ts index 6ddfdb7251..c3f959d072 100644 --- a/packages/workflow/workflow-vm/tests/workflow.e2e.ts +++ b/packages/workflow/workflow-vm/tests/workflow.e2e.ts @@ -39,7 +39,7 @@ async function harness(): Promise { await built.plugin(AgentLoop, { agents: [] }) await built.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) await built.plugin(SubagentService) - await built.plugin(Spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) + await built.plugin(Spawn, { providerName: 'spawn' }) await built.plugin(VmWorkflowEngine, { provider: 'spawn' }) await built.plugin(ToolWorkflow, {}) return built From afab88b139717830590fb65b24274aff124203ea Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:22:57 +0800 Subject: [PATCH 19/57] docs(rfc): record the schema-validation alternatives considered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #192 review discussion, preserved where design rationale lives: a schema-object library (zod/schemastery) cannot sit at a wire-data boundary; ajv replaces only the value walker while the subset gate — the module's point — stays hand-written; provider JSON mode guarantees valid JSON, not schema-conforming JSON, and would trade away mid-run tools and in-turn validation retry for it. Strict tool schemas are named as the accepted upgrade path when the provider ships them. --- docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index 7bf09f742c..daf041a1d8 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -56,6 +56,9 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai - **Workflow-layer JSON parsing for `agent({schema})`**: duplicating a seam concern at one consumer while the seam's capability flag stayed dishonestly `false`. - **Meta as tool parameters instead of `export const meta`**: zero parsing, but scripts stop being self-contained artifacts and CC-authored scripts stop being drop-in. - **`SchemaSpec` as the outputSchema type**: the author-facing DSL cannot express what arrives as data and cannot be validated against without conversion loss. +- **A schema-object library (zod, or the repo's schemastery) for the structured-output subset**: the schema is wire data — plain JSON that crosses the vm realm boundary in `agent({schema})` and lands verbatim in the forced tool's parameters — exactly where live schema objects cannot sit; consuming raw JSON Schema at runtime would need a third-party converter on top (zod core only emits JSON Schema, not the reverse), and it would put a second schema language beside schemastery's config role. +- **ajv for value validation**: it validates FULL JSON Schema, so the subset gate — the module's actual point, since every accepted keyword must be one the harness enforces — would remain hand-written regardless; it compiles validators through `new Function`; and it would be dsh-tools' first runtime dependency, all to replace the ~70-line value walker while the path-qualified, every-violation error reporting stays custom either way. +- **Provider JSON mode (`response_format: {type: json_object}`) instead of the forced capture tool**: the official API guarantees valid JSON, not schema-conforming JSON (no `json_schema` type; the docs' own guidance is to validate client-side, with the schema riding in the prompt), so both walkers survive untouched and only the capture-tool mechanics could go — at the cost of tools during a structured child's run (whether `response_format` composes with tool calling is undocumented), the in-turn validation retry (`ToolArgsError` keeps recovery inside the turn; a JSON-mode empty body — a documented failure mode — ends the turn, and the only recovery is the re-prompt loop this design rejects), and a new per-adapter `LlmCallConfig` surface. The accepted upgrade path is strict TOOL schemas (provider-side constrained decoding on tool parameters) when available: the same forced tool and subset gate, with the gate narrowed to the provider's strict subset. ## Consequences From 1f5db6e0b0ce3b7ee055a6712167ee474910b1e7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:39:05 +0800 Subject: [PATCH 20/57] docs: satisfy the export-JSDoc gate across the workflow packages Master's verify-export-jsdoc landed mid-stack; complete the six missing @param/@returns on the workflow trio's public surface (and the services catalog they regenerate into). --- docs/cordis-catalog/services.md | 2 +- packages/workflow/workflow-vm/src/runtime.ts | 4 ++++ packages/workflow/workflow/src/index.ts | 6 +++++- packages/workflow/workflow/src/types.ts | 6 +++++- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f292045c69..3e991726d4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -243,7 +243,7 @@ Semantics every implementation must honor: abstract start(request: WorkflowStartRequest): WorkflowRun ``` -Source: [`packages/workflow/workflow/src/index.ts:198`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:202`](../../packages/workflow/workflow/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/packages/workflow/workflow-vm/src/runtime.ts b/packages/workflow/workflow-vm/src/runtime.ts index f1901ffaf7..0938b7f2c9 100644 --- a/packages/workflow/workflow-vm/src/runtime.ts +++ b/packages/workflow/workflow-vm/src/runtime.ts @@ -225,6 +225,8 @@ export class WorkflowExecution { * `disposeGraceMs` (parked on a promise no hook owns) is abandoned so * `result` settles regardless (see {@link abandoned}). Idempotent; the * first reason wins. + * @param reason - human-readable cause, carried on the CANCELLED error and + * into child `run.cancel()` calls (default `'workflow cancelled'`). */ cancel(reason?: string): void { if (this.cancelReason !== undefined) return @@ -244,6 +246,8 @@ export class WorkflowExecution { * cancellation (or outlived its post-cancel grace and was abandoned — see * {@link abandoned}). After settlement, any stray children a script fired * without awaiting are aborted (their `agent()` wrappers dispose them). + * @returns the settled outcome — this promise NEVER rejects (the seam's + * `result`-never-rejects contract); every failure maps to a variant. */ async drive(): Promise { try { diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index 31b9eed038..288f6af952 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -168,7 +168,11 @@ export class WorkflowError extends HarnessError { } } -/** Whether combinators must re-throw `error` instead of mapping the item to `null`. */ +/** + * Whether combinators must re-throw `error` instead of mapping the item to `null`. + * @param error - any thrown value; fatality is host `instanceof` (unforgeable from a script realm). + * @returns true iff `error` is a {@link WorkflowError} whose `fatal` flag is set. + */ export function isFatalWorkflowError(error: unknown): boolean { return error instanceof WorkflowError && error.fatal } diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index 01e5d08bf0..767fb8257b 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -12,7 +12,11 @@ import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' /** Identifies one workflow run. */ export type WorkflowRunId = Branded<'WorkflowRunId'> -/** Brand a string as a {@link WorkflowRunId}. */ +/** + * Brand a string as a {@link WorkflowRunId}. + * @param id - the raw id string (the engine mints UUIDs; tests may pass fixtures). + * @returns the same string, branded. + */ export function WorkflowRunId(id: string): WorkflowRunId { return id as WorkflowRunId } From 737e1f7c0ac824bbd9aa61f6d856497767a4e36c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:22:05 +0800 Subject: [PATCH 21/57] chore: absorb master's explicit tool order into the stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool-order feature canonicalizes the model-facing list (alphabetical absent a configured toolOrder), so the header-pinning text-turn fixture is re-recorded on the stacked tree — every other fixture stores the header as scrubbed tokens and needed nothing. AGENTS.md condensed back under its ceiling after the merge union. --- AGENTS.md | 4 +- .../tests/snapshots/text-turn/session.jsonl | 69 +++++++++---------- .../snapshots/text-turn/stdout.golden.jsonl | 9 +-- 3 files changed, 38 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5f2f8e4cdd..fc33e2f049 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ # AGENTS.md -This is the monorepo of the DeepSeek Harness group; it hosts **DeepSeek Harness SDK**, a plugin-based SDK for building agent harnesses. The codebase is built on the vendored Cordis framework, microkernel-style: **everything is a plugin**. Read [docs/architecture.md](docs/architecture.md) before changing `packages/`; the documentation standard is [docs/AGENTS.md](docs/AGENTS.md). +The DeepSeek Harness group monorepo, hosting **DeepSeek Harness SDK** — a plugin-based SDK for building agent harnesses on the vendored Cordis framework, microkernel-style: **everything is a plugin**. Read [docs/architecture.md](docs/architecture.md) before changing `packages/`; the documentation standard is [docs/AGENTS.md](docs/AGENTS.md). ## Pre-release stance: foundation over blast radius -**This applies only while the harness is unreleased — remove this section at the first tagged release.** There are no external consumers, so optimize for the correct foundation, not a small diff: move files, rename public symbols, repackage plugins, and update every reference in the same change. No backward-compat shims, deprecation aliases, or re-export stubs. On-disk formats need no migrations — a backend REJECTS anything not at the current version. Two sanctioned version stances: monotonic bump-and-reject (the SQLite backend's `SCHEMA_VERSION`), and a pinned `0` that absorbs all shape churn (`SESSION_FORMAT_VERSION` in `dsh-session`, documented "no compatibility implied"). Real version policy begins at the first release. +**Applies only while the harness is unreleased — remove this section at the first tagged release.** With no external consumers, optimize for the correct foundation, not a small diff: move files, rename public symbols, repackage plugins, and update every reference in the same change. No backward-compat shims, deprecation aliases, or re-export stubs. On-disk formats need no migrations — a backend REJECTS anything not at the current version. Two sanctioned version stances: monotonic bump-and-reject (the SQLite backend's `SCHEMA_VERSION`), and a pinned `0` that absorbs all shape churn (`SESSION_FORMAT_VERSION` in `dsh-session`, documented "no compatibility implied"). Real version policy begins at the first release. ## Repository layout diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index 008d968c01..3e91a5a62a 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -1,36 +1,33 @@ -{"type":"session","version":0,"id":"72de3ed9-a2cb-44da-a9fd-5249b5c65b52","createdAt":1783352040349,"cwd":"/tmp/acp-snap-cwd-7wwwVQ"} -{"type":"turn/start","seq":0,"time":1783352040353,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352040354,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352040355,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352040356,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-7wwwVQ.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.","tools":[{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe script MUST begin with `export const meta = {...}` — a PURE object literal (no variables, calls, or template interpolation) with required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The body after it is plain JavaScript (NOT TypeScript) running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; `Date.now()`, `Math.random()`, and argless `new Date()` throw (pass timestamps via `args`); no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The complete workflow script: `export const meta = {...}` followed by the plain-JS body (top-level await allowed; end with `return `)."},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352041017,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352041018,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352041114,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352041142,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352041142,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352041142,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352041142,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":11,"time":1783352041143,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":12,"time":1783352041143,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":13,"time":1783352041173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":14,"time":1783352041173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":15,"time":1783352041174,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":16,"time":1783352041174,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":17,"time":1783352041174,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONG"}}} -{"type":"assistant/chunk","seq":18,"time":1783352041174,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":19,"time":1783352041201,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":20,"time":1783352041201,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} -{"type":"assistant/chunk","seq":21,"time":1783352041201,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":22,"time":1783352041201,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} -{"type":"assistant/chunk","seq":23,"time":1783352041202,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} -{"type":"assistant/chunk","seq":24,"time":1783352041202,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":25,"time":1783352041227,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":26,"time":1783352041228,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} -{"type":"assistant/chunk","seq":27,"time":1783352041228,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} -{"type":"assistant/chunk","seq":28,"time":1783352041228,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} -{"type":"assistant/chunk","seq":29,"time":1783352041228,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} -{"type":"assistant/chunk","seq":30,"time":1783352041228,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2868,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} -{"type":"assistant/chunk","seq":31,"time":1783352041228,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":32,"time":1783352041230,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"usage":{"inputTokens":2868,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} -{"type":"step/end","seq":33,"time":1783352041231,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":34,"time":1783352041231,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"423e2c78-075e-4286-8027-85b0e64da45d","createdAt":1783437535685,"cwd":"/tmp/acp-snap-cwd-XUOYdd"} +{"type":"turn/start","seq":0,"time":1783437535688,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783437535689,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783437535690,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783437535690,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-XUOYdd.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe script MUST begin with `export const meta = {...}` — a PURE object literal (no variables, calls, or template interpolation) with required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The body after it is plain JavaScript (NOT TypeScript) running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; `Date.now()`, `Math.random()`, and argless `new Date()` throw (pass timestamps via `args`); no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The complete workflow script: `export const meta = {...}` followed by the plain-JS body (top-level await allowed; end with `return `)."},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783437536390,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783437536390,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783437536568,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783437536591,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783437536592,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783437536592,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783437536592,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1783437536592,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1783437536615,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":13,"time":1783437536616,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":14,"time":1783437536647,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":15,"time":1783437536648,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONG"}}} +{"type":"assistant/chunk","seq":16,"time":1783437536648,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"assistant/chunk","seq":17,"time":1783437536674,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":18,"time":1783437536675,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} +{"type":"assistant/chunk","seq":19,"time":1783437536675,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":20,"time":1783437536675,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":21,"time":1783437536675,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1783437536675,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":23,"time":1783437536702,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":24,"time":1783437536703,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":25,"time":1783437536704,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"PONG.\" and no tools."}}}} +{"type":"assistant/chunk","seq":26,"time":1783437536704,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG."}}}} +{"type":"assistant/chunk","seq":27,"time":1783437536704,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2867,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":16}}}} +{"type":"assistant/chunk","seq":28,"time":1783437536704,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":29,"time":1783437536706,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"PONG.\" and no tools."},{"type":"text","text":"PONG."}],"usage":{"inputTokens":2867,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":16}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"} +{"type":"step/end","seq":30,"time":1783437536706,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":31,"time":1783437536706,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl index dda3afb9c5..bc3582f027 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl @@ -8,18 +8,15 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONG"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" not"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" any"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" no"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"P"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONG"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} From 773ecf03f578d1cf8c775fb3d51f6b872cf3ae81 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:51:19 +0800 Subject: [PATCH 22/57] workflow: drop the determinism bans (unimplemented-resume pre-support) The Date.now()/Math.random()/argless-new-Date() bans existed solely to keep scripts resume-compatible for the deferred journaling/resume feature. Pre-support for an unimplemented feature is speculative cost: scripts may now read the clock freely; implementing resume reintroduces the bans as a script-contract tightening. The RFC's shipped-state description is updated in place, the tool DESCRIPTION drops the constraint sentence (the pinned text-turn header follows), and the engine README's trust-premise paragraph now leans on absent globals alone. --- .../feature/2026-07-05-dynamic-workflows.md | 4 ++-- docs/tool-catalog.md | 2 +- .../tests/snapshots/text-turn/session.jsonl | 2 +- packages/workflow/tool-workflow/README.md | 2 +- packages/workflow/tool-workflow/src/index.ts | 6 +++--- packages/workflow/workflow-vm/README.md | 4 ++-- packages/workflow/workflow-vm/src/runtime.ts | 20 ------------------- .../workflow-vm/tests/workflow-vm.spec.ts | 11 +--------- 8 files changed, 11 insertions(+), 40 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index daf041a1d8..f7749efa56 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -12,7 +12,7 @@ A workflow capability family at `packages/workflow/` in the bash seam shape (int ### The script contract (Claude Code-compatible) -A script is `export const meta = {...}` (a PURE object literal: `name`, `description`, optional `whenToUse`/`phases`) followed by a plain-JS body with top-level `await`, ending in `return `. The body sees exactly: `agent(prompt, {label, phase, schema, model})`, `parallel(thunks)`, `pipeline(items, ...stages)` (NO cross-stage barrier; `(prev, item, index)` callbacks), `phase(title)`, `log(message)`, and `args`. CC semantics are preserved where they matter to script authors: a failed child resolves `null` (scripts `.filter(Boolean)`); an ordinary stage throw nulls the ITEM and skips its remaining stages; `Date.now()`/`Math.random()`/argless `new Date()` throw (kept banned so future resume support cannot break script compatibility). +A script is `export const meta = {...}` (a PURE object literal: `name`, `description`, optional `whenToUse`/`phases`) followed by a plain-JS body with top-level `await`, ending in `return `. The body sees exactly: `agent(prompt, {label, phase, schema, model})`, `parallel(thunks)`, `pipeline(items, ...stages)` (NO cross-stage barrier; `(prev, item, index)` callbacks), `phase(title)`, `log(message)`, and `args`. CC semantics are preserved where they matter to script authors: a failed child resolves `null` (scripts `.filter(Boolean)`); an ordinary stage throw nulls the ITEM and skips its remaining stages. CC's determinism bans (`Date.now()`/`Math.random()`/argless `new Date()` throwing) are NOT enforced — they exist for CC's journaling/resume, which this cut defers — so a CC-authored script runs unchanged while scripts written here may freely read the clock. One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferred options (`effort`/`isolation`/`agentType`), malformed arguments, schemas outside the supported subset, tripped caps, seam start failures — throws a `WorkflowError` with `fatal: true`, and the combinators RE-THROW fatal errors instead of nulling the item. Without this, a typo'd option dissolves into a `null` indistinguishable from a child failure — the accepted-then-ignored failure mode this repo bans. One addition: the tool's `args` parameter is a JSON OBJECT (a bare list is wrapped as a field) so the wire schema stays honest. @@ -41,7 +41,7 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai ## Deferred (documented non-goals of this cut) - **Background collection** (start tool → run id → completion notice → collect), designed alongside bash/subagent background unification. -- **Journaling + resume** (`resumeFromRunId`, cached agent() prefixes) — the determinism bans already keep scripts resume-compatible. +- **Journaling + resume** (`resumeFromRunId`, cached agent() prefixes) — implementing it reintroduces CC's determinism bans as a script-contract tightening (scripts may read the clock today). - **Saved/bundled workflows** (a `.deepseek/workflows/` registry, slash-command surface) and **script persistence to a run directory** (the tool-call event already records the script durably). - **Nested `workflow()`**, **token `budget`**, and the `effort`/`isolation`/`agentType` agent options (each rejects loud with a message naming it deferred). - **An overall run wall-clock timeout** — cancellation always frees the caller (result settles within the grace), so a cap on total run time is a policy knob for the background redesign, not a correctness need here. diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 87b1a8a999..3794b1d7a7 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -293,7 +293,7 @@ Script-body hooks: Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. -Constraints: concurrency and total-agent caps apply; `Date.now()`, `Math.random()`, and argless `new Date()` throw (pass timestamps via `args`); no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. +Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. ```json { diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index 3e91a5a62a..39898c5003 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783437535688,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783437535689,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783437535690,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783437535690,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-XUOYdd.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe script MUST begin with `export const meta = {...}` — a PURE object literal (no variables, calls, or template interpolation) with required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The body after it is plain JavaScript (NOT TypeScript) running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; `Date.now()`, `Math.random()`, and argless `new Date()` throw (pass timestamps via `args`); no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The complete workflow script: `export const meta = {...}` followed by the plain-JS body (top-level await allowed; end with `return `)."},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783437535690,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-XUOYdd.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe script MUST begin with `export const meta = {...}` — a PURE object literal (no variables, calls, or template interpolation) with required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The body after it is plain JavaScript (NOT TypeScript) running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The complete workflow script: `export const meta = {...}` followed by the plain-JS body (top-level await allowed; end with `return `)."},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783437536390,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783437536390,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":6,"time":1783437536568,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/packages/workflow/tool-workflow/README.md b/packages/workflow/tool-workflow/README.md index 37c9f9f426..44160d8fc1 100644 --- a/packages/workflow/tool-workflow/README.md +++ b/packages/workflow/tool-workflow/README.md @@ -4,7 +4,7 @@ The model-facing **`workflow` tool**: run a JavaScript orchestration script that ## What the model sees -Two parameters: `script` (required — the full `export const meta = {...}` + body text; the tool DESCRIPTION carries the complete authoring contract: hooks, semantics, determinism bans, the supported schema subset) and `args` (optional JSON object exposed to the script as the `args` global; a bare list is wrapped as a field, a deliberate deviation from Claude Code's any-JSON `args` so the wire schema stays honest). The plugin also contributes a `tool:` system-prompt section carrying the usage policy — use the tool only on an explicit user ask for a workflow / large orchestration; prefer plain subagent calls for one or two delegations — per the convention that tool guidance ships with the tool plugin, never in the deployment persona. +Two parameters: `script` (required — the full `export const meta = {...}` + body text; the tool DESCRIPTION carries the complete authoring contract: hooks, semantics, the supported schema subset) and `args` (optional JSON object exposed to the script as the `args` global; a bare list is wrapped as a field, a deliberate deviation from Claude Code's any-JSON `args` so the wire schema stays honest). The plugin also contributes a `tool:` system-prompt section carrying the usage policy — use the tool only on an explicit user ask for a workflow / large orchestration; prefer plain subagent calls for one or two delegations — per the convention that tool guidance ships with the tool plugin, never in the deployment persona. ## Lifecycle diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 4df4d6aba1..a5eae80aec 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -51,8 +51,8 @@ export const Config: z = z.object({ /** * The script-authoring contract, embedded in the tool description. This IS the - * model-facing spec: the meta block, the hooks and their exact semantics, the - * determinism bans, and the supported schema subset. + * model-facing spec: the meta block, the hooks and their exact semantics, and + * the supported schema subset. */ const DESCRIPTION = `Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. @@ -66,7 +66,7 @@ Script-body hooks: Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item \`null\`. -Constraints: concurrency and total-agent caps apply; \`Date.now()\`, \`Math.random()\`, and argless \`new Date()\` throw (pass timestamps via \`args\`); no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.` +Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.` type WorkflowCallArgs = { script: string; args?: Record } diff --git a/packages/workflow/workflow-vm/README.md b/packages/workflow/workflow-vm/README.md index e81e74dc7e..e29073c2b3 100644 --- a/packages/workflow/workflow-vm/README.md +++ b/packages/workflow/workflow-vm/README.md @@ -4,13 +4,13 @@ The first [`WorkflowService`](../workflow/README.md) implementation: an in-proce ## Trust premise -Workflow scripts are **model-written** — the same trust level as the model's existing bash access — so this engine defends against **buggy** scripts, never hostile ones. vm is NOT a security boundary and no attempt is made to contain adversarial values: property reads on script values may run script code (a getter, a `toString`, a proxy trap) on the host stack, and a script determined to hang the process can simply spin past its first await (see the limitations below). Concretely, the context is **escapable by construction**: `node:vm` shares object machinery with the host, so a script can reach the host `Function` constructor (`globalThis.constructor.constructor`) and from it `process` and every Node builtin — the absent globals and determinism bans are API surface that keeps honest scripts portable and resume-compatible, not walls. What the engine DOES guarantee, because benign scripts hit these constantly: `result` never rejects, a dropped hook promise never becomes an unhandled rejection (the app boot layer exits the process on those), values that JSON cannot carry are rejected **loud** instead of silently mangled, and hook misuse is fatal instead of dissolving into a per-item `null`. Genuine sandboxing is an engine swap behind the seam (worker-thread/isolated-vm, where the boundary is serialization by construction), not incremental host-side defenses here. +Workflow scripts are **model-written** — the same trust level as the model's existing bash access — so this engine defends against **buggy** scripts, never hostile ones. vm is NOT a security boundary and no attempt is made to contain adversarial values: property reads on script values may run script code (a getter, a `toString`, a proxy trap) on the host stack, and a script determined to hang the process can simply spin past its first await (see the limitations below). Concretely, the context is **escapable by construction**: `node:vm` shares object machinery with the host, so a script can reach the host `Function` constructor (`globalThis.constructor.constructor`) and from it `process` and every Node builtin — the absent globals are API surface that keeps honest scripts portable, not walls. What the engine DOES guarantee, because benign scripts hit these constantly: `result` never rejects, a dropped hook promise never becomes an unhandled rejection (the app boot layer exits the process on those), values that JSON cannot carry are rejected **loud** instead of silently mangled, and hook misuse is fatal instead of dissolving into a per-item `null`. Genuine sandboxing is an engine swap behind the seam (worker-thread/isolated-vm, where the boundary is serialization by construction), not incremental host-side defenses here. ## The script contract it executes - **Meta extraction** (`extractMeta`): a string/comment-aware brace scanner finds the leading `export const meta` literal (template interpolation rejected — the literal must be pure), evaluates it ALONE in an empty timed vm context, materializes the result to plain JSON data, validates the shape (`name`/`description` required; unknown fields rejected loud), and blanks the statement line-preservingly so error stacks keep the script's own line numbers. - **Hooks**: `agent(prompt, {label, phase, schema, model})` (schema = the [structured-output subset](../../core/tools/README.md), forwarded as `outputSchema`; result = validated object, or final text without a schema; a failed child resolves `null`), `parallel(thunks)`, `pipeline(items, ...stages)` with NO cross-stage barrier and `(prev, item, index)` stage callbacks, `phase(title)`, `log(message)`, and the `args` global. Anything else — `effort`/`isolation`/`agentType`, unknown options, malformed arguments, schemas outside the subset — throws a FATAL `WorkflowError` that `parallel`/`pipeline` re-throw rather than nulling (see the seam README's failure discipline). -- **Determinism bans**: `Date.now()`, `Math.random()`, and argless `new Date()` throw (kept even though resume is deferred, so scripts stay resume-compatible); no timers, filesystem, or Node APIs are injected into the context (absence is API surface, not containment — see the trust premise). +- **No ambient APIs**: no timers, filesystem, or Node APIs are injected into the context (absence is API surface, not containment — see the trust premise). ## The value boundary diff --git a/packages/workflow/workflow-vm/src/runtime.ts b/packages/workflow/workflow-vm/src/runtime.ts index 0938b7f2c9..c4e5d1c651 100644 --- a/packages/workflow/workflow-vm/src/runtime.ts +++ b/packages/workflow/workflow-vm/src/runtime.ts @@ -75,25 +75,6 @@ const SUPPORTED_AGENT_OPTIONS = new Set(['label', 'phase', 'schema', 'model']) /** Deferred Claude Code options we name explicitly in the rejection message. */ const DEFERRED_AGENT_OPTIONS = new Set(['effort', 'isolation', 'agentType']) -/** The in-context prelude that bans the nondeterminism sources (kept even though resume is deferred, so scripts stay resume-compatible). */ -const DETERMINISM_PRELUDE = ` -{ - const banned = (name) => () => { - throw new Error(name + ' is not available in workflow scripts (runs must stay deterministic for future resume support; pass timestamps in via args)') - } - Math.random = banned('Math.random()') - Date.now = banned('Date.now()') - const RealDate = Date - globalThis.Date = new Proxy(RealDate, { - construct(target, args, newTarget) { - if (args.length === 0) banned('argless new Date()')() - return Reflect.construct(target, args, newTarget) - }, - apply: banned('Date()'), - }) -} -` - /** Flatten a child's final output blocks to text (the non-schema `agent()` result). */ function outputText(blocks: ContentBlock[]): string { return blocks @@ -167,7 +148,6 @@ export class WorkflowExecution { } this.context = vm.createContext({}, { name: `workflow:${meta.name}` }) - vm.runInContext(DETERMINISM_PRELUDE, this.context) // A run that settles without ever being abandoned leaves `abandoned` // permanently pending or rejecting into the void — consume it so a late // grace timer cannot surface an unhandled rejection. diff --git a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts index 1dc4833426..eeda6bf049 100644 --- a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts +++ b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts @@ -413,16 +413,7 @@ describe('dsh-workflow-vm', () => { }) }) - describe('determinism bans and the value boundary', () => { - it('Date.now, Math.random, and argless new Date throw; parameterized Date stays usable', async () => { - const { ctx, parent } = await setup() - expect((await run(ctx, parent, script('return Date.now()'))).error).toContain('Date.now() is not available') - expect((await run(ctx, parent, script('return Math.random()'))).error).toContain('Math.random() is not available') - expect((await run(ctx, parent, script('return new Date().toISOString()'))).error).toContain('argless new Date()') - const ok = await run(ctx, parent, script('return new Date(0).getTime()')) - expect(ok.value).toBe(0) - }) - + describe('the value boundary', () => { it('args are cloned at start: a script scribbling on them cannot mutate the caller\'s object', async () => { const { ctx, parent } = await setup() const hostArgs = { files: ['a.ts'], nested: { deep: [1, 2] } } From b59d245c7c7a6fa505553de039b1d13e508ef446 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:58:23 +0800 Subject: [PATCH 23/57] =?UTF-8?q?feat:=20Code=20Mode=20=E2=80=94=20the=20r?= =?UTF-8?q?egistry's=20mode=20config,=20the=20SDK=20codegen,=20and=20the?= =?UTF-8?q?=20run=5Fcode=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dsh-tools half of the Code Mode RFC (its fourth, final change): the registry gains its first config — mode: native | code | both — and OWNS how its tools reach the model. 'code' contributes exactly one wire tool, run_code, plus a lazy tools:sdk prompt section declaring every other tool as a generated TypeScript API (jsonSchemaToTs: total over the defineTool subset, unknown degradation, lexicographic byte-identical rendering); 'both' ships both representations; 'native' is byte-for-byte the old behavior. Non-native modes fail every assembly loudly without a typescript-language ctx.codeRuntime. run_code's dispatch bridge: JSON-normalizes each binding argument before dispatch (what dispatches is what the tool/code-dispatch event logs — the append can never fail on payload shape; BigInt/circulars reject that one call), serializes all program tool calls through a per-run queue (even Promise.all — no concurrency-safety metadata yet), routes every sub-call through tools/pre-execute → tools/post-execute (a deny rejects the program-side promise), drops sub-call additionalContext (no safe outlet mid-run; pinned), owns a run-scoped abort that follows the outer signal in and fires on settlement (in-flight sub-dispatch aborted, queued abandoned, queue drained before returning), and converts a failed run into CodeRunFailedError → a structured isError carrying kind + captured logs. tool/code-dispatch joins SessionEventMap by declaration merging (log-only; deriveMessages ignores it). The composed surface: the tools config forwards through agent-core and both app packages; examples/code-agent + demo:code run the worker runtime under mode code (keyless boot smoke + a with-key e2e proving the collapsed [run_code] header, the dispatch events, and the file the program wrote); two new snapshot scenarios (code-mode-turn, both-mode-turn) record the SDK section, collapsed header, dispatch events, and result card — each its own header-pinning class (the harness gains per-scenario config overlays and per-class pins). Catalogs, graphs, cookbook, hooks-bridge notes, and the RFC (moved to implemented/, restructured to decision-era headings) updated in the same change. --- docs/capability-seams.md | 3 +- docs/config-catalog.md | 62 ++- docs/cookbook/adding-a-tool.md | 4 + docs/cordis-catalog/events.md | 6 +- docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/code-runtime.md | 2 +- docs/event-producer-consumer.md | 6 +- docs/module-graph.md | 10 +- docs/persistence-catalog.md | 12 + docs/rfc/INDEX.md | 2 +- .../feature/2026-06-15-code-mode.md | 38 +- docs/tool-catalog.md | 26 + docs/tool-execution-pipeline.md | 4 +- examples/AGENTS.md | 3 +- examples/README.md | 6 + .../acp-agent/both-mode.cordis.snapshot.yml | 32 ++ examples/acp-agent/both-mode.cordis.yml | 29 + .../acp-agent/code-mode.cordis.snapshot.yml | 32 ++ examples/acp-agent/code-mode.cordis.yml | 29 + examples/acp-agent/tests/acp.snapshot.ts | 106 +++- examples/acp-agent/tests/snapshot-harness.ts | 10 +- .../tests/snapshots/both-mode-turn/input.json | 7 + .../snapshots/both-mode-turn/session.jsonl | 110 ++++ .../both-mode-turn/stdout.golden.jsonl | 55 ++ .../tests/snapshots/code-mode-turn/input.json | 7 + .../snapshots/code-mode-turn/session.jsonl | 196 +++++++ .../code-mode-turn/stdout.golden.jsonl | 98 ++++ examples/code-agent/README.md | 17 + examples/code-agent/cordis.yml | 84 +++ examples/code-agent/package.json | 7 + examples/code-agent/tests/code-mode.e2e.ts | 115 ++++ .../code-agent/tests/keyless-smoke.e2e.ts | 90 +++ package.json | 1 + packages/code-runtime/README.md | 2 +- .../code-runtime-worker/README.md | 2 +- packages/code-runtime/code-runtime/README.md | 2 +- .../code-runtime/code-runtime/src/index.ts | 2 +- packages/core/agent-core/src/index.ts | 20 +- packages/core/tools/README.md | 21 +- packages/core/tools/package.json | 7 + packages/core/tools/src/code-mode.ts | 280 ++++++++++ packages/core/tools/src/index.ts | 102 +++- packages/core/tools/src/ts-types.ts | 121 ++++ packages/core/tools/tests/code-mode.spec.ts | 523 ++++++++++++++++++ .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/core/tools/tests/ts-types.spec.ts | 124 +++++ packages/core/tools/tsconfig.json | 6 + packages/hooks/hooks-claude/README.md | 2 +- packages/hooks/hooks-codex/README.md | 2 +- packages/ui/acp-agent/package.json | 2 + packages/ui/acp-agent/src/index.ts | 8 +- packages/ui/stdio-agent/package.json | 2 + packages/ui/stdio-agent/src/index.ts | 5 + pnpm-lock.yaml | 16 + scripts/gen-doc-graphs.ts | 8 +- scripts/gen-tool-catalog.ts | 25 +- 56 files changed, 2395 insertions(+), 102 deletions(-) rename docs/rfc/{proposed => implemented}/feature/2026-06-15-code-mode.md (84%) create mode 100644 examples/acp-agent/both-mode.cordis.snapshot.yml create mode 100644 examples/acp-agent/both-mode.cordis.yml create mode 100644 examples/acp-agent/code-mode.cordis.snapshot.yml create mode 100644 examples/acp-agent/code-mode.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/both-mode-turn/input.json create mode 100644 examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl create mode 100644 examples/acp-agent/tests/snapshots/code-mode-turn/input.json create mode 100644 examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl create mode 100644 examples/code-agent/README.md create mode 100644 examples/code-agent/cordis.yml create mode 100644 examples/code-agent/package.json create mode 100644 examples/code-agent/tests/code-mode.e2e.ts create mode 100644 examples/code-agent/tests/keyless-smoke.e2e.ts create mode 100644 packages/core/tools/src/code-mode.ts create mode 100644 packages/core/tools/src/ts-types.ts create mode 100644 packages/core/tools/tests/code-mode.spec.ts create mode 100644 packages/core/tools/tests/ts-types.spec.ts diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 12ce8033b9..970414d4df 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -102,6 +102,7 @@ flowchart LR svc_bash --> pkg_hooks_claude svc_bash --> pkg_hooks_codex svc_bash --> pkg_tool_bash + svc_codeRuntime --> pkg_tools svc_compact --> pkg_compact_basic svc_fs --> pkg_tool_fs svc_llm --> pkg_agent_loop @@ -139,7 +140,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-agent`](../packages/ui/stdio-agent), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local. | -| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | - | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the Code Mode RFC specifies the worker-thread backend and the tool-registry consumer). | +| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-mock`](../packages/support/subagent-mock) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6339911606..b700431f1d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -42,7 +42,8 @@ Source: [`packages/ui/acp/src/index.ts:115`](../packages/ui/acp/src/index.ts) * pre-created agent — ACP creates agents at `session/new`); `persona` is the * deployment persona (forwarded to the system-prompt plugin); `toolOrder` is * the explicit model-facing tool order (forwarded to the system-prompt plugin); - * `persistenceRoot` is the JSONL backend's directory. + * `tools` is the tool registry's config (its presentation `mode`, forwarded + * through agent-core); `persistenceRoot` is the JSONL backend's directory. */ export interface Config { /** Model name for ACP-created agents (must have a registered adapter). */ @@ -51,12 +52,16 @@ export interface Config { persona?: string /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ toolOrder?: string[] + /** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */ + tools?: ToolsConfig /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string } ``` -Source: [`packages/ui/acp-agent/src/index.ts:49`](../packages/ui/acp-agent/src/index.ts) +Depends on: [`ToolsConfig`](#deepseek-aidsh-tools) + +Source: [`packages/ui/acp-agent/src/index.ts:51`](../packages/ui/acp-agent/src/index.ts) ## `@deepseek-ai/dsh-agent-core` @@ -66,10 +71,12 @@ Source: [`packages/ui/acp-agent/src/index.ts:49`](../packages/ui/acp-agent/src/i * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt * plugin (the deployment's persona section and the explicit model-facing tool - * order). Every field is optional INPUT here because each owner's schema - * supplies the default (`[]` / `''` / absent — lexicographic); the schema is - * the INTERSECTION of the owners' own schemas, so validation and defaulting - * can never drift from them. + * order), the `tools` object to the tool registry (its presentation `mode`). + * Every field is optional INPUT here because each owner's schema + * supplies the default (`[]` / `''` / absent — lexicographic / `native`); the + * schema is the INTERSECTION of the owners' own schemas (the registry's + * nested under its `tools` key), so validation and defaulting can never + * drift from them. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ @@ -78,12 +85,14 @@ export interface Config { persona?: SystemPromptConfig['persona'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ toolOrder?: SystemPromptConfig['toolOrder'] + /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ + tools?: ToolsConfig } ``` -Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) +Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/core/agent-core/src/index.ts:69`](../packages/core/agent-core/src/index.ts) +Source: [`packages/core/agent-core/src/index.ts:71`](../packages/core/agent-core/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -456,6 +465,8 @@ export interface Config { persona?: string /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ toolOrder?: string[] + /** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */ + tools?: ToolsConfig /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ @@ -469,7 +480,9 @@ export interface Config { } ``` -Source: [`packages/ui/stdio-agent/src/index.ts:60`](../packages/ui/stdio-agent/src/index.ts) +Depends on: [`ToolsConfig`](#deepseek-aidsh-tools) + +Source: [`packages/ui/stdio-agent/src/index.ts:61`](../packages/ui/stdio-agent/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` @@ -703,6 +716,36 @@ export interface Config { Source: [`packages/web/tool-web/src/index.ts:37`](../packages/web/tool-web/src/index.ts) +## `@deepseek-ai/dsh-tools` + +Requires: `systemPrompt` + +```ts config-catalog +/** Plugin config: how the registered tools are presented to the model. */ +export interface Config { + /** + * The presentation mode. `'native'` (the default) contributes every + * registered tool as a wire function definition — byte-for-byte today's + * behavior. `'code'` contributes exactly ONE wire tool, `run_code`, plus + * the generated `tools:sdk` prompt section declaring every other tool as a + * TypeScript API the program calls. `'both'` contributes every native + * definition AND `run_code` + the SDK section. Non-native modes require a + * loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing + * or mismatched runtime rejects every prompt assembly with an actionable + * error (misconfiguration fails loud, before any model request). A + * configured `systemPrompt.toolOrder` naming native tools likewise rejects + * every assembly under `'code'` (those names are no longer contributed) — + * a deployment switching modes updates its order config or drops it. + */ + mode?: ToolPresentationMode +} + +/** How the registry presents its tools to the model (see {@link Config.mode}). */ +export type ToolPresentationMode = 'native' | 'code' | 'both' +``` + +Source: [`packages/core/tools/src/index.ts:290`](../packages/core/tools/src/index.ts) + ## `@deepseek-ai/dsh-web` ```ts config-catalog @@ -827,7 +870,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-tool-bash` — requires `tools` · `bash` · `systemPrompt` ([`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/index.ts)) - `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) -- `@deepseek-ai/dsh-tools` — requires `systemPrompt` ([`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts)) ## Seam packages (not directly loadable) diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 9180d88489..50c7cbccf8 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -49,6 +49,10 @@ Follow tool-bash's background pattern: a `run_in_background` flag returns a task Prefer not to build policy into the tool. The seam is the `tools/pre-execute` gate (deny/ask — see the permission-gate example in [extension-cookbook.md](./extension-cookbook.md)) and the `tools/post-execute` inspect/transform seam, or a sandboxing implementation behind the tool's executor seam. +## Code Mode reaches your tool for free + +Under the registry's non-native `mode` ([Code Mode](../../packages/core/tools/README.md)), a registered tool is ALSO callable from a `run_code` program as `await tools.(args)` — nothing to add. The generated SDK declares your parameters from the same JSON Schema `defineTool` emits (constructs outside that subset degrade to `unknown`), each program call re-enters `execute()` through both waterfalls, and a failed call rejects the program-side promise with your error text. Two consequences worth designing for: your `description` and parameter `description`s become JSDoc a model reads while WRITING CODE, and non-text result blocks reach programs as placeholders (text is the lingua franca of the bridge). + ## How your tool renders in an editor (ACP presentation) Your tool's `execute` returns model-facing content; its **editor card** is a separate, optional concern you declare with two pure display methods on the `defineTool` options. Design this alongside `execute`, not after — an editor (Zed, over the ACP bridge) shows the card, and a tool with no presentation falls back to a bland generic card (title = tool name, raw args as input). diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 8d776a75ef..309545554a 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -307,7 +307,7 @@ A tool was registered or unregistered (the available tool set changed). 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:97`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:111`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall @@ -319,7 +319,7 @@ Waterfall AFTER a tool runs — where hook plugins inspect the result and accept Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:92`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:106`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall @@ -331,7 +331,7 @@ Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook pl Types: [ToolExecution](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:76`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:90`](../../packages/core/tools/src/index.ts) ## Inherited events (cordis core + loader/hmr/timer) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 26126c4481..4c1c1a18e5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -213,7 +213,7 @@ Source: [`packages/core/system-prompt/src/index.ts:291`](../../packages/core/sys ## `ctx.tools` — `ToolRegistry` -Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → dispatch → `tools/post-execute` pipeline. The registry contributes its schemas into the system-prompt assembly. +Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → dispatch → `tools/post-execute` pipeline. The registry contributes its schemas into the system-prompt assembly — WHICH schemas is governed by its `mode` config (see Config.mode); under a non-native mode it also registers the `run_code` tool and the `tools:sdk` prompt section itself. ```ts cordis-catalog register(definition: ToolDefinition): () => void @@ -224,7 +224,7 @@ async execute(exec: ToolExecution): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:278`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:316`](../../packages/core/tools/src/index.ts) ## `ctx.web` — `WebService` diff --git a/docs/core-data-structures/code-runtime.md b/docs/core-data-structures/code-runtime.md index 1f87e8e8a4..cb1661e02f 100644 --- a/docs/core-data-structures/code-runtime.md +++ b/docs/core-data-structures/code-runtime.md @@ -1,6 +1,6 @@ # Code Runtime -The code-execution seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) whose interface ([dsh-code-runtime](../../packages/code-runtime/code-runtime), `ctx.codeRuntime`) runs one model-written program against host-provided async bindings and reports what it printed and returned. Code execution is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Backends differ by execution substrate and source language, both readonly descriptors on the service; the worker-thread backend and the tool-registry consumer (Code Mode) are specified in the [Code Mode RFC](../rfc/proposed/feature/2026-06-15-code-mode.md). +The code-execution seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) whose interface ([dsh-code-runtime](../../packages/code-runtime/code-runtime), `ctx.codeRuntime`) runs one model-written program against host-provided async bindings and reports what it printed and returned. Code execution is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Backends differ by execution substrate and source language, both readonly descriptors on the service; the worker-thread backend and the tool-registry consumer (Code Mode) are specified in the [Code Mode RFC](../rfc/implemented/feature/2026-06-15-code-mode.md). Source: [`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code-runtime/code-runtime/src/types.ts) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c10d7caa52..a4d640f44b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -31,8 +31,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:91`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:38`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:44`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:97`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:92`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:76`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:111`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:106`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:90`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | Maintenance mode: hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`. diff --git a/docs/module-graph.md b/docs/module-graph.md index fa02da2430..28627c833d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -112,7 +112,9 @@ flowchart TD pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session pkg_tools --> pkg_agent + pkg_tools --> pkg_code_runtime pkg_tools --> pkg_llm + pkg_tools --> pkg_session pkg_tools --> pkg_system_prompt pkg_compact_basic --> pkg_agent pkg_compact_basic --> pkg_compact @@ -201,12 +203,14 @@ flowchart TD pkg_acp_agent --> pkg_agent_core pkg_acp_agent --> pkg_app_boot pkg_acp_agent --> pkg_session_persistence_jsonl + pkg_acp_agent --> pkg_tools pkg_stdio_agent --> pkg_agent pkg_stdio_agent --> pkg_agent_core pkg_stdio_agent --> pkg_app_boot pkg_stdio_agent --> pkg_llm pkg_stdio_agent --> pkg_session pkg_stdio_agent --> pkg_session_persistence_jsonl + pkg_stdio_agent --> pkg_tools ``` | Package | Group | Depends on | @@ -235,7 +239,7 @@ flowchart TD | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt) | +| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | @@ -256,5 +260,5 @@ flowchart TD | [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | -| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | +| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools) | +| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 58fd548665..116b0f6038 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -209,6 +209,18 @@ Types: [CallId](core-data-structures/core.md) Source: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) +#### `tool/code-dispatch` — log-only + +One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the deterministic sub-call id (`:code:`), the tool `name` with its JSON-normalized `arguments` — the exact value dispatched, normalized BEFORE dispatch, so this append can never fail on payload shape — whether the sub-call errored, and a bounded `resultSummary` of its model-facing text. Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter model context; persistence and UIs get every call. Appended inside the parent `run_code`'s execution (the bridge drains its queue before returning), so the turn-enclosure invariant holds by construction. + +```ts persistence-catalog +'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string } +``` + +Types: [CallId](core-data-structures/core.md) + +Source: [`packages/core/tools/src/code-mode.ts:36`](../packages/core/tools/src/code-mode.ts) + #### `tool/result` — surface A completed tool call's model-facing result, plus an optional tool-private `meta` presentation payload. `meta` is opaque to the core (`unknown` — the producing tool owns its shape and reads it back in `presentResult`) but MUST be JSON-serializable: `Session.append` runtime-validates all event data with `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the durable log reproduces the identical card on replay. Absent unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index b0134ac712..88758efad3 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -10,7 +10,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; |---|---| | [Agent Client Protocol (ACP) support — drive the coding agent from external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 | | [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 | -| [Code Mode — the model writes TypeScript against the tool registry](proposed/feature/2026-06-15-code-mode.md) | 2026-06-15 | | [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 | ### Simplification @@ -49,6 +48,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | Title | First proposed | |---|---| +| [Code Mode — the model writes TypeScript against the tool registry](implemented/feature/2026-06-15-code-mode.md) | 2026-06-15 | | [Filesystem tool schemas — model-facing read/write/edit shapes](implemented/feature/2026-06-17-filesystem-tool-schemas.md) | 2026-06-17 | | [Rich ACP bash rendering — the terminal card via the `_meta` convention](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 | | [Compaction as a capability seam (abstract contract + basic backend)](implemented/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 | diff --git a/docs/rfc/proposed/feature/2026-06-15-code-mode.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.md similarity index 84% rename from docs/rfc/proposed/feature/2026-06-15-code-mode.md rename to docs/rfc/implemented/feature/2026-06-15-code-mode.md index b9a408241e..2f643b432e 100644 --- a/docs/rfc/proposed/feature/2026-06-15-code-mode.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md @@ -1,6 +1,6 @@ # RFC: Code Mode — the model writes TypeScript against the tool registry -Status: proposed +Status: implemented ## Problem @@ -12,7 +12,7 @@ Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) proposes an alt An earlier draft of this RFC designed Code Mode as an add-on consumer plugin with zero core changes, deferring the execution substrate to a follow-up. Both constraints are dropped here, deliberately. First, the harness is pre-release and optimizes for the correct foundation over blast radius: tool presentation is the registry's own concern, and bolting a second presentation onto it from outside means transforming the registry's contribution after the fact — a waterfall listener whose correctness depends on listener ordering, which fights the [reconstructable-requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md) design instead of riding it (that refactor removed request mutation from `agent/request`, the seam the old draft relied on). Second, the substrate question is answerable now: a Node `worker_threads` runtime gives real containment — separate isolate, empty environment, heap caps, and a `terminate()` that reliably stops a hot synchronous loop — where the old draft's `node:vm` stub had none of those, and it fits the harness's existing trust model (§Trust posture) without a hardening follow-up. -## Proposal +## Decision Three decisions, each elaborated in its own section below: @@ -82,16 +82,25 @@ The worker runtime is **containment, not a security boundary**, and the RFC says The `tools:sdk` section carries the `.d.ts` plus fixed instructions: the program is the body of an async TypeScript function (erasable syntax only — no `enum`/namespaces; type annotations are advisory); call tools as `await tools.name(args)` (quoted access for exotic names); a failed tool call **rejects** with an `Error` carrying the tool's error text — catch it to handle and continue; calls run **sequentially** even under `Promise.all`; emit results via `return` and/or `console.log`, and only that curated output returns to the context — intermediate tool results never do. That last line is the payoff the whole design serves: output-side context cost becomes the model's own editorial decision. On the input side the `.d.ts` is not free — for a large tool surface it can rival the native JSON schemas it replaces (and `'both'` pays for the two side by side) — but it is prefix-stable, so provider prefix caching amortizes it; the win is workload-dependent and the RFC claims no more. -## Plan +## Consequences -Four stacked PRs, each gates-green (`typecheck`, `lint`, `test:coverage`, `test:snapshot`, `doc-sync`, `verify-module-graph`, `build`, `hygiene`) with docs updated in the same change: +The design shipped as four stacked changes — this RFC, the `dsh-code-runtime` interface package, the `dsh-code-runtime-worker` backend, and the `dsh-tools` integration — each gates-green with docs in the same change; review fixes landed on the change that introduced them and merged down. -1. **This RFC revision** (docs-only): the file rewritten as above (renamed `2026-06-15-code-mode.md`, same first-proposed date), regenerated RFC index. -2. **`dsh-code-runtime`** (interface package): the group `packages/code-runtime/`, abstract `CodeRuntime`, vocabulary types, ctx-key declaration; docs in the same change — group README + package README, the `packages/README.md` group table row, the `ctx.codeRuntime` row in [docs/architecture.md](../../../architecture.md)'s service map, and the regenerated cordis catalog (the new service class). Unit tier: HMR safety (dispose removes `ctx.codeRuntime`), contract docs. This package has no behavior to snapshot or e2e; its coverage story is unit-only by design. -3. **`dsh-code-runtime-worker`**: the implementation above, plus the regenerated config catalog (its `Config`). Unit tier (real workers, no mocks — they are cheap and local): output/value capture, log source attribution, error kinds (exception incl. non-erasable syntax, abort, worker-exit under OOM), the two budgets from both sides (a hot loop with an un-awaited pending dispatch still dies at `computeMs` busy time; a program idling on a slow binding outlives `computeMs` and dies only at `maxWallMs`), binding bridge hostility cases (unknown name, duplicate id, post-settlement message, `__proto__`/`constructor`/`toString` binding names), structured-clone fallback, cap truncation, `env` emptiness verified from inside the program, dispose-awaits-exit. A real-load-path test runs the built package (`lib/`) so the worker entry resolves both unbuilt (tsx) and built — the published-bin guard from [docs/testing.md](../../../testing.md). -4. **Native code mode in `dsh-tools`** + the end-to-end surface: mode config, provider switch, `tools:sdk` section, `jsonSchemaToTs`, `run_code` + dispatch bridge + serialization queue, `tool/code-dispatch` event; regenerated tool, config, and persistence catalogs; [docs/architecture.md](../../../architecture.md) (tool-pipeline prose) and the [adding-a-tool cookbook](../../../cookbook/adding-a-tool.md) cross-reference updated in the same change; an `examples/` leaf + `demo:code` script wiring the worker runtime with `mode: 'code'`; move this RFC to `implemented/`. Coverage named per tier now, per the plan-time rule: **unit** — codegen table (DSL subset, quoted names, `unknown` degradation, determinism), provider contribution per mode, `toolOrder × mode` rejection, missing-runtime/wrong-language loud failures, serialization non-overlap (a probe tool records enter/exit under `Promise.all`), abort stops the queue, binding rejection on `isError`, `CodeRunFailedError` → structured `isError`, event payloads, `deriveMessages()` ignores the event, HMR safety (mode flip via config reload removes tool + section); **e2e (with-key, self-skips)** — a real model, `mode: 'code'`, a task requiring two tool calls and curation, asserting the wire tool list was exactly `run_code` and the transcript's dispatch events; **snapshot (keyless replay)** — goldens for a `run_code` turn in `'code'` and `'both'`, pinning the SDK section text, the collapsed header tools, dispatch events, and the result card. +What exists now: -The four PRs land in order (each on the previous); per stacked-review practice, review fixes land on the PR that introduced them and merge down. +- **The seam**: `packages/code-runtime/` — `@deepseek-ai/dsh-code-runtime` (abstract `CodeRuntime`, the vocabulary above, `ctx.codeRuntime`) and `@deepseek-ai/dsh-code-runtime-worker` (the worker-thread backend, every cap a validated config field). Rows in the service map, capability-seams graph, config catalog, and cordis catalog. +- **The registry surface**: `ToolRegistry`'s first config (`mode`), the mode-aware wire contribution, the `tools:sdk` section, `jsonSchemaToTs`/`renderToolsSdk` (exported), `run_code` + the dispatch bridge + `CodeRunFailedError`, and the `tool/code-dispatch` log event (declaration-merged into `SessionEventMap`, regenerated into the persistence catalog; `run_code` in the tool catalog). +- **The composed surface**: the `tools` config forwards through `agent-core` and both app packages (`stdio-agent`, `acp-agent`); `examples/code-agent` + `demo:code` run the worker runtime under `mode: 'code'`; the adding-a-tool cookbook states that a registered tool is reachable from programs for free, and the tool-pipeline doc shows sub-dispatches re-entering both waterfalls. +- **Interactions inherited by deployments**: a `toolOrder` naming native tools rejects every assembly under `'code'` (update or drop the order config when switching modes); sub-call `additionalContext` is dropped by the bridge (a plural context channel is deferred until a real hook needs it through Code Mode); sub-dispatch stays serialized until tools can declare concurrency safety — the same metadata the native parallel-dispatch TODO waits on. + +## Testing + +What the suites pin, per tier: + +- **Unit — worker runtime** (real workers, no mocks): output/value capture and log-source attribution; error kinds (exception incl. non-erasable syntax, abort, worker-exit under OOM); the two budgets from both sides (a hot loop behind an un-awaited pending dispatch dies at `computeMs` busy time; a program idling on a slow binding outlives `computeMs` and dies only at `maxWallMs`); binding-bridge hostility (junk/forged port traffic incl. non-object messages and forged `log`/`done` cap bypass attempts, unknown names, duplicate ids, post-settlement replies, `__proto__`/`constructor`/`toString` binding names); structured-clone fallback and cap truncation; `env` emptiness verified from inside a program; dispose-awaits-exit. A real-load-path e2e runs the BUILT package under plain `node` so the worker entry resolves both unbuilt (tsx) and built — the published-artifact guard from [docs/testing.md](../../../testing.md). +- **Unit — registry integration**: the codegen table (DSL subset, quoted names, `unknown` degradation, byte-identical determinism); provider contribution per mode (`'native'` unchanged, `'code'` exactly `[run_code]`, `'both'` all + `run_code`); `toolOrder × mode` rejection; missing-runtime / wrong-language loud failures; serialization non-overlap (a probe tool records enter/exit under `Promise.all`); abort aborting the in-flight sub-dispatch and abandoning queued ones; binding rejection on `isError` and on JSON-unrepresentable arguments; `CodeRunFailedError` → structured `isError` carrying kind + logs; `tool/code-dispatch` payloads (JSON-normalized arguments identical to what dispatched); `deriveMessages()` ignoring the event; sub-call `additionalContext` suppression; HMR safety (disposing the registry removes the tool and the section). +- **e2e (with-key, self-skips)**: a real model under `mode: 'code'` composes two bash calls in one program (`examples/code-agent/tests/code-mode.e2e.ts`) — every logged `request/header` carries exactly `[run_code]`, the dispatch events land under the parent call, the file the program wrote exists, and the final answer is the curated output. +- **Snapshot (keyless replay)**: goldens for a `run_code` turn under `'code'` and `'both'` (`code-mode-turn`, `both-mode-turn`), each its own header-pinning class — the SDK section text, the collapsed header tool list, the dispatch events, and the result card are committed and replayed. ## Alternatives considered @@ -111,17 +120,6 @@ The four PRs land in order (each on the previous); per stacked-review practice, **A REPL-style persistent kernel** (state survives across `run_code` calls). Rejected for the MVP: cross-call state would be invisible to the session log, breaking the reconstructability guarantee that every request is a pure function of the log; fresh-per-run keeps it. A kernel-style backend remains expressible behind the seam later, with its own logging story. -## Acceptance criteria - -- `mode: 'native'` (and unset) is byte-for-byte today's behavior: same assemblies, same headers, same snapshots. -- Under `mode: 'code'`, the assembled tool list (and thus the logged `request/header`) is exactly `[run_code]`; under `'both'`, every native schema plus `run_code`; the `tools:sdk` section is present in both, absent under `'native'`, and its text is deterministic for a fixed tool set (byte-identical across consecutive assemblies). -- The generated `.d.ts` covers every registered tool except `run_code`, non-identifier names via quoted keys, unsupported schema constructs as `unknown`, without codegen ever throwing. -- A program calling two tools returns only its curated output; each sub-call appears as a `tool/code-dispatch` event ordered by log `seq`, flows through `tools/pre-execute`/`post-execute` (a deny reaches the program as a binding rejection), and never enters derived messages; a binding argument that does not survive JSON normalization (`BigInt`, a circular structure) rejects before dispatch — nothing executes unlogged. -- `Promise.all` over three bindings produces non-overlapping `ctx.tools.execute()` intervals (probe-tool assertion); aborting mid-program stops the worker and dispatches nothing further; a budget expiry during a slow sub-dispatch aborts that dispatch (the probe tool observes its signal fire), `run_code` returns only after the queue drains, and no `tool/code-dispatch` event lands after `run_code`'s own `tool/result` in the log. -- Worker runtime: a hot `for(;;){}` run ends at the `computeMs` busy-time budget with `error.kind: 'timeout'` — including when the program fired an un-awaited binding call first (the pending-RPC decoy); a program idling on a slow binding does not consume `computeMs` and is bounded only by `maxWallMs`; OOM under `resourceLimits` yields `kind: 'worker-exit'` with the host process healthy; `process.env` inside a program is empty; non-erasable syntax yields `kind: 'exception'` without a worker spawn; disposal awaits worker exit. -- Misconfiguration is loud before any model request: non-native mode with no `ctx.codeRuntime`, a runtime whose `language ≠ 'typescript'`, and `toolOrder` naming a non-contributed tool all reject the assembly with actionable messages. -- The demo runs against the real API via `demo:code`; the snapshot goldens replay keylessly; all repo gates pass on every PR of the stack. - ## Risks **The worker is not a hard security boundary.** Deliberate and documented (§Trust posture): posture equals the existing bash tool, containment exceeds it, gating uses the same seams. Deployments needing more need a future `isolation: 'container'` backend — tracked as the seam's designed extension, not a TODO on this design. diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index a621d3299d..acfb8e0069 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -15,12 +15,38 @@ This table connects model-visible tool names to the plugin package and service s | Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note | | --- | --- | --- | --- | --- | --- | +| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch per bridged sub-call`, `tool/result` | - | Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time. | | `@deepseek-ai/dsh-tool-bash` | `bash`, `bash_kill`, `bash_output` | `ctx.tools`, `ctx.bash` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. | | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. | +## `@deepseek-ai/dsh-tools` + +### `run_code` + +Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it. + +```json +{ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "The program: the body of an async TypeScript function." + } + }, + "required": [ + "code" + ] +} +``` + +Source: [`packages/core/tools/src/code-mode.ts`](../packages/core/tools/src/code-mode.ts) + +Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time. + ## `@deepseek-ai/dsh-tool-bash` ### `bash` diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index db50a3beec..73711afc1f 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -14,7 +14,7 @@ flowchart TD denied["deny or ask
tool body skipped"] toolBody["Registered tool execute() body"] fsGate["fs/write-intent or fs/edit-intent
tool-fs mutations only"] - owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result"] + owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/code-dispatch"] post["tools/post-execute waterfall
accept, block, replace, add context"] context["Buffered additionalContext
context/message after all tool results"] toolResult["Session event: tool/result
single model-facing outcome"] @@ -34,6 +34,6 @@ flowchart TD toolResult --> presentResult ``` -Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate, while hook bridges and future permission prompts live on the generic tool waterfalls. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. +Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate, while hook bridges and future permission prompts live on the generic tool waterfalls. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. Code Mode rides the same pipeline twice over: `run_code` is itself a registered tool body, and each tool call its program makes re-enters `ctx.tools.execute()` through BOTH waterfalls — serialized one at a time, logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call's `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency). Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs. diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 6c1cc717df..177ee18aa0 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — Examples -Runnable demos that show how the harness is wired. **Examples are NOT workspaces** — each `examples/*/package.json` is a private, dependency-free stub with no build. They are booted as unbuilt `tsx` subprocesses via the cordis Loader reading a `cordis.yml`; the `@deepseek-ai/dsh-*` plugin names in those YAML files resolve through the root `tsconfig.json` `paths` map, not through `node_modules`. +Runnable demos showing how the harness is wired. **Examples are NOT workspaces** — each `examples/*/package.json` is a private, dependency-free stub, never built. They are booted as unbuilt `tsx` subprocesses via the cordis Loader reading a `cordis.yml`; the `@deepseek-ai/dsh-*` plugin names in those YAML files resolve through the root `tsconfig.json` `paths` map, not through `node_modules`. Because examples are not under the `packages/*/src` coverage gate, an example that grows real, reusable *logic* should extract it into a `packages/` package (where it gets the per-file 100% gate and a README). Keep only example-specific glue here: the `cordis.yml` wiring, demo-only mocks/teaching artifacts, and the e2e/snapshot scenarios. There is no `start.ts` — the boot glue (Loader tail, `.env` load, snapshot-mode selection, stdin-dispose lifecycle) lives in each app package's `bin` (`@deepseek-ai/dsh-stdio-agent`, `@deepseek-ai/dsh-acp-agent`), which the `demo:*` scripts invoke against the leaf `cordis.yml`. @@ -21,6 +21,7 @@ A keyless smoke that spawns the example from a temp cwd must set `TSX_TSCONFIG_P |---|---|---| | `echo-agent` | `tests/echo.e2e.ts` — boots the real `cordis.yml`, drives the echo tool round-trip and the direct canned reply | **N/A — keyless by nature** (the `mock-echo` model has no real provider) | | `coding-agent` | `tests/keyless-smoke.e2e.ts` — boots the full real tree (dummy key, no prompt → no model call), asserts banner + clean exit | `tests/{full-loop,coding-task,resume,compaction,todo-write}.e2e.ts` — real model + real bash + real todo_write, world-verified | +| `code-agent` | `tests/keyless-smoke.e2e.ts` — the Code Mode boot guard | `tests/code-mode.e2e.ts` — a real model composes two bash calls in one `run_code` program; collapsed header, dispatch events, written file all verified | | `acp-agent` | `pnpm run test:snapshot` — boots the real ACP subprocess and replays a recorded session keyless (incl. the hook matrix: a scenario per hook point × outcome for BOTH the Claude and Codex bridges — block, deny, ask, context-fold, force-continue); `tests/acp.e2e.ts` also asserts stdout purity without a key | `tests/acp.e2e.ts` — real ACP prompt, verifies a file the agent wrote; `tests/hooks.e2e.ts` — a real `PreToolUse` hook blocks bash, verifies the file is NOT written | See [the root AGENTS.md](../AGENTS.md) for repo-wide conventions and [docs/architecture.md](../docs/architecture.md) for the design. diff --git a/examples/README.md b/examples/README.md index 1e3134ba2d..1b9cdf080e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -19,6 +19,12 @@ A REPL agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + th Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details. +## code-agent + +The coding agent flipped to **Code Mode**: the worker-thread code runtime is loaded and the tool registry runs `mode: code`, so the model gets exactly one wire tool — `run_code` — plus a generated TypeScript SDK section, and composes bash/read/write/edit/todo_write by writing a program whose output it curates. + +Run with: `pnpm run demo:code` (needs `DEEPSEEK_API_KEY`). See [code-agent/README.md](code-agent/README.md) for what to try and how it differs from coding-agent. + ## acp-agent An agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio, via the [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent) app — drive it from Zed or any other ACP client. Also the home of the keyless snapshot tests. diff --git a/examples/acp-agent/both-mode.cordis.snapshot.yml b/examples/acp-agent/both-mode.cordis.snapshot.yml new file mode 100644 index 0000000000..67044b8066 --- /dev/null +++ b/examples/acp-agent/both-mode.cordis.snapshot.yml @@ -0,0 +1,32 @@ +# Both-mode REPLAY overlay: the same patched tree as both-mode.cordis.yml +# (registry in `mode: both` + the worker code runtime) with the keyless model +# swap from cordis.snapshot.yml (llm-deepseek disabled, llm-replay serving +# the recorded fixture). Patches do not compose across nested includes — +# an outer include's patch can only target entries in the file IT loads — so +# this file patches ./cordis.yml directly with the union of both overlays. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-agent' + config: + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + tools: + mode: both + persona: | + You are a coding assistant powered by the {{model}} model. Your working + directory is {{cwd}}. + + Verify your work by running the code or tests. Keep answers brief and + factual. + - insert: + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/both-mode.cordis.yml b/examples/acp-agent/both-mode.cordis.yml new file mode 100644 index 0000000000..b449a568ec --- /dev/null +++ b/examples/acp-agent/both-mode.cordis.yml @@ -0,0 +1,29 @@ +# Both-mode RECORD overlay: the live acp-agent tree (./cordis.yml) with two +# load-time patches — the app entry's config gains `tools: { mode: both }` +# (every native tool definition stays on the wire AND run_code + the generated +# TypeScript SDK prompt section ride along) and the worker-thread code runtime joins the +# tree as `ctx.codeRuntime`. The dsh-acp-agent bin boots this file when the +# snapshot harness records the both-mode scenario; DSH_SNAPSHOT=replay swaps +# it for the sibling both-mode.cordis.snapshot.yml. A config patch REPLACES +# the entry's whole config, so the base entry's fields are restated verbatim. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-agent' + config: + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + tools: + mode: both + persona: | + You are a coding assistant powered by the {{model}} model. Your working + directory is {{cwd}}. + + Verify your work by running the code or tests. Keep answers brief and + factual. + - insert: + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/acp-agent/code-mode.cordis.snapshot.yml b/examples/acp-agent/code-mode.cordis.snapshot.yml new file mode 100644 index 0000000000..bcaa225eba --- /dev/null +++ b/examples/acp-agent/code-mode.cordis.snapshot.yml @@ -0,0 +1,32 @@ +# Code Mode REPLAY overlay: the same patched tree as code-mode.cordis.yml +# (registry in `mode: code` + the worker code runtime) with the keyless model +# swap from cordis.snapshot.yml (llm-deepseek disabled, llm-replay serving +# the recorded fixture). Patches do not compose across nested includes — +# an outer include's patch can only target entries in the file IT loads — so +# this file patches ./cordis.yml directly with the union of both overlays. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-agent' + config: + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + tools: + mode: code + persona: | + You are a coding assistant powered by the {{model}} model. Your working + directory is {{cwd}}. + + Verify your work by running the code or tests. Keep answers brief and + factual. + - insert: + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/code-mode.cordis.yml b/examples/acp-agent/code-mode.cordis.yml new file mode 100644 index 0000000000..244cad9209 --- /dev/null +++ b/examples/acp-agent/code-mode.cordis.yml @@ -0,0 +1,29 @@ +# Code Mode RECORD overlay: the live acp-agent tree (./cordis.yml) with two +# load-time patches — the app entry's config gains `tools: { mode: code }` +# (the registry offers exactly one wire tool, run_code, plus the generated +# TypeScript SDK prompt section) and the worker-thread code runtime joins the +# tree as `ctx.codeRuntime`. The dsh-acp-agent bin boots this file when the +# snapshot harness records the code-mode scenarios; DSH_SNAPSHOT=replay swaps +# it for the sibling code-mode.cordis.snapshot.yml. A config patch REPLACES +# the entry's whole config, so the base entry's fields are restated verbatim. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-agent' + config: + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + tools: + mode: code + persona: | + You are a coding assistant powered by the {{model}} model. Your working + directory is {{cwd}}. + + Verify your work by running the code or tests. Keep answers brief and + factual. + - insert: + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index bde4f4dbb9..fbc6af65b4 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -69,18 +69,36 @@ interface Scenario { * Whether THIS scenario's fixtures keep the full request-header content (the * composed system prompt and tool schema list on `request/header` / * `request/header-delta` events) and compare it verbatim. Exactly one - * scenario pins it; every other scenario stores and compares that content as + * scenario pins it PER HEADER CLASS ({@link headerClass}); every other + * scenario of that class stores and compares that content as * `{{system}}`/`{{tools}}` tokens ({@link scrubRequestHeaders}), so a system - * prompt or tool-schema change shows up as ONE committed-fixture diff, not - * one per scenario. One pin suffices because header composition is - * suite-uniform (parent, spawn child, and fork child all compose the same - * prompt-modulo-cwd and the same tools) — and that premise is ASSERTED, not - * assumed: every non-pinning run's live headers must equal the pinned - * fixture's (normalized), so a session-dependent header (say, a restricted - * subagent toolset) fails loud until it gets its own pinning scenario. + * prompt or tool-schema change shows up as ONE committed-fixture diff per + * class, not one per scenario. One pin per class suffices because header + * composition is class-uniform (parent, spawn child, and fork child all + * compose the same prompt-modulo-cwd and the same tools) — and that premise + * is ASSERTED, not assumed: every non-pinning run's live headers must equal + * its class's pinned fixture's (normalized), so a session-dependent header + * (say, a restricted subagent toolset) fails loud until it gets its own + * pinning scenario. * Defaults to false. */ pinsHeader?: boolean + /** + * Which header-composition class this scenario belongs to. Scenarios that + * boot the same config compose the same header; each class has exactly one + * {@link pinsHeader} scenario, and the uniformity guard compares every + * other member against ITS class's pin. Defaults to `'default'` (the + * example's stock `cordis.yml`); the Code Mode scenarios — booting overlay + * configs whose tool list and prompt sections differ by construction — + * carry their own classes. + */ + headerClass?: string + /** + * Alternate live-config basename under `examples/acp-agent/` for this + * scenario's boot (the replay swap derives `*cordis.snapshot.yml` from it). + * Defaults to `cordis.yml`. + */ + configBase?: string } const SCENARIOS: Scenario[] = [ @@ -146,11 +164,28 @@ const SCENARIOS: Scenario[] = [ { name: 'hook-codex-posttool-block', hasModelTurn: true, recorded: true }, { name: 'hook-codex-posttool-context', hasModelTurn: true, recorded: true }, { name: 'hook-codex-stop-continue', hasModelTurn: true, recorded: true }, + // Code Mode: the registry in `mode: code` — the wire tool list collapses to + // [run_code], the tools:sdk section rides in the prompt, and the program's + // tool calls land as tool/code-dispatch events. Each mode boots its own + // overlay config, composes a different header by construction, and + // therefore pins its own class. + { name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configBase: 'code-mode.cordis.yml' }, + { name: 'both-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'both', configBase: 'both-mode.cordis.yml' }, ] -/** The single header-pinning scenario. Guarded here (and by a meta-test) so the pin cannot silently vanish. */ -const pinningScenario = SCENARIOS.find(s => s.pinsHeader === true) -if (pinningScenario === undefined) throw new Error('acp.snapshot: no scenario pins the request-header content') +/** Each header class's single pinning scenario. Guarded here (and by a meta-test) so a pin cannot silently vanish. */ +const pinningByClass = new Map() +for (const scenario of SCENARIOS) { + if (scenario.pinsHeader !== true) continue + const cls = scenario.headerClass ?? 'default' + const existing = pinningByClass.get(cls) + if (existing) throw new Error(`acp.snapshot: header class "${cls}" pinned by both ${existing.name} and ${scenario.name}`) + pinningByClass.set(cls, scenario) +} +for (const scenario of SCENARIOS) { + const cls = scenario.headerClass ?? 'default' + if (!pinningByClass.has(cls)) throw new Error(`acp.snapshot: no scenario pins the request-header content of class "${cls}" (needed by ${scenario.name})`) +} /** The sibling child-fixture paths for a scenario (`session.1.jsonl` …). */ function childFixturePaths(dir: string, childSessions: number): string[] { @@ -221,6 +256,11 @@ for (const scenario of SCENARIOS) { // replays from its own script. In RECORD they are harvested, not read. ...!RECORDING && childSessions > 0 ? { childFiles: childFixturePaths(dir, childSessions) } : {}, ...existsSync(workspaceDir) ? { workspaceDir } : {}, + // A scenario booting an overlay tree passes its live config; the bin's + // replay swap derives the sibling `*cordis.snapshot.yml` from it. + ...scenario.configBase !== undefined + ? { configPath: join(SNAPSHOTS_DIR, '..', '..', scenario.configBase) } + : {}, }) // Scrub every volatile id the run produced: the ACP server-issued session @@ -281,19 +321,20 @@ for (const scenario of SCENARIOS) { } } - // Header-uniformity guard: the single pin is sound only while every - // session in the suite composes the SAME header and keeps it for the - // whole run. Assert both halves live. (1) Every request/header the run - // produced (parent, spawn child, fork child, initial or resume) must - // equal the pinned fixture's header after each side is normalized - // against its own volatile values. (2) No request/header-delta may - // appear at all — a mid-run header change diverges from the pin by - // construction, and its content would be invisible under the scrub. If - // either fails, either the header changed (update the pin: re-record or - // hand-edit the pinning scenario's fixture) or composition became - // session-dependent by design (give the divergent shape its own - // pinning scenario). + // Header-uniformity guard: a class's single pin is sound only while + // every session in that class composes the SAME header and keeps it for + // the whole run. Assert both halves live. (1) Every request/header the + // run produced (parent, spawn child, fork child, initial or resume) + // must equal the CLASS's pinned fixture's header after each side is + // normalized against its own volatile values. (2) No + // request/header-delta may appear at all — a mid-run header change + // diverges from the pin by construction, and its content would be + // invisible under the scrub. If either fails, either the header changed + // (update the pin: re-record or hand-edit the pinning scenario's + // fixture) or composition became session-dependent by design (give the + // divergent shape its own pinning scenario and class). if (scenario.pinsHeader !== true) { + const pinningScenario = pinningByClass.get(scenario.headerClass ?? 'default')! const pinnedFixture = await readFile(join(SNAPSHOTS_DIR, pinningScenario.name, 'session.jsonl'), 'utf8') const pinned = normalizedHeaders(pinnedFixture, fixtureContext(pinnedFixture)) expect(pinned.length, `the pinning fixture (${pinningScenario.name}) must carry exactly one request/header`) @@ -350,10 +391,21 @@ describe('snapshot fixtures', () => { } }) - it('exactly one scenario pins the request-header content', () => { - // Zero pins would drop the prompt/schema surface from the suite entirely; - // two would split it. The single pin is the design (pinned-header RFC). - expect(SCENARIOS.filter(s => s.pinsHeader === true).map(s => s.name)).toEqual(['text-turn']) + it('exactly one scenario pins the request-header content of each header class', () => { + // Zero pins would drop a class's prompt/schema surface from the suite + // entirely; two would split it. One pin per class is the design + // (pinned-header RFC; the Code Mode classes compose different headers by + // construction, so each carries its own pin). + const pins = new Map() + for (const scenario of SCENARIOS.filter(s => s.pinsHeader === true)) { + const cls = scenario.headerClass ?? 'default' + pins.set(cls, [...pins.get(cls) ?? [], scenario.name]) + } + expect(Object.fromEntries(pins)).toEqual({ + 'default': ['text-turn'], + 'code': ['code-mode-turn'], + 'both': ['both-mode-turn'], + }) }) it('committed fixtures carry request-header content ONLY in the pinning scenario', async () => { diff --git a/examples/acp-agent/tests/snapshot-harness.ts b/examples/acp-agent/tests/snapshot-harness.ts index 8285b870bf..7b04b81e23 100644 --- a/examples/acp-agent/tests/snapshot-harness.ts +++ b/examples/acp-agent/tests/snapshot-harness.ts @@ -124,6 +124,14 @@ interface RunOptions { * start from an empty workspace. */ workspaceDir?: string + /** + * Alternate LIVE config path for the boot (absolute). Defaults to the + * example's `cordis.yml`. A scenario needing a differently-composed tree + * (the Code Mode scenarios) ships an overlay whose basename still ends in + * `cordis.yml`, so the bin's replay swap resolves the sibling + * `*cordis.snapshot.yml` the same way it does for the default. + */ + configPath?: string } /** @@ -163,7 +171,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise child = spawn( process.execPath, - ['--import', tsxLoader, binScript, configPath], + ['--import', tsxLoader, binScript, opts.configPath ?? configPath], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }, ) diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/input.json b/examples/acp-agent/tests/snapshots/both-mode-turn/input.json new file mode 100644 index 0000000000..699e4a2043 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl new file mode 100644 index 0000000000..a2b0e9921b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -0,0 +1,110 @@ +{"type":"session","version":0,"id":"55c51419-0ee3-4c06-8199-cc69eef57a45","createdAt":1783484575071,"cwd":"/tmp/acp-snap-cwd-lORmOD"} +{"type":"turn/start","seq":0,"time":1783484575075,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783484575076,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783484575078,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783484575079,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-lORmOD.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783484575489,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783484575489,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783484575561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783484575587,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783484575587,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783484575588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783484575588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":11,"time":1783484575588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":12,"time":1783484575613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} +{"type":"assistant/chunk","seq":13,"time":1783484575614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":14,"time":1783484575614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":15,"time":1783484575614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} +{"type":"assistant/chunk","seq":16,"time":1783484575639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":17,"time":1783484575639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" runs"}}} +{"type":"assistant/chunk","seq":18,"time":1783484575639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":19,"time":1783484575662,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":20,"time":1783484575663,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" B"}}} +{"type":"assistant/chunk","seq":21,"time":1783484575663,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OTH"}}} +{"type":"assistant/chunk","seq":22,"time":1783484575663,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":23,"time":1783484575688,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":24,"time":1783484575688,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" via"}}} +{"type":"assistant/chunk","seq":25,"time":1783484575689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":26,"time":1783484575689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".b"}}} +{"type":"assistant/chunk","seq":27,"time":1783484575689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ash"}}} +{"type":"assistant/chunk","seq":28,"time":1783484575689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":29,"time":1783484575713,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":30,"time":1783484575714,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":31,"time":1783484575714,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":32,"time":1783484575714,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":33,"time":1783484575714,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":34,"time":1783484575739,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":35,"time":1783484575739,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":36,"time":1783484575739,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":37,"time":1783484575740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":38,"time":1783484575815,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":39,"time":1783484575815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":40,"time":1783484575840,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":41,"time":1783484575841,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783484575841,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"code"}}} +{"type":"assistant/chunk","seq":43,"time":1783484575841,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783484575841,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":45,"time":1783484575865,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1783484575865,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":47,"time":1783484575865,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":48,"time":1783484575865,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":49,"time":1783484575890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":50,"time":1783484575891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":51,"time":1783484575891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":52,"time":1783484575891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":53,"time":1783484575891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":54,"time":1783484575891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":55,"time":1783484575916,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":56,"time":1783484575916,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" B"}}} +{"type":"assistant/chunk","seq":57,"time":1783484575916,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"OTH"}}} +{"type":"assistant/chunk","seq":58,"time":1783484575916,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":59,"time":1783484575916,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":60,"time":1783484575916,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":61,"time":1783484575942,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":62,"time":1783484575942,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":63,"time":1783484575942,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":64,"time":1783484576004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":65,"time":1783484576004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" B"}}} +{"type":"assistant/chunk","seq":66,"time":1783484576004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"OTH"}}} +{"type":"assistant/chunk","seq":67,"time":1783484576004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":68,"time":1783484576004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":69,"time":1783484576004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" }"}}} +{"type":"assistant/chunk","seq":70,"time":1783484576019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":");"}}} +{"type":"assistant/chunk","seq":71,"time":1783484576019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":72,"time":1783484576044,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":73,"time":1783484576075,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call run_code with a program that runs `echo BOTH_OK` via tools.bash and returns the output. Let me do that."}}}} +{"type":"assistant/chunk","seq":74,"time":1783484576075,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","arguments":"{\"code\": \"return await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\"}"}}}} +{"type":"assistant/chunk","seq":75,"time":1783484576075,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3733,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":33}}}} +{"type":"assistant/chunk","seq":76,"time":1783484576075,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":77,"time":1783484576078,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to call run_code with a program that runs `echo BOTH_OK` via tools.bash and returns the output. Let me do that."},{"type":"tool-call","id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","arguments":"{\"code\": \"return await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\"}"}],"usage":{"inputTokens":3733,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":33}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"} +{"type":"tool/call","seq":78,"time":1783484576078,"data":{"turn":1,"step":1,"callId":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","arguments":"{\"code\": \"return await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\"}"}} +{"type":"tool/code-dispatch","seq":79,"time":1783484576205,"data":{"parentCallId":"call_00_fwre7Dvt0ZU6p96bEBdk6728","subCallId":"call_00_fwre7Dvt0ZU6p96bEBdk6728:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Echo BOTH_OK"},"isError":false,"resultSummary":"BOTH_OK\n"}} +{"type":"tool/result","seq":80,"time":1783484576208,"data":{"turn":1,"step":1,"callId":"call_00_fwre7Dvt0ZU6p96bEBdk6728","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[78],"surfaceOp":"append"} +{"type":"step/end","seq":81,"time":1783484576208,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":82,"time":1783484576209,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":83,"time":1783484576645,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":84,"time":1783484576645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":85,"time":1783484576758,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":86,"time":1783484576782,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":87,"time":1783484576783,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":88,"time":1783484576783,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} +{"type":"assistant/chunk","seq":89,"time":1783484576783,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OTH"}}} +{"type":"assistant/chunk","seq":90,"time":1783484576783,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":91,"time":1783484576783,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":92,"time":1783484576810,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":93,"time":1783484576835,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":94,"time":1783484576836,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":95,"time":1783484576836,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":96,"time":1783484576836,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":97,"time":1783484576860,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":98,"time":1783484576894,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":99,"time":1783484576894,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"B"}}} +{"type":"assistant/chunk","seq":100,"time":1783484576894,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OTH"}}} +{"type":"assistant/chunk","seq":101,"time":1783484576894,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} +{"type":"assistant/chunk","seq":102,"time":1783484576894,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output is \"BOTH_OK\". Let me reply with that."}}}} +{"type":"assistant/chunk","seq":103,"time":1783484576894,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BOTH_OK"}}}} +{"type":"assistant/chunk","seq":104,"time":1783484576895,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":138,"outputTokens":18,"cacheReadTokens":3712,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":105,"time":1783484576895,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":106,"time":1783484576895,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output is \"BOTH_OK\". Let me reply with that."},{"type":"text","text":"BOTH_OK"}],"usage":{"inputTokens":138,"outputTokens":18,"cacheReadTokens":3712,"reasoningTokens":14}},"sourceEventSeqs":[83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105],"surfaceOp":"append"} +{"type":"step/end","seq":107,"time":1783484576895,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":108,"time":1783484576895,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl new file mode 100644 index 0000000000..d307e1d60f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl @@ -0,0 +1,55 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" call"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" program"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" runs"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" B"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OTH"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" via"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".b"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" do"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_fwre7Dvt0ZU6p96bEBdk6728","title":"Run code","kind":"execute","status":"in_progress","rawInput":"return await tools.bash({ command: \"echo BOTH_OK\", description: \"Echo BOTH_OK\" });"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_fwre7Dvt0ZU6p96bEBdk6728","status":"completed","content":[{"type":"content","content":{"type":"text","text":"BOTH_OK\n"}}],"title":"Run code (1 tool call)"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"B"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OTH"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"B"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OTH"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_OK"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/input.json b/examples/acp-agent/tests/snapshots/code-mode-turn/input.json new file mode 100644 index 0000000000..c6d4a1039e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO` — and return the two outputs joined with a plus sign. Then reply with that joined string only and stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl new file mode 100644 index 0000000000..dd9c0bed28 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl @@ -0,0 +1,196 @@ +{"type":"session","version":0,"id":"92c80cd8-dddc-4cd6-a05a-9676ef54af5e","createdAt":1783484558135,"cwd":"/tmp/acp-snap-cwd-zej9wx"} +{"type":"turn/start","seq":0,"time":1783484558139,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783484558139,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO` — and return the two outputs joined with a plus sign. Then reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783484558142,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783484558142,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-zej9wx.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783484558789,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783484558789,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783484558877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783484558904,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783484558905,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783484558905,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783484558905,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":11,"time":1783484558933,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1783484558933,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":13,"time":1783484558934,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":14,"time":1783484558934,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} +{"type":"assistant/chunk","seq":15,"time":1783484558934,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} +{"type":"assistant/chunk","seq":16,"time":1783484558957,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":17,"time":1783484558957,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} +{"type":"assistant/chunk","seq":18,"time":1783484558958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":19,"time":1783484558958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":20,"time":1783484558958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Calls"}}} +{"type":"assistant/chunk","seq":21,"time":1783484558982,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":22,"time":1783484558982,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":23,"time":1783484558982,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":24,"time":1783484559009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":25,"time":1783484559009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":26,"time":1783484559009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":27,"time":1783484559009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":28,"time":1783484559035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`\n"}}} +{"type":"assistant/chunk","seq":29,"time":1783484559035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":30,"time":1783484559035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":31,"time":1783484559036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Calls"}}} +{"type":"assistant/chunk","seq":32,"time":1783484559036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":33,"time":1783484559036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":34,"time":1783484559036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":35,"time":1783484559060,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":36,"time":1783484559061,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":37,"time":1783484559061,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":38,"time":1783484559061,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":39,"time":1783484559061,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`\n"}}} +{"type":"assistant/chunk","seq":40,"time":1783484559061,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} +{"type":"assistant/chunk","seq":41,"time":1783484559088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":42,"time":1783484559088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Returns"}}} +{"type":"assistant/chunk","seq":43,"time":1783484559088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":44,"time":1783484559088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} +{"type":"assistant/chunk","seq":45,"time":1783484559088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" outputs"}}} +{"type":"assistant/chunk","seq":46,"time":1783484559089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} +{"type":"assistant/chunk","seq":47,"time":1783484559114,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":48,"time":1783484559115,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":49,"time":1783484559115,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" plus"}}} +{"type":"assistant/chunk","seq":50,"time":1783484559115,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sign"}}} +{"type":"assistant/chunk","seq":51,"time":1783484559115,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n\n"}}} +{"type":"assistant/chunk","seq":52,"time":1783484559115,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Then"}}} +{"type":"assistant/chunk","seq":53,"time":1783484559186,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":54,"time":1783484559187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":55,"time":1783484559187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":56,"time":1783484559201,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":57,"time":1783484559201,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} +{"type":"assistant/chunk","seq":58,"time":1783484559201,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} +{"type":"assistant/chunk","seq":59,"time":1783484559227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":60,"time":1783484559227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":61,"time":1783484559227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":62,"time":1783484559227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":63,"time":1783484559227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":64,"time":1783484559251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" code"}}} +{"type":"assistant/chunk","seq":65,"time":1783484559252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":66,"time":1783484559330,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":67,"time":1783484559330,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":68,"time":1783484559330,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":69,"time":1783484559330,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":70,"time":1783484559357,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"code"}}} +{"type":"assistant/chunk","seq":71,"time":1783484559357,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":72,"time":1783484559357,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":73,"time":1783484559357,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":74,"time":1783484559382,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":75,"time":1783484559383,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":76,"time":1783484559383,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":77,"time":1783484559383,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":78,"time":1783484559383,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":79,"time":1783484559383,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":80,"time":1783484559410,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":81,"time":1783484559410,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":82,"time":1783484559410,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":83,"time":1783484559410,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":84,"time":1783484559410,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":85,"time":1783484559410,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":86,"time":1783484559435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":87,"time":1783484559435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":88,"time":1783484559435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"_"}}} +{"type":"assistant/chunk","seq":89,"time":1783484559435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"ONE"}}} +{"type":"assistant/chunk","seq":90,"time":1783484559435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":91,"time":1783484559435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":92,"time":1783484559461,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":93,"time":1783484559461,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":94,"time":1783484559461,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":95,"time":1783484559489,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":96,"time":1783484559489,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":97,"time":1783484559489,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"_"}}} +{"type":"assistant/chunk","seq":98,"time":1783484559515,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"ONE"}}} +{"type":"assistant/chunk","seq":99,"time":1783484559515,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":100,"time":1783484559516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":101,"time":1783484559516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":102,"time":1783484559516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":103,"time":1783484559516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":104,"time":1783484559541,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":105,"time":1783484559541,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":106,"time":1783484559542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":107,"time":1783484559542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":108,"time":1783484559542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":109,"time":1783484559542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":110,"time":1783484559566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":111,"time":1783484559567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":112,"time":1783484559567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":113,"time":1783484559567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":114,"time":1783484559567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":115,"time":1783484559567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"_T"}}} +{"type":"assistant/chunk","seq":116,"time":1783484559592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"WO"}}} +{"type":"assistant/chunk","seq":117,"time":1783484559592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":118,"time":1783484559593,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":119,"time":1783484559593,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":120,"time":1783484559593,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":121,"time":1783484559593,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":122,"time":1783484559617,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":123,"time":1783484559617,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":124,"time":1783484559618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"_T"}}} +{"type":"assistant/chunk","seq":125,"time":1783484559618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"WO"}}} +{"type":"assistant/chunk","seq":126,"time":1783484559618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":127,"time":1783484559618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":128,"time":1783484559643,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":129,"time":1783484559644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":130,"time":1783484559669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":131,"time":1783484559669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":".trim"}}} +{"type":"assistant/chunk","seq":132,"time":1783484559669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"()"}}} +{"type":"assistant/chunk","seq":133,"time":1783484559669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" +"}}} +{"type":"assistant/chunk","seq":134,"time":1783484559669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" \\\"+"}}} +{"type":"assistant/chunk","seq":135,"time":1783484559697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":136,"time":1783484559698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" +"}}} +{"type":"assistant/chunk","seq":137,"time":1783484559698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":138,"time":1783484559698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":139,"time":1783484559698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":".trim"}}} +{"type":"assistant/chunk","seq":140,"time":1783484559698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"();"}}} +{"type":"assistant/chunk","seq":141,"time":1783484559724,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":142,"time":1783484559725,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":143,"time":1783484559777,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash with `echo CODE_ONE`\n2. Calls bash with `echo CODE_TWO`\n3. Returns the two outputs joined with a plus sign\n\nThen reply with just that joined string.\n\nLet me write the code."}}}} +{"type":"assistant/chunk","seq":144,"time":1783484559777,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}}}} +{"type":"assistant/chunk","seq":145,"time":1783484559777,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2121,"outputTokens":171,"cacheReadTokens":0,"reasoningTokens":61}}}} +{"type":"assistant/chunk","seq":146,"time":1783484559777,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":147,"time":1783484559780,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash with `echo CODE_ONE`\n2. Calls bash with `echo CODE_TWO`\n3. Returns the two outputs joined with a plus sign\n\nThen reply with just that joined string.\n\nLet me write the code."},{"type":"tool-call","id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}],"usage":{"inputTokens":2121,"outputTokens":171,"cacheReadTokens":0,"reasoningTokens":61}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146],"surfaceOp":"append"} +{"type":"tool/call","seq":148,"time":1783484559780,"data":{"turn":1,"step":1,"callId":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}} +{"type":"tool/code-dispatch","seq":149,"time":1783484559896,"data":{"parentCallId":"call_00_TBMd5LxIFwxqRBHOErfg0279","subCallId":"call_00_TBMd5LxIFwxqRBHOErfg0279:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Echo CODE_ONE"},"isError":false,"resultSummary":"CODE_ONE\n"}} +{"type":"tool/code-dispatch","seq":150,"time":1783484559908,"data":{"parentCallId":"call_00_TBMd5LxIFwxqRBHOErfg0279","subCallId":"call_00_TBMd5LxIFwxqRBHOErfg0279:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Echo CODE_TWO"},"isError":false,"resultSummary":"CODE_TWO\n"}} +{"type":"tool/result","seq":151,"time":1783484559913,"data":{"turn":1,"step":1,"callId":"call_00_TBMd5LxIFwxqRBHOErfg0279","content":[{"type":"text","text":"CODE_ONE+CODE_TWO"}],"isError":false,"meta":{"logs":[],"dispatches":2}},"sourceEventSeqs":[148],"surfaceOp":"append"} +{"type":"step/end","seq":152,"time":1783484559913,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":153,"time":1783484559914,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":154,"time":1783484560545,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":155,"time":1783484560545,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":156,"time":1783484560716,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":157,"time":1783484560744,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":158,"time":1783484560744,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":159,"time":1783484560769,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":160,"time":1783484560770,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":161,"time":1783484560795,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requested"}}} +{"type":"assistant/chunk","seq":162,"time":1783484560822,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":163,"time":1783484560823,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":164,"time":1783484560823,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":165,"time":1783484560823,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":166,"time":1783484560847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":167,"time":1783484560847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"+"}}} +{"type":"assistant/chunk","seq":168,"time":1783484560848,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":169,"time":1783484560848,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":170,"time":1783484560848,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":171,"time":1783484560848,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"`."}}} +{"type":"assistant/chunk","seq":172,"time":1783484560872,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":173,"time":1783484560872,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} +{"type":"assistant/chunk","seq":174,"time":1783484560898,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":175,"time":1783484560898,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":176,"time":1783484560898,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":177,"time":1783484560924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":178,"time":1783484560924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} +{"type":"assistant/chunk","seq":179,"time":1783484560924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":180,"time":1783484560924,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":181,"time":1783484560925,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":182,"time":1783484560925,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_"}}} +{"type":"assistant/chunk","seq":183,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":184,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"+"}}} +{"type":"assistant/chunk","seq":185,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":186,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_T"}}} +{"type":"assistant/chunk","seq":187,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} +{"type":"assistant/chunk","seq":188,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The result is exactly what was requested: `CODE_ONE+CODE_TWO`. I'll reply with just that string."}}}} +{"type":"assistant/chunk","seq":189,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} +{"type":"assistant/chunk","seq":190,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":135,"outputTokens":33,"cacheReadTokens":2176,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":191,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":192,"time":1783484560951,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The result is exactly what was requested: `CODE_ONE+CODE_TWO`. I'll reply with just that string."},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"usage":{"inputTokens":135,"outputTokens":33,"cacheReadTokens":2176,"reasoningTokens":25}},"sourceEventSeqs":[154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191],"surfaceOp":"append"} +{"type":"step/end","seq":193,"time":1783484560951,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":194,"time":1783484560951,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl new file mode 100644 index 0000000000..3660515e4c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl @@ -0,0 +1,98 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" program"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Calls"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Calls"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_T"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Returns"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" two"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" outputs"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" joined"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" plus"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sign"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" joined"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" string"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_TBMd5LxIFwxqRBHOErfg0279","title":"Run code","kind":"execute","status":"in_progress","rawInput":"const out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo CODE_ONE\" });\nconst out2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo CODE_TWO\" });\nreturn out1.trim() + \"+\" + out2.trim();"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_TBMd5LxIFwxqRBHOErfg0279","status":"completed","content":[{"type":"content","content":{"type":"text","text":"CODE_ONE+CODE_TWO"}}],"title":"Run code (2 tool calls)"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" requested"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"+"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_T"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'ll"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" string"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"+"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_T"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"WO"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/code-agent/README.md b/examples/code-agent/README.md new file mode 100644 index 0000000000..9b6619c0b3 --- /dev/null +++ b/examples/code-agent/README.md @@ -0,0 +1,17 @@ +# code-agent — the Code Mode demo + +The [Code Mode](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) form of the coding agent: instead of one native tool call per step, the model is offered exactly ONE wire tool — `run_code` — plus a generated TypeScript SDK section declaring every other registered tool (`bash`, `read`, `write`, `edit`, `todo_write`). The model composes tools by writing a program; the program runs in a fresh worker thread (`@deepseek-ai/dsh-code-runtime-worker`), its tool calls bridge back through the ordinary `tools/pre-execute`/`post-execute` pipeline one at a time, each is logged as a `tool/code-dispatch` session event, and ONLY what the program prints or returns re-enters the model's context. + +```sh +pnpm run demo:code # needs DEEPSEEK_API_KEY (repo-root .env works) +``` + +Try a task that spans several tool calls, e.g.: + +> Count the lines of every `*.md` file under docs/ and write the three largest to summary.txt. + +and watch the transcript: one `run_code` call, a program looping over tools, and a result the model curated instead of five round-trips of raw tool output. + +Two lines of `cordis.yml` make the difference from [examples/coding-agent](../coding-agent/README.md): the `code-runtime` entry (the worker-thread backend registering `ctx.codeRuntime`) and `tools: { mode: code }` on the app (flip it to `both` to offer native calls AND `run_code` side by side; remove both lines and it IS the coding agent). + +Tests: `tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with no prompt (the export-shape guard); `tests/code-mode.e2e.ts` is the with-key proof — a real model, a two-tool task, asserting the wire tool list was exactly `[run_code]`, the `tool/code-dispatch` events landed, and the curated answer came back. diff --git a/examples/code-agent/cordis.yml b/examples/code-agent/cordis.yml new file mode 100644 index 0000000000..e4bb4de1e5 --- /dev/null +++ b/examples/code-agent/cordis.yml @@ -0,0 +1,84 @@ +# The code-agent plugin tree: the Code Mode demo. The same spine as +# examples/coding-agent — the DeepSeek adapter, local bash, filesystem and +# todo tool stacks over the stdio chat app — with TWO differences that turn +# it into Cloudflare-style Code Mode: +# +# 1. `code-runtime` loads the worker-thread code-execution backend +# (`ctx.codeRuntime`): one fresh Node worker per run, TypeScript in. +# 2. `stdio-agent` sets `tools: { mode: code }`, so the model is offered +# exactly ONE wire tool — `run_code` — plus a generated TypeScript SDK +# prompt section declaring bash/read/write/edit/todo_write; the model +# composes them by WRITING A PROGRAM, and only what it prints or +# returns re-enters its context. +# +# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the +# environment — the dsh-stdio-agent bin loads the gitignored repo-root .env +# first. cordis.yml reads them via the `!!js` tag. + +# Hot-module reload for the dev/demo loop (needs `node --expose-internals`). +- id: hmr + name: '@cordisjs/plugin-hmr' + config: + root: ['.'] + +# The DeepSeek adapter. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - deepseek-v4-pro + - deepseek-v4-flash + +# Local bash executor for the spine's `bash` tool schemas. +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 60000 + +# The code-execution backend: `run_code` programs execute here, in one fresh +# worker thread per run with an empty environment, port-bridged tool +# bindings, and busy-time/wall-clock/heap caps (all overridable here). +- id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' + +# The stdio chat app with the registry flipped to Code Mode: the wire tool +# list collapses to [run_code] and the `tools:sdk` prompt section carries the +# generated TypeScript declarations for every other registered tool. +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' + config: + model: deepseek-v4-flash + tools: + mode: code + # Set RESUME_SESSION_ID to continue a prior persisted session (the ids + # live under ./.sessions); unset starts a fresh session each run. + resumeSessionId: !!js process.env.RESUME_SESSION_ID + persistenceRoot: './.sessions' + welcome: 'code-mode agent ready. Give it a multi-tool task.' + persona: | + You are code-agent, a coding assistant powered by the {{model}} model. + + You work by writing TypeScript programs for run_code: batch related + tool work into one program, loop and branch where it helps, and print + or return ONLY the findings that matter. + +# The model-facing todo_write tool: whole-list task tracking written to the +# session log (todo/write), rendered as a stdio checklist. +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + +# Filesystem capability stack: local provider, read-before-write/edit policy +# gate, then the model-facing read/write/edit tools — all reachable from a +# run_code program as `tools.read(...)` / `tools.write(...)` / `tools.edit(...)`. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' diff --git a/examples/code-agent/package.json b/examples/code-agent/package.json new file mode 100644 index 0000000000..0aa0e52c52 --- /dev/null +++ b/examples/code-agent/package.json @@ -0,0 +1,7 @@ +{ + "name": "code-agent-example", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "Runnable demo: Code Mode — the model writes TypeScript against the tool registry" +} diff --git a/examples/code-agent/tests/code-mode.e2e.ts b/examples/code-agent/tests/code-mode.e2e.ts new file mode 100644 index 0000000000..6b0771380d --- /dev/null +++ b/examples/code-agent/tests/code-mode.e2e.ts @@ -0,0 +1,115 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' + +/** + * The Code Mode with-key proof (the RFC's e2e tier): a REAL model under + * `mode: 'code'`, a task that requires composing two tool calls, verified + * against the WORLD — the persisted request header carried exactly + * `[run_code]` as the wire tool list, each sub-call landed as a + * `tool/code-dispatch` event, the file the program wrote exists on disk, and + * the final answer is the program's curated output. Key-gated (see + * vitest.e2e.config.ts); the keyless Loader-path smoke lives in + * `keyless-smoke.e2e.ts`. + */ + +const PERSONA = 'You are code-agent. You work by writing TypeScript programs for run_code: ' + + 'batch related tool work into one program and print or return ONLY the findings that matter.' + +let ctx: Context | undefined +let workdir: string | undefined + +afterEach(async () => { + // Always dispose, even on failure/retry/timeout: agent-loop teardown stops + // the loop, the executor kills stray processes, and the code runtime's + // dispose awaits worker exits. + await ctx?.fiber.dispose() + ctx = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +async function codeModeHarness(cwd: string): Promise { + const harness = new Context() + await harness.plugin(LlmService) + await harness.plugin(SessionStore) + await harness.plugin(SystemPrompt, { persona: PERSONA }) + await harness.plugin(ToolRegistry, { mode: 'code' }) + await harness.plugin(AgentRegistry) + await harness.plugin(AgentLoop, { agents: [] }) + await harness.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) + await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) + await harness.plugin(ToolBash) + await harness.plugin(WorkerCodeRuntime, {}) + return harness +} + +function waitForIdle(harness: Context, agent: ReactLoopAgent): Promise { + return new Promise((resolve) => { + const dispose = harness.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a program over real tools', () => { + it('collapses the wire tool list to [run_code], bridges sub-calls, and returns curated output', async () => { + workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-e2e-')) + ctx = await codeModeHarness(workdir) + const agent = ctx.agentLoop.create(AgentId('e2e-code-mode'), { model: 'deepseek-v4-flash' }) + + agent.send([{ + type: 'text', + text: 'Using one run_code program: run `echo alpha-7` with the bash tool, run `echo beta-9` with the bash tool, ' + + 'then write both outputs joined by a plus sign into combined.txt (bash heredoc or redirect), ' + + 'and return only the joined string.', + }]) + await waitForIdle(ctx, agent) + const events: SessionEvent[] = [...agent.session.events] + + // The wire contract: every request this session made offered EXACTLY ONE + // tool — run_code (the logged header snapshots the assembled list). + const headers = events.filter(event => event.type === 'request/header') + expect(headers.length).toBeGreaterThan(0) + for (const header of headers) { + expect(header.data.header.tools?.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + } + // The model actually went through run_code… + const calls = events.filter(event => event.type === 'tool/call') + expect(calls.length).toBeGreaterThan(0) + expect(calls.every(event => event.data.name === RUN_CODE_NAME)).toBe(true) + // …and the program's tool calls landed as dispatch events under it. + const dispatches = events.filter(event => event.type === 'tool/code-dispatch') + expect(dispatches.length).toBeGreaterThanOrEqual(2) + expect(dispatches.every(event => event.data.name === 'bash')).toBe(true) + const parents = new Set(calls.map(event => event.data.callId)) + expect(dispatches.every(event => parents.has(event.data.parentCallId))).toBe(true) + + // World verification: the file the program wrote, and the curated answer. + const combined = await readFile(join(workdir, 'combined.txt'), 'utf8') + expect(combined).toContain('alpha-7') + expect(combined).toContain('beta-9') + const finalMessage = events.findLast(event => event.type === 'assistant/message') + const finalText = finalMessage !== undefined + ? finalMessage.data.content.filter(block => block.type === 'text').map(block => block.text).join('') + : '' + expect(finalText).toContain('alpha-7') + expect(finalText).toContain('beta-9') + }, 180_000) +}) diff --git a/examples/code-agent/tests/keyless-smoke.e2e.ts b/examples/code-agent/tests/keyless-smoke.e2e.ts new file mode 100644 index 0000000000..4b7a150e99 --- /dev/null +++ b/examples/code-agent/tests/keyless-smoke.e2e.ts @@ -0,0 +1,90 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' + +/** + * Keyless Loader-path smoke for examples/code-agent: boot the REAL example + * through the `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` + * (the cordis Loader, `unwrapExports`, the full plugin tree incl. the + * worker-thread code runtime and the registry in `mode: code`), then close + * stdin with no prompt and assert the ready banner + a clean exit. + * + * No prompt is ever sent, so the model is NEVER called and no `run_code` + * turn happens — a dummy key lets `llm-deepseek`'s key-PRESENT check boot + * the tree. This is the export-shape guard (postmortem 0001) for the Code + * Mode composition; the with-key proof lives in `code-mode.e2e.ts`. + */ + +const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +// Dev/test run UNBUILT: resolve `@deepseek-ai/dsh-*` through the root tsconfig +// `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside +// the repo, so point it at the repo tsconfig. +const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) + +let child: ChildProcessWithoutNullStreams | undefined +let workdir: string | undefined + +afterEach(async () => { + if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') + child = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +async function bootAndEof(): Promise<{ stdout: string; code: number }> { + workdir = await mkdtemp(join(tmpdir(), 'code-agent-smoke-')) + const cwd = workdir + return new Promise((resolve, reject) => { + const proc = spawn( + process.execPath, + // --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:code). + ['--expose-internals', '--import', tsxLoader, binScript, configPath], + { + cwd, + env: { + ...process.env, + TSX_TSCONFIG_PATH: repoTsconfig, + // A dummy key so llm-deepseek's apply() (key-PRESENT check only) boots. + // No prompt is sent, so the adapter never streams — no network call. + DEEPSEEK_API_KEY: 'keyless-smoke-no-call', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ) + child = proc + let stdout = '' + let stderr = '' + proc.stdout.setEncoding('utf8') + proc.stdout.on('data', (chunk: string) => { stdout += chunk }) + proc.stderr.setEncoding('utf8') + proc.stderr.on('data', (chunk: string) => { stderr += chunk }) + + const timer = setTimeout(() => { + proc.kill('SIGKILL') + reject(new Error(`code-agent did not exit within 10s. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, 10_000) + + proc.on('exit', (code) => { + clearTimeout(timer) + if (code === 0) resolve({ stdout, code }) + else reject(new Error(`code-agent exited ${code}. stderr:\n${stderr}`)) + }) + proc.on('error', (err) => { clearTimeout(timer); reject(err) }) + + // No prompt — just EOF, so the stdio UI exits without ever running a turn. + proc.stdin.end() + }) +} + +describe('code-agent keyless smoke (real cordis.yml via the Loader)', () => { + it('boots the Code Mode plugin tree, prints its banner, and exits cleanly on EOF', async () => { + const { stdout, code } = await bootAndEof() + expect(code).toBe(0) + expect(stdout).toContain('code-mode agent ready.') + }, 15_000) +}) diff --git a/package.json b/package.json index bba471ea5f..4fce3dad57 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", + "demo:code": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/code-agent/cordis.yml", "demo:acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/cordis.yml", "postinstall": "node scripts/install-lefthook.mjs" }, diff --git a/packages/code-runtime/README.md b/packages/code-runtime/README.md index b98baa43e6..a4ab8956be 100644 --- a/packages/code-runtime/README.md +++ b/packages/code-runtime/README.md @@ -1,6 +1,6 @@ # code-runtime/ — code-execution capability family -The code-execution capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's Code Mode, specified alongside the seam in the [Code Mode RFC](../../docs/rfc/proposed/feature/2026-06-15-code-mode.md). **Product** packages. +The code-execution capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the generated TypeScript SDK); design in the [Code Mode RFC](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md). **Product** packages. | Package | Role | ctx key | |---|---|---| diff --git a/packages/code-runtime/code-runtime-worker/README.md b/packages/code-runtime/code-runtime-worker/README.md index b8f440397a..f691904e88 100644 --- a/packages/code-runtime/code-runtime-worker/README.md +++ b/packages/code-runtime/code-runtime-worker/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-code-runtime-worker -Worker-thread implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam: `WorkerCodeRuntime` runs each program in ONE fresh Node `worker_threads.Worker` — TypeScript in, type-stripped host-side, bindings bridged over the message port, `{ value, logs, error? }` out. **Containment, not a security boundary**: trust posture is bash-equivalent by design (the [Code Mode RFC](../../../docs/rfc/proposed/feature/2026-06-15-code-mode.md) § Trust posture), with containment bash does not have — separate isolate, empty environment, heap cap, hard termination. +Worker-thread implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam: `WorkerCodeRuntime` runs each program in ONE fresh Node `worker_threads.Worker` — TypeScript in, type-stripped host-side, bindings bridged over the message port, `{ value, logs, error? }` out. **Containment, not a security boundary**: trust posture is bash-equivalent by design (the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) § Trust posture), with containment bash does not have — separate isolate, empty environment, heap cap, hard termination. ## Config diff --git a/packages/code-runtime/code-runtime/README.md b/packages/code-runtime/code-runtime/README.md index 2d7b12add1..20c9274b9c 100644 --- a/packages/code-runtime/code-runtime/README.md +++ b/packages/code-runtime/code-runtime/README.md @@ -2,7 +2,7 @@ The **code-execution seam**: an abstract `CodeRuntime` service (`ctx.codeRuntime`) defining WHAT a code runtime does — run one model-written program against a set of host-provided async bindings and report `{ value, logs, error? }` — without saying HOW. -This package is the interface third of the capability (the bash trio is the template — see [capability seams](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): implementations subclass `CodeRuntime` and register the service; the consumer is the tool registry's Code Mode, which generates the model-facing SDK and bridges tool dispatch — both specified in the [Code Mode RFC](../../../docs/rfc/proposed/feature/2026-06-15-code-mode.md), whose first implementation is a Node worker-thread backend. The runtime knows nothing about tools or sessions: it is handed named async functions and a program string, and everything tool-shaped stays with the consumer. +This package is the interface third of the capability (the bash trio is the template — see [capability seams](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): implementations subclass `CodeRuntime` and register the service; the consumer is the tool registry's Code Mode, which generates the model-facing SDK and bridges tool dispatch — both specified in the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md), whose first implementation is a Node worker-thread backend. The runtime knows nothing about tools or sessions: it is handed named async functions and a program string, and everything tool-shaped stays with the consumer. ## Service API (`ctx.codeRuntime`) diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index af967da61d..5595469afe 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -7,7 +7,7 @@ * substrate (worker thread, separate process, container) and by source * language, both declared as readonly descriptors. The design and its * consumer (the tool registry's Code Mode) are specified in the Code Mode RFC - * (docs/rfc/proposed/feature/2026-06-15-code-mode.md). + * (docs/rfc/implemented/feature/2026-06-15-code-mode.md). * * The split mirrors the bash seam (`BashExecutor`): the runtime knows nothing * about tools or sessions — it is handed named async functions and a program, diff --git a/packages/core/agent-core/src/index.ts b/packages/core/agent-core/src/index.ts index 785c8d1ff2..a3221bdf8f 100644 --- a/packages/core/agent-core/src/index.ts +++ b/packages/core/agent-core/src/index.ts @@ -48,7 +48,7 @@ import z from 'schemastery' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt, { type Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import * as invariants from '@deepseek-ai/dsh-invariants' import * as toolBash from '@deepseek-ai/dsh-tool-bash' @@ -61,10 +61,12 @@ export const name = 'agent-core' * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt * plugin (the deployment's persona section and the explicit model-facing tool - * order). Every field is optional INPUT here because each owner's schema - * supplies the default (`[]` / `''` / absent — lexicographic); the schema is - * the INTERSECTION of the owners' own schemas, so validation and defaulting - * can never drift from them. + * order), the `tools` object to the tool registry (its presentation `mode`). + * Every field is optional INPUT here because each owner's schema + * supplies the default (`[]` / `''` / absent — lexicographic / `native`); the + * schema is the INTERSECTION of the owners' own schemas (the registry's + * nested under its `tools` key), so validation and defaulting can never + * drift from them. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ @@ -73,10 +75,12 @@ export interface Config { persona?: SystemPromptConfig['persona'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ toolOrder?: SystemPromptConfig['toolOrder'] + /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ + tools?: ToolsConfig } -/** Intersect the owners' schemas so validation + defaulting stay identical. */ -export const Config = z.intersect([AgentLoop.Config, SystemPrompt.Config]) as unknown as z +/** Intersect the owners' schemas so validation + defaulting stay identical (the registry's nested under `tools`). */ +export const Config = z.intersect([AgentLoop.Config, SystemPrompt.Config, z.object({ tools: ToolRegistry.Config })]) as unknown as z /** * Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber; @@ -101,7 +105,7 @@ export function apply(ctx: Context, config: Config): void { persona: config.persona ?? '', ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, }) - ctx.plugin(ToolRegistry) + ctx.plugin(ToolRegistry, config.tools ?? {}) ctx.plugin(AgentRegistry) ctx.plugin(invariants) ctx.plugin(toolBash) diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index aea87ad76c..7525fb8fd6 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -1,9 +1,18 @@ # dsh-tools -Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the allow/deny gate) → core dispatch → `tools/post-execute` (inspect/replace the result, attach context). +Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the allow/deny gate) → core dispatch → `tools/post-execute` (inspect/replace the result, attach context). The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both. ## Service: `ToolRegistry` (ctx key: `tools`) +### Config + +```yaml +tools: + mode: native # native (default) | code | both +``` + +`native` contributes every registered tool as a wire function definition — the default, byte-for-byte the pre-config behavior. `code` contributes exactly ONE wire tool, `run_code`, plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)). `both` contributes every native definition AND `run_code` + the SDK section. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way. + ### Public API - `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. Disposed with the calling fiber. @@ -116,6 +125,16 @@ const bash = defineTool({ }) ``` +### Code Mode + +Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the ONE wire tool `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per registered tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context. + +- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of every registered tool except `run_code` (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is TOTAL: constructs outside the `defineTool` subset degrade to `unknown`, never throw. +- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized BEFORE dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and the logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order — the tool contract carries no concurrency-safety metadata yet), gated by `tools/pre-execute`/`tools/post-execute` like any native call (a deny reaches the program as a binding rejection), and logged as one `tool/code-dispatch` session event (log-only: `deriveMessages()` never surfaces it) with the deterministic sub-id `:code:`. A failed sub-call REJECTS the program-side promise with the tool's error text — real code error handling, no bespoke envelope. A sub-call's `additionalContext` is deliberately DROPPED (no safe outlet mid-run without breaking tool-call/result adjacency; deferred until a real hook needs it through Code Mode). +- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. + +The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free — under `code`, the assembled tool list is exactly `[run_code]`, pinned by tests and the snapshot goldens. Try it: `pnpm run demo:code` ([examples/code-agent](../../../examples/code-agent/README.md)). + ### What is NOT here (TODO) - **Tool shapes review** — when real tools land (e.g. a concurrency-safety hint for parallel execution); phase 1 executes tool calls sequentially. diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index a6d3bbe0ca..f6425538b1 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -23,13 +23,20 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-code-runtime": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "cordis": "^4.0.0-rc.6" }, + "dependencies": { + "schemastery": "^3.18.0" + }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-code-runtime": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts new file mode 100644 index 0000000000..be3b7c91af --- /dev/null +++ b/packages/core/tools/src/code-mode.ts @@ -0,0 +1,280 @@ +/** + * Code Mode: the `run_code` tool and its dispatch bridge. The model writes a + * TypeScript program; the bridge hands it to `ctx.codeRuntime` with one + * async binding per registered tool, serializes every binding call through a + * per-run queue onto `ToolRegistry.execute()` (so `tools/pre-execute` / + * `tools/post-execute` gate sub-calls exactly like native ones), logs each + * sub-dispatch as a `tool/code-dispatch` session event, and returns only the + * program's curated output. The registry itself decides WHEN this tool + * exists (its `mode` config); this module owns only the tool and the bridge. + * + * @module @deepseek-ai/dsh-tools/src/code-mode + */ + +import { inspect } from 'node:util' +import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime' +import type {} from '@deepseek-ai/dsh-session' +import { defineTool } from './schema.ts' +import type { ToolDefinition, ToolRegistry } from './index.ts' + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * One bridged sub-dispatch from a `run_code` program: the parent + * `run_code` call id, the deterministic sub-call id + * (`:code:`), the tool `name` with its JSON-normalized + * `arguments` — the exact value dispatched, normalized BEFORE dispatch, + * so this append can never fail on payload shape — whether the sub-call + * errored, and a bounded `resultSummary` of its model-facing text. + * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter + * model context; persistence and UIs get every call. Appended inside the + * parent `run_code`'s execution (the bridge drains its queue before + * returning), so the turn-enclosure invariant holds by construction. + */ + 'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string } + } +} + +/** The model-facing name of the Code Mode tool. */ +export const RUN_CODE_NAME = 'run_code' + +/** The `tools:sdk` section order: inside the 100–199 tool-guidance band, after per-tool guidance sections. */ +export const SDK_SECTION_ORDER = 150 + +/** + * Thrown by `run_code` when the program run itself failed — a program + * exception, a budget expiry, an abort, or substrate death. Extends + * {@link HarnessError} (`code: 'CODE_RUN_FAILED'`); the registry's execution + * pipeline converts it into a structured `isError` result whose text carries + * the failure kind plus the captured logs, so the model can self-correct. + */ +export class CodeRunFailedError extends HarnessError { + constructor(message: string) { + super(message, 'CODE_RUN_FAILED') + this.name = 'CodeRunFailedError' + } +} + +/** + * Cap for a `tool/code-dispatch` event's `resultSummary`. A log-ergonomics + * constant, not config: the full result already flows to the program; the + * summary exists so log readers see what a sub-call returned at a glance. + */ +const SUMMARY_MAX_CHARS = 200 + +/** Bounded inspect for rendering a program's completion value into the model-facing text. */ +const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const + +/** Join a result's text blocks; a non-text block becomes a placeholder (an MVP limitation, stated in the SDK instructions). */ +function textOf(content: ContentBlock[]): string { + return content + .map((block) => { + switch (block.type) { + case 'text': return block.text + // ContentBlockMap is merge-extensible — future block kinds land here + // deliberately (no assertNever on merge-extensible unions). + default: return `[${block.type} content]` + } + }) + .join('\n') +} + +/** Bound a sub-call's model-facing text for the log event's `resultSummary`. */ +function summarize(text: string): string { + return text.length > SUMMARY_MAX_CHARS ? `${text.slice(0, SUMMARY_MAX_CHARS)}…` : text +} + +/** + * JSON-normalize one binding call's argument: a `JSON.parse(JSON.stringify(…))` + * round-trip, so the value dispatched to the tool and the value logged on the + * `tool/code-dispatch` event are the same JSON value by construction (the + * runtime's structured-clone boundary is wider than JSON; the session log + * accepts only JSON). A value that does not survive (`BigInt`, a circular + * structure, a bare function) rejects that one call with a model-correctable + * error. `undefined` passes through — the tool's own schema validation + * rejects it with its usual "must be an object" feedback. + */ +function jsonNormalizeArgs(value: unknown): unknown { + if (value === undefined) return undefined + let text: string | undefined + try { + text = JSON.stringify(value) + } catch (error: unknown) { + throw new Error(`tool arguments must be JSON-serializable: ${error instanceof Error ? error.message : String(error)}`) + } + // JSON.stringify's lib type claims `string`, but a bare function or symbol + // root really yields `undefined` at runtime — the guard is live. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (text === undefined) throw new Error('tool arguments must be JSON-serializable (got a value JSON cannot represent)') + return JSON.parse(text) as unknown +} + +/** Render the program's completion value for the model-facing result text (`''` when the program returned nothing). */ +function renderValue(value: unknown): string { + if (value === undefined) return '' + return typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS) +} + +/** The run_code result's `meta` payload (JSON-serializable; `presentResult` narrows it back). */ +interface RunCodeMeta { + logs: CodeRunResult['logs'] + dispatches: number +} + +/** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */ +function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined { + if (typeof meta !== 'object' || meta === null) return undefined + const m = meta as Record + if (!Array.isArray(m.logs) || typeof m.dispatches !== 'number') return undefined + return m as unknown as RunCodeMeta +} + +/** + * Build the `run_code` {@link ToolDefinition}: one required `code` parameter, + * executed through the dispatch bridge described in the module doc. The + * registry registers it under non-native modes. + * @param registry - the owning registry (sub-calls go through its `execute`, + * bindings cover its registered tools). + * @param requireRuntime - resolves `ctx.codeRuntime` or throws the loud + * misconfiguration error (shared with the registry's assembly-time checks). + * @returns the registry-ready definition. + */ +export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => CodeRuntime): ToolDefinition { + return defineTool({ + name: RUN_CODE_NAME, + description: + 'Execute a TypeScript program against the available tools. Write the BODY of an ' + + 'async function (erasable syntax only; top-level `await` and `return` work) and ' + + 'call tools as `await tools.name(args)` per the declarations in the system prompt. ' + + 'Only what you print or return comes back — curate it.', + parameters: { + code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' }, + }, + async execute(args, exec) { + const runtime = requireRuntime() + + // The run-scoped abort: follows the outer signal in, and fires when the + // run settles for ANY reason, so an in-flight sub-dispatch is aborted + // (its executor kills on this signal) instead of orphaned, and + // queued-unstarted dispatches are abandoned. + const runController = new AbortController() + const onOuterAbort = (): void => { runController.abort(exec.signal?.reason) } + if (exec.signal?.aborted) onOuterAbort() + exec.signal?.addEventListener('abort', onOuterAbort, { once: true }) + + let dispatches = 0 + // The per-run serialization queue: every binding call chains onto the + // tail, so even `Promise.all` executes the underlying tool calls one at + // a time in submission order (the tool contract carries no + // concurrency-safety metadata yet). The fold keeps the tail non-rejecting + // so one failed dispatch never poisons the chain. + let queue: Promise = Promise.resolve() + const enqueue = (task: () => Promise): Promise => { + const turn = queue.then(() => { + if (runController.signal.aborted) { + throw new Error(`run_code run is over (${String(runController.signal.reason)}); tool call abandoned`) + } + return task() + }) + queue = turn.then(() => undefined, () => undefined) + return turn + } + + // Read through a call, not a bare property: the abort state genuinely + // changes across awaits, and a direct `.aborted` re-check after one + // would be narrowed away by control flow analysis. + const runOver = (): boolean => runController.signal.aborted + + const binding = (name: string): CodeBindingFunction => async (rawArgs: unknown): Promise => { + if (runOver()) { + throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} not dispatched`) + } + const normalized = jsonNormalizeArgs(rawArgs) + const outcome = await enqueue(async () => { + const n = ++dispatches + const subCallId = CallId(`${String(exec.callId)}:code:${n}`) + const result = await registry.execute({ + callId: subCallId, + name, + arguments: normalized, + ...exec.agent ? { agent: exec.agent } : {}, + signal: runController.signal, + }) + const text = textOf(result.content) + // Sub-call `additionalContext` is deliberately DROPPED here: the + // loop's buffering (append after the step's tool/results) has no + // safe analogue from inside a running run_code — injecting now + // would break tool-call/result adjacency. Deferred until a real + // hook needs it through Code Mode. + exec.agent?.session.append('tool/code-dispatch', { + parentCallId: exec.callId, + subCallId, + name, + arguments: normalized, + isError: result.isError, + resultSummary: summarize(text), + }) + return { text, isError: result.isError } + }) + // A budget expiry or outer cancel that lands while this call was in + // flight already aborted the dispatch; stop the program now rather + // than hand it a result from a run that is over. + if (runOver()) { + throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} result discarded`) + } + // A failed tool call REJECTS — real code signals failure by throwing, + // so try/catch and Promise.all short-circuiting behave as models + // expect (the error text is the tool's model-facing result text). + if (outcome.isError) throw new Error(outcome.text) + return outcome.text + } + + const functions: Record = {} + for (const schema of registry.schemas()) { + if (schema.name === RUN_CODE_NAME) continue + functions[schema.name] = binding(schema.name) + } + + try { + const result = await runtime.run({ + program: args.code, + bindings: [{ global: 'tools', functions }], + signal: runController.signal, + }) + // Quiescence before returning: fire the run-scoped abort (cancelling + // an in-flight sub-dispatch, abandoning queued ones), then await the + // queue's drain — an aborted sub-call still settles and logs its + // event INSIDE the open turn; nothing can append after we return. + runController.abort('run_code settled') + await queue + + if (result.error) { + const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.map(entry => entry.text).join('\n')}` : '' + throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`) + } + const rendered = renderValue(result.value) + const parts = [result.logs.map(entry => entry.text).join('\n'), rendered].filter(part => part.length > 0) + const meta: RunCodeMeta = { logs: result.logs, dispatches } + return { + content: [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }], + meta, + } + } finally { + exec.signal?.removeEventListener('abort', onOuterAbort) + } + }, + presentCall: args => ({ card: 'generic', title: 'Run code', kind: 'execute', rawInput: args.code }), + presentResult: (_args, result) => { + const meta = asRunCodeMeta(result.meta) + if (!meta) return undefined + const output = meta.logs.map(entry => entry.text).join('\n') + return { + card: 'generic', + title: `Run code (${meta.dispatches} tool call${meta.dispatches === 1 ? '' : 's'})`, + ...output.length > 0 ? { content: [{ type: 'text', text: output }] } : {}, + } + }, + }) +} diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 39dafd6f1a..e55113316d 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -5,15 +5,26 @@ * `tools/post-execute` (inspect/replace the result, attach context) for * sandbox, permission, and hook plugins to gate or transform a call. * + * The registry also owns HOW its tools are presented to the model — its + * `mode` config: `'native'` (every tool as a wire function definition, + * today's behavior and the default), `'code'` (the wire carries exactly one + * tool, `run_code`, plus a generated TypeScript SDK prompt section), or + * `'both'`. See `code-mode.ts` (the tool + dispatch bridge) and + * `ts-types.ts` (the SDK codegen); design in the Code Mode RFC. + * * @module @deepseek-ai/dsh-tools */ import { Context, Service } from 'cordis' +import z from 'schemastery' import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-system-prompt' +import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { ToolCallView, ToolResultView } from './presentation.ts' +import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts' +import { renderToolsSdk } from './ts-types.ts' export { defineTool, @@ -38,6 +49,9 @@ export { type StructuredScalar, } from './json-schema.ts' +export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts' +export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts' + // The render-intent vocabulary a tool declares via `presentCall`/`presentResult` // lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools` // stays the single public surface for consumers (producers + the ACP bridge). @@ -269,20 +283,100 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined { return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined } +/** How the registry presents its tools to the model (see {@link Config.mode}). */ +export type ToolPresentationMode = 'native' | 'code' | 'both' + +/** Plugin config: how the registered tools are presented to the model. */ +export interface Config { + /** + * The presentation mode. `'native'` (the default) contributes every + * registered tool as a wire function definition — byte-for-byte today's + * behavior. `'code'` contributes exactly ONE wire tool, `run_code`, plus + * the generated `tools:sdk` prompt section declaring every other tool as a + * TypeScript API the program calls. `'both'` contributes every native + * definition AND `run_code` + the SDK section. Non-native modes require a + * loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing + * or mismatched runtime rejects every prompt assembly with an actionable + * error (misconfiguration fails loud, before any model request). A + * configured `systemPrompt.toolOrder` naming native tools likewise rejects + * every assembly under `'code'` (those names are no longer contributed) — + * a deployment switching modes updates its order config or drops it. + */ + mode?: ToolPresentationMode +} + /** * Tool registry (`ctx.tools`): tool plugins register definitions; the agent * loop executes calls through the `tools/pre-execute` → dispatch → * `tools/post-execute` pipeline. The registry contributes its schemas into the - * system-prompt assembly. + * system-prompt assembly — WHICH schemas is governed by its `mode` config + * (see {@link Config.mode}); under a non-native mode it also registers the + * `run_code` tool and the `tools:sdk` prompt section itself. */ export class ToolRegistry extends Service { static inject = ['systemPrompt'] - private store = new Map() + static Config: z = z.object({ + mode: z.union(['native', 'code', 'both'] as const).default('native'), + }) - constructor(ctx: Context) { + private store = new Map() + private readonly mode: ToolPresentationMode + + constructor(ctx: Context, config: Config = {}) { super(ctx, 'tools') - ctx.systemPrompt.tools(() => this.schemas()) + // The schema already defaulted an omitted mode; the ?? narrows the + // optional-input type for direct (non-Loader) construction in tests. + this.mode = config.mode ?? 'native' + ctx.systemPrompt.tools(() => this.wireSchemas()) + if (this.mode !== 'native') { + this.register(createRunCodeTool(this, () => this.requireCodeRuntime())) + ctx.systemPrompt.section({ + name: 'tools:sdk', + order: SDK_SECTION_ORDER, + // A lazy thunk over the live store: regenerated at each assembly, in + // lexicographic tool order, so an unchanged tool set renders + // byte-identical text (prefix-cache-friendly) and a mid-session + // registration surfaces exactly like a native-mode tool change. + text: () => { + this.requireCodeRuntime() + return renderToolsSdk(this.schemas().filter(schema => schema.name !== RUN_CODE_NAME)) + }, + }) + } + } + + /** + * The registry's contribution to the wire tool list, per {@link Config.mode}. + * Because `PromptAssembly.tools` is what the loop's request header + * snapshots, the mode's collapse is logged and reconstructable for free. + * Under a non-native mode this is also the loud misconfiguration gate: no + * usable code runtime → every assembly rejects before any model request. + */ + private wireSchemas(): ToolSchema[] { + if (this.mode === 'native') return this.schemas() + this.requireCodeRuntime() + const all = this.schemas() + return this.mode === 'code' ? all.filter(schema => schema.name === RUN_CODE_NAME) : all + } + + /** + * Resolve the code runtime or throw the actionable misconfiguration error. + * Read at use time (assembly / run_code execution), NOT via static + * `inject`: an inject entry would hold `ctx.tools` — and every tool plugin + * behind it — hostage to a code runtime existing even under `mode: + * 'native'` (the loop's optional-backend idiom, same as + * `sessionPersistence`). + */ + private requireCodeRuntime(): CodeRuntime { + const runtime = this.ctx.get('codeRuntime') + if (!runtime) { + throw new Error(`dsh-tools: mode "${this.mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`) + } + if (runtime.language !== 'typescript') { + throw new Error(`dsh-tools: mode "${this.mode}" generates a TypeScript SDK, but the loaded code runtime's language is "${runtime.language}"`) + } + return runtime } /** diff --git a/packages/core/tools/src/ts-types.ts b/packages/core/tools/src/ts-types.ts new file mode 100644 index 0000000000..63ebd0f888 --- /dev/null +++ b/packages/core/tools/src/ts-types.ts @@ -0,0 +1,121 @@ +/** + * Code Mode codegen: the pure projection from registered tool schemas to the + * TypeScript SDK text the model programs against (the `tools:sdk` prompt + * section). Sibling of `json-schema.ts` — `schemas()` (native function + * calling) and this module (the generated `declare const tools` surface) are + * two projections of the same store. + * + * TOTAL by design: {@link jsonSchemaToTs} maps the JSON-Schema subset the + * `defineTool` DSL emits and degrades every construct outside it (`$ref`, + * `oneOf`, `integer`, future MCP shapes, …) to `unknown` without ever + * throwing — codegen must never be the thing that fails an assembly. + * Deterministic: a fixed tool set renders byte-identical text (tools in + * lexicographic name order), so the section is prefix-cache-friendly. + * + * @module @deepseek-ai/dsh-tools/src/ts-types + */ + +import type { ToolSchema } from '@deepseek-ai/dsh-llm' + +/** Property names that are valid bare TS identifiers; anything else is quoted. */ +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ + +/** Render an object key: bare when it is a valid identifier, quoted otherwise (every name stays reachable, no aliasing). */ +function renderKey(name: string): string { + return IDENTIFIER.test(name) ? name : JSON.stringify(name) +} + +/** One `indent`-deep line prefix (two spaces per level). */ +function pad(indent: number): string { + return ' '.repeat(indent) +} + +/** A one-line JSDoc block for a schema `description`, or no lines when there is none. */ +function docLines(description: unknown, indent: number): string[] { + if (typeof description !== 'string' || description.length === 0) return [] + // Keep the doc a single-line comment per property: descriptions are prose + // (possibly with newlines); collapse whitespace so the rendered SDK stays + // stable and compact. A comment-closer inside the description is escaped so + // it cannot terminate the generated JSDoc early. + const collapsed = description.replace(/\s+/g, ' ').trim() + return [`${pad(indent)}/** ${collapsed.replaceAll('*/', String.raw`*\/`)} */`] +} + +/** + * Map one JSON-Schema node to a TypeScript type literal. Handles exactly the + * subset the `defineTool` DSL emits — `object` (`properties` + `required`), + * `string` (with `enum` → a literal union), `number`, `boolean`, `array` + * (`items`) — and returns `unknown` for anything else, without throwing. + * @param schema - the JSON-Schema node (any shape; hostile inputs degrade). + * @param indent - the indentation level for nested object members. + * @returns the TS type text (multi-line for objects with properties). + */ +export function jsonSchemaToTs(schema: unknown, indent = 0): string { + if (typeof schema !== 'object' || schema === null) return 'unknown' + const node = schema as Record + switch (node.type) { + case 'string': { + if (Array.isArray(node.enum) && node.enum.length > 0 && node.enum.every(value => typeof value === 'string')) { + return node.enum.map(value => JSON.stringify(value)).join(' | ') + } + return 'string' + } + case 'number': return 'number' + case 'boolean': return 'boolean' + case 'array': { + const item = jsonSchemaToTs(node.items, indent) + // Parenthesize a union item type so `('a' | 'b')[]` parses as intended. + return item.includes('|') ? `(${item})[]` : `${item}[]` + } + case 'object': { + const properties = node.properties + if (typeof properties !== 'object' || properties === null) return 'Record' + const entries = Object.entries(properties as Record) + if (entries.length === 0) return 'Record' + const required = new Set(Array.isArray(node.required) ? node.required.filter(name => typeof name === 'string') : []) + const lines: string[] = ['{'] + for (const [name, prop] of entries) { + const description = typeof prop === 'object' && prop !== null ? (prop as Record).description : undefined + lines.push(...docLines(description, indent + 1)) + lines.push(`${pad(indent + 1)}${renderKey(name)}${required.has(name) ? '' : '?'}: ${jsonSchemaToTs(prop, indent + 1)};`) + } + lines.push(`${pad(indent)}}`) + return lines.join('\n') + } + default: return 'unknown' + } +} + +/** The fixed model-facing usage contract rendered above the declarations (see the Code Mode RFC's "What the model sees"). */ +const SDK_INSTRUCTIONS = `## Writing code for run_code + +Pass \`run_code\` the body of an async TypeScript function (erasable syntax only — no \`enum\` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: + +- Call tools as \`await tools.name(args)\` — quoted access for exotic names: \`tools["my-tool"](args)\`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable. +- A FAILED tool call rejects with an \`Error\` carrying the tool's error text — \`try/catch\` it to handle and continue. +- Calls execute sequentially, even under \`Promise.all\`. +- Emit results with \`return\` and/or \`console.log(...)\`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. + +The available tools:` + +/** + * Render the full `tools:sdk` prompt section: the fixed usage instructions + * plus one `declare const tools` interface covering every given tool. + * Deterministic — tools are emitted in lexicographic name order, so an + * unchanged tool set produces byte-identical text across assemblies. + * @param schemas - the tool schemas to declare (the caller excludes + * `run_code` itself). + * @returns the complete section text. + */ +export function renderToolsSdk(schemas: ToolSchema[]): string { + const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0) + const members: string[] = [] + for (const schema of sorted) { + members.push(...docLines(schema.description, 1)) + members.push(`${pad(1)}${renderKey(schema.name)}(args: ${jsonSchemaToTs(schema.parameters, 1)}): Promise;`) + } + const declaration = members.length > 0 + ? `declare const tools: {\n${members.join('\n')}\n}` + : 'declare const tools: {}' + return `${SDK_INSTRUCTIONS}\n\n\`\`\`ts\n${declaration}\n\`\`\`` +} diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts new file mode 100644 index 0000000000..32dc161a86 --- /dev/null +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -0,0 +1,523 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' +import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' +import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' +import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEventMap } from '@deepseek-ai/dsh-session' + +/** + * Code Mode unit tier (per the RFC's plan): provider contribution per mode, + * misconfiguration rejections, the run_code dispatch bridge (serialization, + * abort, JSON normalization, error mapping, events, quiescence), and HMR + * safety — all against an in-repo fake runtime, exactly the + * interface/implementation/consumer shape the seam promises. + */ + +/** A scriptable in-repo CodeRuntime: each test sets `behavior` to drive the bindings however it needs. */ +class FakeRuntime extends CodeRuntime { + readonly language: string + readonly isolation = 'fake' + behavior: (request: CodeRunRequest) => Promise = () => Promise.resolve({ logs: [] }) + lastRequest?: CodeRunRequest + + constructor(ctx: Context, config: { language?: string } = {}) { + super(ctx) + this.language = config.language ?? 'typescript' + } + + run(request: CodeRunRequest): Promise { + this.lastRequest = request + return this.behavior(request) + } +} + +interface SetupOptions { + mode?: Config['mode'] + runtime?: false | { language?: string } + toolOrder?: string[] +} + +async function setup(options: SetupOptions = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt, { ...options.toolOrder ? { toolOrder: options.toolOrder } : {} }) + await ctx.plugin(ToolRegistry, { mode: options.mode ?? 'code' }) + let runtime: FakeRuntime | undefined + if (options.runtime !== false) { + await ctx.plugin(FakeRuntime, options.runtime ?? {}) + runtime = ctx.codeRuntime as FakeRuntime + } + return { ctx, tools: ctx.tools, systemPrompt: ctx.systemPrompt, runtime: runtime! } +} + +/** Register a trivial echo tool; returns the calls it received. */ +function registerEcho(ctx: Context, name = 'echo'): unknown[] { + const calls: unknown[] = [] + ctx.tools.register(defineTool({ + name, + description: `Echo tool ${name}.`, + parameters: { value: { type: 'string', required: true } }, + execute(args) { + calls.push(args) + return Promise.resolve([{ type: 'text' as const, text: `${name}:${args.value}` }]) + }, + })) + return calls +} + +/** A structural fake of the owning agent: captures session appends. */ +function fakeAgent(): { agent: Agent; events: { type: string; data: unknown }[] } { + const events: { type: string; data: unknown }[] = [] + const agent = { + session: { + append: (type: string, data: unknown) => { events.push({ type, data }) }, + }, + } as unknown as Agent + return { agent, events } +} + +/** Dispatch run_code through the registry pipeline, as the loop would. */ +async function runCode(ctx: Context, code: string, extras: { agent?: Agent; signal?: AbortSignal } = {}): Promise { + return ctx.tools.execute({ + callId: CallId('call-1'), + name: RUN_CODE_NAME, + arguments: { code }, + ...extras.agent ? { agent: extras.agent } : {}, + ...extras.signal ? { signal: extras.signal } : {}, + }) +} + +describe('mode-aware wire contribution', () => { + it("mode 'native' contributes every schema, no run_code, no SDK section — and needs no runtime", async () => { + const { ctx, systemPrompt } = await setup({ mode: 'native', runtime: false }) + registerEcho(ctx) + const assembly = await systemPrompt.assemble() + expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) + expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) + }) + + it("mode 'code' contributes exactly [run_code] plus the SDK section declaring the other tools", async () => { + const { ctx, systemPrompt } = await setup({ mode: 'code' }) + registerEcho(ctx) + const assembly = await systemPrompt.assemble() + expect(assembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + const sdk = assembly.sections.find(section => section.name === 'tools:sdk') + expect(sdk?.text).toContain('declare const tools: {') + expect(sdk?.text).toContain('echo(args:') + expect(sdk?.text).not.toContain('run_code(args:') + }) + + it("mode 'both' contributes every native schema plus run_code, and the SDK section", async () => { + const { ctx, systemPrompt } = await setup({ mode: 'both' }) + registerEcho(ctx) + const assembly = await systemPrompt.assemble() + expect(assembly.tools.map(tool => tool.name)).toEqual(['echo', RUN_CODE_NAME]) + expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(true) + }) + + it('renders byte-identical SDK text across consecutive assemblies of an unchanged tool set', async () => { + const { ctx, systemPrompt } = await setup({ mode: 'code' }) + registerEcho(ctx) + const first = await systemPrompt.assemble() + const second = await systemPrompt.assemble() + const text = (assembly: typeof first) => assembly.sections.find(section => section.name === 'tools:sdk')?.text + expect(text(first)).toBe(text(second)) + }) + + it('rejects every assembly when a non-native mode has no code runtime', async () => { + const { systemPrompt } = await setup({ mode: 'code', runtime: false }) + await expect(systemPrompt.assemble()).rejects.toThrow(/requires a code runtime/) + }) + + it("rejects every assembly when the runtime's language is not typescript", async () => { + const { systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } }) + await expect(systemPrompt.assemble()).rejects.toThrow(/language is "python"/) + }) + + it("rejects the assembly when toolOrder names a native tool that mode 'code' no longer contributes", async () => { + const { ctx, systemPrompt } = await setup({ mode: 'code', toolOrder: ['echo', ''] }) + registerEcho(ctx) + await expect(systemPrompt.assemble()).rejects.toThrow(/toolOrder lists unregistered tool "echo"/) + }) + + it('removes run_code and the SDK section when the registry fiber disposes (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, {}) + await ctx.plugin(FakeRuntime, {}) + const fiber = await ctx.plugin(ToolRegistry, { mode: 'code' }) + expect(ctx.tools.get(RUN_CODE_NAME)).toBeDefined() + await fiber.dispose() + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.tools).toEqual([]) + expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) + }) +}) + +describe('the run_code dispatch bridge', () => { + it('bridges tool calls, returns only the curated output, and logs one event per dispatch', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + const calls = registerEcho(ctx) + const { agent, events } = fakeAgent() + runtime.behavior = async (request) => { + const tools = request.bindings[0]!.functions + const first = await tools.echo!({ value: 'one' }) + const second = await tools.echo!({ value: 'two' }) + return { logs: [{ source: 'console', level: 'log', text: `saw ${String(first)}` }], value: second } + } + const result = await runCode(ctx, 'const …: string = …', { agent }) + expect(result.isError).toBe(false) + expect(result.content).toEqual([{ type: 'text', text: 'saw echo:one\necho:two' }]) + expect(calls).toEqual([{ value: 'one' }, { value: 'two' }]) + const dispatches = events.filter(event => event.type === 'tool/code-dispatch') + expect(dispatches.map(event => event.data)).toEqual([ + { parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', arguments: { value: 'one' }, isError: false, resultSummary: 'echo:one' }, + { parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', arguments: { value: 'two' }, isError: false, resultSummary: 'echo:two' }, + ]) + expect(result.meta).toEqual({ logs: [{ source: 'console', level: 'log', text: 'saw echo:one' }], dispatches: 2 }) + }) + + it('serializes Promise.all dispatches: tool executions never overlap, in submission order', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + const intervals: [string, string][] = [] + let active = 0 + ctx.tools.register(defineTool({ + name: 'probe', + description: 'Records execution overlap.', + parameters: { id: { type: 'string', required: true } }, + async execute(args) { + active++ + expect(active, 'probe executions overlapped').toBe(1) + intervals.push(['enter', args.id]) + await new Promise(resolve => setTimeout(resolve, 20)) + intervals.push(['exit', args.id]) + active-- + return [{ type: 'text' as const, text: args.id }] + }, + })) + runtime.behavior = async (request) => { + const tools = request.bindings[0]!.functions + const values = await Promise.all([tools.probe!({ id: 'a' }), tools.probe!({ id: 'b' }), tools.probe!({ id: 'c' })]) + return { logs: [], value: values.join(',') } + } + const result = await runCode(ctx, 'program') + expect(result.isError).toBe(false) + expect(intervals).toEqual([ + ['enter', 'a'], ['exit', 'a'], + ['enter', 'b'], ['exit', 'b'], + ['enter', 'c'], ['exit', 'c'], + ]) + expect(result.content[0]).toEqual({ type: 'text', text: 'a,b,c' }) + }) + + it('rejects the program-side call when the tool errors, with the tool error text', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + ctx.tools.register(defineTool({ + name: 'fail', + description: 'Always fails.', + parameters: {}, + execute(): Promise { return Promise.reject(new Error('deliberate failure')) }, + })) + runtime.behavior = async (request) => { + try { + await request.bindings[0]!.functions.fail!({}) + return { logs: [], value: 'unreachable' } + } catch (error: unknown) { + return { logs: [], value: `caught: ${error instanceof Error ? error.message : String(error)}` } + } + } + const result = await runCode(ctx, 'program') + expect(result.content[0]).toEqual({ type: 'text', text: 'caught: Error: deliberate failure' }) + }) + + it('a tools/pre-execute deny reaches the program as a binding rejection', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + registerEcho(ctx) + ctx.on('tools/pre-execute', (exec, next) => { + if (exec.name === 'echo') return Promise.resolve({ kind: 'deny' as const, reason: 'not on my watch' }) + return next() + }) + runtime.behavior = async (request) => { + try { + await request.bindings[0]!.functions.echo!({ value: 'x' }) + return { logs: [], value: 'unreachable' } + } catch (error: unknown) { + return { logs: [], value: `denied: ${error instanceof Error ? error.message : String(error)}` } + } + } + const result = await runCode(ctx, 'program') + expect(result.content[0]?.type).toBe('text') + expect((result.content[0] as { text: string }).text).toContain('not on my watch') + }) + + it('rejects a binding argument that does not survive JSON normalization, dispatching nothing', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + const calls = registerEcho(ctx) + const { agent, events } = fakeAgent() + runtime.behavior = async (request) => { + try { + await request.bindings[0]!.functions.echo!({ value: 'x', big: 1n }) + return { logs: [], value: 'unreachable' } + } catch (error: unknown) { + return { logs: [], value: error instanceof Error ? error.message : String(error) } + } + } + const result = await runCode(ctx, 'program', { agent }) + expect((result.content[0] as { text: string }).text).toContain('JSON-serializable') + expect(calls).toEqual([]) + expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([]) + }) + + it('dispatches the JSON-normalized value: what the tool sees is what the event logs', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + const calls = registerEcho(ctx) + const { agent, events } = fakeAgent() + runtime.behavior = async (request) => { + // A Date survives structured clone but is not JSON; the bridge + // normalizes it to its JSON form (an ISO string) BEFORE dispatch. + await request.bindings[0]!.functions.echo!({ value: 'x', when: new Date(0) }).catch(() => undefined) + return { logs: [] } + } + await runCode(ctx, 'program', { agent }) + expect(calls).toEqual([{ value: 'x', when: '1970-01-01T00:00:00.000Z' }]) + const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch'] + expect(dispatch.arguments).toEqual({ value: 'x', when: '1970-01-01T00:00:00.000Z' }) + }) + + it('suppresses sub-call additionalContext (deliberately; pinned)', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + registerEcho(ctx) + ctx.on('tools/post-execute', (exec, _result, next): Promise => { + if (exec.name === 'echo') { + return Promise.resolve({ + kind: 'accept' as const, + additionalContext: { content: [{ type: 'text' as const, text: 'context for the next request' }], source: { kind: 'plugin' as const, plugin: 'test' } }, + }) + } + return next() + }) + runtime.behavior = async (request) => { + await request.bindings[0]!.functions.echo!({ value: 'x' }) + return { logs: [], value: 'done' } + } + const result = await runCode(ctx, 'program') + expect(result.isError).toBe(false) + // The sub-call's context has no safe outlet mid-run; the parent result + // must not carry it either. + expect(result.additionalContext).toBeUndefined() + }) + + it('converts a failed run into a structured isError result carrying kind, message, and captured logs', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + runtime.behavior = () => Promise.resolve({ + logs: [{ source: 'console', level: 'log', text: 'got this far' }], + error: { kind: 'timeout', message: 'compute budget exhausted (300ms busy)' }, + }) + const result = await runCode(ctx, 'program') + expect(result.isError).toBe(true) + expect(result.error).toEqual({ name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' }) + const text = (result.content[0] as { text: string }).text + expect(text).toContain('code run failed (timeout)') + expect(text).toContain('compute budget exhausted') + expect(text).toContain('got this far') + }) + + it('CodeRunFailedError is a HarnessError with the CODE_RUN_FAILED code', () => { + const error = new CodeRunFailedError('boom') + expect(error.code).toBe('CODE_RUN_FAILED') + expect(error.name).toBe('CodeRunFailedError') + }) + + it('aborting the outer signal aborts the in-flight sub-dispatch and abandons queued ones', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + const seen: string[] = [] + let sawAbort = false + ctx.tools.register(defineTool({ + name: 'slow', + description: 'Slow tool observing its signal.', + parameters: { id: { type: 'string', required: true } }, + async execute(args, exec) { + seen.push(args.id) + await new Promise((resolve) => { + const timer = setTimeout(resolve, 500) + exec.signal?.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true }) + }) + return [{ type: 'text' as const, text: args.id }] + }, + })) + const controller = new AbortController() + runtime.behavior = async (request) => { + const tools = request.bindings[0]!.functions + const calls = [tools.slow!({ id: 'first' }).catch(() => 'rejected'), tools.slow!({ id: 'second' }).catch(() => 'rejected')] + setTimeout(() => { controller.abort('user-cancel') }, 50) + await Promise.all(calls) + // A real runtime would be terminated by the abort; the fake honors the + // contract by reporting the abort as the run failure. + return { logs: [], error: { kind: 'abort', message: 'user-cancel' } } + } + const result = await runCode(ctx, 'program', { signal: controller.signal }) + expect(result.isError).toBe(true) + expect((result.content[0] as { text: string }).text).toContain('code run failed (abort)') + expect(seen).toEqual(['first']) + expect(sawAbort).toBe(true) + }) + + it('runs without an owning agent: dispatches work, event logging is skipped', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + const calls = registerEcho(ctx) + runtime.behavior = async (request) => { + await request.bindings[0]!.functions.echo!({ value: 'x' }) + return { logs: [], value: 'ok' } + } + const result = await runCode(ctx, 'program') + expect(result.isError).toBe(false) + expect(calls).toEqual([{ value: 'x' }]) + }) + + it('executing run_code under a missing runtime is a structured isError, not a crash', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, {}) + await ctx.plugin(ToolRegistry, { mode: 'code' }) + const result = await runCode(ctx, 'program') + expect(result.isError).toBe(true) + expect((result.content[0] as { text: string }).text).toContain('requires a code runtime') + }) + + it('presents the pending call as a generic execute card carrying the program, and the result with the captured output', async () => { + const { ctx } = await setup({ mode: 'code' }) + const tool = ctx.tools.get(RUN_CODE_NAME)! + expect(tool.presentCall?.({ code: 'return 1' })).toEqual({ card: 'generic', title: 'Run code', kind: 'execute', rawInput: 'return 1' }) + const view = tool.presentResult?.({ code: 'return 1' }, { + content: [{ type: 'text', text: 'model-facing' }], + isError: false, + meta: { logs: [{ source: 'console', level: 'log', text: 'printed' }], dispatches: 1 }, + }) + expect(view).toEqual({ card: 'generic', title: 'Run code (1 tool call)', content: [{ type: 'text', text: 'printed' }] }) + // Plural title, and no content when the program printed nothing. + expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [], dispatches: 2 } })) + .toEqual({ card: 'generic', title: 'Run code (2 tool calls)' }) + // Replay with an unrecognizable meta falls back to the generic rendering. + expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { other: true } })).toBeUndefined() + expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false })).toBeUndefined() + }) + + it('renders non-text sub-result blocks as placeholders and truncates long event summaries', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + const { agent, events } = fakeAgent() + const long = 'x'.repeat(300) + ctx.tools.register(defineTool({ + name: 'mixed', + description: 'Returns mixed content.', + parameters: {}, + execute() { + return Promise.resolve([ + { type: 'text' as const, text: long }, + { type: 'reasoning' as const, text: 'hidden' }, + ]) + }, + })) + runtime.behavior = async (request) => { + const value = await request.bindings[0]!.functions.mixed!({}) + return { logs: [], value } + } + const result = await runCode(ctx, 'program', { agent }) + expect(result.isError).toBe(false) + expect((result.content[0] as { text: string }).text).toBe(`${long}\n[reasoning content]`) + const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch'] + expect(dispatch.resultSummary.length).toBe(201) + expect(dispatch.resultSummary.endsWith('…')).toBe(true) + }) + + it('rejects undefined, JSON-throwing, and JSON-unrepresentable binding arguments', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + registerEcho(ctx) + runtime.behavior = async (request) => { + const echo = request.bindings[0]!.functions.echo! + const catchMessage = (promise: Promise) => promise.then(() => 'resolved', (error: unknown) => error instanceof Error ? error.message : String(error)) + return { + logs: [], + value: [ + // undefined passes normalization untouched; the tool's own schema + // validation rejects it with its usual feedback. + await catchMessage(echo(undefined)), + // A toJSON that throws a NON-Error propagates out of JSON.stringify. + await catchMessage(echo({ toJSON() { throw 'raw-throw' } })), + // A bare function is a value JSON cannot represent at all. + await catchMessage(echo(() => 1)), + ].join(' | '), + } + } + const result = await runCode(ctx, 'program') + const text = (result.content[0] as { text: string }).text + expect(text).toContain('must be an object') + expect(text).toContain('JSON-serializable: raw-throw') + expect(text).toContain('a value JSON cannot represent') + }) + + it('renders a non-string completion value inspect-style', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + runtime.behavior = () => Promise.resolve({ logs: [], value: { n: 42 } }) + const result = await runCode(ctx, 'program') + expect((result.content[0] as { text: string }).text).toBe('{ n: 42 }') + }) + + it('reports a pre-aborted outer signal as the run failure without dispatching anything', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + const calls = registerEcho(ctx) + runtime.behavior = (request) => { + // The fake honors the seam contract for an already-aborted signal. + if (request.signal?.aborted) return Promise.resolve({ logs: [], error: { kind: 'abort' as const, message: String(request.signal.reason) } }) + return Promise.resolve({ logs: [], value: 'unreachable' }) + } + const controller = new AbortController() + controller.abort('too-late') + const result = await runCode(ctx, 'program', { signal: controller.signal }) + expect(result.isError).toBe(true) + expect((result.content[0] as { text: string }).text).toContain('code run failed (abort)') + expect(calls).toEqual([]) + }) + + it('rejects a binding invoked after the run is over without dispatching it', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + const calls = registerEcho(ctx) + const controller = new AbortController() + runtime.behavior = async (request) => { + controller.abort('cancelled-mid-run') + const message = await request.bindings[0]!.functions.echo!({ value: 'x' }) + .then(() => 'resolved', (error: unknown) => error instanceof Error ? error.message : String(error)) + return { logs: [], value: message } + } + const result = await runCode(ctx, 'program', { signal: controller.signal }) + expect(result.isError).toBe(false) + expect((result.content[0] as { text: string }).text).toContain('not dispatched') + expect(calls).toEqual([]) + }) + + it('a tool/code-dispatch event never derives a model message', () => { + const session = new Session(SessionId('code-mode-derive')) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('tool/code-dispatch', { + parentCallId: CallId('p1'), + subCallId: CallId('p1:code:1'), + name: 'echo', + arguments: { value: 'x' }, + isError: false, + resultSummary: 'echo:x', + }) + const derived = session.deriveMessages() + expect(derived).toHaveLength(1) + expect(derived[0]?.role).toBe('user') + }) + + it('defaults to native mode under direct construction with no config', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, {}) + const registry = new ToolRegistry(ctx) + expect(registry.get(RUN_CODE_NAME)).toBeUndefined() + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) + }) +}) diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index cacc2eef66..88cb05cf7c 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -35,7 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write']) + expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'run_code', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/core/tools/tests/ts-types.spec.ts b/packages/core/tools/tests/ts-types.spec.ts new file mode 100644 index 0000000000..df30a58238 --- /dev/null +++ b/packages/core/tools/tests/ts-types.spec.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { jsonSchemaToTs, renderToolsSdk } from '@deepseek-ai/dsh-tools/src/ts-types.ts' +import { schemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools' +import type { ToolSchema } from '@deepseek-ai/dsh-llm' + +describe('jsonSchemaToTs', () => { + it('maps the defineTool DSL subset', () => { + const cases: [unknown, string][] = [ + [{ type: 'string' }, 'string'], + [{ type: 'number' }, 'number'], + [{ type: 'boolean' }, 'boolean'], + [{ type: 'string', enum: ['a', 'b'] }, '"a" | "b"'], + [{ type: 'array', items: { type: 'number' } }, 'number[]'], + [{ type: 'array', items: { type: 'string', enum: ['x', 'y'] } }, '("x" | "y")[]'], + [{ type: 'array' }, 'unknown[]'], + [{ type: 'object' }, 'Record'], + [{ type: 'object', properties: {} }, 'Record'], + ] + for (const [schema, expected] of cases) { + expect(jsonSchemaToTs(schema), JSON.stringify(schema)).toBe(expected) + } + }) + + it('renders objects with required/optional keys, nested shapes, and per-property docs', () => { + const schema = schemaSpecToJsonSchema({ + path: { type: 'string', required: true, description: 'Absolute file path' }, + limit: { type: 'number' }, + opts: { + type: 'object', + properties: { deep: { type: 'boolean', required: true } }, + }, + }) + expect(jsonSchemaToTs(schema)).toBe([ + '{', + ' /** Absolute file path */', + ' path: string;', + ' limit?: number;', + ' opts?: {', + ' deep: boolean;', + ' };', + '}', + ].join('\n')) + }) + + it('is total: unsupported or hostile constructs degrade to unknown, never throw', () => { + const cases: unknown[] = [ + undefined, + null, + 42, + 'string-schema', + {}, + { type: 'integer' }, + { type: 'null' }, + { oneOf: [{ type: 'string' }] }, + { $ref: '#/defs/x' }, + { type: 'object', properties: 7 }, + { type: 'object', properties: { bad: { $ref: 'x' } } }, + { type: 'string', enum: [1, 2] }, + { type: 'string', enum: [] }, + ] + for (const schema of cases) { + expect(() => jsonSchemaToTs(schema), JSON.stringify(schema)).not.toThrow() + } + expect(jsonSchemaToTs({ type: 'integer' })).toBe('unknown') + expect(jsonSchemaToTs({ oneOf: [] })).toBe('unknown') + expect(jsonSchemaToTs({ type: 'object', properties: 7 })).toBe('Record') + expect(jsonSchemaToTs({ type: 'object', properties: { bad: { $ref: 'x' } }, required: ['bad'] })).toContain('bad: unknown;') + // A non-string-only enum degrades to plain string; an empty one too. + expect(jsonSchemaToTs({ type: 'string', enum: [1, 2] })).toBe('string') + expect(jsonSchemaToTs({ type: 'string', enum: [] })).toBe('string') + // A hostile required list only accepts string members. + expect(jsonSchemaToTs({ type: 'object', properties: { a: { type: 'string' } }, required: [7] })).toContain('a?: string;') + // A property VALUE that is not an object degrades to unknown (and can + // carry no description). + expect(jsonSchemaToTs({ type: 'object', properties: { weird: 42 } })).toContain('weird?: unknown;') + }) + + it('escapes a comment-closer inside a description so the generated JSDoc cannot end early', () => { + const rendered = jsonSchemaToTs({ + type: 'object', + properties: { glob: { type: 'string', description: 'a pattern like packages/*/tool-*/ over here' } }, + }) + expect(rendered).not.toContain('tool-*/ over') + expect(rendered).toContain(String.raw`tool-*\/ over`) + }) +}) + +describe('renderToolsSdk', () => { + const bash: ToolSchema = { + name: 'bash', + description: 'Run a shell command.', + parameters: schemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record, + } + const exotic: ToolSchema = { + name: 'my-mcp.tool', + description: 'Exotic name.', + parameters: schemaSpecToJsonSchema({}) as unknown as Record, + } + + it('declares every tool in lexicographic order with quoted keys for exotic names', () => { + const text = renderToolsSdk([exotic, bash]) + expect(text).toContain('declare const tools: {') + expect(text.indexOf('bash(args:')).toBeGreaterThan(0) + expect(text).toContain('"my-mcp.tool"(args:') + expect(text.indexOf('bash(args:')).toBeLessThan(text.indexOf('"my-mcp.tool"(args:')) + expect(text).toContain('): Promise;') + expect(text).toContain('/** Run a shell command. */') + // The fixed instruction lines the model relies on. + expect(text).toContain('erasable syntax only') + expect(text).toContain('rejects with an `Error`') + expect(text).toContain('sequentially, even under `Promise.all`') + expect(text).toContain('JSON-serializable') + }) + + it('is deterministic: same tool set, byte-identical text regardless of input order', () => { + expect(renderToolsSdk([bash, exotic])).toBe(renderToolsSdk([exotic, bash])) + // Equal names sort stably (the comparator's equal arm). + expect(renderToolsSdk([bash, bash])).toBe(renderToolsSdk([bash, bash])) + }) + + it('renders an empty declaration for an empty tool set', () => { + expect(renderToolsSdk([])).toContain('declare const tools: {}') + }) +}) diff --git a/packages/core/tools/tsconfig.json b/packages/core/tools/tsconfig.json index dedc111d87..68edd3b003 100644 --- a/packages/core/tools/tsconfig.json +++ b/packages/core/tools/tsconfig.json @@ -8,6 +8,12 @@ "src" ], "references": [ + { + "path": "../../core/session" + }, + { + "path": "../../code-runtime/code-runtime" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index f7afe835d2..306bfdbfeb 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -37,7 +37,7 @@ The hooks **themselves** run in the agent's session workspace: for the agent-sco | `SessionStart` | `agent/session-start` (emit) | additionalContext → `agent.inject()` into the new session (cannot block) | | `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `deny` → `PromptDecision.block`; additionalContext-only → delegate via `next()` then fold context onto the downstream decision (a later listener can still block/rewrite) | | `PreToolUse` | `tools/pre-execute` (waterfall) | `deny` → `PreToolDecision.deny`; `ask` → `PreToolDecision.ask` | -| `PostToolUse` | `tools/post-execute` (waterfall) | `deny` → `block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision | +| `PostToolUse` | `tools/post-execute` (waterfall) | `deny` → `block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision (a Code Mode sub-call’s context is dropped by the run_code bridge — see [the pipeline doc](../../../docs/tool-execution-pipeline.md)) | | `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue`, feeding its reason as next-step steering | | `SubagentStart` | `subagent/start` (emit) | additionalContext → `agent.inject()` into the live child | | `SubagentStop` | `subagent/end` (emit) | observe-only | diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index 736e8a5100..b3fbc39928 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -43,7 +43,7 @@ The hooks themselves run in the agent's session workspace: for the agent-scoped | `SessionStart` | `agent/session-start` (emit) | a plain-stdout hook's output → additionalContext → `agent.inject()` | | `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `block` (exit 2) → `PromptDecision.block`; additionalContext-only → delegate via `next()` then fold context onto the downstream decision | | `PreToolUse` | `tools/pre-execute` (waterfall) | `block` → `PreToolDecision.deny` (no `allow`/`ask`) | -| `PostToolUse` | `tools/post-execute` (waterfall) | `block` → `block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision | +| `PostToolUse` | `tools/post-execute` (waterfall) | `block` → `block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision (a Code Mode sub-call’s context is dropped by the run_code bridge — see [the pipeline doc](../../../docs/tool-execution-pipeline.md)) | | `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue` with the reason as next-step steering | A tool call's payload carries the real `tool_name` (the same value the matcher tests) and Codex's `tool_input: { command }` shape (the `command` arg when present, else `''`). The matcher subject is the tool name (`PreToolUse`/`PostToolUse`) or the session source (`SessionStart`); `UserPromptSubmit`/`Stop` ignore matchers. diff --git a/packages/ui/acp-agent/package.json b/packages/ui/acp-agent/package.json index e8b4f3723b..940a1cadab 100644 --- a/packages/ui/acp-agent/package.json +++ b/packages/ui/acp-agent/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-acp": "^0.0.1", "@deepseek-ai/dsh-agent-core": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6", "schemastery": "^3.17.0" }, @@ -45,6 +46,7 @@ "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent-core": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "cordis": "^4.0.0-rc.6", diff --git a/packages/ui/acp-agent/src/index.ts b/packages/ui/acp-agent/src/index.ts index 898ec9a509..0f5cbc630c 100644 --- a/packages/ui/acp-agent/src/index.ts +++ b/packages/ui/acp-agent/src/index.ts @@ -34,6 +34,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import * as acp from '@deepseek-ai/dsh-acp' import * as agentCore from '@deepseek-ai/dsh-agent-core' +import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' export const name = 'acp-agent' @@ -44,7 +45,8 @@ export const name = 'acp-agent' * pre-created agent — ACP creates agents at `session/new`); `persona` is the * deployment persona (forwarded to the system-prompt plugin); `toolOrder` is * the explicit model-facing tool order (forwarded to the system-prompt plugin); - * `persistenceRoot` is the JSONL backend's directory. + * `tools` is the tool registry's config (its presentation `mode`, forwarded + * through agent-core); `persistenceRoot` is the JSONL backend's directory. */ export interface Config { /** Model name for ACP-created agents (must have a registered adapter). */ @@ -53,6 +55,8 @@ export interface Config { persona?: string /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ toolOrder?: string[] + /** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */ + tools?: ToolsConfig /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string } @@ -64,6 +68,7 @@ export const Config: z = z.object({ // order" (the owning dsh-system-prompt schema does the same), while // schemastery's native [] default would read as an invalid configured list. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), + tools: ToolRegistry.Config, persistenceRoot: z.string().default('./.sessions'), }) @@ -78,6 +83,7 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(agentCore, { ...config.persona !== undefined ? { persona: config.persona } : {}, ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, + ...config.tools !== undefined ? { tools: config.tools } : {}, }) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) ctx.plugin(acp, { model: config.model }) diff --git a/packages/ui/stdio-agent/package.json b/packages/ui/stdio-agent/package.json index b8cf84cac4..bcac9d8d5d 100644 --- a/packages/ui/stdio-agent/package.json +++ b/packages/ui/stdio-agent/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-agent-core": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "cordis": "^4.0.0-rc.6", "schemastery": "^3.17.0" @@ -52,6 +53,7 @@ "@deepseek-ai/dsh-agent-core": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "cordis": "^4.0.0-rc.6", "schemastery": "^3.17.0" diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index 7d36db373e..f0328349aa 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -43,6 +43,7 @@ import ConsoleExporter from '@cordisjs/plugin-logger-console' import z from 'schemastery' import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' +import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import * as agentCore from '@deepseek-ai/dsh-agent-core' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import * as uiStdio from './stdio-chat.ts' @@ -64,6 +65,8 @@ export interface Config { persona?: string /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ toolOrder?: string[] + /** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */ + tools?: ToolsConfig /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ @@ -83,6 +86,7 @@ export const Config: z = z.object({ // order" (the owning dsh-system-prompt schema does the same), while // schemastery's native [] default would read as an invalid configured list. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), + tools: ToolRegistry.Config, persistenceRoot: z.string().default('./.sessions'), welcome: z.string().default('ready.'), resumeSessionId: z.string(), @@ -100,6 +104,7 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(agentCore, { ...config.persona !== undefined ? { persona: config.persona } : {}, ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, + ...config.tools !== undefined ? { tools: config.tools } : {}, agents: [{ id: AgentId('main'), model: config.model, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03a47d6866..88bd9d1147 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -303,13 +303,23 @@ importers: version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) packages/core/tools: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../agent + '@deepseek-ai/dsh-code-runtime': + specifier: workspace:^ + version: link:../../code-runtime/code-runtime '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../session '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt @@ -921,6 +931,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) @@ -972,6 +985,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 2848578f49..81829f121a 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -155,8 +155,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Code-execution seam', mode: 'seam', implementations: ['code-runtime-worker'], - consumers: [], - note: 'Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the Code Mode RFC specifies the worker-thread backend and the tool-registry consumer).', + consumers: ['tools'], + note: 'Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode).', }, { key: 'fs', @@ -641,7 +641,7 @@ function renderToolPipeline(): string { ' denied["deny or ask
tool body skipped"]', ' toolBody["Registered tool execute() body"]', ` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}
tool-fs mutations only"]`, - ` owned["Tool-owned session events
${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}"]`, + ` owned["Tool-owned session events
${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`, ` post["${mermaidCode('tools/post-execute')} waterfall
accept, block, replace, add context"]`, ' context["Buffered additionalContext
context/message after all tool results"]', ` toolResult["Session event: ${mermaidCode('tool/result')}
single model-facing outcome"]`, @@ -661,7 +661,7 @@ function renderToolPipeline(): string { ' toolResult --> presentResult', '```', '', - 'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate, while hook bridges and future permission prompts live on the generic tool waterfalls. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service.', + 'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate, while hook bridges and future permission prompts live on the generic tool waterfalls. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. Code Mode rides the same pipeline twice over: `run_code` is itself a registered tool body, and each tool call its program makes re-enters `ctx.tools.execute()` through BOTH waterfalls — serialized one at a time, logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).', '', ...maintenanceFooter(maintenance), ].join('\n') diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 24739e1388..67964aae36 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -38,7 +38,7 @@ import { basename, resolve } from 'node:path' import { Context } from 'cordis' import type { ToolSchema } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import WebService from '@deepseek-ai/dsh-web' @@ -84,6 +84,13 @@ interface ToolPackage { /** Plug the injected seams + the tool plugin onto a context that already * carries `systemPrompt` + `tools`. */ mount: (ctx: Context) => Promise + /** + * Config for the caller's `ToolRegistry` mount. The registry itself ships a + * model-facing tool (`run_code`, registered under a non-native `mode`), so + * ITS catalog entry boots the registry in the mode that surfaces it; + * every other entry uses the default (native) registry. + */ + toolsConfig?: ToolsConfig /** * A deployment note rendered after the package's tools, for a fact that * booting the package alone cannot show. The registered tool NAME can be a @@ -100,6 +107,20 @@ interface ToolPackage { * guard proves it is exhaustive against the on-disk glob. */ const TOOL_PACKAGES: ToolPackage[] = [ + { + pkg: '@deepseek-ai/dsh-tools', + dir: 'tools', + source: 'packages/core/tools/src/code-mode.ts', + requires: ['ctx.tools', 'ctx.codeRuntime (execution time)', 'ctx.systemPrompt'], + writes: ['tool/call', 'one tool/code-dispatch per bridged sub-call', 'tool/result'], + // The registry's OWN tool: run_code exists only under a non-native mode + // (the registry registers it in its constructor; the code runtime is read + // at assembly/execution time, so the schema harvest needs none mounted). + toolsConfig: { mode: 'code' }, + async mount() {}, + note: + 'Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time.', + }, { pkg: '@deepseek-ai/dsh-tool-bash', dir: 'tool-bash', @@ -231,7 +252,7 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES // fiber) — the repo's "dispose must reach quiescence" rule. try { await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) + await ctx.plugin(ToolRegistry, entry.toolsConfig ?? {}) await entry.mount(ctx) const schemas = ctx.tools.schemas().sort((a, b) => a.name.localeCompare(b.name)) catalog.push({ From 84088300bc437188e8f90f5044ea9907e467afb6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:39:51 +0800 Subject: [PATCH 24/57] fix: pre-dispatch rejection of unloggable args, mutation-proof event copies, proto-safe bindings (Codex round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the PR-4 convergence round: (A) A root-undefined binding argument passed normalization untouched, so the sub-call DISPATCHED and only then failed the tool/code-dispatch append (Session.append rejects undefined event data) — a sub-call executed with no log record, violating the nothing-executes-unlogged contract. And the tool received the SAME object later handed to the append, so a tool mutating its args desynced the logged record from what was dispatched (or re-poisoned the append). jsonNormalizeArgs now rejects undefined up front with a model-correctable message and returns TWO independent parses of the canonical JSON text: the tool gets one, the event logs the sibling — identical by construction, mutation-proof. (B) The bridge built its bindings record with plain-object assignment, so a registered tool named __proto__ hit the prototype setter and silently vanished (the runtime host resolves binding names as own properties). The record is now null-prototype with defineProperty, mirroring the worker-side namespace build. (B) The header-pin sanity assertions ran only inside NON-pinning scenarios, so a class consisting solely of its pinning scenario (the two Code Mode classes) would accept a re-recorded pin carrying several headers or a header-delta. A fixtures meta-test now asserts every pinning fixture directly. --- examples/acp-agent/tests/acp.snapshot.ts | 14 +++++ packages/core/tools/src/code-mode.ts | 42 +++++++++------ packages/core/tools/tests/code-mode.spec.ts | 57 ++++++++++++++++++--- 3 files changed, 92 insertions(+), 21 deletions(-) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index fbc6af65b4..5a22209c16 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -408,6 +408,20 @@ describe('snapshot fixtures', () => { }) }) + it('every pinning fixture carries exactly one request/header and no deltas', async () => { + // The live uniformity guard runs only in NON-pinning scenarios, so a + // class made of just its pinning scenario (the Code Mode classes) would + // otherwise accept a re-recorded pin with several headers or a mid-run + // header-delta — shapes the pin design cannot represent. Assert the + // committed pins directly. + for (const scenario of pinningByClass.values()) { + const fixture = await readFile(join(SNAPSHOTS_DIR, scenario.name, 'session.jsonl'), 'utf8') + const headers = normalizedHeaders(fixture, fixtureContext(fixture)) + expect(headers.length, `${scenario.name}: a pinning fixture must carry exactly one request/header`).toBe(1) + expect(headerDeltaCount(fixture), `${scenario.name}: a pinning fixture must carry no request/header-delta`).toBe(0) + } + }) + it('committed fixtures carry request-header content ONLY in the pinning scenario', async () => { // The whole point of the pin: a system-prompt or tool-schema change must // churn exactly one committed line. A non-pinning fixture that carries the diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index be3b7c91af..904c65ef0e 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -87,17 +87,21 @@ function summarize(text: string): string { } /** - * JSON-normalize one binding call's argument: a `JSON.parse(JSON.stringify(…))` - * round-trip, so the value dispatched to the tool and the value logged on the - * `tool/code-dispatch` event are the same JSON value by construction (the - * runtime's structured-clone boundary is wider than JSON; the session log - * accepts only JSON). A value that does not survive (`BigInt`, a circular - * structure, a bare function) rejects that one call with a model-correctable - * error. `undefined` passes through — the tool's own schema validation - * rejects it with its usual "must be an object" feedback. + * JSON-normalize one binding call's argument into TWO independent parses of + * the same canonical text: `dispatched` goes to the tool, `logged` to the + * `tool/code-dispatch` event — identical by construction (the runtime's + * structured-clone boundary is wider than JSON; the session log accepts only + * JSON), and separate objects, so a tool mutating its args can neither + * desync the log from what was dispatched nor re-poison the append. A value + * that does not survive the round-trip (`undefined` — the log rejects it as + * event data — `BigInt`, a circular structure, a bare function) rejects that + * one call BEFORE dispatch with a model-correctable error: nothing ever + * executes unlogged. */ -function jsonNormalizeArgs(value: unknown): unknown { - if (value === undefined) return undefined +function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } { + if (value === undefined) { + throw new Error('tool arguments must be JSON-serializable (call the tool with an arguments object, e.g. `{}`)') + } let text: string | undefined try { text = JSON.stringify(value) @@ -108,7 +112,7 @@ function jsonNormalizeArgs(value: unknown): unknown { // root really yields `undefined` at runtime — the guard is live. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (text === undefined) throw new Error('tool arguments must be JSON-serializable (got a value JSON cannot represent)') - return JSON.parse(text) as unknown + return { dispatched: JSON.parse(text) as unknown, logged: JSON.parse(text) as unknown } } /** Render the program's completion value for the model-facing result text (`''` when the program returned nothing). */ @@ -198,7 +202,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => const result = await registry.execute({ callId: subCallId, name, - arguments: normalized, + arguments: normalized.dispatched, ...exec.agent ? { agent: exec.agent } : {}, signal: runController.signal, }) @@ -212,7 +216,10 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => parentCallId: exec.callId, subCallId, name, - arguments: normalized, + // The SIBLING parse of the dispatched value: byte-identical JSON, + // but a separate object — a tool mutating its args cannot desync + // this record from what it actually received. + arguments: normalized.logged, isError: result.isError, resultSummary: summarize(text), }) @@ -231,10 +238,15 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => return outcome.text } - const functions: Record = {} + // Null-prototype + defineProperty, mirroring the worker-side namespace + // build: a registered tool named `__proto__` must become an ordinary + // own key (a plain-object assignment would hit the prototype setter, + // silently dropping the binding), and the runtime host resolves + // binding names as own properties only. + const functions: Record = Object.create(null) as Record for (const schema of registry.schemas()) { if (schema.name === RUN_CODE_NAME) continue - functions[schema.name] = binding(schema.name) + Object.defineProperty(functions, schema.name, { enumerable: true, value: binding(schema.name) }) } try { diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 32dc161a86..7fcd0890e6 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -431,17 +431,18 @@ describe('the run_code dispatch bridge', () => { expect(dispatch.resultSummary.endsWith('…')).toBe(true) }) - it('rejects undefined, JSON-throwing, and JSON-unrepresentable binding arguments', async () => { + it('rejects undefined, JSON-throwing, and JSON-unrepresentable binding arguments BEFORE dispatch', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) - registerEcho(ctx) + const calls = registerEcho(ctx) + const { agent, events } = fakeAgent() runtime.behavior = async (request) => { const echo = request.bindings[0]!.functions.echo! const catchMessage = (promise: Promise) => promise.then(() => 'resolved', (error: unknown) => error instanceof Error ? error.message : String(error)) return { logs: [], value: [ - // undefined passes normalization untouched; the tool's own schema - // validation rejects it with its usual feedback. + // Root undefined must reject up front: the event log rejects it as + // data, and nothing may execute unlogged. await catchMessage(echo(undefined)), // A toJSON that throws a NON-Error propagates out of JSON.stringify. await catchMessage(echo({ toJSON() { throw 'raw-throw' } })), @@ -450,11 +451,55 @@ describe('the run_code dispatch bridge', () => { ].join(' | '), } } - const result = await runCode(ctx, 'program') + const result = await runCode(ctx, 'program', { agent }) const text = (result.content[0] as { text: string }).text - expect(text).toContain('must be an object') + expect(text).toContain('call the tool with an arguments object') expect(text).toContain('JSON-serializable: raw-throw') expect(text).toContain('a value JSON cannot represent') + // None of the three dispatched, none logged. + expect(calls).toEqual([]) + expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([]) + }) + + it('logs the value the tool RECEIVED even when the tool mutates its arguments', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + const { agent, events } = fakeAgent() + ctx.tools.register(defineTool({ + name: 'mutator', + description: 'Mutates its own args object.', + parameters: { list: { type: 'array', required: true } }, + execute(args) { + args.list.push('injected-by-tool') + return Promise.resolve([{ type: 'text' as const, text: 'mutated' }]) + }, + })) + runtime.behavior = async (request) => { + await request.bindings[0]!.functions.mutator!({ list: ['original'] }) + return { logs: [] } + } + const result = await runCode(ctx, 'program', { agent }) + expect(result.isError).toBe(false) + const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch'] + expect(dispatch.arguments).toEqual({ list: ['original'] }) + }) + + it('exposes a tool named __proto__ as an ordinary own binding', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + ctx.tools.register(defineTool({ + name: '__proto__', + description: 'A prototype-colliding tool name.', + parameters: {}, + execute() { return Promise.resolve([{ type: 'text' as const, text: 'proto-tool-ok' }]) }, + })) + runtime.behavior = async (request) => { + const functions = request.bindings[0]!.functions + expect(Object.getPrototypeOf(functions)).toBeNull() + const value = await functions['__proto__']!({}) + return { logs: [], value } + } + const result = await runCode(ctx, 'program') + expect(result.isError).toBe(false) + expect(result.content[0]).toEqual({ type: 'text', text: 'proto-tool-ok' }) }) it('renders a non-string completion value inspect-style', async () => { From d7a27b20df82be857a8c8c86ead561f9d4ac646f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:11:15 +0800 Subject: [PATCH 25/57] test: pin the no-recursive-run_code invariant; document the fold at the drain site (bot review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both bot criticals verified against the code and rejected as exploit paths — pinned instead of patched: The bindings loop already excludes run_code (the skip predates the finding), and the runtime host resolves forged port calls as own properties of the bindings record, so an absent binding is unreachable from a program under any mode. A new both-mode test pins the invariant: the record has no run_code key on any lookup path. The drain await cannot mask a run failure: `queue` is the folded tail (every link swallows its rejection), so `await queue` never rejects and the runtime's own result.error always reaches the CodeRunFailedError conversion — the existing abort test exercises exactly the queued-abandonment-plus-run-failure scenario. Stated at the drain site so the fold's purpose is explicit. --- packages/core/tools/src/code-mode.ts | 4 ++++ packages/core/tools/tests/code-mode.spec.ts | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 904c65ef0e..970bb6e208 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -259,6 +259,10 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => // an in-flight sub-dispatch, abandoning queued ones), then await the // queue's drain — an aborted sub-call still settles and logs its // event INSIDE the open turn; nothing can append after we return. + // `queue` is the FOLDED tail (every link swallows its rejection into + // undefined), so this await cannot itself reject — an abandoned + // queued call can never mask the runtime's own `result.error` below; + // rejections surface only on the per-call promises the program holds. runController.abort('run_code settled') await queue diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 7fcd0890e6..7cbceb5361 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -119,6 +119,26 @@ describe('mode-aware wire contribution', () => { expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(true) }) + it("never exposes run_code to programs, even under mode 'both' (no recursive dispatch path)", async () => { + const { ctx, runtime } = await setup({ mode: 'both' }) + registerEcho(ctx) + runtime.behavior = (request) => { + const functions = request.bindings[0]!.functions + return Promise.resolve({ + logs: [], + value: JSON.stringify({ + names: Object.keys(functions).sort(), + // Own-property AND prototype-chain reads both come back empty — + // there is no handle a program could re-enter run_code through. + runCode: String(functions[RUN_CODE_NAME]), + }), + }) + } + const result = await runCode(ctx, 'program') + expect(result.isError).toBe(false) + expect(JSON.parse((result.content[0] as { text: string }).text)).toEqual({ names: ['echo'], runCode: 'undefined' }) + }) + it('renders byte-identical SDK text across consecutive assemblies of an unchanged tool set', async () => { const { ctx, systemPrompt } = await setup({ mode: 'code' }) registerEcho(ctx) From cdd1ce2ad4ab5851de949f13ba085b306b8e70b2 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 8 Jul 2026 15:31:14 +0800 Subject: [PATCH 26/57] feat(subagent): extract dsh-subagent-process shared out-of-process machinery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credential env scrub (SENSITIVE_ENV_PATTERN/buildChildEnv), the spawn-failure capture (spawnFailure), the child-exit waits (waitForExit/exitsWithin), and the stdin-EOF -> SIGTERM -> SIGKILL dispose ladder move out of subagent-acp into a new pure library package (the subagent-inprocess shape), with the ladder taking its two grace periods as parameters — defaults stay in the plugin Config. New isolated-config-dir helpers (mkdtemp create, best-effort remove; a pinned dir is never removed) land alongside for the CLAUDE_CONFIG_DIR / CODEX_HOME redirection the RFC names. The ACP backend migrates onto the library with no semantic change: its suite passes with import-path edits only. bash-local keeps its sibling copy, per the RFC's blast-radius call. RFC: docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md --- docs/config-catalog.md | 1 + docs/module-graph.md | 5 +- knip.json | 5 + packages/subagent/README.md | 3 +- packages/subagent/subagent-acp/package.json | 2 + packages/subagent/subagent-acp/src/run.ts | 98 ++---- .../subagent-acp/tests/subagent-acp.spec.ts | 3 +- packages/subagent/subagent-acp/tsconfig.json | 3 + packages/subagent/subagent-process/README.md | 40 +++ .../subagent/subagent-process/package.json | 30 ++ .../subagent/subagent-process/src/index.ts | 207 ++++++++++++ .../tests/subagent-process.spec.ts | 307 ++++++++++++++++++ .../subagent/subagent-process/tsconfig.json | 11 + pnpm-lock.yaml | 9 + tsconfig.build.json | 1 + tsconfig.json | 1 + 16 files changed, 645 insertions(+), 81 deletions(-) create mode 100644 packages/subagent/subagent-process/README.md create mode 100644 packages/subagent/subagent-process/package.json create mode 100644 packages/subagent/subagent-process/src/index.ts create mode 100644 packages/subagent/subagent-process/tests/subagent-process.spec.ts create mode 100644 packages/subagent/subagent-process/tsconfig.json diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e55066101f..c01d42f87d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -816,3 +816,4 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) +- `@deepseek-ai/dsh-subagent-process` ([`packages/subagent/subagent-process/src/index.ts`](../packages/subagent/subagent-process/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index 5e043d22d4..cadf8d0d14 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -43,6 +43,7 @@ flowchart TD pkg_subagent_acp["subagent-acp"] pkg_subagent_fork["subagent-fork"] pkg_subagent_inprocess["subagent-inprocess"] + pkg_subagent_process["subagent-process"] pkg_subagent_spawn["subagent-spawn"] pkg_tool_subagent["tool-subagent"] end @@ -171,6 +172,7 @@ flowchart TD pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_llm pkg_subagent_acp --> pkg_subagent + pkg_subagent_acp --> pkg_subagent_process pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_llm pkg_subagent_inprocess --> pkg_session @@ -211,6 +213,7 @@ flowchart TD | Package | Group | Depends on | | --- | --- | --- | | [`brand`](../packages/util/brand) | `util` | — | +| [`subagent-process`](../packages/subagent/subagent-process) | `subagent` | — | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — | | [`app-boot`](../packages/ui/app-boot) | `ui` | — | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | @@ -248,7 +251,7 @@ flowchart TD | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tools`](../packages/core/tools) | -| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-process`](../packages/subagent/subagent-process) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | diff --git a/knip.json b/knip.json index 8c0f71f3af..1ab6d39a9e 100644 --- a/knip.json +++ b/knip.json @@ -66,6 +66,11 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/mock-acp-server.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/subagent/subagent-process": { + "entry": ["tests/**/*.spec.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"], + "ignoreDependencies": ["cordis"] + }, "packages/fs/tool-fs": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 6da8ee42f5..38c64d22a5 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -8,9 +8,10 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `subagent-inprocess/` | Shared in-process run driver (pure lib; registers nothing) | — | | `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) | | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | +| `subagent-process/` | Shared out-of-process machinery: env scrub, dispose ladder, isolated config dirs (pure lib; registers nothing) | — | | `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a pure library — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock. +The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a pure library — both depend on it, neither on the other), the out-of-process `subagent-acp` backend builds on the `subagent-process` library (the credential env scrub, the dispose ladder, isolated config dirs) and ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock. The proposal and design rationale: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 2c55051da9..45fc6b072c 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -25,6 +25,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-subagent-process": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "dependencies": { @@ -35,6 +36,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-process": "workspace:^", "@cordisjs/plugin-loader": "^1.0.0-rc.4", "cordis": "^4.0.0-rc.6" } diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index d7631f4d8d..a94c923f46 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -22,7 +22,7 @@ * @module @deepseek-ai/dsh-subagent-acp/run */ -import { spawn, type ChildProcess } from 'node:child_process' +import { spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' import { Readable, Writable } from 'node:stream' import { @@ -40,6 +40,7 @@ import { import { AgentId } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' +import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-process' /** * How the client answers a child's `session/request_permission`. The first cut @@ -110,31 +111,6 @@ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 /** Default grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config; mirrors the bash executor). */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -/** - * Credential-shaped ambient env vars are NOT forwarded to the child by default - * (the parent harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a - * spawned process implicitly). Same pattern as the bash executor. The child - * agent needs its OWN credentials to reach a model — those are supplied - * explicitly via {@link AcpRunSpec.env}, which is layered on top AFTER the - * scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental - * `AWS_SECRET_ACCESS_KEY` does not. - */ -export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i - -/** - * The ambient env minus credential-shaped vars, plus the spec's explicit env. - * @param extra - explicit vars layered on top AFTER the scrub, so a - * credential-shaped name supplied deliberately still reaches the child. - * @returns the environment to spawn the child with. - */ -export function buildChildEnv(extra: Record): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {} - for (const [key, value] of Object.entries(process.env)) { - if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value - } - return { ...env, ...extra } -} - /** * Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}. * @param reason - the terminal reason from the child's `session/prompt` response. @@ -196,24 +172,6 @@ function toError(value: unknown): Error { return value instanceof Error ? value : new Error(String(value)) } -/** Resolve once the child process exits (any code/signal); immediate if gone. */ -function waitForExit(child: ChildProcess): Promise { - // Already-exited fast path: dispose guards on exitCode before calling, so in - // tests the child is always still alive here. - /* v8 ignore next */ - if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() - return new Promise(resolve => child.once('exit', () => { resolve() })) -} - -/** Resolve `true` if the child exits within `ms`, `false` on timeout. */ -function exitsWithin(child: ChildProcess, ms: number): Promise { - return Promise.race([ - waitForExit(child).then(() => true), - // `.unref()` so a pending grace timer never keeps the parent's loop alive. - new Promise(resolve => setTimeout(() => { resolve(false) }, ms).unref()), - ]) -} - /** * Start an out-of-process ACP child for `request` and return a {@link SubagentRun}. * @@ -254,13 +212,11 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su env: buildChildEnv(spec.env), stdio: ['pipe', 'pipe', 'inherit'], }) - // A spawn-level failure (e.g. ENOENT for a bad command) is emitted as an - // `error` event, NOT a thrown exception — without a listener Node treats it as - // an unhandled error and crashes the parent. Capture it into a promise the - // result path races, so a bad command settles `error` like any child failure. - const spawnFailed = new Promise((resolve) => { - child.once('error', (err) => { resolve(err) }) - }) + // Same-tick capture (the library's contract): a spawn-level failure (e.g. + // ENOENT for a bad command) is an `error` EVENT that would crash the parent + // unheard; the result path races this promise, so a bad command settles + // `error` like any child failure. + const spawnFailed = spawnFailure(child) // Accumulate the child's streamed assistant text — the SubagentResult output. const output: string[] = [] @@ -393,33 +349,19 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su }, async dispose(): Promise { request.signal?.removeEventListener('abort', onAbort) - // Reach quiescence, not merely request it (dispose must AWAIT the child - // actually stopping). If the child is already gone, nothing to do. - if (child.exitCode !== null || child.signalCode !== null) return - const eofGraceMs = spec.disposeEofGraceMs - const graceMs = spec.disposeGraceMs - // 1. Graceful: end the ACP request stream (stdin EOF) and let the child - // quiesce ON ITS OWN. Our acp-agent has NO SIGTERM handler in a normal - // session — it tears down via the server bridge's connection-close path - // (conn.closed → per-agent dispose → final session/flush), driven by the - // stdin EOF, NOT by a signal. A prompt response can resolve from a - // turn/end BEFORE that post-turn flush lands, so the child still has - // durable work owed when dispose runs. Give the EOF-driven quiesce a real - // window — wider than a single signal-grace, since the child's own - // teardown may itself be awaiting a signal-trapping grandchild (a bash - // subprocess in its own SIGTERM→SIGKILL grace) plus a flush — and only - // escalate if it overruns. Sending SIGTERM in the same tick (or too soon) - // would default-terminate the child mid-flush, orphaning its nested work. - child.stdin.end() - if (await exitsWithin(child, eofGraceMs)) return - // 2. SIGTERM, then escalate to SIGKILL if it still does not exit within the - // grace period — a child that ignores EOF and traps SIGTERM must not - // wedge dispose forever (the seam requires bounded quiescence). - child.kill('SIGTERM') - if (await exitsWithin(child, graceMs)) return - // 3. Force-kill and await the (now-certain) exit. - child.kill('SIGKILL') - await waitForExit(child) + // Quiescent teardown via the shared ladder (stdin EOF → SIGTERM → + // SIGKILL, awaiting the actual exit). For THIS child the EOF tier is the + // one that matters: our acp-agent has NO SIGTERM handler in a normal + // session — it tears down via the server bridge's connection-close path + // (conn.closed → per-agent dispose → final session/flush), driven by the + // stdin EOF, NOT by a signal — and a prompt response can resolve from a + // turn/end BEFORE that post-turn flush lands, so the child still has + // durable work owed when dispose runs (hence the wide EOF grace; see + // DEFAULT_DISPOSE_EOF_GRACE_MS). + await disposeChildProcess(child, { + disposeEofGraceMs: spec.disposeEofGraceMs, + disposeGraceMs: spec.disposeGraceMs, + }) }, } } diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 3eb12fac38..6d351c71d0 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -6,9 +6,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' +import { buildChildEnv, SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subagent-process' import type { Agent } from '@deepseek-ai/dsh-agent' import * as acp from '../src/index.ts' -import { acpStopReason, acpContentText, buildChildEnv, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, SENSITIVE_ENV_PATTERN, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' +import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' /** * Keyless integration tests for the ACP subagent backend. Each spawns a REAL diff --git a/packages/subagent/subagent-acp/tsconfig.json b/packages/subagent/subagent-acp/tsconfig.json index 3c06fef150..ab24f60f93 100644 --- a/packages/subagent/subagent-acp/tsconfig.json +++ b/packages/subagent/subagent-acp/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../subagent" + }, + { + "path": "../subagent-process" } ] } diff --git a/packages/subagent/subagent-process/README.md b/packages/subagent/subagent-process/README.md new file mode 100644 index 0000000000..597470f52a --- /dev/null +++ b/packages/subagent/subagent-process/README.md @@ -0,0 +1,40 @@ +# @deepseek-ai/dsh-subagent-process + +Shared machinery for **out-of-process subagent backends** — providers that spawn an external agent as a child process, such as the [ACP backend](../subagent-acp/README.md). A pure library (no provider, no registration, no Config): what every spawn-a-CLI-child backend needs to keep the parent deployment's credentials out of the child, tear the child down to quiescence, and isolate it from the host user's on-disk CLI state. Design rationale: [the Claude Code / Codex subagent backends RFC](../../../docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md). + +Every tunable is a **parameter**: the dispose ladder takes its grace periods per call, the config-dir helper takes an optional pinned path. Defaults live in each consuming plugin's Config (defaulted, validated fields changeable from `cordis.yml`), never in this library. + +## What it exports + +### `SENSITIVE_ENV_PATTERN` / `buildChildEnv(extra)` + +The credential env scrub (same pattern as the [bash executor](../../bash/bash-local/README.md)): the child env is the ambient env minus credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `extra` layered on top AFTER the scrub. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive, so the child CLI runs normally; the parent's own secrets never leak implicitly, while an explicitly supplied credential (the child's OWN key in a backend's `env` config) still reaches the child. + +### `spawnFailure(child)` + +Spawn-failure capture: a promise that resolves (never rejects) with the child's first `error` event. A spawn failure such as `ENOENT` is an event, not a thrown exception — without a listener Node crashes the parent process — so call this in the same tick as `spawn()` and race it in the run's result path; a bad command then settles as an ordinary child-level failure. For a child that spawns cleanly the promise never settles. + +### `waitForExit(child)` / `exitsWithin(child, ms)` + +Exit waits over a `ChildProcess`: resolve once the child exits by any code or signal (immediately if it is already gone), or race that against a timer (`true` = exited in time; the pending timer is `unref()`ed so a grace window never keeps the parent's event loop alive). + +### `disposeChildProcess(child, graces)` + +The three-tier dispose ladder. Resolves only once the child has ACTUALLY exited — quiescence reached, not merely requested (see [defensive patterns](../../../docs/defensive-patterns.md)): + +1. stdin EOF (when stdin is piped), then wait `graces.disposeEofGraceMs` — a cooperative child quiesces on its own, its flushes and nested-subprocess teardown intact; +2. `SIGTERM`, then wait `graces.disposeGraceMs`; +3. `SIGKILL`, then await the now-certain exit — a child that ignores EOF and traps `SIGTERM` cannot wedge dispose forever. + +The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields; the EOF window is deliberately a separate — usually wider — grace than the signal tier, since a cooperative child's EOF teardown may itself await a signal-trapping grandchild plus a final flush. + +### `createIsolatedConfigDir(prefix, pinnedPath?)` + +A per-run isolated config directory for an external CLI child (the target of `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection), so child behavior is a function of deployment config alone — never of whatever `~/.claude` / `~/.codex`-style state exists on the host. Returns an `IsolatedConfigDir` handle: `path` goes into the child env, `remove()` runs on dispose. + +- **Fresh (default)**: a private (0700) `mkdtemp` dir under the OS temp root; `remove()` deletes it best-effort (never rejects — a leftover temp dir beats a failed dispose) and is idempotent. +- **Pinned** (`pinnedPath` set): the path is returned as-is — never created, never removed. A deployment that pins a directory to share child state across runs owns that directory's lifecycle. + +## Testing + +`tests/subagent-process.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (including an rm-failure path proving `remove()` never rejects); the exit waits and the dispose ladder run against a scriptable fake child, driving each escalation tier deterministically. The [ACP backend suite](../subagent-acp/README.md) exercises the same ladder against real subprocesses (EOF-cooperative, EOF-ignoring, and SIGTERM-trapping children) end to end. diff --git a/packages/subagent/subagent-process/package.json b/packages/subagent/subagent-process/package.json new file mode 100644 index 0000000000..218276be44 --- /dev/null +++ b/packages/subagent/subagent-process/package.json @@ -0,0 +1,30 @@ +{ + "name": "@deepseek-ai/dsh-subagent-process", + "description": "Shared out-of-process subagent machinery: credential env scrub, spawn-failure capture, child-exit waits, the EOF-to-SIGTERM-to-SIGKILL dispose ladder, and isolated config dirs (pure lib; registers nothing)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/subagent/subagent-process/src/index.ts b/packages/subagent/subagent-process/src/index.ts new file mode 100644 index 0000000000..b54681d3c7 --- /dev/null +++ b/packages/subagent/subagent-process/src/index.ts @@ -0,0 +1,207 @@ +/** + * Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn + * an external agent as a child process and must keep the parent deployment's + * credentials out of it, tear it down to quiescence, and isolate it from the + * host user's on-disk CLI state. The pieces: the credential env scrub + * ({@link SENSITIVE_ENV_PATTERN} / {@link buildChildEnv}), the spawn-failure + * capture ({@link spawnFailure}), the child-exit waits ({@link waitForExit} / + * {@link exitsWithin}), the stdin-EOF → SIGTERM → SIGKILL dispose ladder + * ({@link disposeChildProcess}), and the per-run isolated config dir + * ({@link createIsolatedConfigDir}). + * + * This package owns no provider and registers nothing; it is a pure library + * the out-of-process backend packages depend on (the `subagent-inprocess` + * shape, for the process boundary). Every tunable — the ladder's grace + * periods, a pinned config dir — is a PARAMETER here: defaults belong in each + * consuming plugin's Config, per the no-hardcoded-tunables rule. + * + * @module @deepseek-ai/dsh-subagent-process + */ + +import type { ChildProcess } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +/** + * Credential-shaped ambient env vars are NOT forwarded to a child by default + * (the parent harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a + * spawned process implicitly). Same pattern as the bash executor. The child + * agent needs its OWN credentials to reach a model — those are supplied + * explicitly via the `extra` layer of {@link buildChildEnv}, which lands AFTER + * the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental + * `AWS_SECRET_ACCESS_KEY` does not. + */ +export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i + +/** + * The ambient env minus credential-shaped vars, plus the caller's explicit + * env. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive the scrub, so + * a child CLI runs normally; only {@link SENSITIVE_ENV_PATTERN}-shaped names + * are dropped. + * @param extra - explicit vars layered on top AFTER the scrub, so a + * credential-shaped name supplied deliberately still reaches the child. + * @returns the environment to spawn the child with. + */ +export function buildChildEnv(extra: Record): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {} + for (const [key, value] of Object.entries(process.env)) { + if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value + } + return { ...env, ...extra } +} + +/** + * Capture the child's spawn-level failure as a promise the run's result path + * can race. A spawn failure (e.g. `ENOENT` for a bad command) is emitted as an + * `error` EVENT, not a thrown exception — and without a listener Node treats + * it as an unhandled error and crashes the parent process. Call this in the + * SAME TICK as `spawn()`, so no window exists for the event to fire unheard. + * @param child - the just-spawned child process. + * @returns a promise that RESOLVES (never rejects) with the child's first + * `error` event; for a child that spawns cleanly it never settles. + */ +export function spawnFailure(child: ChildProcess): Promise { + return new Promise((resolve) => { + child.once('error', (err) => { resolve(err) }) + }) +} + +/** + * Resolve once the child process exits (any code/signal); immediate if it is + * already gone. + * @param child - the child process to await. + */ +export function waitForExit(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() + return new Promise(resolve => child.once('exit', () => { resolve() })) +} + +/** + * Race the child's exit against a timer. + * @param child - the child process to watch. + * @param ms - the wait window in milliseconds. + * @returns `true` if the child exits within `ms`, `false` on timeout. + */ +export function exitsWithin(child: ChildProcess, ms: number): Promise { + return Promise.race([ + waitForExit(child).then(() => true), + // `.unref()` so a pending grace timer never keeps the parent's loop alive. + new Promise(resolve => setTimeout(() => { resolve(false) }, ms).unref()), + ]) +} + +/** + * The two grace periods of the dispose ladder, supplied per call by the + * consuming backend — each plugin carries them as defaulted, validated + * `disposeEofGraceMs`/`disposeGraceMs` Config fields, so teardown timing is + * deployment-tunable and this library hardcodes nothing. + */ +export interface DisposeLadderGraces { + /** + * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce + * ON ITS OWN — flush durable state, tear down its own nested subprocesses — + * before the parent escalates to `SIGTERM`. A separate (usually WIDER) + * grace than {@link DisposeLadderGraces.disposeGraceMs}: a cooperative + * child's EOF-driven teardown may itself be waiting on a signal-trapping + * grandchild plus a final flush, needing more than one signal-grace of + * headroom. + */ + disposeEofGraceMs: number + /** Tier-2 window (ms): between `SIGTERM` and the `SIGKILL` escalation. */ + disposeGraceMs: number +} + +/** + * Tear a child process down to QUIESCENCE: resolves only once the child has + * actually exited (or was already gone), never merely after requesting it. + * Three-tier escalation — + * + * 1. stdin EOF (when stdin is piped), then wait `disposeEofGraceMs`: a + * cooperative child quiesces on its own, its teardown and flushes intact; + * 2. `SIGTERM`, then wait `disposeGraceMs`; + * 3. `SIGKILL`, then await the (now-certain) exit — a child that ignores EOF + * and traps `SIGTERM` must not wedge dispose forever. + * + * @param child - the child process to tear down. + * @param graces - the two grace periods, from the consuming plugin's Config. + */ +export async function disposeChildProcess(child: ChildProcess, graces: DisposeLadderGraces): Promise { + // Already gone: nothing to reap. + if (child.exitCode !== null || child.signalCode !== null) return + // 1. Graceful: end the request stream (stdin EOF) and let the child quiesce + // on its own. Sending SIGTERM in the same tick (or too soon) would + // default-terminate a cooperative child mid-flush, orphaning its nested + // work. A child spawned without a stdin pipe skips straight to the wait. + child.stdin?.end() + if (await exitsWithin(child, graces.disposeEofGraceMs)) return + // 2. SIGTERM, escalating if the child still does not exit within the grace. + child.kill('SIGTERM') + if (await exitsWithin(child, graces.disposeGraceMs)) return + // 3. Force-kill and await the (now-certain) exit. + child.kill('SIGKILL') + await waitForExit(child) +} + +/** + * A per-run config directory handle for an external CLI child — the target of + * `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection. Hand {@link path} to + * the child's environment; call {@link remove} on dispose. + */ +export interface IsolatedConfigDir { + /** The directory to point the child at. */ + path: string + /** + * Best-effort cleanup: removes the directory (recursively) iff this handle + * CREATED it — a pinned directory is never removed. Idempotent; never + * rejects (a leftover dir under the OS temp root is preferable to a failed + * dispose). + */ + remove(): Promise +} + +/** + * An isolated config dir for one child run, so the child's behavior is a + * function of deployment config alone — never of whatever `~/.claude` / + * `~/.codex`-style state happens to exist on the host machine. Two modes: + * + * - no `pinnedPath` (the default): creates a FRESH private (0700) `mkdtemp` + * dir under the OS temp root; {@link IsolatedConfigDir.remove} deletes it + * best-effort; + * - `pinnedPath` set (a deployment deliberately sharing state across runs): + * the pinned path is returned as-is — never created, never removed — the + * deployment owns that directory's lifecycle. + * + * @param prefix - the `mkdtemp` name prefix for a fresh dir (e.g. + * `dsh-subagent-codex-`); ignored when `pinnedPath` is set. + * @param pinnedPath - a deployment-pinned directory to use instead of a + * fresh one. + * @returns the directory handle: `path` for the child env, `remove()` for + * dispose. + */ +export async function createIsolatedConfigDir(prefix: string, pinnedPath?: string): Promise { + if (pinnedPath !== undefined) { + return { + path: pinnedPath, + remove(): Promise { + // A pinned dir is deployment-owned state (config the user asked to + // persist across runs); removing it here would destroy it. No-op. + return Promise.resolve() + }, + } + } + const path = await mkdtemp(join(tmpdir(), prefix)) + return { + path, + async remove(): Promise { + try { + await rm(path, { recursive: true, force: true }) + } catch { + // Best-effort by contract: swallows rm failures (EACCES/EBUSY-style — + // e.g. the dead child left an unreadable entry behind). The dir lives + // under the OS temp root, which reclaims it; failing dispose over + // cleanup would be worse than a leftover temp dir. + } + }, + } +} diff --git a/packages/subagent/subagent-process/tests/subagent-process.spec.ts b/packages/subagent/subagent-process/tests/subagent-process.spec.ts new file mode 100644 index 0000000000..c4828d8498 --- /dev/null +++ b/packages/subagent/subagent-process/tests/subagent-process.spec.ts @@ -0,0 +1,307 @@ +import { describe, expect, it } from 'vitest' +import { EventEmitter } from 'node:events' +import { existsSync } from 'node:fs' +import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ChildProcess } from 'node:child_process' +import { + buildChildEnv, + createIsolatedConfigDir, + disposeChildProcess, + exitsWithin, + SENSITIVE_ENV_PATTERN, + spawnFailure, + waitForExit, +} from '../src/index.ts' + +/** + * Unit tests for the shared out-of-process machinery. The env scrub and the + * isolated-config-dir helpers run against the REAL process env and REAL + * filesystem; the exit waits and the dispose ladder run against a scriptable + * fake child so each escalation tier's timing is driven deterministically + * (the ACP backend's suite exercises the same ladder against real + * subprocesses end to end). + */ + +/** What fells a scripted {@link FakeChild}. */ +type LethalTrigger = 'eof' | NodeJS.Signals + +/** Per-scenario script for a {@link FakeChild}. */ +interface FakeChildScript { + /** + * The one trigger that makes the child exit (SIGKILL always does, + * uncatchable, like a real process). Omitted: only SIGKILL fells it. + */ + diesOn?: LethalTrigger + /** Delay (ms) between the lethal trigger and the exit event. */ + delayMs?: number + /** `false` models a child spawned without a stdin pipe. */ + stdin?: boolean +} + +/** + * A scriptable stand-in for a ChildProcess carrying exactly the surface the + * helpers read: `exitCode`/`signalCode`, `stdin.end()`, `kill()`, and the + * `exit` event. + */ +class FakeChild extends EventEmitter { + exitCode: number | null = null + signalCode: NodeJS.Signals | null = null + readonly kills: NodeJS.Signals[] = [] + stdinEnded = false + readonly stdin: { end: () => void } | null + + constructor(private readonly script: FakeChildScript = {}) { + super() + this.stdin = script.stdin === false + ? null + : { end: () => { this.stdinEnded = true; this.maybeDie('eof') } } + } + + kill(signal: NodeJS.Signals): boolean { + this.kills.push(signal) + this.maybeDie(signal) + return true + } + + private maybeDie(trigger: LethalTrigger): void { + // SIGKILL is uncatchable — it always fells the child; any other trigger + // only when the scenario scripts it as the lethal one. + if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return + setTimeout(() => { + if (trigger === 'eof') this.exitCode = 0 + else this.signalCode = trigger + this.emit('exit', this.exitCode, this.signalCode) + }, this.script.delayMs ?? 0) + } +} + +/** The helpers take a real ChildProcess; the fake carries the read surface. */ +function asChild(fake: FakeChild): ChildProcess { + return fake as unknown as ChildProcess +} + +describe('buildChildEnv / SENSITIVE_ENV_PATTERN', () => { + it('drops credential-shaped ambient vars (KEY/SECRET/TOKEN, case-insensitive)', () => { + process.env.DSH_PROC_TEST_API_KEY = 'leak' + process.env.dsh_proc_test_secret = 'leak' + process.env.DSH_PROC_TEST_TOKEN = 'leak' + try { + const env = buildChildEnv({}) + expect(env.DSH_PROC_TEST_API_KEY).toBeUndefined() + expect(env.dsh_proc_test_secret).toBeUndefined() + expect(env.DSH_PROC_TEST_TOKEN).toBeUndefined() + } finally { + delete process.env.DSH_PROC_TEST_API_KEY + delete process.env.dsh_proc_test_secret + delete process.env.DSH_PROC_TEST_TOKEN + } + }) + + it('forwards normal ambient vars', () => { + expect(SENSITIVE_ENV_PATTERN.test('PATH')).toBe(false) + expect(buildChildEnv({}).PATH).toBe(process.env.PATH) + }) + + it('layers extras AFTER the scrub, so a deliberate credential-shaped name survives', () => { + process.env.DSH_PROC_TEST_EXTRA_TOKEN = 'ambient-leak' + try { + const env = buildChildEnv({ DSH_PROC_TEST_EXTRA_TOKEN: 'explicit' }) + // The ambient value was scrubbed; ONLY the explicit opt-in reaches the child. + expect(env.DSH_PROC_TEST_EXTRA_TOKEN).toBe('explicit') + } finally { + delete process.env.DSH_PROC_TEST_EXTRA_TOKEN + } + }) + + it('an extra overrides the ambient value of a non-credential var', () => { + process.env.DSH_PROC_TEST_PLAIN = 'ambient' + try { + expect(buildChildEnv({ DSH_PROC_TEST_PLAIN: 'override' }).DSH_PROC_TEST_PLAIN).toBe('override') + } finally { + delete process.env.DSH_PROC_TEST_PLAIN + } + }) +}) + +describe('spawnFailure', () => { + it('resolves (never rejects) with the first error event', async () => { + const fake = new FakeChild() + const failure = spawnFailure(asChild(fake)) + const err = new Error('spawn ENOENT') + fake.emit('error', err) + await expect(failure).resolves.toBe(err) + }) + + it('never settles for a child that spawns cleanly and exits', async () => { + const fake = new FakeChild({ diesOn: 'SIGTERM' }) + const failure = spawnFailure(asChild(fake)) + fake.kill('SIGTERM') + await waitForExit(asChild(fake)) + // A clean lifecycle emits `exit`, never `error` — the capture stays + // pending forever, so a race against it is decided by the other arms. + const settled = await Promise.race([ + failure.then(() => 'settled'), + new Promise(resolve => setTimeout(() => { resolve('pending') }, 30)), + ]) + expect(settled).toBe('pending') + }) +}) + +describe('waitForExit / exitsWithin', () => { + it('resolves immediately for a child that already exited by code', async () => { + const fake = new FakeChild() + fake.exitCode = 0 + await expect(waitForExit(asChild(fake))).resolves.toBeUndefined() + }) + + it('resolves immediately for a child that already died by signal', async () => { + const fake = new FakeChild() + fake.signalCode = 'SIGTERM' + await expect(waitForExit(asChild(fake))).resolves.toBeUndefined() + }) + + it('resolves on the exit event of a live child', async () => { + const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) + const exited = waitForExit(asChild(fake)) + fake.kill('SIGTERM') + await expect(exited).resolves.toBeUndefined() + expect(fake.signalCode).toBe('SIGTERM') + }) + + it('exitsWithin resolves true when the child exits inside the window', async () => { + const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) + fake.kill('SIGTERM') + await expect(exitsWithin(asChild(fake), 1000)).resolves.toBe(true) + }) + + it('exitsWithin resolves false on timeout for a child that never exits', async () => { + const fake = new FakeChild() // nothing short of SIGKILL fells it; no signal sent + await expect(exitsWithin(asChild(fake), 20)).resolves.toBe(false) + }) +}) + +describe('disposeChildProcess', () => { + it('returns immediately for an already-exited child (no EOF, no signals)', async () => { + const fake = new FakeChild() + fake.exitCode = 0 + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) + expect(fake.stdinEnded).toBe(false) + expect(fake.kills).toEqual([]) + }) + + it('returns immediately for a child already dead by signal', async () => { + const fake = new FakeChild() + fake.signalCode = 'SIGKILL' + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) + expect(fake.stdinEnded).toBe(false) + expect(fake.kills).toEqual([]) + }) + + it('tier 1: a cooperative child quiesces on stdin EOF — no signal is ever sent', async () => { + const fake = new FakeChild({ diesOn: 'eof', delayMs: 5 }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) + expect(fake.stdinEnded).toBe(true) + expect(fake.kills).toEqual([]) + expect(fake.exitCode).toBe(0) + }) + + it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => { + const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }) + expect(fake.stdinEnded).toBe(true) + expect(fake.kills).toEqual(['SIGTERM']) + expect(fake.signalCode).toBe('SIGTERM') + }) + + it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => { + const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }) + expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL']) + // Quiescence, not a request: at resolution the child has ACTUALLY exited + // (the exit event landed, despite the scripted post-SIGKILL delay). + expect(fake.signalCode).toBe('SIGKILL') + }) + + it('walks the ladder for a child spawned without a stdin pipe', async () => { + const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }) + expect(fake.kills).toEqual(['SIGTERM']) + }) +}) + +describe('createIsolatedConfigDir', () => { + it('creates a fresh private mkdtemp dir under the OS temp root', async () => { + const dir = await createIsolatedConfigDir('dsh-subagent-process-test-') + try { + expect(dir.path.startsWith(join(tmpdir(), 'dsh-subagent-process-test-'))).toBe(true) + const st = await stat(dir.path) + expect(st.isDirectory()).toBe(true) + // Private (0700) per the defensive-patterns temp-dir rule. + expect(st.mode & 0o777).toBe(0o700) + } finally { + await dir.remove() + } + }) + + it('creates a distinct dir per call (per-run isolation)', async () => { + const a = await createIsolatedConfigDir('dsh-subagent-process-test-') + const b = await createIsolatedConfigDir('dsh-subagent-process-test-') + try { + expect(a.path).not.toBe(b.path) + } finally { + await a.remove() + await b.remove() + } + }) + + it('remove() deletes a fresh dir recursively and is idempotent', async () => { + const dir = await createIsolatedConfigDir('dsh-subagent-process-test-') + await writeFile(join(dir.path, 'settings.json'), '{}') + await dir.remove() + expect(existsSync(dir.path)).toBe(false) + // Second remove: nothing left to delete, still resolves. + await expect(dir.remove()).resolves.toBeUndefined() + }) + + it('returns a pinned dir verbatim and NEVER removes it', async () => { + const pinned = await mkdtemp(join(tmpdir(), 'dsh-subagent-process-pinned-')) + try { + const dir = await createIsolatedConfigDir('ignored-prefix-', pinned) + expect(dir.path).toBe(pinned) + await dir.remove() + // The deployment owns a pinned dir's lifecycle — remove() must not touch it. + expect(existsSync(pinned)).toBe(true) + } finally { + await rm(pinned, { recursive: true, force: true }) + } + }) + + it('does not create a missing pinned path (the deployment owns its lifecycle)', async () => { + const missing = join(tmpdir(), `dsh-subagent-process-missing-${process.pid}`) + const dir = await createIsolatedConfigDir('ignored-prefix-', missing) + expect(dir.path).toBe(missing) + expect(existsSync(missing)).toBe(false) + await dir.remove() + expect(existsSync(missing)).toBe(false) + }) + + it('remove() is best-effort: an rm failure resolves instead of rejecting', async () => { + const dir = await createIsolatedConfigDir('dsh-subagent-process-locked-') + const locked = join(dir.path, 'locked') + await mkdir(locked) + await writeFile(join(locked, 'entry'), 'x') + // An unreadable, unwritable non-empty subdir makes recursive rm fail + // (EACCES on readdir/unlink) for a non-root user. + await chmod(locked, 0o000) + try { + await expect(dir.remove()).resolves.toBeUndefined() + // rm really did fail — the locked subtree is still there. + expect(existsSync(locked)).toBe(true) + } finally { + await chmod(locked, 0o700) + await rm(dir.path, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/subagent/subagent-process/tsconfig.json b/packages/subagent/subagent-process/tsconfig.json new file mode 100644 index 0000000000..749cb0208e --- /dev/null +++ b/packages/subagent/subagent-process/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32ffa0d389..359b68b6d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -594,6 +594,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent + '@deepseek-ai/dsh-subagent-process': + specifier: workspace:^ + version: link:../subagent-process cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -671,6 +674,12 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/subagent-process: + devDependencies: + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/subagent-spawn: dependencies: schemastery: diff --git a/tsconfig.build.json b/tsconfig.build.json index 3d99ad4e28..21fa726927 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -50,6 +50,7 @@ { "path": "./packages/support/subagent-mock" }, { "path": "./packages/subagent/tool-subagent" }, { "path": "./packages/subagent/subagent-inprocess" }, + { "path": "./packages/subagent/subagent-process" }, { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, diff --git a/tsconfig.json b/tsconfig.json index 2091283c93..c4127b2b5d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -61,6 +61,7 @@ { "path": "./packages/support/subagent-mock" }, { "path": "./packages/subagent/tool-subagent" }, { "path": "./packages/subagent/subagent-inprocess" }, + { "path": "./packages/subagent/subagent-process" }, { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, From 1b29273f12a1e3659d9792ee55bca5ce8e2198bf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:03:27 +0800 Subject: [PATCH 27/57] fix: reach quiescence even when the runtime rejects (agent review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [P1] review finding: the run-scoped abort + queue drain ran only after runtime.run() FULFILLED, so a backend that starts a binding call and then throws left the sub-dispatch running past run_code's settlement — its tool/code-dispatch event could append after the parent call returned, breaking the drain-before-return contract. The quiescence pair now lives in a finally around runtime.run(); the folded queue tail keeps the drain from masking the thrown error. Pinned by a test whose fake runtime fails mid-flight: pre-fix it returns in milliseconds with the slow tool still running. --- packages/core/tools/src/code-mode.ts | 38 +++++++++++++-------- packages/core/tools/tests/code-mode.spec.ts | 38 +++++++++++++++++++++ 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 970bb6e208..404364d57c 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -250,21 +250,29 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => } try { - const result = await runtime.run({ - program: args.code, - bindings: [{ global: 'tools', functions }], - signal: runController.signal, - }) - // Quiescence before returning: fire the run-scoped abort (cancelling - // an in-flight sub-dispatch, abandoning queued ones), then await the - // queue's drain — an aborted sub-call still settles and logs its - // event INSIDE the open turn; nothing can append after we return. - // `queue` is the FOLDED tail (every link swallows its rejection into - // undefined), so this await cannot itself reject — an abandoned - // queued call can never mask the runtime's own `result.error` below; - // rejections surface only on the per-call promises the program holds. - runController.abort('run_code settled') - await queue + let result: CodeRunResult + try { + result = await runtime.run({ + program: args.code, + bindings: [{ global: 'tools', functions }], + signal: runController.signal, + }) + } finally { + // Quiescence before returning, whether the runtime fulfilled or + // REJECTED (a backend that starts a binding call and then throws + // must not leak a live sub-dispatch past this settlement): fire + // the run-scoped abort (cancelling an in-flight sub-dispatch, + // abandoning queued ones), then await the queue's drain — an + // aborted sub-call still settles and logs its event INSIDE the + // open turn; nothing can append after we return. `queue` is the + // FOLDED tail (every link swallows its rejection into undefined), + // so this await cannot itself reject — an abandoned queued call + // can never mask the runtime's own failure, returned or thrown; + // rejections surface only on the per-call promises the program + // holds. + runController.abort('run_code settled') + await queue + } if (result.error) { const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.map(entry => entry.text).join('\n')}` : '' diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 7cbceb5361..03afc260ee 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -385,6 +385,44 @@ describe('the run_code dispatch bridge', () => { expect(sawAbort).toBe(true) }) + it('a runtime that starts a binding call and then REJECTS still reaches quiescence before returning', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + const { agent, events } = fakeAgent() + let sawAbort = false + let started!: () => void + const inFlight = new Promise((resolve) => { started = resolve }) + ctx.tools.register(defineTool({ + name: 'slow', + description: 'Slow tool observing its signal.', + parameters: { id: { type: 'string', required: true } }, + async execute(args, exec) { + started() + await new Promise((resolve) => { + const timer = setTimeout(resolve, 500) + exec.signal?.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true }) + }) + return [{ type: 'text' as const, text: args.id }] + }, + })) + runtime.behavior = async (request) => { + // Start a sub-dispatch, keep its rejection held, and fail the run once + // the tool is genuinely in flight — a seam error AFTER work has begun. + // The bridge's settlement still owes quiescence: without the finally, + // run_code would return now and the slow tool would finish (and log) + // afterwards. + request.bindings[0]!.functions.slow!({ id: 'orphan' }).catch(() => 'held') + await inFlight + throw new Error('backend exploded') + } + const result = await runCode(ctx, 'program', { agent }) + expect(result.isError).toBe(true) + expect((result.content[0] as { text: string }).text).toContain('backend exploded') + // Quiescence held: the in-flight sub-dispatch was aborted and its event + // logged INSIDE the run_code execution, not after it returned. + expect(sawAbort).toBe(true) + expect(events.filter(event => event.type === 'tool/code-dispatch').map(event => (event.data as { name: string }).name)).toEqual(['slow']) + }) + it('runs without an owning agent: dispatches work, event logging is skipped', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const calls = registerEcho(ctx) From 7ccf31a59b5227d66a95b2d52932d68b59e2b702 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 9 Jul 2026 10:20:42 +0800 Subject: [PATCH 28/57] fix review finding: root-portable rm-failure injection in the config-dir test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The best-effort-remove test provoked a real EACCES via a chmod-000 subtree, which only fails for unprivileged users — under root, recursive rm ignores permission bits, deleting the subtree: the existsSync assertion goes red and the swallow branch loses coverage, failing the per-file gate. The rejection is now injected deterministically at the node:fs/promises boundary (rm wrapped with a real-passthrough vi.fn; one test queues a single rejection), the fs-failure boundary being exactly the non-deterministic seam the testing policy sanctions mocking. Everything else in the suite stays on the real filesystem, and the swallow contract stays error-kind agnostic. --- packages/subagent/subagent-process/README.md | 2 +- .../tests/subagent-process.spec.ts | 39 +++++++++++-------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/subagent/subagent-process/README.md b/packages/subagent/subagent-process/README.md index 597470f52a..e42bb4b611 100644 --- a/packages/subagent/subagent-process/README.md +++ b/packages/subagent/subagent-process/README.md @@ -37,4 +37,4 @@ A per-run isolated config directory for an external CLI child (the target of `CL ## Testing -`tests/subagent-process.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (including an rm-failure path proving `remove()` never rejects); the exit waits and the dispose ladder run against a scriptable fake child, driving each escalation tier deterministically. The [ACP backend suite](../subagent-acp/README.md) exercises the same ladder against real subprocesses (EOF-cooperative, EOF-ignoring, and SIGTERM-trapping children) end to end. +`tests/subagent-process.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (the rm-failure path injects its rejection at the fs boundary — a real recursive-rm failure is not portably provokable, and root ignores permission bits); the exit waits and the dispose ladder run against a scriptable fake child, driving each escalation tier deterministically. The [ACP backend suite](../subagent-acp/README.md) exercises the same ladder against real subprocesses (EOF-cooperative, EOF-ignoring, and SIGTERM-trapping children) end to end. diff --git a/packages/subagent/subagent-process/tests/subagent-process.spec.ts b/packages/subagent/subagent-process/tests/subagent-process.spec.ts index c4828d8498..24f2a3a97d 100644 --- a/packages/subagent/subagent-process/tests/subagent-process.spec.ts +++ b/packages/subagent/subagent-process/tests/subagent-process.spec.ts @@ -1,7 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { EventEmitter } from 'node:events' import { existsSync } from 'node:fs' -import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { ChildProcess } from 'node:child_process' @@ -15,13 +15,24 @@ import { waitForExit, } from '../src/index.ts' +// `rm` is wrapped (real-passthrough by default) so ONE test can inject a +// rejection deterministically. A real recursive-rm failure is not portably +// provokable — permission tricks (a chmod-000 subtree) fail only for +// unprivileged users and are ignored by root — so this is the fs boundary +// the testing policy sanctions mocking; everything else stays the real fs. +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, rm: vi.fn(actual.rm) } +}) + /** * Unit tests for the shared out-of-process machinery. The env scrub and the * isolated-config-dir helpers run against the REAL process env and REAL - * filesystem; the exit waits and the dispose ladder run against a scriptable - * fake child so each escalation tier's timing is driven deterministically - * (the ACP backend's suite exercises the same ladder against real - * subprocesses end to end). + * filesystem (one exception: the rm-failure path injects its rejection at the + * mocked fs boundary, see above); the exit waits and the dispose ladder run + * against a scriptable fake child so each escalation tier's timing is driven + * deterministically (the ACP backend's suite exercises the same ladder + * against real subprocesses end to end). */ /** What fells a scripted {@link FakeChild}. */ @@ -287,20 +298,16 @@ describe('createIsolatedConfigDir', () => { expect(existsSync(missing)).toBe(false) }) - it('remove() is best-effort: an rm failure resolves instead of rejecting', async () => { + it('remove() is best-effort: an rm rejection resolves instead of rejecting', async () => { const dir = await createIsolatedConfigDir('dsh-subagent-process-locked-') - const locked = join(dir.path, 'locked') - await mkdir(locked) - await writeFile(join(locked, 'entry'), 'x') - // An unreadable, unwritable non-empty subdir makes recursive rm fail - // (EACCES on readdir/unlink) for a non-root user. - await chmod(locked, 0o000) try { + // The swallow contract is error-kind agnostic; EACCES stands in for the + // family (EBUSY, a vanished mount, …) that best-effort must absorb. + vi.mocked(rm).mockRejectedValueOnce(Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' })) await expect(dir.remove()).resolves.toBeUndefined() - // rm really did fail — the locked subtree is still there. - expect(existsSync(locked)).toBe(true) + // The injected rejection consumed the only rm call — nothing was deleted. + expect(existsSync(dir.path)).toBe(true) } finally { - await chmod(locked, 0o700) await rm(dir.path, { recursive: true, force: true }) } }) From 2471e2b2bb45d1f8f350571451feda04a4a8786d Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 9 Jul 2026 10:42:34 +0800 Subject: [PATCH 29/57] fix review finding: exitsWithin cleans up its listener and timer on both arms Each timed-out wait used to leave the once('exit') listener from its inner waitForExit attached to the child; the dispose ladder accumulates at most a couple, but in a shared library a caller polling exitsWithin in a loop would pile listeners onto one child (MaxListenersExceededWarning at 11) and retain their closures. The race now owns its wiring: the timeout arm removes the exit listener, the exit arm clears the (still unref'ed) grace timer, and an already-exited child short-circuits true without attaching anything. Tests pin listenerCount('exit') === 0 after every outcome. --- packages/subagent/subagent-process/README.md | 2 +- .../subagent/subagent-process/src/index.ts | 24 ++++++++++++++----- .../tests/subagent-process.spec.ts | 12 ++++++++++ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/subagent/subagent-process/README.md b/packages/subagent/subagent-process/README.md index e42bb4b611..e6c8e10a96 100644 --- a/packages/subagent/subagent-process/README.md +++ b/packages/subagent/subagent-process/README.md @@ -16,7 +16,7 @@ Spawn-failure capture: a promise that resolves (never rejects) with the child's ### `waitForExit(child)` / `exitsWithin(child, ms)` -Exit waits over a `ChildProcess`: resolve once the child exits by any code or signal (immediately if it is already gone), or race that against a timer (`true` = exited in time; the pending timer is `unref()`ed so a grace window never keeps the parent's event loop alive). +Exit waits over a `ChildProcess`: resolve once the child exits by any code or signal (immediately if it is already gone), or race that against a timer (`true` = exited in time). The race cleans up after itself on both outcomes — the pending timer is `unref()`ed and cleared on exit, the exit listener removed on timeout — so repeated calls (the dispose ladder's tiers, a poll loop) never accumulate listeners on the child. ### `disposeChildProcess(child, graces)` diff --git a/packages/subagent/subagent-process/src/index.ts b/packages/subagent/subagent-process/src/index.ts index b54681d3c7..219ed6b083 100644 --- a/packages/subagent/subagent-process/src/index.ts +++ b/packages/subagent/subagent-process/src/index.ts @@ -78,17 +78,29 @@ export function waitForExit(child: ChildProcess): Promise { } /** - * Race the child's exit against a timer. + * Race the child's exit against a timer. Neither outcome leaves anything + * behind on the child: the exit listener is removed on timeout and the timer + * is cleared on exit, so repeated calls (the dispose ladder's tiers, a poll + * loop) never accumulate listeners. * @param child - the child process to watch. * @param ms - the wait window in milliseconds. - * @returns `true` if the child exits within `ms`, `false` on timeout. + * @returns `true` if the child exits within `ms` (immediately if it is + * already gone), `false` on timeout. */ export function exitsWithin(child: ChildProcess, ms: number): Promise { - return Promise.race([ - waitForExit(child).then(() => true), + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true) + return new Promise((resolve) => { + const onExit = (): void => { + clearTimeout(timer) + resolve(true) + } // `.unref()` so a pending grace timer never keeps the parent's loop alive. - new Promise(resolve => setTimeout(() => { resolve(false) }, ms).unref()), - ]) + const timer = setTimeout(() => { + child.removeListener('exit', onExit) + resolve(false) + }, ms).unref() + child.once('exit', onExit) + }) } /** diff --git a/packages/subagent/subagent-process/tests/subagent-process.spec.ts b/packages/subagent/subagent-process/tests/subagent-process.spec.ts index 24f2a3a97d..d23f075284 100644 --- a/packages/subagent/subagent-process/tests/subagent-process.spec.ts +++ b/packages/subagent/subagent-process/tests/subagent-process.spec.ts @@ -181,15 +181,27 @@ describe('waitForExit / exitsWithin', () => { expect(fake.signalCode).toBe('SIGTERM') }) + it('exitsWithin resolves true immediately for an already-exited child (no listener attached)', async () => { + const fake = new FakeChild() + fake.exitCode = 0 + await expect(exitsWithin(asChild(fake), 1000)).resolves.toBe(true) + expect(fake.listenerCount('exit')).toBe(0) + }) + it('exitsWithin resolves true when the child exits inside the window', async () => { const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) fake.kill('SIGTERM') await expect(exitsWithin(asChild(fake), 1000)).resolves.toBe(true) + // The once-listener fired and the grace timer was cleared — nothing lingers. + expect(fake.listenerCount('exit')).toBe(0) }) it('exitsWithin resolves false on timeout for a child that never exits', async () => { const fake = new FakeChild() // nothing short of SIGKILL fells it; no signal sent await expect(exitsWithin(asChild(fake), 20)).resolves.toBe(false) + // The timeout arm removed its exit listener: repeated waits (a poll loop, + // the ladder's tiers) never accumulate listeners on the same child. + expect(fake.listenerCount('exit')).toBe(0) }) }) From a11000030a14dd382548839894ba49b0568ab801 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 9 Jul 2026 10:42:41 +0800 Subject: [PATCH 30/57] docs(subagent-acp): point the env-scrub section at its one home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scrub pattern and layering semantics live in the dsh-subagent-process README (the fact's home since the extraction); the ACP section restated them in full — two prose copies drift word by word until they disagree (the one-home-per-fact rule in docs/AGENTS.md). The section now links the library and keeps only the backend's own story: which credential enters via config.env and why. --- packages/subagent/subagent-acp/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 81bf886067..2cc330aefd 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -57,7 +57,7 @@ A spawn/transport/RPC failure resolves `error` (or `aborted` if a cancel was req ## Environment scrub -Credential-shaped ambient vars (`/KEY|SECRET|TOKEN/i`) are NOT forwarded to the child by default — the parent harness's own secrets must not leak into a spawned process implicitly. The child's OWN credentials are supplied explicitly via `config.env`, layered AFTER the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental `AWS_SECRET_ACCESS_KEY` does not. +The child env is built by [`buildChildEnv` from `@deepseek-ai/dsh-subagent-process`](../subagent-process/README.md) — the ambient env minus credential-shaped vars, with `config.env` layered on top after the scrub; the pattern and full semantics live there. For this backend that means the parent harness's own secrets never leak into the spawned agent implicitly, while the child's OWN `DEEPSEEK_API_KEY` is supplied deliberately via `config.env` and survives. ## Testing From 1be9baeb7bc9def875be4fe97397755d15685f27 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:44:10 +0800 Subject: [PATCH 31/57] =?UTF-8?q?feat:=20add=20demo:acp-code=20=E2=80=94?= =?UTF-8?q?=20the=20ACP=20demo=20in=20Code=20Mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boots the acp-agent example through the existing code-mode.cordis.yml overlay (tool surface collapses to run_code + the generated TypeScript SDK, dispatching through the worker-thread runtime), mirroring how demo:code relates to demo:repl on the stdio side. The overlay header and both READMEs now name the demo as a consumer. Smoke: the server answers an ACP initialize handshake with a clean frame on stdout. --- examples/README.md | 2 +- examples/acp-agent/README.md | 3 ++- examples/acp-agent/code-mode.cordis.yml | 11 ++++++----- package.json | 1 + 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/examples/README.md b/examples/README.md index 1b9cdf080e..d42144ec9a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -29,4 +29,4 @@ Run with: `pnpm run demo:code` (needs `DEEPSEEK_API_KEY`). See [code-agent/READM An agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio, via the [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent) app — drive it from Zed or any other ACP client. Also the home of the keyless snapshot tests. -Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`). See [acp-agent/README.md](acp-agent/README.md) for the Zed setup and the snapshot-test design. +Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`); `pnpm run demo:acp-code` boots the same server in Code Mode via the `code-mode.cordis.yml` overlay. See [acp-agent/README.md](acp-agent/README.md) for the Zed setup and the snapshot-test design. diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index a1f6e818ab..b00604a056 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -4,9 +4,10 @@ The DeepSeek Harness SDK agent demo exposed as an **Agent Client Protocol (ACP)* ```sh pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) +pnpm run demo:acp-code # the same server in Code Mode: one wire tool, run_code ``` -This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand), the swappable DeepSeek, bash, and filesystem backends, the model-facing `read`/`write`/`edit`/`subagent`/`subagent_fork`/`todo_write` tool entries, and the advisory `repeat-tool-guard` loop-hygiene plugin. The app package bakes in the no-stdout-logger cluster, so a leaf has no logger entry to get wrong by default — keeping stdout pure for JSON-RPC. +This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand), the swappable DeepSeek, bash, and filesystem backends, the model-facing `read`/`write`/`edit`/`subagent`/`subagent_fork`/`todo_write` tool entries, and the advisory `repeat-tool-guard` loop-hygiene plugin. The app package bakes in the no-stdout-logger cluster, so a leaf has no logger entry to get wrong by default — keeping stdout pure for JSON-RPC. `demo:acp-code` boots the same tree through the [`code-mode.cordis.yml`](code-mode.cordis.yml) overlay — the tool surface collapses to `run_code` + the generated TypeScript SDK, dispatching through the worker-thread code runtime (see the [dsh-tools Code Mode section](../../packages/core/tools/README.md#code-mode)). ## stdout is the protocol diff --git a/examples/acp-agent/code-mode.cordis.yml b/examples/acp-agent/code-mode.cordis.yml index 244cad9209..0dfbd73d26 100644 --- a/examples/acp-agent/code-mode.cordis.yml +++ b/examples/acp-agent/code-mode.cordis.yml @@ -1,11 +1,12 @@ -# Code Mode RECORD overlay: the live acp-agent tree (./cordis.yml) with two +# Code Mode overlay: the live acp-agent tree (./cordis.yml) with two # load-time patches — the app entry's config gains `tools: { mode: code }` # (the registry offers exactly one wire tool, run_code, plus the generated # TypeScript SDK prompt section) and the worker-thread code runtime joins the -# tree as `ctx.codeRuntime`. The dsh-acp-agent bin boots this file when the -# snapshot harness records the code-mode scenarios; DSH_SNAPSHOT=replay swaps -# it for the sibling code-mode.cordis.snapshot.yml. A config patch REPLACES -# the entry's whole config, so the base entry's fields are restated verbatim. +# tree as `ctx.codeRuntime`. The dsh-acp-agent bin boots this file for +# `pnpm run demo:acp-code` and when the snapshot harness records the +# code-mode scenarios; DSH_SNAPSHOT=replay swaps it for the sibling +# code-mode.cordis.snapshot.yml. A config patch REPLACES the entry's whole +# config, so the base entry's fields are restated verbatim. - id: base name: '@cordisjs/plugin-include' config: diff --git a/package.json b/package.json index 4fce3dad57..4ed429f1d0 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", "demo:code": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/code-agent/cordis.yml", "demo:acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/cordis.yml", + "demo:acp-code": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/code-mode.cordis.yml", "postinstall": "node scripts/install-lefthook.mjs" }, "devDependencies": { From bc7da642d4cb4770a31571c042ece8bac0b5d6d3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:16:37 +0800 Subject: [PATCH 32/57] feat: fold the Code Mode demos into demo:code-mode with a UI argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code Mode is the point; the UI is just the surface it happens to wear. demo:code and demo:acp-code collapse into one dispatcher (scripts/demo-code-mode.mjs): `pnpm run demo:code-mode [repl|acp]` — repl (default) boots the stdio REPL over examples/code-agent, acp serves examples/acp-agent's code-mode overlay; each UI runs the exact node invocation its standalone script ran, and an unknown argument fails loud with usage. All nine references across READMEs, the RFC, the overlay header, and the keyless-smoke comment renamed. Smoked all three paths: usage exit 2, ACP initialize handshake, REPL boot + EOF. --- .../feature/2026-06-15-code-mode.md | 2 +- examples/README.md | 4 +-- examples/acp-agent/README.md | 4 +-- examples/acp-agent/code-mode.cordis.yml | 2 +- examples/code-agent/README.md | 2 +- .../code-agent/tests/keyless-smoke.e2e.ts | 2 +- package.json | 3 +-- packages/core/tools/README.md | 2 +- scripts/demo-code-mode.mjs | 27 +++++++++++++++++++ 9 files changed, 37 insertions(+), 11 deletions(-) create mode 100644 scripts/demo-code-mode.mjs diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.md index 2f643b432e..5ce9d784b9 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md @@ -90,7 +90,7 @@ What exists now: - **The seam**: `packages/code-runtime/` — `@deepseek-ai/dsh-code-runtime` (abstract `CodeRuntime`, the vocabulary above, `ctx.codeRuntime`) and `@deepseek-ai/dsh-code-runtime-worker` (the worker-thread backend, every cap a validated config field). Rows in the service map, capability-seams graph, config catalog, and cordis catalog. - **The registry surface**: `ToolRegistry`'s first config (`mode`), the mode-aware wire contribution, the `tools:sdk` section, `jsonSchemaToTs`/`renderToolsSdk` (exported), `run_code` + the dispatch bridge + `CodeRunFailedError`, and the `tool/code-dispatch` log event (declaration-merged into `SessionEventMap`, regenerated into the persistence catalog; `run_code` in the tool catalog). -- **The composed surface**: the `tools` config forwards through `agent-core` and both app packages (`stdio-agent`, `acp-agent`); `examples/code-agent` + `demo:code` run the worker runtime under `mode: 'code'`; the adding-a-tool cookbook states that a registered tool is reachable from programs for free, and the tool-pipeline doc shows sub-dispatches re-entering both waterfalls. +- **The composed surface**: the `tools` config forwards through `agent-core` and both app packages (`stdio-agent`, `acp-agent`); `examples/code-agent` + `demo:code-mode` run the worker runtime under `mode: 'code'`; the adding-a-tool cookbook states that a registered tool is reachable from programs for free, and the tool-pipeline doc shows sub-dispatches re-entering both waterfalls. - **Interactions inherited by deployments**: a `toolOrder` naming native tools rejects every assembly under `'code'` (update or drop the order config when switching modes); sub-call `additionalContext` is dropped by the bridge (a plural context channel is deferred until a real hook needs it through Code Mode); sub-dispatch stays serialized until tools can declare concurrency safety — the same metadata the native parallel-dispatch TODO waits on. ## Testing diff --git a/examples/README.md b/examples/README.md index d42144ec9a..3027d01f0a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,10 +23,10 @@ Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a The coding agent flipped to **Code Mode**: the worker-thread code runtime is loaded and the tool registry runs `mode: code`, so the model gets exactly one wire tool — `run_code` — plus a generated TypeScript SDK section, and composes bash/read/write/edit/todo_write by writing a program whose output it curates. -Run with: `pnpm run demo:code` (needs `DEEPSEEK_API_KEY`). See [code-agent/README.md](code-agent/README.md) for what to try and how it differs from coding-agent. +Run with: `pnpm run demo:code-mode` (needs `DEEPSEEK_API_KEY`; the REPL is the default UI — `acp` as the argument serves the acp-agent example's Code Mode overlay instead). See [code-agent/README.md](code-agent/README.md) for what to try and how it differs from coding-agent. ## acp-agent An agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio, via the [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent) app — drive it from Zed or any other ACP client. Also the home of the keyless snapshot tests. -Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`); `pnpm run demo:acp-code` boots the same server in Code Mode via the `code-mode.cordis.yml` overlay. See [acp-agent/README.md](acp-agent/README.md) for the Zed setup and the snapshot-test design. +Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`); `pnpm run demo:code-mode acp` boots the same server in Code Mode via the `code-mode.cordis.yml` overlay. See [acp-agent/README.md](acp-agent/README.md) for the Zed setup and the snapshot-test design. diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index b00604a056..5b3c936651 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -4,10 +4,10 @@ The DeepSeek Harness SDK agent demo exposed as an **Agent Client Protocol (ACP)* ```sh pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) -pnpm run demo:acp-code # the same server in Code Mode: one wire tool, run_code +pnpm run demo:code-mode acp # the same server in Code Mode: one wire tool, run_code ``` -This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand), the swappable DeepSeek, bash, and filesystem backends, the model-facing `read`/`write`/`edit`/`subagent`/`subagent_fork`/`todo_write` tool entries, and the advisory `repeat-tool-guard` loop-hygiene plugin. The app package bakes in the no-stdout-logger cluster, so a leaf has no logger entry to get wrong by default — keeping stdout pure for JSON-RPC. `demo:acp-code` boots the same tree through the [`code-mode.cordis.yml`](code-mode.cordis.yml) overlay — the tool surface collapses to `run_code` + the generated TypeScript SDK, dispatching through the worker-thread code runtime (see the [dsh-tools Code Mode section](../../packages/core/tools/README.md#code-mode)). +This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand), the swappable DeepSeek, bash, and filesystem backends, the model-facing `read`/`write`/`edit`/`subagent`/`subagent_fork`/`todo_write` tool entries, and the advisory `repeat-tool-guard` loop-hygiene plugin. The app package bakes in the no-stdout-logger cluster, so a leaf has no logger entry to get wrong by default — keeping stdout pure for JSON-RPC. `demo:code-mode acp` boots the same tree through the [`code-mode.cordis.yml`](code-mode.cordis.yml) overlay — the tool surface collapses to `run_code` + the generated TypeScript SDK, dispatching through the worker-thread code runtime (see the [dsh-tools Code Mode section](../../packages/core/tools/README.md#code-mode)). ## stdout is the protocol diff --git a/examples/acp-agent/code-mode.cordis.yml b/examples/acp-agent/code-mode.cordis.yml index 0dfbd73d26..d46e490494 100644 --- a/examples/acp-agent/code-mode.cordis.yml +++ b/examples/acp-agent/code-mode.cordis.yml @@ -3,7 +3,7 @@ # (the registry offers exactly one wire tool, run_code, plus the generated # TypeScript SDK prompt section) and the worker-thread code runtime joins the # tree as `ctx.codeRuntime`. The dsh-acp-agent bin boots this file for -# `pnpm run demo:acp-code` and when the snapshot harness records the +# `pnpm run demo:code-mode acp` and when the snapshot harness records the # code-mode scenarios; DSH_SNAPSHOT=replay swaps it for the sibling # code-mode.cordis.snapshot.yml. A config patch REPLACES the entry's whole # config, so the base entry's fields are restated verbatim. diff --git a/examples/code-agent/README.md b/examples/code-agent/README.md index 9b6619c0b3..6e08a883a3 100644 --- a/examples/code-agent/README.md +++ b/examples/code-agent/README.md @@ -3,7 +3,7 @@ The [Code Mode](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) form of the coding agent: instead of one native tool call per step, the model is offered exactly ONE wire tool — `run_code` — plus a generated TypeScript SDK section declaring every other registered tool (`bash`, `read`, `write`, `edit`, `todo_write`). The model composes tools by writing a program; the program runs in a fresh worker thread (`@deepseek-ai/dsh-code-runtime-worker`), its tool calls bridge back through the ordinary `tools/pre-execute`/`post-execute` pipeline one at a time, each is logged as a `tool/code-dispatch` session event, and ONLY what the program prints or returns re-enters the model's context. ```sh -pnpm run demo:code # needs DEEPSEEK_API_KEY (repo-root .env works) +pnpm run demo:code-mode # needs DEEPSEEK_API_KEY (repo-root .env works) ``` Try a task that spans several tool calls, e.g.: diff --git a/examples/code-agent/tests/keyless-smoke.e2e.ts b/examples/code-agent/tests/keyless-smoke.e2e.ts index 4b7a150e99..d5913e48d5 100644 --- a/examples/code-agent/tests/keyless-smoke.e2e.ts +++ b/examples/code-agent/tests/keyless-smoke.e2e.ts @@ -42,7 +42,7 @@ async function bootAndEof(): Promise<{ stdout: string; code: number }> { return new Promise((resolve, reject) => { const proc = spawn( process.execPath, - // --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:code). + // --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:code-mode). ['--expose-internals', '--import', tsxLoader, binScript, configPath], { cwd, diff --git a/package.json b/package.json index 4ed429f1d0..b906964b1e 100644 --- a/package.json +++ b/package.json @@ -63,9 +63,8 @@ "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", - "demo:code": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/code-agent/cordis.yml", + "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/cordis.yml", - "demo:acp-code": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/code-mode.cordis.yml", "postinstall": "node scripts/install-lefthook.mjs" }, "devDependencies": { diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 7525fb8fd6..c0c7cfe437 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -133,7 +133,7 @@ Under `mode: code` (or `both`) the registry turns the tool surface into a progra - **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized BEFORE dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and the logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order — the tool contract carries no concurrency-safety metadata yet), gated by `tools/pre-execute`/`tools/post-execute` like any native call (a deny reaches the program as a binding rejection), and logged as one `tool/code-dispatch` session event (log-only: `deriveMessages()` never surfaces it) with the deterministic sub-id `:code:`. A failed sub-call REJECTS the program-side promise with the tool's error text — real code error handling, no bespoke envelope. A sub-call's `additionalContext` is deliberately DROPPED (no safe outlet mid-run without breaking tool-call/result adjacency; deferred until a real hook needs it through Code Mode). - **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. -The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free — under `code`, the assembled tool list is exactly `[run_code]`, pinned by tests and the snapshot goldens. Try it: `pnpm run demo:code` ([examples/code-agent](../../../examples/code-agent/README.md)). +The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free — under `code`, the assembled tool list is exactly `[run_code]`, pinned by tests and the snapshot goldens. Try it: `pnpm run demo:code-mode` ([examples/code-agent](../../../examples/code-agent/README.md)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL. ### What is NOT here (TODO) diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs new file mode 100644 index 0000000000..465e5f2982 --- /dev/null +++ b/scripts/demo-code-mode.mjs @@ -0,0 +1,27 @@ +/** + * Boot the Code Mode demo under the UI named on the command line: + * `pnpm run demo:code-mode [repl|acp]`, default `repl`. Code Mode is the + * point — the UI is just the surface it happens to wear: `repl` starts the + * stdio REPL over examples/code-agent, `acp` starts the ACP server over + * examples/acp-agent's code-mode overlay. Both need DEEPSEEK_API_KEY + * (repo-root .env works). Anything else on the command line is a + * misconfiguration and fails loud with usage. + */ +import { spawn } from 'node:child_process' + +// Each UI's node invocation, verbatim what its standalone demo script ran +// (the stdio bin keeps --expose-internals for the cordis Loader's HMR path). +const UIS = new Map([ + ['repl', ['--expose-internals', '--import', 'tsx', 'packages/ui/stdio-agent/src/bin.ts', 'examples/code-agent/cordis.yml']], + ['acp', ['--import', 'tsx', 'packages/ui/acp-agent/src/bin.ts', 'examples/acp-agent/code-mode.cordis.yml']], +]) + +const ui = process.argv[2] ?? 'repl' +const args = UIS.get(ui) +if (!args || process.argv.length > 3) { + console.error('usage: pnpm run demo:code-mode [repl|acp]') + process.exit(2) +} + +const child = spawn(process.execPath, args, { stdio: 'inherit' }) +child.on('exit', (code, signal) => { process.exit(signal !== null ? 1 : code ?? 1) }) From 9fddbac09593484a0a66bff8b75ee881ee0ef1f1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:02:19 +0800 Subject: [PATCH 33/57] refactor: unify the Code Mode demos on base-plus-overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both demo:code-mode UIs now share one mechanism: the base example plus a same-shaped code-mode.cordis.yml include overlay (insert the worker runtime, flip tools.mode). Previously the REPL side was a hand-forked example (examples/code-agent) that had also silently diverged — it dropped compaction and the subagent stack — so the demo's UI argument switched agents, not just surfaces. The fork is retired: coding-agent gains the overlay, a Code Mode README section absorbing code-agent's, and both of its tests (the keyless boot guard, retargeted at the overlay; the with-key RFC proof, which hand-mounts its own harness and moves untouched). The RFC's composed-surface and e2e-tier lines, the examples index, the AGENTS.md smoke table, and the dsh-tools README link now describe the overlay shape. Verified live: overlay keyless smoke, with-key code-mode e2e from its new home, demo:code-mode banner + EOF exit, and the acp handshake. --- .../feature/2026-06-15-code-mode.md | 4 +- examples/AGENTS.md | 3 +- examples/README.md | 6 +- examples/code-agent/README.md | 17 ---- examples/code-agent/cordis.yml | 84 ------------------- examples/code-agent/package.json | 7 -- examples/coding-agent/README.md | 17 +++- examples/coding-agent/code-mode.cordis.yml | 33 ++++++++ .../tests/code-mode-keyless-smoke.e2e.ts} | 23 ++--- .../tests/code-mode.e2e.ts | 6 +- packages/core/tools/README.md | 2 +- scripts/demo-code-mode.mjs | 18 ++-- 12 files changed, 79 insertions(+), 141 deletions(-) delete mode 100644 examples/code-agent/README.md delete mode 100644 examples/code-agent/cordis.yml delete mode 100644 examples/code-agent/package.json create mode 100644 examples/coding-agent/code-mode.cordis.yml rename examples/{code-agent/tests/keyless-smoke.e2e.ts => coding-agent/tests/code-mode-keyless-smoke.e2e.ts} (75%) rename examples/{code-agent => coding-agent}/tests/code-mode.e2e.ts (96%) diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.md index 5ce9d784b9..4cc373c65c 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md @@ -90,7 +90,7 @@ What exists now: - **The seam**: `packages/code-runtime/` — `@deepseek-ai/dsh-code-runtime` (abstract `CodeRuntime`, the vocabulary above, `ctx.codeRuntime`) and `@deepseek-ai/dsh-code-runtime-worker` (the worker-thread backend, every cap a validated config field). Rows in the service map, capability-seams graph, config catalog, and cordis catalog. - **The registry surface**: `ToolRegistry`'s first config (`mode`), the mode-aware wire contribution, the `tools:sdk` section, `jsonSchemaToTs`/`renderToolsSdk` (exported), `run_code` + the dispatch bridge + `CodeRunFailedError`, and the `tool/code-dispatch` log event (declaration-merged into `SessionEventMap`, regenerated into the persistence catalog; `run_code` in the tool catalog). -- **The composed surface**: the `tools` config forwards through `agent-core` and both app packages (`stdio-agent`, `acp-agent`); `examples/code-agent` + `demo:code-mode` run the worker runtime under `mode: 'code'`; the adding-a-tool cookbook states that a registered tool is reachable from programs for free, and the tool-pipeline doc shows sub-dispatches re-entering both waterfalls. +- **The composed surface**: the `tools` config forwards through `agent-core` and both app packages (`stdio-agent`, `acp-agent`); `demo:code-mode` boots each UI example's `code-mode.cordis.yml` overlay (the worker runtime + `mode: 'code'` over the base tree); the adding-a-tool cookbook states that a registered tool is reachable from programs for free, and the tool-pipeline doc shows sub-dispatches re-entering both waterfalls. - **Interactions inherited by deployments**: a `toolOrder` naming native tools rejects every assembly under `'code'` (update or drop the order config when switching modes); sub-call `additionalContext` is dropped by the bridge (a plural context channel is deferred until a real hook needs it through Code Mode); sub-dispatch stays serialized until tools can declare concurrency safety — the same metadata the native parallel-dispatch TODO waits on. ## Testing @@ -99,7 +99,7 @@ What the suites pin, per tier: - **Unit — worker runtime** (real workers, no mocks): output/value capture and log-source attribution; error kinds (exception incl. non-erasable syntax, abort, worker-exit under OOM); the two budgets from both sides (a hot loop behind an un-awaited pending dispatch dies at `computeMs` busy time; a program idling on a slow binding outlives `computeMs` and dies only at `maxWallMs`); binding-bridge hostility (junk/forged port traffic incl. non-object messages and forged `log`/`done` cap bypass attempts, unknown names, duplicate ids, post-settlement replies, `__proto__`/`constructor`/`toString` binding names); structured-clone fallback and cap truncation; `env` emptiness verified from inside a program; dispose-awaits-exit. A real-load-path e2e runs the BUILT package under plain `node` so the worker entry resolves both unbuilt (tsx) and built — the published-artifact guard from [docs/testing.md](../../../testing.md). - **Unit — registry integration**: the codegen table (DSL subset, quoted names, `unknown` degradation, byte-identical determinism); provider contribution per mode (`'native'` unchanged, `'code'` exactly `[run_code]`, `'both'` all + `run_code`); `toolOrder × mode` rejection; missing-runtime / wrong-language loud failures; serialization non-overlap (a probe tool records enter/exit under `Promise.all`); abort aborting the in-flight sub-dispatch and abandoning queued ones; binding rejection on `isError` and on JSON-unrepresentable arguments; `CodeRunFailedError` → structured `isError` carrying kind + logs; `tool/code-dispatch` payloads (JSON-normalized arguments identical to what dispatched); `deriveMessages()` ignoring the event; sub-call `additionalContext` suppression; HMR safety (disposing the registry removes the tool and the section). -- **e2e (with-key, self-skips)**: a real model under `mode: 'code'` composes two bash calls in one program (`examples/code-agent/tests/code-mode.e2e.ts`) — every logged `request/header` carries exactly `[run_code]`, the dispatch events land under the parent call, the file the program wrote exists, and the final answer is the curated output. +- **e2e (with-key, self-skips)**: a real model under `mode: 'code'` composes two bash calls in one program (`examples/coding-agent/tests/code-mode.e2e.ts`) — every logged `request/header` carries exactly `[run_code]`, the dispatch events land under the parent call, the file the program wrote exists, and the final answer is the curated output. - **Snapshot (keyless replay)**: goldens for a `run_code` turn under `'code'` and `'both'` (`code-mode-turn`, `both-mode-turn`), each its own header-pinning class — the SDK section text, the collapsed header tool list, the dispatch events, and the result card are committed and replayed. ## Alternatives considered diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 177ee18aa0..1434c46667 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -20,8 +20,7 @@ A keyless smoke that spawns the example from a temp cwd must set `TSX_TSCONFIG_P | Example | Keyless smoke | With-key smoke | |---|---|---| | `echo-agent` | `tests/echo.e2e.ts` — boots the real `cordis.yml`, drives the echo tool round-trip and the direct canned reply | **N/A — keyless by nature** (the `mock-echo` model has no real provider) | -| `coding-agent` | `tests/keyless-smoke.e2e.ts` — boots the full real tree (dummy key, no prompt → no model call), asserts banner + clean exit | `tests/{full-loop,coding-task,resume,compaction,todo-write}.e2e.ts` — real model + real bash + real todo_write, world-verified | -| `code-agent` | `tests/keyless-smoke.e2e.ts` — the Code Mode boot guard | `tests/code-mode.e2e.ts` — a real model composes two bash calls in one `run_code` program; collapsed header, dispatch events, written file all verified | +| `coding-agent` | `tests/keyless-smoke.e2e.ts` — boots the full real tree (dummy key, no prompt → no model call), asserts banner + clean exit; `tests/code-mode-keyless-smoke.e2e.ts` — the same boot guard for the Code Mode overlay | `tests/{full-loop,coding-task,resume,compaction,todo-write}.e2e.ts` — real model + real bash + real todo_write, world-verified; `tests/code-mode.e2e.ts` — a real model composes two bash calls in one `run_code` program; collapsed header, dispatch events, written file all verified | | `acp-agent` | `pnpm run test:snapshot` — boots the real ACP subprocess and replays a recorded session keyless (incl. the hook matrix: a scenario per hook point × outcome for BOTH the Claude and Codex bridges — block, deny, ask, context-fold, force-continue); `tests/acp.e2e.ts` also asserts stdout purity without a key | `tests/acp.e2e.ts` — real ACP prompt, verifies a file the agent wrote; `tests/hooks.e2e.ts` — a real `PreToolUse` hook blocks bash, verifies the file is NOT written | See [the root AGENTS.md](../AGENTS.md) for repo-wide conventions and [docs/architecture.md](../docs/architecture.md) for the design. diff --git a/examples/README.md b/examples/README.md index 3027d01f0a..56c10f676b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -19,11 +19,7 @@ A REPL agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + th Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details. -## code-agent - -The coding agent flipped to **Code Mode**: the worker-thread code runtime is loaded and the tool registry runs `mode: code`, so the model gets exactly one wire tool — `run_code` — plus a generated TypeScript SDK section, and composes bash/read/write/edit/todo_write by writing a program whose output it curates. - -Run with: `pnpm run demo:code-mode` (needs `DEEPSEEK_API_KEY`; the REPL is the default UI — `acp` as the argument serves the acp-agent example's Code Mode overlay instead). See [code-agent/README.md](code-agent/README.md) for what to try and how it differs from coding-agent. +Its `code-mode.cordis.yml` overlay flips the same tree to **Code Mode**: the worker-thread code runtime is loaded and the tool registry runs `mode: code`, so the model gets exactly one wire tool — `run_code` — plus a generated TypeScript SDK section, and composes the other tools by writing a program whose output it curates. Run with: `pnpm run demo:code-mode` (the REPL is the default UI; `acp` as the argument serves the acp-agent example's same-shaped overlay instead) — see the [Code Mode section](coding-agent/README.md#code-mode) for what to try. ## acp-agent diff --git a/examples/code-agent/README.md b/examples/code-agent/README.md deleted file mode 100644 index 6e08a883a3..0000000000 --- a/examples/code-agent/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# code-agent — the Code Mode demo - -The [Code Mode](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) form of the coding agent: instead of one native tool call per step, the model is offered exactly ONE wire tool — `run_code` — plus a generated TypeScript SDK section declaring every other registered tool (`bash`, `read`, `write`, `edit`, `todo_write`). The model composes tools by writing a program; the program runs in a fresh worker thread (`@deepseek-ai/dsh-code-runtime-worker`), its tool calls bridge back through the ordinary `tools/pre-execute`/`post-execute` pipeline one at a time, each is logged as a `tool/code-dispatch` session event, and ONLY what the program prints or returns re-enters the model's context. - -```sh -pnpm run demo:code-mode # needs DEEPSEEK_API_KEY (repo-root .env works) -``` - -Try a task that spans several tool calls, e.g.: - -> Count the lines of every `*.md` file under docs/ and write the three largest to summary.txt. - -and watch the transcript: one `run_code` call, a program looping over tools, and a result the model curated instead of five round-trips of raw tool output. - -Two lines of `cordis.yml` make the difference from [examples/coding-agent](../coding-agent/README.md): the `code-runtime` entry (the worker-thread backend registering `ctx.codeRuntime`) and `tools: { mode: code }` on the app (flip it to `both` to offer native calls AND `run_code` side by side; remove both lines and it IS the coding agent). - -Tests: `tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with no prompt (the export-shape guard); `tests/code-mode.e2e.ts` is the with-key proof — a real model, a two-tool task, asserting the wire tool list was exactly `[run_code]`, the `tool/code-dispatch` events landed, and the curated answer came back. diff --git a/examples/code-agent/cordis.yml b/examples/code-agent/cordis.yml deleted file mode 100644 index e4bb4de1e5..0000000000 --- a/examples/code-agent/cordis.yml +++ /dev/null @@ -1,84 +0,0 @@ -# The code-agent plugin tree: the Code Mode demo. The same spine as -# examples/coding-agent — the DeepSeek adapter, local bash, filesystem and -# todo tool stacks over the stdio chat app — with TWO differences that turn -# it into Cloudflare-style Code Mode: -# -# 1. `code-runtime` loads the worker-thread code-execution backend -# (`ctx.codeRuntime`): one fresh Node worker per run, TypeScript in. -# 2. `stdio-agent` sets `tools: { mode: code }`, so the model is offered -# exactly ONE wire tool — `run_code` — plus a generated TypeScript SDK -# prompt section declaring bash/read/write/edit/todo_write; the model -# composes them by WRITING A PROGRAM, and only what it prints or -# returns re-enters its context. -# -# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the -# environment — the dsh-stdio-agent bin loads the gitignored repo-root .env -# first. cordis.yml reads them via the `!!js` tag. - -# Hot-module reload for the dev/demo loop (needs `node --expose-internals`). -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] - -# The DeepSeek adapter. -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - models: - - deepseek-v4-pro - - deepseek-v4-flash - -# Local bash executor for the spine's `bash` tool schemas. -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 - -# The code-execution backend: `run_code` programs execute here, in one fresh -# worker thread per run with an empty environment, port-bridged tool -# bindings, and busy-time/wall-clock/heap caps (all overridable here). -- id: code-runtime - name: '@deepseek-ai/dsh-code-runtime-worker' - -# The stdio chat app with the registry flipped to Code Mode: the wire tool -# list collapses to [run_code] and the `tools:sdk` prompt section carries the -# generated TypeScript declarations for every other registered tool. -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-agent' - config: - model: deepseek-v4-flash - tools: - mode: code - # Set RESUME_SESSION_ID to continue a prior persisted session (the ids - # live under ./.sessions); unset starts a fresh session each run. - resumeSessionId: !!js process.env.RESUME_SESSION_ID - persistenceRoot: './.sessions' - welcome: 'code-mode agent ready. Give it a multi-tool task.' - persona: | - You are code-agent, a coding assistant powered by the {{model}} model. - - You work by writing TypeScript programs for run_code: batch related - tool work into one program, loop and branch where it helps, and print - or return ONLY the findings that matter. - -# The model-facing todo_write tool: whole-list task tracking written to the -# session log (todo/write), rendered as a stdio checklist. -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - -# Filesystem capability stack: local provider, read-before-write/edit policy -# gate, then the model-facing read/write/edit tools — all reachable from a -# run_code program as `tools.read(...)` / `tools.write(...)` / `tools.edit(...)`. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' diff --git a/examples/code-agent/package.json b/examples/code-agent/package.json deleted file mode 100644 index 0aa0e52c52..0000000000 --- a/examples/code-agent/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "code-agent-example", - "private": true, - "version": "0.0.1", - "type": "module", - "description": "Runnable demo: Code Mode — the model writes TypeScript against the tool registry" -} diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 4b15d2dc5a..7731aa8f09 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -31,6 +31,21 @@ RESUME_SESSION_ID= pnpm run demo:repl The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing/unreadable id is non-fatal — it logs a warning and starts no `main` agent. +## Code Mode + +[`code-mode.cordis.yml`](code-mode.cordis.yml) is this same tree flipped to [Code Mode](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): an include overlay over `./cordis.yml` whose two patches insert the worker-thread code runtime (`@deepseek-ai/dsh-code-runtime-worker`, registering `ctx.codeRuntime`) and set `tools: { mode: code }` on the app. The model is then offered exactly ONE wire tool — `run_code` — plus a generated TypeScript SDK section declaring every other registered tool; it composes them by writing a program, each program tool call bridges back through the ordinary `tools/pre-execute`/`post-execute` pipeline one at a time and is logged as a `tool/code-dispatch` session event, and ONLY what the program prints or returns re-enters its context. (Flip the mode to `both` to offer native calls AND `run_code` side by side.) + +```sh +pnpm run demo:code-mode # this overlay under the REPL (default UI) +pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay +``` + +Try a task that spans several tool calls, e.g.: + +> Count the lines of every `*.md` file under docs/ and write the three largest to summary.txt. + +and watch the transcript: one `run_code` call, a program looping over tools, and a result the model curated instead of five round-trips of raw tool output. + ## What each leaf entry demonstrates This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (console logger, JSONL persistence, readline UI, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent) app and the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) bundle it loads; the leaf wires the backends and model-facing optional tools: @@ -54,4 +69,4 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads - `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so the auto-compaction listener fires MID-SESSION. Verifies the WORLD — a `compact/start…end` pair landed in the real log, the surface shrank (a replace node shadowed older nodes), and the agent still produced a correct final answer after compaction. - `tests/todo-write.e2e.ts` — a real model drives the real `todo_write` tool and the test verifies the resulting `todo/write` session event. -These self-skip without `DEEPSEEK_API_KEY`. The keyless boot smoke is `tests/keyless-smoke.e2e.ts` (boots the full real tree with a dummy key and no prompt, so no model call), which runs in the default e2e gate. +These self-skip without `DEEPSEEK_API_KEY`. `tests/code-mode.e2e.ts` is the with-key Code Mode proof — a real model, a two-tool task, asserting the wire tool list was exactly `[run_code]`, the `tool/code-dispatch` events landed under the parent call, and the curated answer came back. The keyless boot smokes run in the default e2e gate: `tests/keyless-smoke.e2e.ts` (the full real tree, dummy key, no prompt → no model call) and `tests/code-mode-keyless-smoke.e2e.ts` (the same guard for the Code Mode overlay). diff --git a/examples/coding-agent/code-mode.cordis.yml b/examples/coding-agent/code-mode.cordis.yml new file mode 100644 index 0000000000..ac4ce03570 --- /dev/null +++ b/examples/coding-agent/code-mode.cordis.yml @@ -0,0 +1,33 @@ +# Code Mode overlay: the live coding-agent tree (./cordis.yml) with two +# load-time patches — the app entry's config gains `tools: { mode: code }` +# (the registry offers exactly one wire tool, run_code, plus the generated +# TypeScript SDK prompt section declaring bash/read/write/edit/subagent/ +# todo_write) and the worker-thread code runtime joins the tree as +# `ctx.codeRuntime`. The dsh-stdio-agent bin boots this file for +# `pnpm run demo:code-mode` (the acp-agent example carries the same-shaped +# overlay for the `acp` UI). A config patch REPLACES the entry's whole +# config, so the base entry's fields are restated verbatim; only `tools`, +# the welcome, and the persona's second paragraph are Code Mode deltas. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' + config: + model: deepseek-v4-flash + resumeSessionId: !!js process.env.RESUME_SESSION_ID + persistenceRoot: './.sessions' + tools: + mode: code + welcome: 'code-mode agent ready. Give it a multi-tool task.' + persona: | + You are coding-agent, a coding assistant powered by the {{model}} model. + + You work by writing TypeScript programs for run_code: batch related + tool work into one program, loop and branch where it helps, and print + or return ONLY the findings that matter. + - insert: + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/code-agent/tests/keyless-smoke.e2e.ts b/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts similarity index 75% rename from examples/code-agent/tests/keyless-smoke.e2e.ts rename to examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts index d5913e48d5..7894e9ff9f 100644 --- a/examples/code-agent/tests/keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts @@ -6,11 +6,12 @@ import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' /** - * Keyless Loader-path smoke for examples/code-agent: boot the REAL example - * through the `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` - * (the cordis Loader, `unwrapExports`, the full plugin tree incl. the - * worker-thread code runtime and the registry in `mode: code`), then close - * stdin with no prompt and assert the ready banner + a clean exit. + * Keyless Loader-path smoke for the Code Mode overlay: boot the REAL + * example through the `@deepseek-ai/dsh-stdio-agent` bin against + * `code-mode.cordis.yml` (the cordis Loader, `unwrapExports`, the include + * patches over ./cordis.yml, the worker-thread code runtime, and the + * registry in `mode: code`), then close stdin with no prompt and assert + * the Code Mode banner + a clean exit. * * No prompt is ever sent, so the model is NEVER called and no `run_code` * turn happens — a dummy key lets `llm-deepseek`'s key-PRESENT check boot @@ -19,7 +20,7 @@ import { afterEach, describe, expect, it } from 'vitest' */ const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +const configPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // Dev/test run UNBUILT: resolve `@deepseek-ai/dsh-*` through the root tsconfig // `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside @@ -37,12 +38,12 @@ afterEach(async () => { }) async function bootAndEof(): Promise<{ stdout: string; code: number }> { - workdir = await mkdtemp(join(tmpdir(), 'code-agent-smoke-')) + workdir = await mkdtemp(join(tmpdir(), 'code-mode-smoke-')) const cwd = workdir return new Promise((resolve, reject) => { const proc = spawn( process.execPath, - // --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:code-mode). + // --expose-internals: the included cordis.yml loads the HMR plugin (mirrors demo:code-mode). ['--expose-internals', '--import', tsxLoader, binScript, configPath], { cwd, @@ -66,13 +67,13 @@ async function bootAndEof(): Promise<{ stdout: string; code: number }> { const timer = setTimeout(() => { proc.kill('SIGKILL') - reject(new Error(`code-agent did not exit within 10s. stdout:\n${stdout}\nstderr:\n${stderr}`)) + reject(new Error(`code-mode overlay did not exit within 10s. stdout:\n${stdout}\nstderr:\n${stderr}`)) }, 10_000) proc.on('exit', (code) => { clearTimeout(timer) if (code === 0) resolve({ stdout, code }) - else reject(new Error(`code-agent exited ${code}. stderr:\n${stderr}`)) + else reject(new Error(`code-mode overlay exited ${code}. stderr:\n${stderr}`)) }) proc.on('error', (err) => { clearTimeout(timer); reject(err) }) @@ -81,7 +82,7 @@ async function bootAndEof(): Promise<{ stdout: string; code: number }> { }) } -describe('code-agent keyless smoke (real cordis.yml via the Loader)', () => { +describe('code-mode overlay keyless smoke (real code-mode.cordis.yml via the Loader)', () => { it('boots the Code Mode plugin tree, prints its banner, and exits cleanly on EOF', async () => { const { stdout, code } = await bootAndEof() expect(code).toBe(0) diff --git a/examples/code-agent/tests/code-mode.e2e.ts b/examples/coding-agent/tests/code-mode.e2e.ts similarity index 96% rename from examples/code-agent/tests/code-mode.e2e.ts rename to examples/coding-agent/tests/code-mode.e2e.ts index 6b0771380d..512688d88d 100644 --- a/examples/code-agent/tests/code-mode.e2e.ts +++ b/examples/coding-agent/tests/code-mode.e2e.ts @@ -22,11 +22,11 @@ import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' * `[run_code]` as the wire tool list, each sub-call landed as a * `tool/code-dispatch` event, the file the program wrote exists on disk, and * the final answer is the program's curated output. Key-gated (see - * vitest.e2e.config.ts); the keyless Loader-path smoke lives in - * `keyless-smoke.e2e.ts`. + * vitest.e2e.config.ts); the keyless Loader-path smoke of the overlay lives + * in `code-mode-keyless-smoke.e2e.ts`. */ -const PERSONA = 'You are code-agent. You work by writing TypeScript programs for run_code: ' +const PERSONA = 'You are coding-agent. You work by writing TypeScript programs for run_code: ' + 'batch related tool work into one program and print or return ONLY the findings that matter.' let ctx: Context | undefined diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index c0c7cfe437..2ca93b94af 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -133,7 +133,7 @@ Under `mode: code` (or `both`) the registry turns the tool surface into a progra - **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized BEFORE dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and the logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order — the tool contract carries no concurrency-safety metadata yet), gated by `tools/pre-execute`/`tools/post-execute` like any native call (a deny reaches the program as a binding rejection), and logged as one `tool/code-dispatch` session event (log-only: `deriveMessages()` never surfaces it) with the deterministic sub-id `:code:`. A failed sub-call REJECTS the program-side promise with the tool's error text — real code error handling, no bespoke envelope. A sub-call's `additionalContext` is deliberately DROPPED (no safe outlet mid-run without breaking tool-call/result adjacency; deferred until a real hook needs it through Code Mode). - **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. -The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free — under `code`, the assembled tool list is exactly `[run_code]`, pinned by tests and the snapshot goldens. Try it: `pnpm run demo:code-mode` ([examples/code-agent](../../../examples/code-agent/README.md)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL. +The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free — under `code`, the assembled tool list is exactly `[run_code]`, pinned by tests and the snapshot goldens. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL. ### What is NOT here (TODO) diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index 465e5f2982..a93ea98173 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -1,18 +1,20 @@ /** * Boot the Code Mode demo under the UI named on the command line: * `pnpm run demo:code-mode [repl|acp]`, default `repl`. Code Mode is the - * point — the UI is just the surface it happens to wear: `repl` starts the - * stdio REPL over examples/code-agent, `acp` starts the ACP server over - * examples/acp-agent's code-mode overlay. Both need DEEPSEEK_API_KEY - * (repo-root .env works). Anything else on the command line is a - * misconfiguration and fails loud with usage. + * point — the UI is just the surface it happens to wear: each UI boots its + * base example through that example's `code-mode.cordis.yml` overlay + * (include ./cordis.yml, flip `tools.mode` to `code`, insert the + * worker-thread code runtime). Both need DEEPSEEK_API_KEY (repo-root .env + * works). Anything else on the command line is a misconfiguration and + * fails loud with usage. */ import { spawn } from 'node:child_process' -// Each UI's node invocation, verbatim what its standalone demo script ran -// (the stdio bin keeps --expose-internals for the cordis Loader's HMR path). +// Each UI's node invocation, verbatim what its base demo script runs plus +// the overlay config (the stdio bin keeps --expose-internals for the cordis +// Loader's HMR path). const UIS = new Map([ - ['repl', ['--expose-internals', '--import', 'tsx', 'packages/ui/stdio-agent/src/bin.ts', 'examples/code-agent/cordis.yml']], + ['repl', ['--expose-internals', '--import', 'tsx', 'packages/ui/stdio-agent/src/bin.ts', 'examples/coding-agent/code-mode.cordis.yml']], ['acp', ['--import', 'tsx', 'packages/ui/acp-agent/src/bin.ts', 'examples/acp-agent/code-mode.cordis.yml']], ]) From c321819053c29468f79adb87dc66c7ff22f37668 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 9 Jul 2026 13:42:03 +0800 Subject: [PATCH 34/57] rename: @deepseek-ai/dsh-subagent-process -> @deepseek-ai/dsh-subagent-subprocess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extracted library's name sat one edit away from @deepseek-ai/dsh-subagent-inprocess (process/inprocess), inviting a typo'd import to silently resolve to the wrong package. subagent-subprocess also reads as the deliberate counterpart to subagent-inprocess (in-process vs. subprocess), matching how the two shared drivers actually differ. Package directory, npm name, module doc, JSDoc module tags, test-file name and its temp-dir prefixes, the subagent-acp import and its Config/tsconfig/package.json references, root tsconfig.json/tsconfig.build.json/knip.json entries, and the packages/subagent group README all renamed together; regenerated docs/module-graph.md and docs/config-catalog.md. Pure rename — no behavior, export, or Config shape changed. --- docs/config-catalog.md | 2 +- docs/module-graph.md | 8 ++++---- knip.json | 2 +- packages/subagent/README.md | 4 ++-- packages/subagent/subagent-acp/README.md | 2 +- packages/subagent/subagent-acp/package.json | 4 ++-- packages/subagent/subagent-acp/src/run.ts | 2 +- .../subagent-acp/tests/subagent-acp.spec.ts | 2 +- packages/subagent/subagent-acp/tsconfig.json | 2 +- .../README.md | 4 ++-- .../package.json | 2 +- .../src/index.ts | 2 +- .../tests/subagent-subprocess.spec.ts} | 16 ++++++++-------- .../tsconfig.json | 0 pnpm-lock.yaml | 16 ++++++++-------- tsconfig.build.json | 2 +- tsconfig.json | 2 +- 17 files changed, 36 insertions(+), 36 deletions(-) rename packages/subagent/{subagent-process => subagent-subprocess}/README.md (86%) rename packages/subagent/{subagent-process => subagent-subprocess}/package.json (93%) rename packages/subagent/{subagent-process => subagent-subprocess}/src/index.ts (99%) rename packages/subagent/{subagent-process/tests/subagent-process.spec.ts => subagent-subprocess/tests/subagent-subprocess.spec.ts} (94%) rename packages/subagent/{subagent-process => subagent-subprocess}/tsconfig.json (100%) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 34cb543303..642a4d719a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -884,4 +884,4 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) -- `@deepseek-ai/dsh-subagent-process` ([`packages/subagent/subagent-process/src/index.ts`](../packages/subagent/subagent-process/src/index.ts)) +- `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index 57fb397e54..c0e64c1d36 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -43,8 +43,8 @@ flowchart TD pkg_subagent_acp["subagent-acp"] pkg_subagent_fork["subagent-fork"] pkg_subagent_inprocess["subagent-inprocess"] - pkg_subagent_process["subagent-process"] pkg_subagent_spawn["subagent-spawn"] + pkg_subagent_subprocess["subagent-subprocess"] pkg_tool_subagent["tool-subagent"] end subgraph group_web["packages/web"] @@ -179,7 +179,7 @@ flowchart TD pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_llm pkg_subagent_acp --> pkg_subagent - pkg_subagent_acp --> pkg_subagent_process + pkg_subagent_acp --> pkg_subagent_subprocess pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_llm pkg_subagent_inprocess --> pkg_session @@ -220,7 +220,7 @@ flowchart TD | Package | Group | Depends on | | --- | --- | --- | | [`brand`](../packages/util/brand) | `util` | — | -| [`subagent-process`](../packages/subagent/subagent-process) | `subagent` | — | +| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — | | [`app-boot`](../packages/ui/app-boot) | `ui` | — | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | @@ -260,7 +260,7 @@ flowchart TD | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tools`](../packages/core/tools) | -| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-process`](../packages/subagent/subagent-process) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | diff --git a/knip.json b/knip.json index d4c44e9aa5..ecfd431c1c 100644 --- a/knip.json +++ b/knip.json @@ -70,7 +70,7 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/mock-acp-server.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, - "packages/subagent/subagent-process": { + "packages/subagent/subagent-subprocess": { "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"], "ignoreDependencies": ["cordis"] diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 38c64d22a5..87930167e9 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -8,10 +8,10 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `subagent-inprocess/` | Shared in-process run driver (pure lib; registers nothing) | — | | `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) | | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | -| `subagent-process/` | Shared out-of-process machinery: env scrub, dispose ladder, isolated config dirs (pure lib; registers nothing) | — | +| `subagent-subprocess/` | Shared out-of-process machinery: env scrub, dispose ladder, isolated config dirs (pure lib; registers nothing) | — | | `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a pure library — both depend on it, neither on the other), the out-of-process `subagent-acp` backend builds on the `subagent-process` library (the credential env scrub, the dispose ladder, isolated config dirs) and ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock. +The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a pure library — both depend on it, neither on the other), the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs) and ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock. The proposal and design rationale: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 2cc330aefd..7fb087dcdc 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -57,7 +57,7 @@ A spawn/transport/RPC failure resolves `error` (or `aborted` if a cancel was req ## Environment scrub -The child env is built by [`buildChildEnv` from `@deepseek-ai/dsh-subagent-process`](../subagent-process/README.md) — the ambient env minus credential-shaped vars, with `config.env` layered on top after the scrub; the pattern and full semantics live there. For this backend that means the parent harness's own secrets never leak into the spawned agent implicitly, while the child's OWN `DEEPSEEK_API_KEY` is supplied deliberately via `config.env` and survives. +The child env is built by [`buildChildEnv` from `@deepseek-ai/dsh-subagent-subprocess`](../subagent-subprocess/README.md) — the ambient env minus credential-shaped vars, with `config.env` layered on top after the scrub; the pattern and full semantics live there. For this backend that means the parent harness's own secrets never leak into the spawned agent implicitly, while the child's OWN `DEEPSEEK_API_KEY` is supplied deliberately via `config.env` and survives. ## Testing diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 45fc6b072c..e73d861a79 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -25,7 +25,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-subagent-process": "^0.0.1", + "@deepseek-ai/dsh-subagent-subprocess": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "dependencies": { @@ -36,7 +36,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-process": "workspace:^", + "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", "@cordisjs/plugin-loader": "^1.0.0-rc.4", "cordis": "^4.0.0-rc.6" } diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index a94c923f46..a9fefba27c 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -40,7 +40,7 @@ import { import { AgentId } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' -import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-process' +import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess' /** * How the client answers a child's `session/request_permission`. The first cut diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 6d351c71d0..92c025077a 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' -import { buildChildEnv, SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subagent-process' +import { buildChildEnv, SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subagent-subprocess' import type { Agent } from '@deepseek-ai/dsh-agent' import * as acp from '../src/index.ts' import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' diff --git a/packages/subagent/subagent-acp/tsconfig.json b/packages/subagent/subagent-acp/tsconfig.json index ab24f60f93..e415ace1de 100644 --- a/packages/subagent/subagent-acp/tsconfig.json +++ b/packages/subagent/subagent-acp/tsconfig.json @@ -27,7 +27,7 @@ "path": "../subagent" }, { - "path": "../subagent-process" + "path": "../subagent-subprocess" } ] } diff --git a/packages/subagent/subagent-process/README.md b/packages/subagent/subagent-subprocess/README.md similarity index 86% rename from packages/subagent/subagent-process/README.md rename to packages/subagent/subagent-subprocess/README.md index e6c8e10a96..ccc68bf31b 100644 --- a/packages/subagent/subagent-process/README.md +++ b/packages/subagent/subagent-subprocess/README.md @@ -1,4 +1,4 @@ -# @deepseek-ai/dsh-subagent-process +# @deepseek-ai/dsh-subagent-subprocess Shared machinery for **out-of-process subagent backends** — providers that spawn an external agent as a child process, such as the [ACP backend](../subagent-acp/README.md). A pure library (no provider, no registration, no Config): what every spawn-a-CLI-child backend needs to keep the parent deployment's credentials out of the child, tear the child down to quiescence, and isolate it from the host user's on-disk CLI state. Design rationale: [the Claude Code / Codex subagent backends RFC](../../../docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md). @@ -37,4 +37,4 @@ A per-run isolated config directory for an external CLI child (the target of `CL ## Testing -`tests/subagent-process.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (the rm-failure path injects its rejection at the fs boundary — a real recursive-rm failure is not portably provokable, and root ignores permission bits); the exit waits and the dispose ladder run against a scriptable fake child, driving each escalation tier deterministically. The [ACP backend suite](../subagent-acp/README.md) exercises the same ladder against real subprocesses (EOF-cooperative, EOF-ignoring, and SIGTERM-trapping children) end to end. +`tests/subagent-subprocess.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (the rm-failure path injects its rejection at the fs boundary — a real recursive-rm failure is not portably provokable, and root ignores permission bits); the exit waits and the dispose ladder run against a scriptable fake child, driving each escalation tier deterministically. The [ACP backend suite](../subagent-acp/README.md) exercises the same ladder against real subprocesses (EOF-cooperative, EOF-ignoring, and SIGTERM-trapping children) end to end. diff --git a/packages/subagent/subagent-process/package.json b/packages/subagent/subagent-subprocess/package.json similarity index 93% rename from packages/subagent/subagent-process/package.json rename to packages/subagent/subagent-subprocess/package.json index 218276be44..68f525dd8e 100644 --- a/packages/subagent/subagent-process/package.json +++ b/packages/subagent/subagent-subprocess/package.json @@ -1,5 +1,5 @@ { - "name": "@deepseek-ai/dsh-subagent-process", + "name": "@deepseek-ai/dsh-subagent-subprocess", "description": "Shared out-of-process subagent machinery: credential env scrub, spawn-failure capture, child-exit waits, the EOF-to-SIGTERM-to-SIGKILL dispose ladder, and isolated config dirs (pure lib; registers nothing)", "version": "0.0.1", "private": true, diff --git a/packages/subagent/subagent-process/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts similarity index 99% rename from packages/subagent/subagent-process/src/index.ts rename to packages/subagent/subagent-subprocess/src/index.ts index 219ed6b083..35d7383456 100644 --- a/packages/subagent/subagent-process/src/index.ts +++ b/packages/subagent/subagent-subprocess/src/index.ts @@ -15,7 +15,7 @@ * periods, a pinned config dir — is a PARAMETER here: defaults belong in each * consuming plugin's Config, per the no-hardcoded-tunables rule. * - * @module @deepseek-ai/dsh-subagent-process + * @module @deepseek-ai/dsh-subagent-subprocess */ import type { ChildProcess } from 'node:child_process' diff --git a/packages/subagent/subagent-process/tests/subagent-process.spec.ts b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts similarity index 94% rename from packages/subagent/subagent-process/tests/subagent-process.spec.ts rename to packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts index d23f075284..2766ed0a41 100644 --- a/packages/subagent/subagent-process/tests/subagent-process.spec.ts +++ b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts @@ -256,9 +256,9 @@ describe('disposeChildProcess', () => { describe('createIsolatedConfigDir', () => { it('creates a fresh private mkdtemp dir under the OS temp root', async () => { - const dir = await createIsolatedConfigDir('dsh-subagent-process-test-') + const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-') try { - expect(dir.path.startsWith(join(tmpdir(), 'dsh-subagent-process-test-'))).toBe(true) + expect(dir.path.startsWith(join(tmpdir(), 'dsh-subagent-subprocess-test-'))).toBe(true) const st = await stat(dir.path) expect(st.isDirectory()).toBe(true) // Private (0700) per the defensive-patterns temp-dir rule. @@ -269,8 +269,8 @@ describe('createIsolatedConfigDir', () => { }) it('creates a distinct dir per call (per-run isolation)', async () => { - const a = await createIsolatedConfigDir('dsh-subagent-process-test-') - const b = await createIsolatedConfigDir('dsh-subagent-process-test-') + const a = await createIsolatedConfigDir('dsh-subagent-subprocess-test-') + const b = await createIsolatedConfigDir('dsh-subagent-subprocess-test-') try { expect(a.path).not.toBe(b.path) } finally { @@ -280,7 +280,7 @@ describe('createIsolatedConfigDir', () => { }) it('remove() deletes a fresh dir recursively and is idempotent', async () => { - const dir = await createIsolatedConfigDir('dsh-subagent-process-test-') + const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-') await writeFile(join(dir.path, 'settings.json'), '{}') await dir.remove() expect(existsSync(dir.path)).toBe(false) @@ -289,7 +289,7 @@ describe('createIsolatedConfigDir', () => { }) it('returns a pinned dir verbatim and NEVER removes it', async () => { - const pinned = await mkdtemp(join(tmpdir(), 'dsh-subagent-process-pinned-')) + const pinned = await mkdtemp(join(tmpdir(), 'dsh-subagent-subprocess-pinned-')) try { const dir = await createIsolatedConfigDir('ignored-prefix-', pinned) expect(dir.path).toBe(pinned) @@ -302,7 +302,7 @@ describe('createIsolatedConfigDir', () => { }) it('does not create a missing pinned path (the deployment owns its lifecycle)', async () => { - const missing = join(tmpdir(), `dsh-subagent-process-missing-${process.pid}`) + const missing = join(tmpdir(), `dsh-subagent-subprocess-missing-${process.pid}`) const dir = await createIsolatedConfigDir('ignored-prefix-', missing) expect(dir.path).toBe(missing) expect(existsSync(missing)).toBe(false) @@ -311,7 +311,7 @@ describe('createIsolatedConfigDir', () => { }) it('remove() is best-effort: an rm rejection resolves instead of rejecting', async () => { - const dir = await createIsolatedConfigDir('dsh-subagent-process-locked-') + const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-locked-') try { // The swallow contract is error-kind agnostic; EACCES stands in for the // family (EBUSY, a vanished mount, …) that best-effort must absorb. diff --git a/packages/subagent/subagent-process/tsconfig.json b/packages/subagent/subagent-subprocess/tsconfig.json similarity index 100% rename from packages/subagent/subagent-process/tsconfig.json rename to packages/subagent/subagent-subprocess/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 248405f643..023588835a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -635,9 +635,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent - '@deepseek-ai/dsh-subagent-process': + '@deepseek-ai/dsh-subagent-subprocess': specifier: workspace:^ - version: link:../subagent-process + version: link:../subagent-subprocess cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -715,12 +715,6 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/subagent/subagent-process: - devDependencies: - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/subagent/subagent-spawn: dependencies: schemastery: @@ -773,6 +767,12 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/subagent-subprocess: + devDependencies: + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/tool-subagent: dependencies: schemastery: diff --git a/tsconfig.build.json b/tsconfig.build.json index c5318e83fe..7fc770fac7 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -51,7 +51,7 @@ { "path": "./packages/support/subagent-mock" }, { "path": "./packages/subagent/tool-subagent" }, { "path": "./packages/subagent/subagent-inprocess" }, - { "path": "./packages/subagent/subagent-process" }, + { "path": "./packages/subagent/subagent-subprocess" }, { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, diff --git a/tsconfig.json b/tsconfig.json index 8882714d50..380d5f72f5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -62,7 +62,7 @@ { "path": "./packages/support/subagent-mock" }, { "path": "./packages/subagent/tool-subagent" }, { "path": "./packages/subagent/subagent-inprocess" }, - { "path": "./packages/subagent/subagent-process" }, + { "path": "./packages/subagent/subagent-subprocess" }, { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, From 0ba6d832f80049a6f7c7c89092619296b915b0ac Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 9 Jul 2026 14:19:36 +0800 Subject: [PATCH 35/57] docs: regenerate the module graph on the merged tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The master merge (5309ea54) resolved the module-graph conflict by hand, placing the subagent-subprocess dependency-table row ahead of util/timeout's; the generator's deterministic order (group order, util first) wants them swapped, so the freshness gate (gen-module-graph --check) failed CI's static job. Regenerated on the merged tree — a two-line swap; every other generated catalog was already resolution-fresh (regen-all changed nothing else). --- docs/module-graph.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 3cb0d6c386..2b606d38db 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -229,8 +229,8 @@ flowchart TD | Package | Group | Depends on | | --- | --- | --- | | [`brand`](../packages/util/brand) | `util` | — | -| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — | | [`timeout`](../packages/util/timeout) | `util` | — | +| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — | | [`app-boot`](../packages/ui/app-boot) | `ui` | — | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | From 4a15c8a4794788be27a17d02a6ee1795f83375c9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:13:34 +0800 Subject: [PATCH 36/57] workflow: make the seam's listener containment total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emitWorkflowEvent's catch rendered the thrown value with a bare String(error), which itself throws when the value's toString / Symbol.toPrimitive throws — breaking the documented containment guarantee: such a listener could fail the run mid-emit, starve later listeners, and turn the detached workflow/end settle hook into an unhandled rejection. Render through a local total fallback instead (String in a try, a fixed label when even coercion throws); local because the seam sits below every engine and cannot import an engine's renderer. Regression: a listener throwing a coercion-trap value — the emit does not propagate and later listeners still run. --- packages/workflow/workflow/src/index.ts | 31 +++++++++++++++---- .../workflow/workflow/tests/workflow.spec.ts | 16 ++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index 288f6af952..0e5a03c438 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -218,11 +218,12 @@ export abstract class WorkflowService extends Service { * with its OWN structural clone of the payload (the payloads are plain JSON * data by the seam contract), so a listener mutating what it received can * corrupt neither the engine's live state nor any other listener's or later - * event's view; a thrown listener is logged (never propagated), so one bad - * subscriber can neither fail the engine mid-run, surface as an unhandled - * rejection on a detached settle hook, nor starve the listeners registered - * after it (cordis `emit` halts on the first throw — same guarantee as the - * subagent seam's lifecycle emits). + * event's view; a thrown listener is logged (never propagated — the logging + * itself is total, even for a thrown value whose own string coercion + * throws), so one bad subscriber can neither fail the engine mid-run, + * surface as an unhandled rejection on a detached settle hook, nor starve + * the listeners registered after it (cordis `emit` halts on the first throw + * — same guarantee as the subagent seam's lifecycle emits). * @param name - the `workflow/*` event to dispatch. * @param args - the event's payload, matching its declared signature. */ @@ -233,10 +234,28 @@ export abstract class WorkflowService extends Service { // dispatch callback applies the payload tuple. ;(callback as (...payload: unknown[]) => void)(...structuredClone(args)) } catch (error: unknown) { - this.ctx.logger.warn(`workflow: ${name} listener threw: ${String(error)}`) + this.ctx.logger.warn(`workflow: ${name} listener threw: ${renderListenerError(error)}`) } } } } +/** + * Total renderer for a listener-thrown value: the containment catch must never + * itself throw, and `String(error)` does when the value's own `toString` / + * `Symbol.toPrimitive` throws. Local rather than an engine package's renderer + * — the seam sits below every engine and cannot import one. + * @param error - any thrown value. + * @returns `String(error)`, or a fixed label when even coercion throws. + */ +function renderListenerError(error: unknown): string { + try { + return String(error) + } catch { + // Only a throwing toString/Symbol.toPrimitive lands here; the fixed label + // keeps the containment guarantee total. + return '[unrenderable thrown value]' + } +} + export default WorkflowService diff --git a/packages/workflow/workflow/tests/workflow.spec.ts b/packages/workflow/workflow/tests/workflow.spec.ts index b303c02962..a983e5a2b3 100644 --- a/packages/workflow/workflow/tests/workflow.spec.ts +++ b/packages/workflow/workflow/tests/workflow.spec.ts @@ -102,6 +102,22 @@ describe('dsh-workflow (interface)', () => { expect(String(warn.mock.calls[0]![0])).toContain('workflow/phase listener threw') }) + it('containment is total: a listener throwing a value whose coercion throws neither propagates nor starves later listeners', async () => { + const ctx = new Context() + await ctx.plugin(StubEngine) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger) + const reached: string[] = [] + ctx.on('workflow/phase', () => { + throw { toString: () => { throw new Error('coercion trap') } } + }) + ctx.on('workflow/phase', (_info, title) => { reached.push(title) }) + const engine = ctx.workflows as StubEngine + expect(() => { engine.emit('workflow/phase', INFO, 'Scan') }).not.toThrow() + expect(reached).toEqual(['Scan']) + expect(warn).toHaveBeenCalledOnce() + expect(String(warn.mock.calls[0]![0])).toContain('[unrenderable thrown value]') + }) + it('has the expected export surface (default = the abstract service class)', () => { expect(WorkflowServiceDefault).toBe(WorkflowService) }) From fbc9eb313c47570294a4cf53b0984276da8e6a00 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:16:51 +0800 Subject: [PATCH 37/57] workflow: harden the seam-contract tests ahead of the engine swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three engine-agnostic pins, landed BEFORE the worker-thread port so the port commit demonstrates contract preservation against them: - tool-workflow: the tool: prompt-section registration was entirely unasserted — assemble() now pins the section present under the CONFIGURED name and gone after fiber dispose (the packages AGENTS.md dispose-and-assert-cleanup rule; tool-bash is the template). - tool-workflow: drop the dead `??` re-defaulting of already- schemastery-resolved config (the hidden-fallback shape AGENTS.md bans) and the direct-apply test that existed only to cover those branches; both engines' `config as ResolvedConfig` is the pattern. - workflow-vm: workflow/end was asserted only on completed runs — the cancelled path and the grace force-settle path now pin the event and its stopReason/error/agentsStarted payload (an observer's only death signal on those paths). --- packages/workflow/tool-workflow/src/index.ts | 7 +++++-- .../tool-workflow/tests/tool-workflow.spec.ts | 19 ++++++++----------- .../workflow-vm/tests/workflow-vm.spec.ts | 12 +++++++++++- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index a5eae80aec..22ab21ffc6 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -49,6 +49,8 @@ export const Config: z = z.object({ maxResultChars: z.natural().min(1).default(50_000), }) +type ResolvedConfig = Required + /** * The script-authoring contract, embedded in the tool description. This IS the * model-facing spec: the meta block, the hooks and their exact semantics, and @@ -120,8 +122,9 @@ function renderResult(run: WorkflowRun, result: WorkflowResult, maxChars: number } export function apply(ctx: Context, config: Config): void { - const maxResultChars = config.maxResultChars ?? 50_000 - const toolName = config.toolName ?? 'workflow' + // schemastery (the exported Config schema) has already filled the defaulted + // fields; the assertion records that resolution, not a hidden fallback. + const { toolName, maxResultChars } = config as ResolvedConfig // Usage policy ships with the tool (the master convention: tool guidance // lives in tool plugins as prompt sections, not in the deployment persona). ctx.systemPrompt.section({ diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index c9e0cf3713..68daecbe37 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -130,17 +130,6 @@ describe('dsh-tool-workflow', () => { expect(engine.disposed).toBe(1) }) - it('applies raw-config fallbacks when loaded without schemastery defaults (direct apply)', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(StubEngine) - // Direct apply with an empty RAW config: the `??` fallbacks resolve the - // tool name and render cap without schemastery having filled them. - toolWorkflow.apply(ctx, {}) - expect(ctx.tools.get('workflow')).toBeDefined() - }) - it('a synchronous engine start throw (parse/meta failure) becomes an isError result', async () => { const { ctx, engine, parent } = await setup() engine.startError = new Error('script must begin with `export const meta = {...}`') @@ -192,8 +181,16 @@ describe('dsh-tool-workflow', () => { const fiber = await ctx.plugin(toolWorkflow, { toolName: 'orchestrate' }) expect(ctx.tools.get('orchestrate')).toBeDefined() expect(ctx.tools.get('workflow')).toBeUndefined() + // The usage-policy prompt section rides the same registration: present + // under the CONFIGURED name (its guidance names the tool it describes)… + const sections = (await ctx.systemPrompt.assemble()).sections + const section = sections.find(s => s.name === 'tool:orchestrate') + expect(section?.text).toContain('orchestrate') + expect(sections.some(s => s.name === 'tool:workflow')).toBe(false) await fiber.dispose() expect(ctx.tools.get('orchestrate')).toBeUndefined() + // …and gone with the fiber — a reload must not leak a stale section. + expect((await ctx.systemPrompt.assemble()).sections.some(s => s.name === 'tool:orchestrate')).toBe(false) }) it('presents a generic pending card titled by the sniffed meta name, with the script as rawInput', async () => { diff --git a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts index eeda6bf049..bb685f536f 100644 --- a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts +++ b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts @@ -5,7 +5,7 @@ import { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import type { WorkflowResult, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' +import type { WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import * as vmEngineModule from '../src/index.ts' import VmWorkflowEngine, { type Config } from '../src/index.ts' @@ -534,6 +534,8 @@ describe('dsh-workflow-vm', () => { it('cancel() aborts in-flight children and settles the run cancelled', async () => { const { ctx, parent, provider } = await setup({ manual: true }) + const ends: WorkflowResultInfo[] = [] + ctx.on('workflow/end', (_info, result) => { ends.push(result) }) const handle = ctx.workflows.start({ script: script("return await agent('long job')"), parent }) await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) handle.cancel('user stopped it') @@ -541,6 +543,9 @@ describe('dsh-workflow-vm', () => { expect(result.stopReason).toBe('cancelled') expect(result.error).toContain('user stopped it') expect(provider.runs[0]!.disposed).toBe(true) + // workflow/end is an observer's only death signal: it fires for a + // cancelled run too, mirroring the settled outcome data. + expect(ends).toEqual([{ stopReason: 'cancelled', error: result.error, agentsStarted: 1 }]) await handle.dispose() }) @@ -787,6 +792,8 @@ describe('dsh-workflow-vm', () => { it('cancel() force-settles the result of a script parked on a promise no hook owns', async () => { const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } }) + const ends: WorkflowResultInfo[] = [] + ctx.on('workflow/end', (_info, result) => { ends.push(result) }) const handle = ctx.workflows.start({ // No hooks involved: an unsettleable await cancellation cannot reject // — the abandon grace is the only thing that can settle this run. @@ -797,6 +804,9 @@ describe('dsh-workflow-vm', () => { const result = await handle.result expect(result.stopReason).toBe('cancelled') expect(result.error).toContain('user aborted') + // The grace force-settle fires workflow/end exactly like an ordinary + // settlement — an abandoned script's death still reaches observers. + expect(ends).toEqual([{ stopReason: 'cancelled', error: result.error, agentsStarted: 0 }]) await handle.dispose() }) From b5f618bcfb1ddd95adc82f2f0bac88e4ace52bb3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:39:31 +0800 Subject: [PATCH 38/57] workflow: swap the engine's internals to node:worker_threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-place port of dsh-workflow-vm from the in-process node:vm execution to one worker thread per run (the workflow-workerthread engine of PR #215, adopted as THE engine): the script's vm context moves inside the worker, agent() bridges to ctx.subagents over the message port (host.ts/protocol.ts/session.ts/worker.ts are new; runtime.ts loses the abandon channel — the host's grace timer force-settles and TERMINATES instead), start() pre-parses the body host-side to keep the seam's synchronous SCRIPT_PARSE throw, and a ready→go handshake keeps a run cancelled before start from ever executing the body. start() no longer blocks the host, termination is real, and the value boundary is serialization by construction. The package keeps its name until the follow-up rename commit; scripts see the identical hook surface, and the seam-contract tests hardened ahead of this swap pass unchanged. The run and child-RPC surfaces are class-shaped rather than literal bundles: WorkerRun IMPLEMENTS the seam's WorkflowRun (id/meta are its own clone, separate from event payloads') and start() returns the instance directly — interface parity with the seam is compiler-checked; worker-side, ChildRpcBridge (implements ChildPort; callId allocation + pending book-keeping settled by onChild* entry points) and RpcChildHandle (every member an RPC keyed by its callId) carry names in stacks. ChildPort's method is startAgent — it names what it starts, matching the script-side agent() hook and the agentsStarted / workflow/agent-* vocabulary; the Child* type names deliberately stay (the worker side is cordis- and subagent-free; these are reduced JSON projections, not the seam's types). Review findings from the reference PR are folded in rather than re-introduced: - cancel() drives BOTH child-cancel channels host-side: the request signal aborts AND each registered child's explicit cancel() is called — a worker wedged in a synchronous spin cannot relay its own ChildCancel RPCs (regression: cancel-only provider + wedged worker). - All host warn paths render through the total renderThrown; a child dispose() rejecting a value whose coercion throws still acks ChildDisposed instead of wedging the script's finally (regression). - built-worker.e2e.ts is wired into builtBinSmokeGate and the AGENTS.md CI sequence — the built lib/worker.js resolution contract now runs in an automated gate. - workflow/end payload pinned on the worker-death path (with the cancelled and grace-force-settle pins riding the ported spec). - Real-Worker scripted timing budgets widened (50-300ms → 150-1000ms) for starved CI hosts. Workspace plumbing: the "./worker" subpath export sanctions the second runtime bundle (check-workspace-constraints), tsdown builds two single-entry passes, tsx becomes a devDependency for the unbuilt worker spawn. --- AGENTS.md | 2 +- .../tool-workflow/tests/tool-workflow.spec.ts | 6 +- packages/workflow/workflow-vm/README.md | 36 +- packages/workflow/workflow-vm/package.json | 10 +- packages/workflow/workflow-vm/src/host.ts | 384 +++++++ packages/workflow/workflow-vm/src/index.ts | 184 ++-- packages/workflow/workflow-vm/src/protocol.ts | 115 ++ packages/workflow/workflow-vm/src/realm.ts | 27 +- packages/workflow/workflow-vm/src/runtime.ts | 264 ++--- packages/workflow/workflow-vm/src/session.ts | 210 ++++ packages/workflow/workflow-vm/src/types.ts | 97 ++ packages/workflow/workflow-vm/src/worker.ts | 18 + .../workflow-vm/tests/built-worker.e2e.ts | 56 + .../workflow-vm/tests/integration.spec.ts | 14 +- .../workflow-vm/tests/session.spec.ts | 504 +++++++++ .../workflow-vm/tests/workflow-vm.spec.ts | 990 +++++++----------- .../workflow-vm/tests/workflow.e2e.ts | 55 +- .../workflow/workflow-vm/tsdown.config.ts | 32 + pnpm-lock.yaml | 3 + scripts/check-workspace-constraints.ts | 15 +- scripts/run-gates.ts | 4 + 21 files changed, 2073 insertions(+), 953 deletions(-) create mode 100644 packages/workflow/workflow-vm/src/host.ts create mode 100644 packages/workflow/workflow-vm/src/protocol.ts create mode 100644 packages/workflow/workflow-vm/src/session.ts create mode 100644 packages/workflow/workflow-vm/src/types.ts create mode 100644 packages/workflow/workflow-vm/src/worker.ts create mode 100644 packages/workflow/workflow-vm/tests/built-worker.e2e.ts create mode 100644 packages/workflow/workflow-vm/tests/session.spec.ts create mode 100644 packages/workflow/workflow-vm/tsdown.config.ts diff --git a/AGENTS.md b/AGENTS.md index fc33e2f049..eb34d404a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' ls .sessions/_no-cwd/main-session-*.jsonl >/dev/null rm -rf .sessions -pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts +pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts packages/workflow/workflow-vm/tests/built-worker.e2e.ts ``` `test:coverage`, not `test`, is the gating run ([why](docs/testing.md)); a sign-off counts only for commands actually run. diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 68daecbe37..2b6503cd45 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -10,7 +10,7 @@ import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow' import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' import { CallId } from '@deepseek-ai/dsh-llm' import SubagentService from '@deepseek-ai/dsh-subagent' -import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-vm' +import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-vm' import * as toolWorkflow from '../src/index.ts' /** A controllable engine standing in behind ctx.workflows (the tool's only seam). */ @@ -221,7 +221,7 @@ describe('dsh-tool-workflow', () => { expect(typeof unwrapped.apply).toBe('function') }) - describe('composition with the REAL vm engine (the mock above must stay honest)', () => { + describe('composition with the REAL worker-thread engine (the mock above must stay honest)', () => { it('an abort releases the tool even when the script parks on a promise no hook owns', async () => { // Regression for the review-found turn wedge: the tool awaits // run.result BEFORE its disposing finally, the registry and the loop @@ -234,7 +234,7 @@ describe('dsh-tool-workflow', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(SubagentService) - await ctx.plugin(VmWorkflowEngine, { disposeGraceMs: 30 }) + await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 }) await ctx.plugin(toolWorkflow, {}) const parent = { id: AgentId('caller'), options: {} } as unknown as Agent const controller = new AbortController() diff --git a/packages/workflow/workflow-vm/README.md b/packages/workflow/workflow-vm/README.md index e29073c2b3..9a0ab21fef 100644 --- a/packages/workflow/workflow-vm/README.md +++ b/packages/workflow/workflow-vm/README.md @@ -1,34 +1,48 @@ # @deepseek-ai/dsh-workflow-vm -The first [`WorkflowService`](../workflow/README.md) implementation: an in-process **`node:vm` engine**. It parses the Claude Code-format script (`export const meta = {...}` + plain-JS body), runs the body in a fresh vm context with the workflow hooks injected, and fans `agent()` calls out to [`ctx.subagents`](../../subagent/README.md). +The [`WorkflowService`](../workflow/README.md) implementation, on **`node:worker_threads`**: each run gets its OWN worker thread (one run = one worker, no pooling — a run is heavyweight, so the ~tens-of-ms thread spin-up is noise), the script executes in a vm context INSIDE that worker with the workflow hooks injected, and every `agent()` call bridges back over the message port to [`ctx.subagents`](../../subagent/README.md) on the host. Child agents are I/O-bound LLM loops and stay on the host event loop; the thread isolates the SCRIPT, the only part that can spin synchronously. -## Trust premise +## Trust premise: what the thread buys (and what it does not) -Workflow scripts are **model-written** — the same trust level as the model's existing bash access — so this engine defends against **buggy** scripts, never hostile ones. vm is NOT a security boundary and no attempt is made to contain adversarial values: property reads on script values may run script code (a getter, a `toString`, a proxy trap) on the host stack, and a script determined to hang the process can simply spin past its first await (see the limitations below). Concretely, the context is **escapable by construction**: `node:vm` shares object machinery with the host, so a script can reach the host `Function` constructor (`globalThis.constructor.constructor`) and from it `process` and every Node builtin — the absent globals are API surface that keeps honest scripts portable, not walls. What the engine DOES guarantee, because benign scripts hit these constantly: `result` never rejects, a dropped hook promise never becomes an unhandled rejection (the app boot layer exits the process on those), values that JSON cannot carry are rejected **loud** instead of silently mangled, and hook misuse is fatal instead of dissolving into a per-item `null`. Genuine sandboxing is an engine swap behind the seam (worker-thread/isolated-vm, where the boundary is serialization by construction), not incremental host-side defenses here. +Workflow scripts are **model-written** — the same trust level as the model's existing bash access — so this engine defends against **buggy** scripts, never hostile ones. A worker thread is NOT a security boundary: the vm context inside it is escapable by construction (`node:vm` shares object machinery with its surrounding realm, so a script can reach the `Function` constructor via `globalThis.constructor.constructor` and from it `process` and every Node builtin), and an escapee holds the same process privileges as the host — Node's permission model is process-wide. The absent globals are API surface that keeps honest scripts portable, not walls. What the thread concretely buys: + +- **The host never blocks**: `start()` returns without running any script code on the host; a synchronous spin anywhere in the script occupies the worker's loop, not the harness's. +- **Termination is real**: a script that outlives its post-cancel grace is `worker.terminate()`d — nothing of it survives `dispose()`, where an in-process engine could only abandon the spin on its own loop. +- **Serialization by construction**: everything crossing the thread is structured-clone data, and plain JSON before that — the `materializeFromRealm` walk rejects loud what JSON cannot carry, which is also what makes every postMessage hop total. + +What the seam guarantees regardless, because benign scripts hit these constantly: `result` never rejects, a dropped hook promise never becomes an unhandled rejection, values JSON cannot carry are rejected **loud** instead of silently mangled, and hook misuse is fatal instead of dissolving into a per-item `null`. Genuine sandboxing (containing what an escaped script may touch) remains an isolated-vm/separate-process engine swap behind the seam, still deferred. ## The script contract it executes -- **Meta extraction** (`extractMeta`): a string/comment-aware brace scanner finds the leading `export const meta` literal (template interpolation rejected — the literal must be pure), evaluates it ALONE in an empty timed vm context, materializes the result to plain JSON data, validates the shape (`name`/`description` required; unknown fields rejected loud), and blanks the statement line-preservingly so error stacks keep the script's own line numbers. +- **Meta extraction** (`extractMeta`, host-side): a string/comment-aware brace scanner finds the leading `export const meta` literal (template interpolation rejected — the literal must be pure), evaluates it ALONE in an empty timed vm context, materializes the result to plain JSON data, validates the shape (`name`/`description` required; unknown fields rejected loud), and blanks the statement line-preservingly so error stacks keep the script's own line numbers. - **Hooks**: `agent(prompt, {label, phase, schema, model})` (schema = the [structured-output subset](../../core/tools/README.md), forwarded as `outputSchema`; result = validated object, or final text without a schema; a failed child resolves `null`), `parallel(thunks)`, `pipeline(items, ...stages)` with NO cross-stage barrier and `(prev, item, index)` stage callbacks, `phase(title)`, `log(message)`, and the `args` global. Anything else — `effort`/`isolation`/`agentType`, unknown options, malformed arguments, schemas outside the subset — throws a FATAL `WorkflowError` that `parallel`/`pipeline` re-throw rather than nulling (see the seam README's failure discipline). - **No ambient APIs**: no timers, filesystem, or Node APIs are injected into the context (absence is API surface, not containment — see the trust premise). +## How a run executes + +`start()` extracts and validates the meta HOST-side and parse-checks the body with the identical wrapper the worker compiles (`new vm.Script`, discarded), preserving the seam's synchronous `SCRIPT_PARSE`/`META_INVALID` throws; one redundant parse per run is the deliberate price. It then spawns the worker (`src/worker.ts` unbuilt via an explicit tsx `execArgv`; the sibling `lib/worker.js` bundle when built) with the meta, blanked body, `args`, and worker-side limits as `workerData`. + +Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**: `agent()` sends `child-start` and the host starts the child on `ctx.subagents` (parent attribution, the shared per-run abort signal, `outputSchema`/`model` pass-through), replying with the child id, its settlement (a JSON projection; an infrastructure REJECTION crosses as `child-failed` and stays the fatal `AGENT_RESULT`), and dispose acks. Observer narration (`phase`/`log`/`agent-start`/`agent-end`) crosses as messages and re-emits as the seam's `workflow/*` events. A **ready→go handshake** gates the body: a cancellation racing worker boot arrives before `go`, so a run cancelled before start never executes the body at all. + ## The value boundary -Values ENTERING the host (the meta literal, hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying into host containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Getters are read ordinarily — the RESULT is what crosses; a read that throws fails loud. Values ENTERING the realm (`args`, `agent()` results, hook promises and their failures, combinator arrays) are handed over directly as host values — the script is trusted, so host prototypes are not a leak; `args` is `structuredClone`d once at start so a script scribbling on it cannot mutate the caller's object. One script-visible consequence: an error thrown by a hook is a HOST error, so `e instanceof Error` inside the script is `false` — branch on `e.name`/`e.code` instead (the combinators recognize fatality by host `instanceof`, which a script-built object can never pass, so fatal-vs-null cannot be forged or dissolved). +Values LEAVING the script (the meta literal, hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying into plain containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Getters are read ordinarily — the RESULT is what crosses; a read that throws fails loud. Values ENTERING the realm (`args`, `agent()` results, hook promises and their failures, combinator arrays) are handed over directly as worker-realm values — the script is trusted, so outer prototypes are not a leak; `args` is cloned once at start so a script scribbling on it cannot mutate the caller's object. One script-visible consequence: an error thrown by a hook is built OUTSIDE the script's vm context, so `e instanceof Error` inside the script is `false` — branch on `e.name`/`e.code` instead (the combinators recognize fatality by `instanceof` against their own realm's class, which a script-built object can never pass, so fatal-vs-null cannot be forged or dissolved). -## Limits, cancellation, disposal +## Cancellation, death, disposal -Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` aborts every child (a shared `AbortSignal`), rejects waiting `agent()` slots, and makes every future hook call throw `CANCELLED` — the script dies at its next await and the run settles `cancelled`; a cancellation that lands before the body runs (or before it settles) reports `cancelled` even if the script itself needed no hooks, and a script that STILL has not settled `disposeGraceMs` after the cancel (parked on a promise no hook owns, like `await new Promise(() => {})`) is ABANDONED with `result` force-settling `cancelled` — a consumer awaiting `result` is never wedged past a cancellation. Once a run settles, stray children a script fired without awaiting are aborted too, and `dispose()` waits for those children to finish disposing (bounded by the grace) before returning. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection; thrown script values are rendered by a total host-side renderer (stack, then message, then `String()`, with a fixed label if rendering itself throws) — `result` cannot reject. +Per-run limits: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` posts the cancel to the worker (its hooks start throwing `CANCELLED`; the script dies at its next await) and cancels every host-side child NOW on **both seam channels** — the shared request signal aborts AND each registered child's explicit `cancel()` is called host-side, because the seam leaves a provider free to honor either channel and a worker wedged in a synchronous spin could not relay its own per-child cancel RPCs (those later land as idempotent no-ops). The grace then arms: a run still unsettled `disposeGraceMs` later force-settles `cancelled` and the worker is **terminated**. A cancellation that lands before the body runs (the ready→go handshake) reports `cancelled` without executing anything; a worker `result` racing an in-flight host cancellation reports `cancelled` too (first-wins settlement — the seam-visible result had not settled when cancellation was requested); post-cancel `phase`/`log` narration is suppressed host-side, while cancelled children still deliver their paired `agent-end`. -**Documented limitations** (the accepted cost of the in-process mechanism; the seam exists so a worker-thread/isolated-vm engine can swap in): `start()` runs the script's initial synchronous slice inline, so the caller blocks until the first await or the vm `timeout`; that `timeout` covers ONLY the initial slice, so a synchronous spin past it (an await continuation, a thenable's `then` invoked by promise resolution, or script code the host runs while rendering a thrown value) cannot be killed; `dispose()` waits `disposeGraceMs` then ABANDONS such a script (its settlement stays contained, but an abandoned spin would still occupy the event loop). A returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited `return agent('x')` work — and the value-boundary guard applies to the resolution. +A worker that dies unexpectedly (an OOM, a script reaching `process.exit` through the documented vm escape) settles the run `stopReason: 'error'` with the exit diagnostics — or `'cancelled'` when a cancel was in flight — and the host-side child registry is what winds every surviving child down. `dispose()` = cancel + bounded wait (result, then child-registry quiescence, capped by the grace) + unconditional `worker.terminate()`: the thread never outlives its run. Once a run settles, stray children a script fired without awaiting are cancelled too, and `dispose()` waits for their disposal (bounded by the grace) before returning. + +**Engine-specific limitations**: worker startup is paid per run; on a termination path `agentsStarted` reports the HOST-observed count (accepted `child-start`s — calls still queued worker-side for a concurrency slot are unknowable then); and a returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited `return agent('x')` work — with the value-boundary guard applying to the resolution. ## Config | Key | Default | Meaning | |---|---|---| -| `provider` | `spawn` | The `ctx.subagents` provider children run on. | +| `provider` | `spawn` | The `ctx.subagents` provider children run on (host-side). | | `maxConcurrentAgents` | `0` (auto) | Concurrent `agent()` ceiling; `0` resolves to `min(16, max(1, cores - 2))`. | | `maxTotalAgents` | `1000` | Total `agent()` calls one run may start (runaway-loop backstop). | | `maxItemsPerCall` | `4096` | Items accepted by one `parallel()`/`pipeline()` call. | -| `syncTimeoutMs` | `5000` | vm timeout for the initial synchronous slice and the meta evaluation. | -| `disposeGraceMs` | `5000` | How long `dispose()` waits for a cancelled script and its children before abandoning them. | +| `syncTimeoutMs` | `5000` | vm timeout for the initial synchronous slice (in the worker) and the host-side meta evaluation. | +| `disposeGraceMs` | `5000` | How long a cancelled run may stay unsettled before force-settle + terminate; also bounds `dispose()`. | diff --git a/packages/workflow/workflow-vm/package.json b/packages/workflow/workflow-vm/package.json index 8b1217acc0..a174893b9c 100644 --- a/packages/workflow/workflow-vm/package.json +++ b/packages/workflow/workflow-vm/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-workflow-vm", - "description": "node:vm workflow engine: executes model-written orchestration scripts over ctx.subagents", + "description": "worker-thread workflow engine: executes model-written orchestration scripts off the host event loop, bridging agent() calls back to ctx.subagents", "version": "0.0.1", "private": true, "type": "module", @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./worker": { + "types": "./lib/types/worker.d.ts", + "default": "./lib/worker.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/worker.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -45,6 +50,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.6", + "tsx": "^4.19.2" } } diff --git a/packages/workflow/workflow-vm/src/host.ts b/packages/workflow/workflow-vm/src/host.ts new file mode 100644 index 0000000000..b329e0c7d2 --- /dev/null +++ b/packages/workflow/workflow-vm/src/host.ts @@ -0,0 +1,384 @@ +/** + * The host half of one worker-engine run: spawn the Worker, bridge its child + * RPC onto `ctx.subagents`, fan its observer messages into the engine's + * events, and own cancellation, the settle-within-grace guarantee, and child + * cleanup. The worker's lifetime IS the run's lifetime: `dispose()` always + * ends with `worker.terminate()`, so no thread outlives its run. + * + * The run's `result` promise settles exactly once, from whichever of these + * lands first: the worker's `result` message (a host-side cancellation in + * flight overrides a non-cancelled report — the seam-visible result had not + * settled when cancellation was requested), an unexpected worker death + * (`error`/`messageerror`/premature `exit` → `stopReason: 'error'`, or + * `'cancelled'` when a cancel was in flight), or the post-cancel grace timer + * (a script that never settles is force-settled `cancelled` and its worker + * terminated — the real kill an in-process engine could not perform). + * + * Children live in a host-side registry (callId → run): the worker drives + * their disposal by RPC on the graceful path, and the registry is what lets + * the host abort and dispose every survivor when the worker dies or is + * terminated mid-flight. On a termination path `agentsStarted` reports the + * HOST-observed count (accepted `child-start` messages) — `agent()` calls + * still queued worker-side for a concurrency slot are unknowable then; the + * worker's own count rides the result message on every graceful path. + * + * @module @deepseek-ai/dsh-workflow-vm/host + */ + +import { fileURLToPath } from 'node:url' +import { Worker } from 'node:worker_threads' +import type { WorkerOptions } from 'node:worker_threads' +import type { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { assertNever } from '@deepseek-ai/dsh-llm' +import type { SubagentRun } from '@deepseek-ai/dsh-subagent' +import type { WorkflowMeta, WorkflowResult, WorkflowRun, WorkflowRunId } from '@deepseek-ai/dsh-workflow' +import { renderThrown } from './realm.ts' +import type { ExecutionObserver } from './runtime.ts' +import { HostToWorkerType, WorkerToHostType } from './protocol.ts' +import type { HostToWorkerPayloads, WorkerToHostMessage } from './protocol.ts' +import type { ChildStartRequest, WorkerInit } from './types.ts' + +/** + * Resolve the worker entry and spawn options for the current runtime shape. + * Unbuilt (tsx demos, vitest — `import.meta.url` points into `src/`), the + * entry is the TypeScript sibling and the worker needs the tsx loader + * registered explicitly: a worker thread inherits no transform pipeline from + * vitest (vite transforms in-process, not via a node loader), and passing + * execArgv explicitly also shields the worker from any loader flags the + * parent was started with. Built (`lib/index.js`), the entry is the sibling + * bundle the package tsdown config emits and no loader is needed. + * @param init - the run payload, passed as `workerData`. + * @returns the entry URL and the Worker options to spawn it with. + */ +function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOptions } { + /* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/); the built-worker e2e exercises this shape for real */ + if (!import.meta.url.endsWith('.ts')) { + return { entry: new URL('./worker.js', import.meta.url), options: { workerData: init } } + } + // Lazy tsx resolution: only the unbuilt shape needs it, so the built + // bundle never requires tsx to be installed. + return { + entry: new URL('./worker.ts', import.meta.url), + options: { workerData: init, execArgv: ['--import', fileURLToPath(import.meta.resolve('tsx'))] }, + } +} + +/** + * One live worker-engine run — the seam's {@link WorkflowRun}, returned by + * `start()` directly. Owns the Worker, the child registry, and the result + * settlement; `result` never rejects. `meta` is this handle's OWN clone + * (event payloads carry separate clones), so a consumer mutating it corrupts + * nothing. + */ +export class WorkerRun implements WorkflowRun { + /** Settles exactly once with the run's outcome; never rejects. */ + readonly result: Promise + private settleResolve!: (result: WorkflowResult) => void + private settled = false + private cancelReason: string | undefined + private graceTimer: NodeJS.Timeout | undefined + private readonly worker: Worker + /** Set on `exit`: the thread is gone, so posting has nowhere to go. */ + private workerGone = false + /** Accepted `child-start` messages — the terminate-path `agentsStarted` (see module doc). */ + private hostStarted = 0 + /** Live children by callId; an entry leaves ONLY after its dispose settles (quiescence = empty). */ + private readonly children = new Map() + private readonly quiescenceWaiters: (() => void)[] = [] + /** The per-run abort fanout every child start request carries. */ + private readonly controller = new AbortController() + private disposed: Promise | undefined + + constructor( + private readonly ctx: Context, + readonly id: WorkflowRunId, + readonly meta: WorkflowMeta, + private readonly parent: Agent, + init: WorkerInit, + private readonly provider: string, + private readonly disposeGraceMs: number, + private readonly observer: ExecutionObserver, + signal: AbortSignal | undefined, + ) { + this.result = new Promise((resolve) => { this.settleResolve = resolve }) + // workerData rides the structured clone: args are plain JSON by the seam + // contract, so the clone is total and doubles as the caller-isolation + // copy (a clone failure throws loud out of start()). + const { entry, options } = resolveWorkerSpawn(init) + this.worker = new Worker(entry, options) + this.worker.on('message', (message: WorkerToHostMessage) => { this.onMessage(message) }) + this.worker.on('error', (error) => { this.onWorkerDeath(`workflow worker failed: ${renderThrown(error)}`) }) + /* v8 ignore next -- messageerror: not constructible from the engine's own protocol (every payload is JSON data) */ + this.worker.on('messageerror', (error) => { this.onWorkerDeath(`workflow worker message failed to deserialize: ${renderThrown(error)}`) }) + this.worker.on('exit', (code) => { + this.workerGone = true + this.onWorkerDeath(`workflow worker exited before the run settled (exit code ${code})`) + }) + if (signal?.aborted) { + this.cancel('workflow start signal already aborted') + } else { + signal?.addEventListener('abort', () => { this.cancel('workflow signal aborted') }, { once: true }) + } + } + + /** + * Cancel the run: the worker is told (its hooks start throwing and the + * script dies at its next await), every host-side child is cancelled NOW on + * BOTH seam channels — the shared request signal aborts and each registered + * child's explicit `cancel()` is called (the seam leaves a provider free to + * honor either, and a worker wedged in a synchronous spin could not relay + * its own per-child cancel RPCs until far too late) — and the grace timer + * arms: a run still unsettled `disposeGraceMs` later force-settles + * `cancelled` and its worker is TERMINATED. Idempotent; the first reason + * wins. + * @param reason - human-readable cause (default `'workflow cancelled'`). + */ + cancel(reason?: string): void { + // A settled run has nothing left to cancel: without this guard the + // ordinary consumer path (await result, then dispose -> cancel) would arm + // a grace timer nothing ever clears, pinning the run and its Worker + // closure until the grace expires - a bounded leak per completed run. + if (this.settled || this.cancelReason !== undefined) return + this.cancelReason = reason ?? 'workflow cancelled' + this.post(HostToWorkerType.Cancel, { reason: this.cancelReason }) + this.controller.abort(this.cancelReason) + // The explicit channel is driven host-side, not left to the worker: a + // provider honoring only run.cancel() must not wait on a wedged worker's + // ChildCancel relay (those later RPCs land as idempotent no-ops). + for (const run of this.children.values()) run.cancel(this.cancelReason) + this.graceTimer = setTimeout(() => { + this.settleResult(this.cancelledResult(this.hostStarted)) + void this.worker.terminate() + }, this.disposeGraceMs) + // unref'd: an armed grace timer must never hold the process open. + this.graceTimer.unref() + } + + /** + * Cancel + bounded settle + termination. Waits (at most the grace) for the + * result and child quiescence, then terminates the worker unconditionally + * — the thread never outlives its run — and reaps whatever children + * remain (their disposal is contained, not awaited past the grace, the + * same abandonment the seam documents for a slow-disposing child). + * Idempotent; safe on every path. + * @returns resolves when the run's resources are released or abandoned. + */ + dispose(): Promise { + this.disposed ??= (async () => { + this.cancel('workflow disposed') + await Promise.race([ + (async () => { + await this.result + await this.childQuiescence() + })(), + sleep(this.disposeGraceMs), + ]) + await this.worker.terminate() + this.reapChildren('workflow disposed') + })() + return this.disposed + } + + /** Post one message to the worker (payload looked up from the tag's map entry), tolerating a thread that is already gone. */ + private post(type: T, payload: HostToWorkerPayloads[T]): void { + if (this.workerGone) return + try { + this.worker.postMessage({ type, ...payload }) + } catch (error: unknown) { + // Only a teardown race can land here (every engine message is JSON + // data, so serialization cannot fail); there is nothing left to + // deliver to — log and move on. + /* v8 ignore next -- postMessage teardown race (a throw between exit and its event): not constructible in-process */ + this.ctx.logger.warn(`workflow-vm: postMessage failed: ${renderThrown(error)}`) + } + } + + private onMessage(message: WorkerToHostMessage): void { + switch (message.type) { + case WorkerToHostType.Ready: + this.post(HostToWorkerType.Go, {}) + break + case WorkerToHostType.Phase: + // Post-cancel narration is suppressed host-side: worker-side the + // hooks throw once the cancel message is PROCESSED, but narration + // already in flight (or emitted while the cancel crossed the + // boundary) must not reach observers — nothing is emitted after + // cancel() returns. + if (this.cancelReason === undefined) this.observer.phase(message.title) + break + case WorkerToHostType.Log: + if (this.cancelReason === undefined) this.observer.log(message.message) + break + case WorkerToHostType.AgentStart: + this.observer.agentStart(message.info) + break + case WorkerToHostType.AgentEnd: + // NOT suppressed on cancel: cancelled children report their paired + // agent-end with outcome 'cancelled' (the one-pair-per-started-child + // contract holds on every stop path). + this.observer.agentEnd(message.info) + break + case WorkerToHostType.ChildStart: + this.onChildStart(message.callId, message.request) + break + case WorkerToHostType.ChildCancel: + this.children.get(message.callId)?.cancel(message.reason) + break + case WorkerToHostType.ChildDispose: + this.onChildDispose(message.callId) + break + case WorkerToHostType.Result: + this.onResult(message.result) + break + /* v8 ignore next 2 -- closed engine-owned union; the arm only makes adding a message type a compile error */ + default: + assertNever(message, 'worker-to-host message') + } + } + + private onChildStart(callId: number, request: ChildStartRequest): void { + if (this.cancelReason !== undefined) { + // The worker's start raced our cancel: refuse — a child must never + // start on an already-aborted signal (a provider subscribing only to + // future abort events would never observe it). + this.post(HostToWorkerType.ChildStartError, { callId, rendered: `workflow run cancelled: ${this.cancelReason}` }) + return + } + this.hostStarted += 1 + let run: SubagentRun + try { + run = this.ctx.subagents.start(this.provider, { + prompt: [{ type: 'text', text: request.prompt }], + parent: this.parent, + signal: this.controller.signal, + ...request.schema !== undefined ? { outputSchema: request.schema } : {}, + ...request.model !== undefined ? { agentOptions: { model: request.model } } : {}, + }) + } catch (error: unknown) { + this.post(HostToWorkerType.ChildStartError, { callId, rendered: renderThrown(error) }) + return + } + this.children.set(callId, run) + this.post(HostToWorkerType.ChildStarted, { callId, childId: run.id }) + run.result.then( + (result) => { + this.post(HostToWorkerType.ChildSettled, { + callId, + result: { + output: result.output, + ...result.structured !== undefined ? { structured: result.structured } : {}, + stopReason: result.stopReason, + }, + }) + }, + (error: unknown) => { this.post(HostToWorkerType.ChildFailed, { callId, rendered: renderThrown(error) }) }, + ) + } + + private onChildDispose(callId: number): void { + const run = this.children.get(callId) + /* v8 ignore next 5 -- dispose RPC for an already-reaped child: only a worker-death race can produce it, not orderable in-process */ + if (run === undefined) { + // Already reaped — the ack is still owed (the worker-side wrapper awaits it). + this.post(HostToWorkerType.ChildDisposed, { callId }) + return + } + void run.dispose().then( + () => { + this.finishChild(callId) + this.post(HostToWorkerType.ChildDisposed, { callId }) + }, + (error: unknown) => { + // The subagent seam's dispose() is not supposed to reject; a backend + // that does anyway must not wedge the script's finally (which awaits + // the ack) — ack and move on. + this.ctx.logger.warn(`workflow-vm: child dispose failed: ${renderThrown(error)}`) + this.finishChild(callId) + this.post(HostToWorkerType.ChildDisposed, { callId }) + }, + ) + } + + /** Drop a child from the registry, releasing quiescence waiters at zero. */ + private finishChild(callId: number): void { + this.children.delete(callId) + if (this.children.size === 0) { + for (const waiter of this.quiescenceWaiters.splice(0)) waiter() + } + } + + /** Resolves once the child registry is empty (every disposal settled). */ + private childQuiescence(): Promise { + if (this.children.size === 0) return Promise.resolve() + return new Promise((resolve) => { this.quiescenceWaiters.push(resolve) }) + } + + /** Abort + dispose every registered child (worker death / final teardown); disposal is contained, not awaited. */ + private reapChildren(reason: string): void { + this.controller.abort(this.cancelReason ?? reason) + for (const [callId, run] of [...this.children]) { + run.cancel(this.cancelReason ?? reason) + void run.dispose().then( + () => { this.finishChild(callId) }, + (error: unknown) => { + this.ctx.logger.warn(`workflow-vm: child dispose failed during reap: ${renderThrown(error)}`) + this.finishChild(callId) + }, + ) + } + } + + private onResult(result: WorkflowResult): void { + // The worker's settle-reap already child-cancel()s every stray; this + // abort fires the seam signal too, for providers that only honor the + // request signal (both channels, on every path). + if (this.cancelReason === undefined) this.controller.abort('workflow settled') + if (this.cancelReason !== undefined && result.stopReason !== 'cancelled') { + // The script settled while our cancel was crossing the thread boundary + // — the seam-visible result had NOT settled when cancellation was + // requested, so report cancelled (the vm drive()'s post-settle check, + // relocated to the receiving side of the race). + this.settleResult(this.cancelledResult(result.agentsStarted)) + return + } + this.settleResult(result) + } + + /** An unexpected worker death (or the expected exit after termination). */ + private onWorkerDeath(message: string): void { + // Whatever the worker left behind must not leak — abort + dispose it all. + if (this.children.size > 0) this.reapChildren('workflow worker gone') + // settleResult no-ops on an already-settled run (the expected exit after + // a dispose's terminate lands here too). + if (this.cancelReason !== undefined) { + this.settleResult(this.cancelledResult(this.hostStarted)) + return + } + this.settleResult({ value: null, stopReason: 'error', error: message, agentsStarted: this.hostStarted }) + } + + private cancelledResult(agentsStarted: number): WorkflowResult { + // cancel() is the only writer of cancelReason and every caller checks it + // first; the fallback guards the type, not a reachable path. + /* v8 ignore next */ + const reason = this.cancelReason ?? 'workflow cancelled' + return { value: null, stopReason: 'cancelled', error: `workflow run cancelled: ${reason}`, agentsStarted } + } + + /** First settle wins; disarms the grace timer. */ + private settleResult(result: WorkflowResult): void { + if (this.settled) return + this.settled = true + clearTimeout(this.graceTimer) + this.settleResolve(result) + } +} + +/** A plain timer sleep (the dispose grace); unref'd so it never holds the process open. */ +function sleep(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) + timer.unref() + }) +} diff --git a/packages/workflow/workflow-vm/src/index.ts b/packages/workflow/workflow-vm/src/index.ts index c1300427a2..1b65381bfb 100644 --- a/packages/workflow/workflow-vm/src/index.ts +++ b/packages/workflow/workflow-vm/src/index.ts @@ -1,39 +1,37 @@ /** - * The `node:vm` workflow engine: the first {@link WorkflowService} - * implementation. Parses the Claude Code-format script (meta + body), runs the - * body in a fresh in-process vm context with the workflow hooks injected, and - * fans `agent()` calls out to `ctx.subagents`. + * The `node:worker_threads` workflow engine: the {@link WorkflowService} + * implementation. Runs each script in its OWN worker thread (one run = one + * worker, no pooling — a run is heavyweight, so thread spin-up is noise): the + * body executes in a vm context INSIDE the worker with the workflow hooks + * injected, and `agent()` calls bridge back to `ctx.subagents` over the + * message port — child agents are I/O-bound LLM loops and stay on the host + * event loop; the thread isolates the SCRIPT, the only part that can spin + * synchronously. * * TRUST PREMISE: scripts are MODEL-WRITTEN — the same trust level as the * model's existing bash access — so this engine defends against BUGGY - * scripts, never hostile ones. vm is NOT a security boundary and no attempt - * is made to contain adversarial values (see ./realm.ts); the context is - * escapable by construction (the host `Function` constructor is reachable via - * `globalThis.constructor.constructor`, and `process` from there), so the - * absent globals are API surface, not containment. Genuine sandboxing is an - * engine swap behind the seam (worker-thread/isolated-vm), not incremental - * host-side defenses here. + * scripts, never hostile ones. A worker thread is NOT a security boundary: + * the vm context inside it is escapable by construction, and an escapee + * holds the same process privileges as the host (Node's permission model is + * process-wide); genuine sandboxing (isolated-vm, a separate process) is an + * engine swap behind the seam. What the thread buys, concretely: * - * Engine limitations, documented as the accepted cost of the in-process - * mechanism: + * - `start()` never blocks the host: the script's initial synchronous slice + * (and any later synchronous spin) occupies the WORKER's event loop, not + * the harness's. + * - Termination is REAL: a script that outlives its post-cancel grace is + * `worker.terminate()`d — nothing of the script survives `dispose()`, + * where an in-process engine could only abandon the spin on its own loop. + * - The value boundary is serialization by construction: everything crossing + * the thread is structured-clone data (and plain JSON before that, by the + * materialization walk in ./realm.ts). * - * - `start()` runs the script's initial SYNCHRONOUS slice inline, so the - * CALLER blocks on the host event loop until the script's first await (or - * the vm `timeout` kills the slice); the meta-literal evaluation has its - * own timeout budget on the same call. - * - The vm `timeout` covers only that initial slice; realm code running past - * it — an await continuation, a thenable's `then` invoked by promise - * resolution (including one the script RETURNS: a returned thenable - * resolves per JavaScript semantics before materialization, which is what - * makes an un-awaited `return agent('x')` work) — is beyond the timeout, so - * a synchronous spin there cannot be killed in-process, and neither can - * script code the host invokes while rendering a failure (a getter on a - * thrown value). `dispose()` waits a bounded grace for the script to settle - * AND its children (stray `agent()` calls included) to finish disposing, - * then ABANDONS whatever is left: pending hook promises are already - * rejected and the script's settlement is contained (no unhandled - * rejection), but an abandoned synchronous spin would still occupy the - * event loop. + * Engine-specific limitations: worker startup (~tens of ms) is paid per run; + * on a termination path `agentsStarted` reports the host-observed child + * count (calls still queued worker-side for a slot are unknowable — see + * ./host.ts); and a worker that dies unexpectedly (an OOM, a script reaching + * `process.exit` through the documented vm escape) settles the run + * `stopReason: 'error'` with the exit diagnostics. * * Plugin export shape: a default-exported {@link WorkflowService} subclass * (the class-based service form, like `dsh-bash-local`). @@ -43,16 +41,29 @@ import { randomUUID } from 'node:crypto' import { availableParallelism } from 'node:os' +import * as vm from 'node:vm' import type { Context } from 'cordis' import z from 'schemastery' -import WorkflowService, { WorkflowRunId } from '@deepseek-ai/dsh-workflow' -import type { WorkflowResult, WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' +import WorkflowService, { WorkflowError, WorkflowRunId } from '@deepseek-ai/dsh-workflow' +import type { WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' +import { WorkerRun } from './host.ts' import { extractMeta } from './meta.ts' -import { WorkflowExecution, type ExecutionLimits } from './runtime.ts' +import type { WorkerInit, WorkerLimits } from './types.ts' export { extractMeta, type ExtractedScript } from './meta.ts' +export { HostToWorkerType, WorkerToHostType } from './protocol.ts' +export type { HostToWorkerMessage, HostToWorkerPayloads, WorkerToHostMessage, WorkerToHostPayloads } from './protocol.ts' export { materializeFromRealm, MaterializeError } from './realm.ts' -export { WorkflowExecution, type ExecutionLimits, type ExecutionObserver } from './runtime.ts' +export { WorkflowExecution, type ExecutionObserver } from './runtime.ts' +export { requireParentPort, runWorkerSession } from './session.ts' +export type { + ChildHandle, + ChildPort, + ChildResult, + ChildStartRequest, + WorkerInit, + WorkerLimits, +} from './types.ts' /** Plugin config (all optional — `static Config` supplies the defaults). */ export interface Config { @@ -64,12 +75,12 @@ export interface Config { maxTotalAgents?: number /** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */ maxItemsPerCall?: number - /** vm timeout for the script's initial synchronous slice AND the meta-literal evaluation (default 5000 ms). */ + /** vm timeout for the initial synchronous slice (inside the worker) AND the host-side meta evaluation (default 5000 ms). */ syncTimeoutMs?: number /** * How long after a cancellation an unsettled script may keep running before - * it is abandoned and `result` force-settles `cancelled` (default 5000 ms); - * also bounds `dispose()`. + * the run force-settles `cancelled` and its worker is TERMINATED (default + * 5000 ms); also bounds `dispose()`. */ disposeGraceMs?: number } @@ -77,11 +88,27 @@ export interface Config { type ResolvedConfig = Required /** - * The vm engine service. `start()` validates the script up front (meta + - * body compile) and returns a {@link WorkflowRun} whose `result` never - * rejects; the `workflow/*` events fire around the run per the seam contract. + * Parse-check the body with the SAME wrapper the worker-side runtime + * compiles, so `start()` keeps the seam's synchronous `SCRIPT_PARSE` throw + * (the worker's own compile happens a thread away, after `start()` returned). + * One redundant parse per run, bought deliberately for the contract. */ -export class VmWorkflowEngine extends WorkflowService { +function assertBodyParses(body: string, name: string): void { + try { + // Parse only — the script object is discarded, nothing executes. + void new vm.Script(`(async () => {\n${body}\n})()`, { filename: `workflow:${name}`, lineOffset: -1 }) + } catch (error: unknown) { + throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error }) + } +} + +/** + * The worker-thread engine service. `start()` validates the script up front + * (meta + a host-side body parse) and returns a {@link WorkflowRun} whose + * `result` never rejects; the `workflow/*` events fire around the run per + * the seam contract. + */ +export class WorkerWorkflowEngine extends WorkflowService { static inject = ['subagents'] static Config: z = z.object({ @@ -103,51 +130,56 @@ export class VmWorkflowEngine extends WorkflowService { } /** - * Parse and execute a workflow script. Throws {@link WorkflowError} - * synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot - * begin; once a run is returned, every failure resolves through - * `result.stopReason` instead. + * Parse and execute a workflow script in a fresh worker thread. Throws + * {@link WorkflowError} synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a + * script that cannot begin; once a run is returned, every failure resolves + * through `result.stopReason` instead. * @param request - the script, its `args`, the parent agent, and an * optional cancel signal. * @returns the live run (its `result` resolves when the script settles). */ start(request: WorkflowStartRequest): WorkflowRun { const { meta, body } = extractMeta(request.script, this.config.syncTimeoutMs) + assertBodyParses(body, meta.name) const id = WorkflowRunId(randomUUID()) // The event payloads and the run handle get SEPARATE meta clones: a // listener mutating its snapshot must not corrupt the holder's view. const info: WorkflowRunInfo = { id, meta: structuredClone(meta) } - const limits: ExecutionLimits = { - provider: this.config.provider, + const limits: WorkerLimits = { maxConcurrentAgents: this.config.maxConcurrentAgents === 0 ? Math.min(16, Math.max(1, availableParallelism() - 2)) : this.config.maxConcurrentAgents, maxTotalAgents: this.config.maxTotalAgents, maxItemsPerCall: this.config.maxItemsPerCall, syncTimeoutMs: this.config.syncTimeoutMs, - disposeGraceMs: this.config.disposeGraceMs, } - const execution = new WorkflowExecution( - this.ctx, + const init: WorkerInit = { meta, body, - request.parent, - request.args, - request.signal, + ...request.args !== undefined ? { args: request.args } : {}, limits, + } + const workerRun = new WorkerRun( + this.ctx, + id, + structuredClone(meta), + request.parent, + init, + this.config.provider, + this.config.disposeGraceMs, { phase: (title) => { this.emitWorkflowEvent('workflow/phase', info, title) }, log: (message) => { this.emitWorkflowEvent('workflow/log', info, message) }, agentStart: (agent) => { this.emitWorkflowEvent('workflow/agent-start', info, agent) }, agentEnd: (agent) => { this.emitWorkflowEvent('workflow/agent-end', info, agent) }, }, + request.signal, ) this.emitWorkflowEvent('workflow/start', info) - const result: Promise = execution.drive() // `workflow/end` fires as the (never-rejecting) result settles, with the // outcome DATA only — the value stays with the run's holder. - void result.then((settled) => { + void workerRun.result.then((settled) => { this.emitWorkflowEvent('workflow/end', info, { stopReason: settled.stopReason, ...settled.error !== undefined ? { error: settled.error } : {}, @@ -155,46 +187,8 @@ export class VmWorkflowEngine extends WorkflowService { }) }) - let disposed: Promise | undefined - return { - id, - meta: structuredClone(meta), - result, - cancel(reason?: string): void { - execution.cancel(reason) - }, - dispose: (): Promise => { - // Idempotent: cancel, then wait min(settle + child quiescence, grace). - // The cancel itself bounds `result` (the execution abandons a script - // still unsettled `disposeGraceMs` later), so this outer race exists - // for CHILD quiescence: a slow-disposing child must not hold dispose - // past the grace. `result` and `quiesce()` never reject, so the race - // needs no rejection handling. - disposed ??= (async () => { - execution.cancel('workflow disposed') - await Promise.race([ - (async () => { - await result - // The result settles with the SCRIPT; stray children a script - // fired without awaiting are still winding down — dispose must - // not return while they hold live resources. - await execution.quiesce() - })(), - sleep(this.config.disposeGraceMs), - ]) - })() - return disposed - }, - } + return workerRun } } -/** A plain timer sleep (the dispose grace); unref'd so it never holds the process open. */ -function sleep(ms: number): Promise { - return new Promise((resolve) => { - const timer = setTimeout(resolve, ms) - timer.unref() - }) -} - -export default VmWorkflowEngine +export default WorkerWorkflowEngine diff --git a/packages/workflow/workflow-vm/src/protocol.ts b/packages/workflow/workflow-vm/src/protocol.ts new file mode 100644 index 0000000000..293676706e --- /dev/null +++ b/packages/workflow/workflow-vm/src/protocol.ts @@ -0,0 +1,115 @@ +/** + * The host⇄worker wire protocol: one string-valued enum of message tags per + * direction, a payload map giving each tag its parameters (the single source + * of truth), and the message unions derived from them. Everything in a + * payload is plain JSON data by construction (the runtime materializes + * script values before they reach a message; the host projects seam results + * down to their JSON fields), so the structured-clone hop never meets a + * value it cannot carry. + * + * Both directions are CLOSED (engine-owned): each side switches on `type` + * and ends with `assertNever` — an unknown message is a protocol bug, never + * something to skip silently. Senders go through a generic + * `post(type, payload)` whose payload parameter is looked up from the map, + * so a tag/payload mismatch is a compile error at the call site. + * + * @module @deepseek-ai/dsh-workflow-vm/protocol + */ + +import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult } from '@deepseek-ai/dsh-workflow' +import type { ChildResult, ChildStartRequest } from './types.ts' + +/** Message tags the worker sends the host (the wire values are the tag strings). */ +export enum WorkerToHostType { + /** The startup handshake: the session is listening and awaits {@link HostToWorkerType.Go}. */ + Ready = 'ready', + /** Observer narration: a `phase(title)` call. */ + Phase = 'phase', + /** Observer narration: a `log(message)` call. */ + Log = 'log', + /** Observer lifecycle: one `agent()` call started a child. */ + AgentStart = 'agent-start', + /** Observer lifecycle: one `agent()` call settled. */ + AgentEnd = 'agent-end', + /** Child RPC: start a child on the host (answered by ChildStarted or ChildStartError). */ + ChildStart = 'child-start', + /** Child RPC: cancel a started child (fire-and-forget). */ + ChildCancel = 'child-cancel', + /** Child RPC: dispose a started child (answered by ChildDisposed). */ + ChildDispose = 'child-dispose', + /** The run's single terminal result. */ + Result = 'result', +} + +/** The payload each worker→host tag carries. */ +export interface WorkerToHostPayloads { + /** Ready carries nothing. */ + [WorkerToHostType.Ready]: Record + /** The phase title, verbatim. */ + [WorkerToHostType.Phase]: { title: string } + /** The logged message, verbatim. */ + [WorkerToHostType.Log]: { message: string } + /** The call's sequence number, label, phase, and child id. */ + [WorkerToHostType.AgentStart]: { info: WorkflowAgentInfo } + /** The call identity plus its outcome. */ + [WorkerToHostType.AgentEnd]: { info: WorkflowAgentEndInfo } + /** The RPC correlation id and the prompt plus validated options. */ + [WorkerToHostType.ChildStart]: { callId: number; request: ChildStartRequest } + /** The RPC correlation id and the cancel reason (undefined = unspecified). */ + [WorkerToHostType.ChildCancel]: { callId: number; reason: string | undefined } + /** The RPC correlation id of the child to dispose. */ + [WorkerToHostType.ChildDispose]: { callId: number } + /** The run's terminal outcome. */ + [WorkerToHostType.Result]: { result: WorkflowResult } +} + +/** Message tags the host sends the worker (the wire values are the tag strings). */ +export enum HostToWorkerType { + /** Releases the startup gate: run the script body. */ + Go = 'go', + /** Cancel the run: hooks start throwing and the script dies at its next await. */ + Cancel = 'cancel', + /** Child RPC reply: the start succeeded (exactly one of ChildStarted/ChildStartError per ChildStart). */ + ChildStarted = 'child-started', + /** Child RPC reply: the start was refused or threw. */ + ChildStartError = 'child-start-error', + /** Child RPC: a started child's result RESOLVED (its JSON projection). */ + ChildSettled = 'child-settled', + /** Child RPC: a started child's result REJECTED (an infrastructure fault, rendered). */ + ChildFailed = 'child-failed', + /** Child RPC reply: a requested disposal completed. */ + ChildDisposed = 'child-disposed', +} + +/** The payload each host→worker tag carries. */ +export interface HostToWorkerPayloads { + /** Go carries nothing. */ + [HostToWorkerType.Go]: Record + /** The cancel reason, canonical for the whole run. */ + [HostToWorkerType.Cancel]: { reason: string } + /** The RPC correlation id and the child agent's id (minted by the subagent seam). */ + [HostToWorkerType.ChildStarted]: { callId: number; childId: string } + /** The RPC correlation id and the rendered start failure. */ + [HostToWorkerType.ChildStartError]: { callId: number; rendered: string } + /** The RPC correlation id and the child's terminal result projection. */ + [HostToWorkerType.ChildSettled]: { callId: number; result: ChildResult } + /** The RPC correlation id and the rendered infrastructure fault. */ + [HostToWorkerType.ChildFailed]: { callId: number; rendered: string } + /** The RPC correlation id of the completed disposal. */ + [HostToWorkerType.ChildDisposed]: { callId: number } +} + +/** + * One worker→host message of tag `T`; unparameterized, the closed union over + * every tag (a discriminated union — `switch` on `type` narrows). + */ +export type WorkerToHostMessage = + { [K in T]: { type: K } & WorkerToHostPayloads[K] }[T] + +/** + * One host→worker message of tag `T`; unparameterized, the closed union over + * every tag (a discriminated union — `switch` on `type` narrows). + */ +export type HostToWorkerMessage = + { [K in T]: { type: K } & HostToWorkerPayloads[K] }[T] + diff --git a/packages/workflow/workflow-vm/src/realm.ts b/packages/workflow/workflow-vm/src/realm.ts index f5a2665626..017de76069 100644 --- a/packages/workflow/workflow-vm/src/realm.ts +++ b/packages/workflow/workflow-vm/src/realm.ts @@ -1,7 +1,10 @@ /** - * The vm engine's value boundary: copy script-realm values into plain host - * JSON data — loud about everything JSON cannot carry — and render thrown - * script values to failure text. + * The engine's value boundary: copy script-realm values into plain JSON data + * — loud about everything JSON cannot carry — and render thrown script + * values to failure text. The script runs in a vm context INSIDE the worker + * thread, so "host" here means the worker-side JavaScript around that + * context; everything that later crosses the thread boundary is JSON by this + * walk, which is what makes the postMessage hop total. * * TRUST PREMISE (everything in this module hangs on it): workflow scripts are * MODEL-WRITTEN, the same trust level as the model's existing bash access, so @@ -13,18 +16,16 @@ * properties ordinarily (a getter runs, and whatever it returns is what * crosses), {@link renderThrown} reads `stack`/`message`/`String()` directly, * and a proxy is walked through its traps. A hostile script gains nothing - * worth defending here — it can already occupy the event loop forever with a - * synchronous spin past the first await (the engine's documented, accepted - * limitation) — so host-side hostile-value containment would be cost without - * a threat model; genuine hardening is an ENGINE SWAP (worker/isolated-vm, - * where the boundary is serialization by construction), not incremental - * defenses here. + * worth defending here — the vm context inside the worker is escapable by + * construction, so hostile-value containment would be cost without a threat + * model (what the worker thread DOES buy is that a spin occupies the + * worker's loop, not the host's, and termination is real). * * The host→realm direction needs no machinery at all: hooks hand the script - * plain host values, host prototypes included — the script is trusted. One - * consequence is documented in the engine README: an error thrown by a hook - * is a HOST error, so an in-script `instanceof Error` check is false; read - * `name`/`code`/`message` instead. + * plain values of the worker realm, prototypes included — the script is + * trusted. One consequence is documented in the engine README: an error + * thrown by a hook is built OUTSIDE the script's vm context, so an in-script + * `instanceof Error` check is false; read `name`/`code`/`message` instead. * * @module @deepseek-ai/dsh-workflow-vm/realm */ diff --git a/packages/workflow/workflow-vm/src/runtime.ts b/packages/workflow/workflow-vm/src/runtime.ts index c4e5d1c651..3005520fa0 100644 --- a/packages/workflow/workflow-vm/src/runtime.ts +++ b/packages/workflow/workflow-vm/src/runtime.ts @@ -1,40 +1,44 @@ /** - * Per-run execution state for the vm workflow engine: the script context and - * its injected hooks (`agent`/`parallel`/`pipeline`/`phase`/`log`/`args`), the - * concurrency semaphore and caps, cancellation, and the drive loop that turns - * a script settlement into a {@link WorkflowResult}. + * Per-run execution state for the engine's THREAD side: the script's vm + * context and its injected hooks (`agent`/`parallel`/`pipeline`/`phase`/ + * `log`/`args`), the concurrency semaphore and caps, cancellation, and the + * drive loop that turns a script settlement into a {@link WorkflowResult}. + * Children are started by RPC to the host through a {@link ChildPort}, so + * this module never touches a cordis context — it runs inside the worker + * thread. * * Value boundary (the trust premise lives in ./realm.ts): values ENTERING the - * host from the script (hook options, schemas, the return value) are - * materialized by `materializeFromRealm` — a plain walk that rejects loud - * everything JSON cannot carry. Values ENTERING the realm (`args`, `agent()` - * results, hook promises and their failures, combinator arrays) are handed - * over DIRECTLY as host values: the script is model-written and trusted, so - * host prototypes are not a leak. `args` is host-side `structuredClone`d once - * at start so a script scribbling on it cannot mutate the caller's object — - * that is a benign-bug guard, not isolation. Realm functions (pipeline - * stages, parallel thunks) are called, not materialized — their values stay - * realm-side until they cross through a hook or the final return. + * worker-side host code from the script (hook options, schemas, the return + * value) are materialized by `materializeFromRealm` — a plain walk that + * rejects loud everything JSON cannot carry, which also makes every value + * safe for the later postMessage hop. Values ENTERING the realm (`args`, + * `agent()` results, hook promises and their failures, combinator arrays) are + * handed over DIRECTLY as worker-realm values: the script is model-written + * and trusted, so outer prototypes are not a leak. `args` is cloned once at + * start so a script scribbling on it cannot mutate the session's init object + * (a benign-bug guard; the postMessage clone already isolated the caller). * * Failure discipline: fatal {@link WorkflowError}s (bad hook arguments, - * unsupported options/schemas, tripped caps, seam start failures and result - * rejections, cancellation) ALWAYS propagate through `parallel`/`pipeline` — - * recognized by host `instanceof`, which a script cannot forge — and the - * per-item `null` is reserved for child-run failures and ordinary in-stage - * script errors. - * Every hook-returned promise gets a no-op rejection consumer attached, so a - * script that drops a promise (fires an `agent()` without awaiting it) cannot - * surface an unhandled rejection when cancellation rejects it — the app boot - * layer exits the process on unhandled rejections. + * unsupported options/schemas, tripped caps, host start refusals and child + * result rejections, cancellation) ALWAYS propagate through + * `parallel`/`pipeline` — recognized by `instanceof` against this realm's + * class, which a script inside the vm context cannot forge — and the per-item + * `null` is reserved for child-run failures and ordinary in-stage script + * errors. Every hook-returned promise gets a no-op rejection consumer, so a + * dropped promise cannot surface an unhandled rejection (which would kill the + * worker and read as an engine fault). + * + * There is deliberately NO worker-side abandon channel: a script that never + * settles after a cancel simply never posts a result, and the HOST enforces + * the settles-within-grace guarantee by force-settling `cancelled` and + * terminating the worker — the real kill an in-process engine could not have. * * @module @deepseek-ai/dsh-workflow-vm/runtime */ import * as vm from 'node:vm' -import type { Context } from 'cordis' +import { AgentId } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-subagent' import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools' import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools' import { isFatalWorkflowError, WorkflowError } from '@deepseek-ai/dsh-workflow' @@ -45,24 +49,9 @@ import type { WorkflowResult, } from '@deepseek-ai/dsh-workflow' import { materializeFromRealm, MaterializeError, renderThrown } from './realm.ts' +import type { ChildHandle, ChildPort, WorkerLimits } from './types.ts' -/** The per-run knobs the engine resolves from its Config. */ -export interface ExecutionLimits { - /** The `ctx.subagents` provider name to start children on. */ - provider: string - /** Concurrent `agent()` ceiling (already auto-resolved; ≥ 1). */ - maxConcurrentAgents: number - /** Total `agent()` calls per run (the runaway-loop backstop). */ - maxTotalAgents: number - /** Items accepted by one `parallel()`/`pipeline()` call. */ - maxItemsPerCall: number - /** vm timeout for the script's initial synchronous slice. */ - syncTimeoutMs: number - /** How long after `cancel()` a still-unsettled script is abandoned (result force-settles `cancelled`). */ - disposeGraceMs: number -} - -/** The engine-side observers the execution reports progress through. */ +/** The observers the execution reports progress through (the session posts them to the host). */ export interface ExecutionObserver { phase(title: string): void log(message: string): void @@ -91,9 +80,9 @@ function defaultLabel(prompt: string): string { } /** - * One live script execution. Constructed per run by the engine; `drive()` is - * called exactly once and NEVER rejects — every failure becomes a - * {@link WorkflowResult} with a non-`completed` stop reason. + * One live script execution inside the worker. Constructed per run by the + * session; `drive()` is called exactly once and NEVER rejects — every failure + * becomes a {@link WorkflowResult} with a non-`completed` stop reason. */ export class WorkflowExecution { /** 1-based count of `agent()` calls started (the `agentsStarted` result field). */ @@ -106,36 +95,19 @@ export class WorkflowExecution { private currentPhase: string | undefined private readonly context: vm.Context private readonly compiled: vm.Script - /** Every live `agent()` call promise — awaited or stray — for {@link quiesce}. */ - private readonly inFlightAgents = new Set>() - /** Fires {@link abandoned}; assigned by the promise executor at field initialization. */ - private declareAbandoned!: () => void - private abandonTimer: NodeJS.Timeout | undefined - /** - * Rejects `disposeGraceMs` after {@link cancel} if the script has not - * settled by then. `drive()` races the script against it, so `result` - * ALWAYS settles within the grace of a cancellation — even when the script - * is parked on a promise no hook owns (`await new Promise(() => {})`), which - * cancellation cannot reject. Without this, a consumer awaiting `result` - * before disposing (the tool's shape) would hang forever on such a script, - * wedging its caller past any abort. - */ - private readonly abandoned = new Promise((_, reject) => { - this.declareAbandoned = () => { reject(new WorkflowError('workflow script abandoned after the cancellation grace', 'CANCELLED')) } - }) constructor( - private readonly ctx: Context, meta: WorkflowMeta, body: string, - private readonly parent: Agent, args: unknown, - signal: AbortSignal | undefined, - private readonly limits: ExecutionLimits, + private readonly limits: WorkerLimits, private readonly observer: ExecutionObserver, + private readonly children: ChildPort, ) { // Compile FIRST: a body syntax error must throw out of the constructor - // (the engine maps it to SCRIPT_PARSE) before any realm state exists. + // before any realm state exists. The host pre-parses the identical + // wrapper, so under one Node version this throw is unreachable in + // production — the session still maps it to an error result defensively. // lineOffset compensates for the wrapper line, so stack traces carry the // script's own line numbers (the meta statement was blanked, not removed). try { @@ -148,20 +120,17 @@ export class WorkflowExecution { } this.context = vm.createContext({}, { name: `workflow:${meta.name}` }) - // A run that settles without ever being abandoned leaves `abandoned` - // permanently pending or rejecting into the void — consume it so a late - // grace timer cannot surface an unhandled rejection. - void this.contain(this.abandoned) const globals: Record = { - agent: (prompt: unknown, opts?: unknown) => this.contain(this.track(this.agent(prompt, opts))), + agent: (prompt: unknown, opts?: unknown) => this.contain(this.agent(prompt, opts)), parallel: (thunks: unknown) => this.contain(this.parallel(thunks)), pipeline: (items: unknown, ...stages: unknown[]) => this.contain(this.pipeline(items, stages)), phase: (title: unknown) => { this.phase(title) }, log: (message: unknown) => { this.log(message) }, - // Host-side clone: a script scribbling on args must not mutate the - // caller's object (a benign-bug guard; args is plain JSON by the seam - // contract, so structuredClone is total here and throws loud otherwise). + // Cloned once: a script scribbling on args must not mutate the + // session's init object (a benign-bug guard; args is plain JSON by the + // seam contract and already crossed one structured clone as workerData, + // so this clone is total). args: args === undefined ? undefined : structuredClone(args), } for (const [key, value] of Object.entries(globals)) { @@ -169,18 +138,12 @@ export class WorkflowExecution { // a script overwriting its own hooks only sabotages itself. ;(this.context as Record)[key] = typeof value === 'function' ? Object.freeze(value) : value } - - if (signal?.aborted) { - this.cancel('workflow start signal already aborted') - } else { - signal?.addEventListener('abort', () => { this.cancel('workflow signal aborted') }, { once: true }) - } } /** * Whether the run has been cancelled. A METHOD, not an inline property - * read: `cancel()` mutates `cancelReason` concurrently (a signal listener, - * a raced dispose), and an inline read after an `await` gets narrowed by + * read: `cancel()` mutates `cancelReason` concurrently (the session's + * message handler), and an inline read after an `await` gets narrowed by * control flow into an always-false comparison. */ private isCancelled(): boolean { @@ -199,45 +162,41 @@ export class WorkflowExecution { } /** - * Cancel the run: children abort (the shared signal), waiting `agent()` - * slots reject, and every future hook call throws `CANCELLED` — the script - * dies at its next await. A script that STILL has not settled after - * `disposeGraceMs` (parked on a promise no hook owns) is abandoned so - * `result` settles regardless (see {@link abandoned}). Idempotent; the - * first reason wins. + * Cancel the run: in-flight children get a cancel RPC (the shared abort + * fanout), waiting `agent()` slots reject, and every future hook call + * throws `CANCELLED` — the script dies at its next await. A script that + * never settles anyway (parked on a promise no hook owns) is the HOST's + * problem: its grace timer force-settles the run and terminates the + * worker. Idempotent; the first reason wins. * @param reason - human-readable cause, carried on the CANCELLED error and - * into child `run.cancel()` calls (default `'workflow cancelled'`). + * into child cancel RPCs. Required: every caller (the session's cancel + * message, drive()'s settle-reap) has a concrete reason. */ - cancel(reason?: string): void { + cancel(reason: string): void { if (this.cancelReason !== undefined) return - this.cancelReason = reason ?? 'workflow cancelled' + this.cancelReason = reason this.cancelError = new WorkflowError(`workflow run cancelled: ${this.cancelReason}`, 'CANCELLED') this.controller.abort(this.cancelReason) for (const waiter of this.slotWaiters.splice(0)) waiter.reject(this.cancelledError()) - this.abandonTimer = setTimeout(() => { this.declareAbandoned() }, this.limits.disposeGraceMs) - // unref'd: an armed grace timer must never hold the process open. - this.abandonTimer.unref() } /** * Run the script to settlement. Resolves — never rejects — with the run's * {@link WorkflowResult}: the materialized return value on `completed`, the * failure message on `error`, and `cancelled` when the script died of - * cancellation (or outlived its post-cancel grace and was abandoned — see - * {@link abandoned}). After settlement, any stray children a script fired - * without awaiting are aborted (their `agent()` wrappers dispose them). + * cancellation. After settlement, any stray children a script fired without + * awaiting are cancelled (their `agent()` wrappers dispose them via RPC). * @returns the settled outcome — this promise NEVER rejects (the seam's * `result`-never-rejects contract); every failure maps to a variant. */ async drive(): Promise { try { - // Cancelled before the body ever ran (an already-aborted start signal): - // the script must not execute at all, let alone report `completed`. + // Cancelled before the body ever ran (an already-aborted start signal, + // relayed by the host before its `go`): the script must not execute at + // all, let alone report `completed`. if (this.isCancelled()) throw this.cancelledError() const scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs }) as Promise - // The race is the result-settles-after-cancel guarantee: a parked - // script loses to the abandon channel once the grace expires. - const raw: unknown = await Promise.race([this.contain(Promise.resolve(scriptPromise)), this.abandoned]) + const raw: unknown = await this.contain(Promise.resolve(scriptPromise)) // Cancelled while the body ran: a script that settled without touching // another hook (or without any) must still report `cancelled` — the // holder asked for cancellation and `completed` would be a lie. @@ -250,59 +209,30 @@ export class WorkflowExecution { if (this.isCancelled()) { return { value: null, stopReason: 'cancelled', error: this.cancelledError().message, agentsStarted: this.started } } - // renderThrown is total (host- and realm-thrown values alike), so this - // arm cannot throw — drive() resolving is the `result` never-rejects - // seam contract. + // renderThrown is total (thrown values of any realm), so this arm + // cannot throw — drive() resolving is the `result` never-rejects seam + // contract. return { value: null, stopReason: 'error', error: renderThrown(error), agentsStarted: this.started } } finally { // Reap strays: a script that fired agent() calls without awaiting them - // leaves live children behind after settlement — abort them all. (The + // leaves live children behind after settlement — cancel them all. (The // per-call wrappers dispose each child; the contain() consumer keeps // their rejections from going unhandled.) if (this.cancelReason === undefined) this.cancel('workflow settled') - // drive() settling means nothing is left to abandon — including the - // timer the self-cancel above just armed (cancel() always arms it, so - // it is never undefined here; clearTimeout tolerates undefined anyway). - clearTimeout(this.abandonTimer) } } /** * Attach a no-op rejection consumer WITHOUT changing what the caller * receives: if the script drops the promise (no await), cancellation cannot - * become an unhandled rejection (the app boot layer exits the process on - * those); if the script does await it, it still observes the rejection. + * become an unhandled rejection (which would kill the worker thread); if + * the script does await it, it still observes the rejection. */ private contain(promise: Promise): Promise { promise.catch(() => { /* consumed: see method contract — a dropped hook promise must not surface an unhandled rejection */ }) return promise } - /** - * Register one `agent()` call promise for {@link quiesce} tracking; the - * entry drops when the call fully settles (which is AFTER its child's - * `dispose()` — the call wrapper disposes in its `finally`). - */ - private track(promise: Promise): Promise { - this.inFlightAgents.add(promise) - const drop = (): void => { this.inFlightAgents.delete(promise) } - promise.then(drop, drop) - return promise - } - - /** - * Settles once every `agent()` call — awaited or stray — has fully settled, - * INCLUDING each child's `dispose()`. The reap in {@link drive}'s finally - * aborts strays; this is the wait for those aborts to reach quiescence, so - * the engine's `dispose()` cannot return while a child is still winding - * down. Never rejects (the tracked promises' rejections are contained). - */ - async quiesce(): Promise { - while (this.inFlightAgents.size > 0) { - await Promise.allSettled([...this.inFlightAgents]) - } - } - private cancelledError(): WorkflowError { // cancel() arms cancelError before any caller can observe isCancelled() // === true; the fallback guards the type, not a reachable path. @@ -375,28 +305,37 @@ export class WorkflowExecution { // Re-check after the acquire: the await yields at least one microtask // tick even when a slot is free, and a queued waiter resumes a tick // after its release — a cancel() landing in either window must not - // start a child (it would carry an ALREADY-aborted signal, which a - // provider subscribing only to future abort events would never see). + // reach the host (which would refuse anyway, but the refusal reads as + // a start failure rather than the cancellation it is). this.throwIfCancelled() - let run + let run: ChildHandle try { - run = this.ctx.subagents.start(this.limits.provider, { - prompt: [{ type: 'text', text: rawPrompt }], - parent: this.parent, - signal: this.controller.signal, - ...opts.schema !== undefined ? { outputSchema: opts.schema } : {}, - ...opts.model !== undefined ? { agentOptions: { model: opts.model } } : {}, + run = await this.children.startAgent({ + prompt: rawPrompt, + ...opts.schema !== undefined ? { schema: opts.schema } : {}, + ...opts.model !== undefined ? { model: opts.model } : {}, }) } catch (error: unknown) { - throw new WorkflowError(`agent() could not start a child on provider "${this.limits.provider}": ${String(error)}`, 'AGENT_START', { cause: error }) + // The host refuses starts once the run is cancelled — a refusal that + // races our own cancel state must read as the cancellation it is, + // not as a broken seam. + if (this.isCancelled()) throw this.cancelledError() + throw new WorkflowError(`agent() could not start a child: ${renderThrown(error)}`, 'AGENT_START', { cause: error }) } - const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: run.id } + // The start round-trip yields to the event loop, so a cancel CAN land + // between the host starting the child and this continuation running — + // wind the fresh child down instead of leaving it live behind a dead + // script. + if (this.isCancelled()) { + run.cancel(this.cancelReason) + await run.dispose() + throw this.cancelledError() + } + const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: AgentId(run.id) } this.observer.agentStart(info) - // Cancellation bridges to run.cancel() as well as the request signal: - // the seam leaves a provider free to honor either channel, so the - // consumer must drive both. The signal cannot be aborted yet (the block - // since the post-acquire check is synchronous), so the listener always - // arms; `once` plus the finally removal keep it leak-free. + // Cancellation reaches the child through an explicit cancel RPC per + // child (the host also aborts its own per-run signal, but the seam + // leaves a provider free to honor either channel, so both are driven). const onAbort = (): void => { run.cancel(this.cancelReason) } this.controller.signal.addEventListener('abort', onAbort, { once: true }) try { @@ -404,8 +343,8 @@ export class WorkflowExecution { try { result = await run.result } catch (error: unknown) { - // The seam allows `result` to reject for an INFRASTRUCTURE fault — - // distinct from a child that failed and resolved. Pair the + // A rejected child result is an INFRASTRUCTURE fault relayed by the + // host — distinct from a child that failed and resolved. Pair the // lifecycle before propagating, and propagate FATAL: an ordinary // throw would dissolve to a per-item null inside the combinators, // and a broken provider must not read as a failed child. @@ -510,9 +449,10 @@ export class WorkflowExecution { try { return await thunk() } catch (error: unknown) { - // Hook failures are host WorkflowErrors; a fatal one is recognized by - // host `instanceof` — a script-built object can never pass it, so - // fatality cannot be forged (nor accidentally dissolved). + // Hook failures are WorkflowErrors built OUTSIDE the script's realm; + // fatality is recognized by `instanceof` against this realm's class — + // a script-built object can never pass it, so fatality cannot be + // forged (nor accidentally dissolved). if (isFatalWorkflowError(error)) throw error return null } @@ -544,8 +484,8 @@ export class WorkflowExecution { return value } catch (error: unknown) { // An ordinary stage throw drops the ITEM to null and skips its - // remaining stages; a fatal host WorkflowError (see parallel()) kills - // the whole script. + // remaining stages; a fatal WorkflowError (see parallel()) kills the + // whole script. if (isFatalWorkflowError(error)) throw error return null } diff --git a/packages/workflow/workflow-vm/src/session.ts b/packages/workflow/workflow-vm/src/session.ts new file mode 100644 index 0000000000..131f760ff9 --- /dev/null +++ b/packages/workflow/workflow-vm/src/session.ts @@ -0,0 +1,210 @@ +/** + * The worker-side half of the engine: {@link runWorkerSession} wires one + * MessagePort to one {@link WorkflowExecution} — hook progress and child + * starts go out as messages, run control and child lifecycle come back in — + * and posts the run's terminal result exactly once. Deliberately separated + * from the thread bootstrap (./worker.ts): the whole session is drivable + * in-process over a `MessageChannel`, which is where its unit coverage lives + * (code inside a real Worker is invisible to the main process's coverage). + * + * Startup handshake: the session posts `ready` and runs the script only + * after the host's `go` — without it, a cancellation racing the worker's + * boot could arrive AFTER the script's initial synchronous slice already + * ran, and a run cancelled before start must not execute the body at all. + * A `cancel` arriving instead of `go` still releases the gate: `drive()` + * sees the cancelled state and settles without running the body. + * + * @module @deepseek-ai/dsh-workflow-vm/session + */ + +import type { MessagePort } from 'node:worker_threads' +import { assertNever } from '@deepseek-ai/dsh-llm' +import { HostToWorkerType, WorkerToHostType } from './protocol.ts' +import type { HostToWorkerMessage, WorkerToHostPayloads } from './protocol.ts' +import { renderThrown } from './realm.ts' +import { WorkflowExecution } from './runtime.ts' +import type { ExecutionObserver } from './runtime.ts' +import type { + ChildHandle, + ChildPort, + ChildResult, + ChildStartRequest, + WorkerInit, +} from './types.ts' + +/** The book-keeping for one in-flight child RPC (keyed by callId). */ +interface PendingChild { + started: PromiseWithResolvers + settled: PromiseWithResolvers + disposed: PromiseWithResolvers +} + +/** The typed post half of the port: each tag pairs with ITS payload from the map (a mismatch is a compile error at the call site). */ +type Post = (type: T, payload: WorkerToHostPayloads[T]) => void + +/** + * The worker-side handle for one started child agent ({@link ChildHandle}): + * every member is an RPC to the host keyed by this call's `callId`, resolved + * by the session's message handler through the bridge's pending entry. + */ +class RpcChildHandle implements ChildHandle { + readonly result: Promise + + constructor( + private readonly post: Post, + private readonly callId: number, + private readonly entry: PendingChild, + readonly id: string, + ) { + this.result = entry.settled.promise + } + + cancel(reason?: string): void { + this.post(WorkerToHostType.ChildCancel, { callId: this.callId, reason }) + } + + dispose(): Promise { + this.post(WorkerToHostType.ChildDispose, { callId: this.callId }) + return this.entry.disposed.promise + } +} + +/** + * The worker-side child-RPC bridge ({@link ChildPort}): allocates callIds, + * posts the start/cancel/dispose RPCs, and owns the per-call pending + * book-keeping the session's message handler settles via the `onChild*` + * entry points. + */ +class ChildRpcBridge implements ChildPort { + private nextCallId = 0 + private readonly pending = new Map() + + constructor(private readonly post: Post) {} + + async startAgent(request: ChildStartRequest): Promise { + this.nextCallId += 1 + const callId = this.nextCallId + const entry: PendingChild = { + started: Promise.withResolvers(), + settled: Promise.withResolvers(), + disposed: Promise.withResolvers(), + } + // Containment: when the start is refused (or the run torn down) the + // settled promise may never gain a consumer — it must not surface as an + // unhandled rejection and kill the worker. + entry.settled.promise.catch(() => { /* consumed: unconsumed child settlement after a refused start */ }) + this.pending.set(callId, entry) + this.post(WorkerToHostType.ChildStart, { callId, request }) + const childId = await entry.started.promise + return new RpcChildHandle(this.post, callId, entry, childId) + } + + /** The host started the child; releases the `startAgent` await. */ + onChildStarted(callId: number, childId: string): void { + this.pending.get(callId)?.started.resolve(childId) + } + + /** The host refused the start; `startAgent` rejects with the rendered cause. */ + onChildStartError(callId: number, rendered: string): void { + this.pending.get(callId)?.started.reject(new Error(rendered)) + } + + /** The child's terminal result arrived. */ + onChildSettled(callId: number, result: ChildResult): void { + this.pending.get(callId)?.settled.resolve(result) + } + + /** The child's `result` rejected host-side (an infrastructure fault, relayed as fatal). */ + onChildFailed(callId: number, rendered: string): void { + this.pending.get(callId)?.settled.reject(new Error(rendered)) + } + + /** The host acked the dispose; the call's book-keeping is complete. */ + onChildDisposed(callId: number): void { + const entry = this.pending.get(callId) + this.pending.delete(callId) + entry?.disposed.resolve() + } +} + +/** + * Narrow the nullable `parentPort` the bootstrap reads from + * `node:worker_threads`. + * @param port - `parentPort` as imported (null on the main thread). + * @returns the port, non-null. + */ +export function requireParentPort(port: MessagePort | null): MessagePort { + if (port === null) throw new Error('the workflow worker entry must be loaded inside a worker thread (no parentPort)') + return port +} + +/** + * Run one workflow script to settlement against `port`, posting the terminal + * result message exactly once; resolves after that post (stray children may + * still be winding down through the port — the host owns their teardown and + * ultimately terminates the thread). Never rejects: a constructor failure + * (unparseable body — host pre-parse makes this a Node-version-skew signal) + * is reported as an `error` result rather than dying without a result. + * @param port - the channel to the host (the real `parentPort`, or one side + * of an in-process `MessageChannel` in tests). + * @param init - the run payload the host provided as `workerData`. + */ +export async function runWorkerSession(port: MessagePort, init: WorkerInit): Promise { + const post: Post = (type, payload) => { + port.postMessage({ type, ...payload }) + } + const children = new ChildRpcBridge(post) + + const observer: ExecutionObserver = { + phase: (title) => { post(WorkerToHostType.Phase, { title }) }, + log: (message) => { post(WorkerToHostType.Log, { message }) }, + agentStart: (info) => { post(WorkerToHostType.AgentStart, { info }) }, + agentEnd: (info) => { post(WorkerToHostType.AgentEnd, { info }) }, + } + + let execution: WorkflowExecution + try { + execution = new WorkflowExecution(init.meta, init.body, init.args, init.limits, observer, children) + } catch (error: unknown) { + post(WorkerToHostType.Result, { result: { value: null, stopReason: 'error', error: renderThrown(error), agentsStarted: 0 } }) + return + } + + const gate = Promise.withResolvers() + port.on('message', (message: HostToWorkerMessage) => { + switch (message.type) { + case HostToWorkerType.Go: + gate.resolve() + break + case HostToWorkerType.Cancel: + execution.cancel(message.reason) + // A cancel doubles as the gate release: drive() checks the cancelled + // state before running the body, so the script never executes. + gate.resolve() + break + case HostToWorkerType.ChildStarted: + children.onChildStarted(message.callId, message.childId) + break + case HostToWorkerType.ChildStartError: + children.onChildStartError(message.callId, message.rendered) + break + case HostToWorkerType.ChildSettled: + children.onChildSettled(message.callId, message.result) + break + case HostToWorkerType.ChildFailed: + children.onChildFailed(message.callId, message.rendered) + break + case HostToWorkerType.ChildDisposed: + children.onChildDisposed(message.callId) + break + /* v8 ignore next 2 -- closed engine-owned union; the arm only makes adding a message type a compile error */ + default: + assertNever(message, 'host-to-worker message') + } + }) + + post(WorkerToHostType.Ready, {}) + await gate.promise + const result = await execution.drive() + post(WorkerToHostType.Result, { result }) +} diff --git a/packages/workflow/workflow-vm/src/types.ts b/packages/workflow/workflow-vm/src/types.ts new file mode 100644 index 0000000000..c87fc4668c --- /dev/null +++ b/packages/workflow/workflow-vm/src/types.ts @@ -0,0 +1,97 @@ +/** + * Non-protocol wire vocabulary for the worker-thread engine: the `workerData` init + * payload and the child-port interfaces the worker-side runtime consumes. + * The host⇄worker MESSAGE protocol lives in ./protocol.ts; everything here + * that a message transports (`ChildStartRequest`, `ChildResult`) is plain + * JSON data by construction, so the structured-clone hop never meets a value + * it cannot carry. Types only, per the package convention. + * + * @module @deepseek-ai/dsh-workflow-vm/types + */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools' +import type { WorkflowMeta } from '@deepseek-ai/dsh-workflow' + +/** + * The per-run limits the worker-side runtime enforces. The host keeps the + * knobs only it can act on (`provider`, `disposeGraceMs`). + */ +export interface WorkerLimits { + /** Concurrent `agent()` ceiling (already auto-resolved; ≥ 1). */ + maxConcurrentAgents: number + /** Total `agent()` calls per run (the runaway-loop backstop). */ + maxTotalAgents: number + /** Items accepted by one `parallel()`/`pipeline()` call. */ + maxItemsPerCall: number + /** vm timeout for the script's initial synchronous slice (inside the worker). */ + syncTimeoutMs: number +} + +/** The `workerData` payload one run is initialized with (host → worker, once, at spawn). */ +export interface WorkerInit { + /** The validated meta block (extracted host-side). */ + meta: WorkflowMeta + /** The script body with the meta statement blanked (host-side `extractMeta`). */ + body: string + /** The run's `args` value; the workerData structured clone is the copy that isolates the caller. */ + args?: unknown + /** The worker-enforced limits. */ + limits: WorkerLimits +} + +/** What the worker asks the host to start for one `agent()` call (options already validated worker-side). */ +export interface ChildStartRequest { + /** The child's prompt text. */ + prompt: string + /** The structured-output schema, if the call passed one (already subset-checked). */ + schema?: StructuredOutputSchema + /** The per-child model override, if the call passed one. */ + model?: string +} + +/** + * The JSON projection of a child's `SubagentResult` crossing the port. The + * seam's `stopReason` union is merge-extensible, so it degrades to `string` + * on the wire — the runtime only ever branches on `'completed'`. + */ +export interface ChildResult { + /** The child's final assistant output blocks. */ + output: ContentBlock[] + /** The structured value, present iff the request carried a schema AND the provider honored it. */ + structured?: unknown + /** Why the child run ended (`'completed'` is the only value the runtime branches on). */ + stopReason: string +} + +/** + * The worker-side handle for one started child — the RPC mirror of the + * subagent seam's run handle, reduced to what the runtime consumes. + */ +export interface ChildHandle { + /** The child agent's id (minted host-side by the subagent seam). */ + readonly id: string + /** + * Resolves with the child's terminal {@link ChildResult}; REJECTS only when + * the host reports an infrastructure fault (`child-failed`) — a child that + * failed for its own reasons resolves with a non-`completed` stop reason. + */ + readonly result: Promise + /** Ask the host to cancel the child (fire-and-forget). */ + cancel(reason?: string): void + /** Ask the host to dispose the child; resolves on the host's ack. */ + dispose(): Promise +} + +/** + * The worker-side port the runtime starts child agents through — the seam + * that lets the execution core stay ignorant of the thread boundary. + */ +export interface ChildPort { + /** + * Start one child agent on the host (the `agent()` hook's start half). + * @param request - the prompt and validated options. + * @returns the child handle; rejects when the host refuses the start. + */ + startAgent(request: ChildStartRequest): Promise +} diff --git a/packages/workflow/workflow-vm/src/worker.ts b/packages/workflow/workflow-vm/src/worker.ts new file mode 100644 index 0000000000..3b20600d7d --- /dev/null +++ b/packages/workflow/workflow-vm/src/worker.ts @@ -0,0 +1,18 @@ +/** + * The worker-thread entry the engine spawns: bootstrap ./session.ts on the + * real `parentPort`. Deliberately a single statement — every piece of logic + * lives in `runWorkerSession`, which the unit suite drives in-process over a + * `MessageChannel` (code inside a real Worker is invisible to main-process + * coverage); loading this module on the main thread throws via + * `requireParentPort`, which is how the suite covers the file itself. + * + * @module @deepseek-ai/dsh-workflow-vm/worker + */ + +import { parentPort, workerData } from 'node:worker_threads' +import { requireParentPort, runWorkerSession } from './session.ts' +import type { WorkerInit } from './types.ts' + +// workerData is `any` at the node:worker_threads boundary; the engine is the +// only spawner and always provides a WorkerInit. +void runWorkerSession(requireParentPort(parentPort), workerData as WorkerInit) diff --git a/packages/workflow/workflow-vm/tests/built-worker.e2e.ts b/packages/workflow/workflow-vm/tests/built-worker.e2e.ts new file mode 100644 index 0000000000..9bdd3865bb --- /dev/null +++ b/packages/workflow/workflow-vm/tests/built-worker.e2e.ts @@ -0,0 +1,56 @@ +import { existsSync } from 'node:fs' +import { rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const packageRoot = fileURLToPath(new URL('..', import.meta.url)) +const builtIndex = join(packageRoot, 'lib', 'index.js') +const builtWorker = join(packageRoot, 'lib', 'worker.js') +const run = promisify(execFile) + +/** + * The BUILT-output guard for the worker entry: every other suite runs + * unbuilt (src/ + tsx), so nothing else proves that `lib/index.js` resolves + * its sibling `lib/worker.js` and that the bundle boots a worker under plain + * node (no tsx loader). Keyless — a zero-agent script needs no provider — + * and self-skips until `pnpm run build` has produced the bundles. + */ +describe.skipIf(!existsSync(builtIndex) || !existsSync(builtWorker))('built worker entry (lib/worker.js)', () => { + it('the built engine spawns its built worker under plain node and completes a run', async () => { + // ESM resolves bare specifiers from the IMPORTING FILE's location, so the + // driver must live inside the package for its node_modules to apply — a + // temp-named file at the package root, removed on the way out. + const driver = join(packageRoot, `.built-worker-driver-${process.pid}.mjs`) + try { + await writeFile(driver, ` +import { Context } from 'cordis' +import SubagentService from '@deepseek-ai/dsh-subagent' +import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-vm' + +const ctx = new Context() +await ctx.plugin(SubagentService) +await ctx.plugin(WorkerWorkflowEngine, {}) +const run = ctx.workflows.start({ + script: "export const meta = { name: 'built-smoke', description: 'built worker smoke' }\\nreturn 6 * 7", + // A zero-agent script never touches the provider, so a bare id suffices. + parent: { id: 'built-smoke-parent', options: {} }, +}) +const result = await run.result +await run.dispose() +if (result.stopReason !== 'completed' || result.value !== 42) { + console.error('unexpected result: ' + JSON.stringify(result)) + process.exit(1) +} +console.log('built-worker-smoke-ok') +`, 'utf8') + // Plain node — no tsx loader anywhere; the bundle must stand on its own. + const { stdout } = await run(process.execPath, [driver], { cwd: packageRoot, timeout: 60_000 }) + expect(stdout).toContain('built-worker-smoke-ok') + } finally { + await rm(driver, { force: true }) + } + }, 120_000) +}) diff --git a/packages/workflow/workflow-vm/tests/integration.spec.ts b/packages/workflow/workflow-vm/tests/integration.spec.ts index 3572131a8a..e53ea518bc 100644 --- a/packages/workflow/workflow-vm/tests/integration.spec.ts +++ b/packages/workflow/workflow-vm/tests/integration.spec.ts @@ -11,15 +11,17 @@ import SubagentService from '@deepseek-ai/dsh-subagent' import * as spawn from '@deepseek-ai/dsh-subagent-spawn' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import VmWorkflowEngine from '../src/index.ts' +import WorkerWorkflowEngine from '../src/index.ts' type Script = ConstructorParameters[0] /** - * The whole in-process stack, keyless: the vm engine drives the REAL spawn - * backend (with its structured runtime) on a real agent loop; the scripted - * mock MODEL is the only mocked boundary. This is the integration guard the - * per-hook unit tests (which stub the subagent seam) structurally cannot give. + * The whole in-process stack, keyless, with the script in a REAL worker + * thread: the engine drives the REAL spawn backend (with its + * structured runtime) on a real agent loop; the scripted mock MODEL is the + * only mocked boundary. This is the guard the unit suites structurally + * cannot give — the MessageChannel suite fakes the host, and the host suite + * stubs the subagent seam. */ async function setup(script: Script) { const ctx = new Context() @@ -33,7 +35,7 @@ async function setup(script: Script) { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(spawn, { providerName: 'spawn' }) - await ctx.plugin(VmWorkflowEngine, {}) + await ctx.plugin(WorkerWorkflowEngine, {}) ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) return { ctx, parent, adapter } diff --git a/packages/workflow/workflow-vm/tests/session.spec.ts b/packages/workflow/workflow-vm/tests/session.spec.ts new file mode 100644 index 0000000000..00051ad56a --- /dev/null +++ b/packages/workflow/workflow-vm/tests/session.spec.ts @@ -0,0 +1,504 @@ +import { describe, expect, it, vi } from 'vitest' +import { MessageChannel } from 'node:worker_threads' +import type { MessagePort } from 'node:worker_threads' +import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts' +import type { HostToWorkerMessage, WorkerToHostMessage } from '../src/protocol.ts' +import { requireParentPort, runWorkerSession } from '../src/session.ts' +import type { ChildResult, WorkerInit } from '../src/types.ts' + +/** Default limits for in-process sessions (concurrency pinned; auto is machine-derived). */ +function limits(overrides?: Partial): WorkerInit['limits'] { + return { maxConcurrentAgents: 8, maxTotalAgents: 1000, maxItemsPerCall: 4096, syncTimeoutMs: 5000, ...overrides } +} + +/** Wrap a body in the minimal valid meta header (the session receives it pre-extracted). */ +function init(body: string, args?: unknown, limitOverrides?: Partial): WorkerInit { + return { + meta: { name: 'test-flow', description: 'a test workflow' }, + body, + ...args !== undefined ? { args } : {}, + limits: limits(limitOverrides), + } +} + +/** One scripted host over the other end of a MessageChannel. */ +interface FakeHost { + port: MessagePort + messages: WorkerToHostMessage[] + /** Messages of one type, as they arrive. */ + ofType(type: T): Extract[] + send(message: HostToWorkerMessage): void + /** Resolves with the terminal result message. */ + result(): Promise['result']> + close(): void +} + +interface FakeHostOptions { + /** Auto-respond to child-start: reply started + settled per child index. Omit a reply to leave the child pending. */ + reply?: (request: { prompt: string; schema?: unknown; model?: string }, index: number) => ChildResult | undefined + /** Reject the start instead (child-start-error) when returning a string. */ + refuse?: (index: number) => string | undefined + /** Auto-send `go` on `ready` (default true). */ + go?: boolean + /** Manual mode: do NOT auto-answer child-start at all (the test scripts the replies). */ + manual?: boolean +} + +/** + * Drive runWorkerSession IN-PROCESS over a MessageChannel: this is where the + * worker-side files earn their coverage — code inside a real Worker is + * invisible to main-process coverage. The fake host mirrors the real host's + * protocol discipline (one started/start-error per start; settled/disposed + * follow). + */ +function fakeHost(options?: FakeHostOptions): FakeHost { + const channel = new MessageChannel() + const messages: WorkerToHostMessage[] = [] + const resultGate = Promise.withResolvers['result']>() + let childIndex = 0 + channel.port1.on('message', (message: WorkerToHostMessage) => { + messages.push(message) + switch (message.type) { + case WorkerToHostType.Ready: + if (options?.go !== false) channel.port1.postMessage({ type: HostToWorkerType.Go } satisfies HostToWorkerMessage) + break + case WorkerToHostType.ChildStart: { + if (options?.manual) break + const index = childIndex + childIndex += 1 + const refusal = options?.refuse?.(index) + if (refusal !== undefined) { + channel.port1.postMessage( + { type: HostToWorkerType.ChildStartError, callId: message.callId, rendered: refusal } satisfies HostToWorkerMessage, + ) + break + } + channel.port1.postMessage({ type: HostToWorkerType.ChildStarted, callId: message.callId, childId: `child-${index}` } satisfies HostToWorkerMessage) + const reply = options?.reply?.(message.request, index) + if (reply !== undefined) { + channel.port1.postMessage( + { type: HostToWorkerType.ChildSettled, callId: message.callId, result: reply } satisfies HostToWorkerMessage, + ) + } + break + } + case WorkerToHostType.ChildDispose: + channel.port1.postMessage({ type: HostToWorkerType.ChildDisposed, callId: message.callId } satisfies HostToWorkerMessage) + break + case WorkerToHostType.Result: + resultGate.resolve(message.result) + break + default: + break + } + }) + return { + port: channel.port2, + messages, + ofType: type => messages.filter((message): message is never => message.type === type), + send: (message) => { channel.port1.postMessage(message) }, + result: () => resultGate.promise, + close: () => { channel.port1.close() }, + } +} + +/** A completed text child result. */ +function text(reply: string): ChildResult { + return { output: [{ type: 'text', text: reply }], stopReason: 'completed' } +} + +describe('runWorkerSession over an in-process MessageChannel', () => { + it('runs a script end to end: ready/go handshake, phases, log, agents, result', async () => { + const host = fakeHost({ reply: (_request, index) => text(`answer-${index}`) }) + const session = runWorkerSession(host.port, init(` + phase('Scan') + log('starting with ' + args.files.length + ' files') + const answers = await pipeline(args.files, (prev, item) => agent('read ' + item)) + return { answers } + `, { files: ['a.ts', 'b.ts'] })) + const result = await host.result() + await session + expect(result.stopReason).toBe('completed') + expect(result.agentsStarted).toBe(2) + expect(result.value).toEqual({ answers: ['answer-0', 'answer-1'] }) + expect(host.messages[0]!.type).toBe('ready') + expect(host.ofType(WorkerToHostType.Phase).map(m => m.title)).toEqual(['Scan']) + expect(host.ofType(WorkerToHostType.Log).map(m => m.message)).toEqual(['starting with 2 files']) + expect(host.ofType(WorkerToHostType.AgentStart).map(m => m.info.childId)).toEqual(['child-0', 'child-1']) + expect(host.ofType(WorkerToHostType.AgentEnd).every(m => m.info.outcome === 'completed')).toBe(true) + host.close() + }) + + it('agent({schema}) forwards the schema on the start request and returns the structured value', async () => { + const host = fakeHost({ reply: () => ({ output: [], structured: { files: ['x.ts'] }, stopReason: 'completed' }) }) + void runWorkerSession(host.port, init(` + const found = await agent('list files', { schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } } }, model: 'deepseek-v4-pro' }) + return { first: found.files[0] } + `)) + const result = await host.result() + expect(result.value).toEqual({ first: 'x.ts' }) + const start = host.ofType(WorkerToHostType.ChildStart)[0]! + expect(start.request.schema).toEqual({ type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } } }) + expect(start.request.model).toBe('deepseek-v4-pro') + host.close() + }) + + it('a schema child completing WITHOUT a structured value resolves null with a failed outcome', async () => { + const host = fakeHost({ reply: () => text('prose, no structure') }) + void runWorkerSession(host.port, init("return await agent('p', { schema: { type: 'object' } })")) + const result = await host.result() + expect(result.value).toBeNull() + expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('failed') + host.close() + }) + + it('a child settling non-completed resolves null (scripts filter), never throwing into the script', async () => { + const host = fakeHost({ reply: (_request, index) => index === 0 ? { output: [], stopReason: 'error' } : text('ok') }) + void runWorkerSession(host.port, init("return await parallel([() => agent('one'), () => agent('two')])")) + const result = await host.result() + expect(result.value).toEqual([null, 'ok']) + expect(host.ofType(WorkerToHostType.AgentEnd).map(m => m.info.outcome)).toEqual(expect.arrayContaining(['failed', 'completed'])) + host.close() + }) + + it('a start refusal (child-start-error) is a fatal AGENT_START that kills the script through a combinator', async () => { + const host = fakeHost({ refuse: () => 'no provider here' }) + void runWorkerSession(host.port, init("return await pipeline([1], () => agent('p'))")) + const result = await host.result() + expect(result.stopReason).toBe('error') + expect(result.error).toContain('agent() could not start a child') + expect(result.error).toContain('no provider here') + host.close() + }) + + it('a child-failed message (infrastructure rejection) is fatal AGENT_RESULT with the paired failed outcome', async () => { + const host = fakeHost() + void runWorkerSession(host.port, init(` + try { await agent('p'); return 'unreachable' } catch (e) { return { name: e.name, code: e.code, fatal: e.fatal } } + `)) + await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) }) + const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId + host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' }) + host.send({ type: HostToWorkerType.ChildFailed, callId, rendered: 'backend exploded' }) + const result = await host.result() + expect(result.value).toMatchObject({ name: 'WorkflowError', code: 'AGENT_RESULT', fatal: true }) + expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('failed') + host.close() + }) + + it('cancel before go: the body never runs at all and the result is cancelled (a second cancel is a no-op)', async () => { + const host = fakeHost({ go: false }) + const session = runWorkerSession(host.port, init("log('ran')\nreturn 123")) + await vi.waitFor(() => { expect(host.messages.some(m => m.type === WorkerToHostType.Ready)).toBe(true) }) + host.send({ type: HostToWorkerType.Cancel, reason: 'aborted before start' }) + // Idempotence: the first reason wins; a duplicate cancel changes nothing. + host.send({ type: HostToWorkerType.Cancel, reason: 'a later reason that must lose' }) + const result = await host.result() + await session + expect(result.stopReason).toBe('cancelled') + expect(result.error).toContain('aborted before start') + expect(result.error).not.toContain('must lose') + expect(result.value).toBeNull() + expect(host.ofType(WorkerToHostType.Log)).toEqual([]) + host.close() + }) + + it('a script with no return value resolves value: null', async () => { + const host = fakeHost({ reply: () => text('ok') }) + void runWorkerSession(host.port, init("await agent('p')")) + const result = await host.result() + expect(result.stopReason).toBe('completed') + expect(result.value).toBeNull() + host.close() + }) + + it('cancel mid-run: in-flight children get cancel RPCs, hooks throw at entry, the run reports cancelled', async () => { + const host = fakeHost() + void runWorkerSession(host.port, init(` + phase('before') + try { await agent('x') } catch (e) {} + try { phase('after') } catch (e) {} + try { log('after') } catch (e) {} + try { await parallel([() => 'ran']) } catch (e) {} + try { await pipeline(['item'], p => p) } catch (e) {} + return 'survived by catching' + `)) + await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) }) + const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId + host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' }) + host.send({ type: HostToWorkerType.Cancel, reason: 'stop everything' }) + // The real host settles the aborted child; mirror it. + host.send({ type: HostToWorkerType.ChildSettled, callId, result: { output: [], stopReason: 'aborted' } }) + const result = await host.result() + expect(result.stopReason).toBe('cancelled') + expect(result.error).toContain('stop everything') + expect(host.ofType(WorkerToHostType.ChildCancel).map(m => m.callId)).toContain(callId) + expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('cancelled') + // No post-cancel narration left the runtime (the hooks threw at entry). + expect(host.ofType(WorkerToHostType.Phase).map(m => m.title)).toEqual(['before']) + expect(host.ofType(WorkerToHostType.Log)).toEqual([]) + host.close() + }) + + it('cancellation between a queued waiter and its slot: the waiter rejects without a child-start', async () => { + const host = fakeHost({ go: true }) + void runWorkerSession(host.port, init( + "return await parallel([() => agent('a'), () => agent('b')])", + undefined, + { maxConcurrentAgents: 1 }, + )) + await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) }) + host.send({ type: HostToWorkerType.Cancel, reason: 'raced' }) + const result = await host.result() + expect(result.stopReason).toBe('cancelled') + // Only the first agent ever reached the host. + expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) + host.close() + }) + + it('a stray (never-awaited) agent is reaped after settlement: cancel + dispose RPCs flow, no unhandled rejection', async () => { + const unhandled: unknown[] = [] + const onUnhandled = (reason: unknown): void => { unhandled.push(reason) } + process.on('unhandledRejection', onUnhandled) + try { + const host = fakeHost() + void runWorkerSession(host.port, init(` + agent('stray, never awaited') + return 'done without awaiting' + `)) + const result = await host.result() + expect(result.stopReason).toBe('completed') + await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) }) + const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId + host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' }) + host.send({ type: HostToWorkerType.ChildSettled, callId, result: { output: [], stopReason: 'aborted' } }) + await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildDispose).map(m => m.callId)).toContain(callId) }) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(unhandled).toEqual([]) + host.close() + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + + it('an unparseable body settles an error result instead of dying without one (host pre-parse skew guard)', async () => { + const host = fakeHost() + await runWorkerSession(host.port, init('return (((')) + const result = await host.result() + expect(result.stopReason).toBe('error') + expect(result.error).toContain('does not parse') + expect(result.agentsStarted).toBe(0) + host.close() + }) + + it('a synchronous spin in the initial slice dies by the in-worker vm timeout', async () => { + const host = fakeHost() + void runWorkerSession(host.port, init('while (true) {}', undefined, { syncTimeoutMs: 50 })) + const result = await host.result() + expect(result.stopReason).toBe('error') + expect(result.error?.toLowerCase()).toContain('timed out') + host.close() + }) + + it('a non-JSON return value fails loud as RESULT_UNSERIALIZABLE', async () => { + const host = fakeHost() + void runWorkerSession(host.port, init('return { when: new Date(0) }')) + const result = await host.result() + expect(result.stopReason).toBe('error') + expect(result.error).toContain('not plain JSON data') + host.close() + }) + + it('tolerates replies for unknown callIds (a teardown race): nothing crashes, the run completes', async () => { + const host = fakeHost({ reply: () => text('fine') }) + void runWorkerSession(host.port, init("return await agent('p')")) + host.send({ type: HostToWorkerType.ChildStarted, callId: 999, childId: 'ghost' }) + host.send({ type: HostToWorkerType.ChildStartError, callId: 999, rendered: 'ghost' }) + host.send({ type: HostToWorkerType.ChildSettled, callId: 999, result: text('ghost') }) + host.send({ type: HostToWorkerType.ChildFailed, callId: 999, rendered: 'ghost' }) + host.send({ type: HostToWorkerType.ChildDisposed, callId: 999 }) + const result = await host.result() + expect(result.stopReason).toBe('completed') + expect(result.value).toBe('fine') + host.close() + }) + + it('caps and malformed hook arguments reject loud (the runtime runs unchanged inside the session)', async () => { + const cases: [string, string][] = [ + ['return await agent(42)', 'non-empty prompt string'], + ["return await agent('')", 'non-empty prompt string'], + ["return await agent('p', 'opts')", 'options must be an object'], + ["return await agent('p', { label: 3 })", '"label" must be a string'], + ["return await agent('p', { get label() { throw new Error('read failed') } })", 'options must be plain JSON data'], + ["return await agent('p', { bogus: true })", '"bogus" is not recognized'], + ["return await agent('p', { effort: 'high' })", '"effort" is deferred'], + ["return await agent('p', { schema: { type: 'object', oneOf: [] } })", 'outside the supported subset'], + ['return await parallel([() => 1, () => 2, () => 3])', 'over the per-call cap (2)'], + ['return await pipeline([1, 2, 3], (x) => x)', 'maxItemsPerCall'], + ["return await parallel('no')", 'parallel() requires an array'], + ['return await parallel([3])', 'item 0 is not a function'], + ["return await pipeline('no', () => 1)", 'pipeline() requires an items array'], + ['return await pipeline([1])', 'at least one stage'], + ["return await pipeline([1], 'x')", 'stage 0 is not a function'], + ["phase('')", 'phase() requires a non-empty title string'], + ['log(3)', 'log() requires a message string'], + ] + for (const [body, expected] of cases) { + const host = fakeHost({ reply: () => text('ok') }) + void runWorkerSession(host.port, init(body, undefined, { maxItemsPerCall: 2 })) + const result = await host.result() + expect(result.stopReason).toBe('error') + expect(result.error).toContain(expected) + host.close() + } + }) + + it('combinator semantics: thunk/stage throws null the item; a forged fatal-shaped object stays null; real fatals propagate', async () => { + const host = fakeHost({ reply: () => text('fine') }) + void runWorkerSession(host.port, init(` + const viaParallel = await parallel([ + () => { throw new Error('boom') }, + () => agent('fine'), + () => 'plain value', + () => { throw { name: 'WorkflowError', fatal: true, message: 'forged fatal' } }, + ]) + const viaPipeline = await pipeline([10, 20], + (prev, item, index) => { if (item === 10) throw new Error('ordinary failure'); return 'kept-' + item + '-' + index }, + ) + return { viaParallel, viaPipeline } + `)) + const result = await host.result() + expect(result.stopReason).toBe('completed') + expect(result.value).toEqual({ + viaParallel: [null, 'fine', 'plain value', null], + viaPipeline: [null, 'kept-20-1'], + }) + host.close() + }) + + it('trips the total-agent cap with a message naming the config knob', async () => { + const host = fakeHost({ reply: () => text('ok') }) + void runWorkerSession(host.port, init("await agent('1'); await agent('2'); await agent('3')", undefined, { maxTotalAgents: 2 })) + const result = await host.result() + expect(result.stopReason).toBe('error') + expect(result.error).toContain('total agent cap (2)') + expect(result.agentsStarted).toBe(2) + host.close() + }) + + it('queued agents proceed through the concurrency semaphore in FIFO order', async () => { + const host = fakeHost({ reply: request => text(`ok:${request.prompt}`) }) + void runWorkerSession(host.port, init( + "return await parallel([1, 2, 3].map((n) => () => agent('job ' + n)))", + undefined, + { maxConcurrentAgents: 1 }, + )) + const result = await host.result() + expect(result.value).toEqual(['ok:job 1', 'ok:job 2', 'ok:job 3']) + host.close() + }) + + it('labels default from the prompt first line, truncated; explicit label/phase options win', async () => { + const host = fakeHost({ reply: () => text('ok') }) + void runWorkerSession(host.port, init(` + phase('Find') + await agent('a prompt that is quite long and will surely get truncated down to a display label\\n' + + 'with a second line the label must not include') + await agent('short', { label: 'named', phase: 'Custom' }) + return null + `)) + await host.result() + const starts = host.ofType(WorkerToHostType.AgentStart).map(m => m.info) + expect(starts[0]).toMatchObject({ seq: 1, phase: 'Find' }) + expect(starts[0]!.label.length).toBeLessThanOrEqual(48) + expect(starts[0]!.label).not.toContain('second line') + expect(starts[1]).toMatchObject({ seq: 2, label: 'named', phase: 'Custom' }) + host.close() + }) + + it('non-text output blocks are filtered out of the text result', async () => { + const host = fakeHost({ + reply: () => ({ + output: [ + { type: 'text', text: 'first ' }, + { type: 'tool_call', id: 'c1', name: 'x', arguments: {} } as never, + { type: 'text', text: 'second' }, + ], + stopReason: 'completed', + }), + }) + void runWorkerSession(host.port, init("return await agent('p')")) + const result = await host.result() + expect(result.value).toBe('first second') + host.close() + }) + + it('a cancel landing DURING the start round-trip winds the fresh child down (cancel + dispose) and dies cancelled', async () => { + const host = fakeHost({ manual: true }) + void runWorkerSession(host.port, init("return await agent('p')")) + await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) }) + const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId + // Cancel FIRST, then the (stale) started reply: the worker processes them + // in order, so the agent() continuation resumes already-cancelled — the + // window the real host cannot produce (it refuses starts once cancelled) + // but a teardown race can. + host.send({ type: HostToWorkerType.Cancel, reason: 'raced the start' }) + host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' }) + const result = await host.result() + expect(result.stopReason).toBe('cancelled') + await vi.waitFor(() => { + expect(host.ofType(WorkerToHostType.ChildCancel).map(m => m.callId)).toContain(callId) + expect(host.ofType(WorkerToHostType.ChildDispose).map(m => m.callId)).toContain(callId) + }) + // The child never became an agent-start: it was wound down pre-lifecycle. + expect(host.ofType(WorkerToHostType.AgentStart)).toEqual([]) + host.close() + }) + + it('a start refusal arriving after a cancel reads as the cancellation, not a broken seam', async () => { + const host = fakeHost({ manual: true }) + void runWorkerSession(host.port, init(` + try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code } } + `)) + await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) }) + const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId + host.send({ type: HostToWorkerType.Cancel, reason: 'stopping' }) + host.send({ type: HostToWorkerType.ChildStartError, callId, rendered: 'workflow run cancelled: stopping' }) + const result = await host.result() + // The run reports cancelled (the script died of CANCELLED, not AGENT_START). + expect(result.stopReason).toBe('cancelled') + host.close() + }) + + it('a child result rejection while cancelled pairs a cancelled agent-end, and the run reports cancelled', async () => { + const host = fakeHost({ manual: true }) + void runWorkerSession(host.port, init("return await agent('doomed')")) + await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) }) + const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId + host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' }) + await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.AgentStart).length).toBe(1) }) + host.send({ type: HostToWorkerType.Cancel, reason: 'user aborted' }) + host.send({ type: HostToWorkerType.ChildFailed, callId, rendered: 'backend crashed on abort' }) + const result = await host.result() + expect(result.stopReason).toBe('cancelled') + expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('cancelled') + host.close() + }) + +}) + +describe('the worker bootstrap', () => { + it('requireParentPort narrows a real port and throws on the main thread', () => { + const channel = new MessageChannel() + expect(requireParentPort(channel.port1)).toBe(channel.port1) + channel.port1.close() + expect(() => requireParentPort(null)).toThrow(/inside a worker thread/) + }) + + it('the entry module itself throws when loaded on the main thread (no parentPort)', async () => { + // This import EXECUTES ../src/worker.ts on the main thread, which is what + // covers the bootstrap file: requireParentPort throws before + // runWorkerSession is reached. + await expect(import('../src/worker.ts')).rejects.toThrow(/inside a worker thread/) + }) +}) diff --git a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts index bb685f536f..7264877f48 100644 --- a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts +++ b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts @@ -6,14 +6,17 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' -import * as vmEngineModule from '../src/index.ts' -import VmWorkflowEngine, { type Config } from '../src/index.ts' +import * as workerEngineModule from '../src/index.ts' +import WorkerWorkflowEngine, { type Config } from '../src/index.ts' /** A minimal parent stand-in: the engine only threads it through to the provider. */ function fakeParent(): Agent { return { id: AgentId('workflow-parent'), options: {} } as unknown as Agent } +/** The vm-context escape hatch, spelled once: real Worker tests use it to make the WORKER misbehave. */ +const ESCAPE = "globalThis.constructor.constructor('return process')()" + /** One controllable child run: the test (or auto mode) settles it. */ interface ControlledRun { request: SubagentStartRequest @@ -25,13 +28,11 @@ interface ControlledRun { /** * A scripted in-test provider over the REAL SubagentService registry: `auto` * settles each run via the reply function on a microtask; `manual` piles runs - * up in `runs` for the test to settle (concurrency/cancellation tests). A run - * aborts (settles `aborted`) when the request signal fires, like the real - * in-process backends. + * up in `runs` for the test to settle. A run aborts (settles `aborted`) when + * the request signal fires, like the real in-process backends. */ class StubProvider implements SubagentProvider { readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true } - // Context contract: stub children start fresh, mirroring the spawn backend. readonly inheritsParentContext = false readonly runs: ControlledRun[] = [] @@ -64,7 +65,6 @@ class StubProvider implements SubagentProvider { controlled.disposed = true return Promise.resolve() } - // A slow-winding child (quiescence tests): disposal completes late. return new Promise((resolve) => { setTimeout(() => { controlled.disposed = true @@ -99,8 +99,8 @@ async function setup(options?: SetupOptions) { ctx.subagents.registerProvider(provider) // A fixed concurrency ceiling: the auto-resolved default is machine-derived // (cores - 2, floored at 1), so tests that expect N children in flight - // would wedge on small CI runners. Tests about the ceiling override it. - await ctx.plugin(VmWorkflowEngine, { provider: 'stub', maxConcurrentAgents: 8, ...options?.config }) + // would wedge on small CI runners. + await ctx.plugin(WorkerWorkflowEngine, { provider: 'stub', maxConcurrentAgents: 8, ...options?.config }) return { ctx, provider, parent: fakeParent() } } @@ -120,8 +120,8 @@ async function run(ctx: Context, parent: Agent, source: string, args?: unknown): } describe('dsh-workflow-vm', () => { - describe('script execution', () => { - it('runs a script end-to-end: agent() text results, phases, log, args, return value', async () => { + describe('script execution over a real worker thread', () => { + it('runs a script end-to-end: agent() text results, phases, log, args, return value, events', async () => { const { ctx, parent, provider } = await setup({ reply: (_request, index) => text(`answer-${index}`) }) const events: [string, unknown[]][] = [] for (const name of ['workflow/start', 'workflow/phase', 'workflow/log', 'workflow/agent-start', 'workflow/agent-end', 'workflow/end'] as const) { @@ -152,32 +152,12 @@ describe('dsh-workflow-vm', () => { expect('value' in end).toBe(false) }) - it('agent-start/end events carry seq, label (defaulted from the prompt), phase, and outcome', async () => { - const { ctx, parent } = await setup() - const starts: unknown[] = [] - const ends: unknown[] = [] - ctx.on('workflow/agent-start', (_info, agent) => starts.push(agent)) - ctx.on('workflow/agent-end', (_info, agent) => ends.push(agent)) - await run(ctx, parent, script(` - phase('Find') - await agent('a prompt that is quite long and will surely get truncated down to a display label\\n' - + 'with a second line the label must not include') - await agent('short', { label: 'named', phase: 'Custom' }) - return null - `)) - expect(starts[0]).toMatchObject({ seq: 1, phase: 'Find', childId: 'stub-child-0' }) - expect((starts[0] as { label: string }).label.length).toBeLessThanOrEqual(48) - expect((starts[0] as { label: string }).label).not.toContain('second line') - expect(starts[1]).toMatchObject({ seq: 2, label: 'named', phase: 'Custom' }) - expect(ends[0]).toMatchObject({ seq: 1, outcome: 'completed' }) - }) - - it('agent({schema}) forwards outputSchema to the provider and returns the structured value into the realm', async () => { + it('agent({schema, model}) forwards outputSchema and agentOptions to the provider across the thread', async () => { const { ctx, parent, provider } = await setup({ reply: () => ({ output: [], structured: { files: ['x.ts', 'y.ts'] }, stopReason: 'completed' }), }) const result = await run(ctx, parent, script(` - const found = await agent('list files', { schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } }, required: ['files'] } }) + const found = await agent('list files', { model: 'deepseek-v4-pro', schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } }, required: ['files'] } }) return { first: found.files[0], count: found.files.length } `)) expect(result.value).toEqual({ first: 'x.ts', count: 2 }) @@ -186,271 +166,25 @@ describe('dsh-workflow-vm', () => { properties: { files: { type: 'array', items: { type: 'string' } } }, required: ['files'], }) - }) - - it('model option maps to agentOptions.model on the start request', async () => { - const { ctx, parent, provider } = await setup() - await run(ctx, parent, script("return await agent('p', { model: 'deepseek-v4-pro' })")) expect(provider.runs[0]!.request.agentOptions).toEqual({ model: 'deepseek-v4-pro' }) + expect(provider.runs[0]!.request.parent).toBeDefined() }) - it('a failed child resolves null (scripts filter), never throwing into the script', async () => { - const { ctx, parent } = await setup({ - reply: (_request, index) => index === 0 ? { output: [], stopReason: 'error' } : text('ok'), - }) - const result = await run(ctx, parent, script(` - const results = await parallel([() => agent('one'), () => agent('two')]) - return results - `)) - expect(result.value).toEqual([null, 'ok']) - }) - - it('a schema run that completes WITHOUT a structured value is a child failure (null + failed outcome)', async () => { - const { ctx, parent } = await setup({ reply: () => text('prose, no structure') }) - const ends: unknown[] = [] - ctx.on('workflow/agent-end', (_info, agent) => ends.push(agent)) - const result = await run(ctx, parent, script(` - return await agent('p', { schema: { type: 'object' } }) - `)) - expect(result.value).toBeNull() - expect(ends[0]).toMatchObject({ outcome: 'failed' }) - }) - - it('a script with no return value resolves value: null', async () => { + it('a fatal hook error inside the worker kills the script and reports the error', async () => { const { ctx, parent } = await setup() - const result = await run(ctx, parent, script("await agent('p')")) - expect(result.stopReason).toBe('completed') - expect(result.value).toBeNull() - }) - - it('a returned promise/thenable resolves per async-JS semantics before materialization', async () => { - const { ctx, parent } = await setup() - // Load-bearing ergonomics: forgetting await on the final hook call works. - expect((await run(ctx, parent, script("return agent('x')"))).value).toBe('stub reply') - // A hand-built thenable is assimilated by the async return — the - // RESOLUTION is the script's return value (standard JavaScript), and the - // realm-boundary guard applies to that resolution, not the thenable. - expect((await run(ctx, parent, script('return { value: 1, then(resolve) { resolve({ ok: true }) } }'))).value).toEqual({ ok: true }) - const nonJson = await run(ctx, parent, script('return { then(resolve) { resolve({ bad: new Date(0) }) } }')) - expect(nonJson.stopReason).toBe('error') - expect(nonJson.error).toContain('not plain JSON data') - }) - }) - - describe('combinator semantics', () => { - it('pipeline has NO cross-stage barrier: a fast item finishes stage 2 while a slow item holds stage 1', async () => { - const { ctx, parent, provider } = await setup({ manual: true }) - const handle = ctx.workflows.start({ - script: script(` - const out = await pipeline(['slow', 'fast'], - (prev, item) => agent('s1 ' + item), - (prev, item) => agent('s2 ' + item + ' after ' + prev), - ) - return out - `), - parent: fakeParent(), - }) - // Both items enter stage 1 concurrently. - await vi.waitFor(() => { expect(provider.runs.length).toBe(2) }) - // Settle only the FAST item's stage 1 → its stage 2 starts with no barrier. - provider.runs[1]!.settle(text('fast-1')) - await vi.waitFor(() => { expect(provider.runs.length).toBe(3) }) - expect((provider.runs[2]!.request.prompt[0] as { text: string }).text).toBe('s2 fast after fast-1') - // The slow item is still sitting in stage 1. - provider.runs[2]!.settle(text('fast-2')) - provider.runs[0]!.settle(text('slow-1')) - await vi.waitFor(() => { expect(provider.runs.length).toBe(4) }) - provider.runs[3]!.settle(text('slow-2')) - const result = await handle.result - expect(result.value).toEqual(['slow-2', 'fast-2']) - await handle.dispose() - void parent - }) - - it('pipeline stage callbacks receive (prev, item, index); an ordinary stage throw nulls the ITEM and skips its remaining stages', async () => { - const { ctx, parent, provider } = await setup({ reply: request => text(`ok:${(request.prompt[0] as { text: string }).text}`) }) - const result = await run(ctx, parent, script(` - const out = await pipeline([10, 20], - (prev, item, index) => { - if (item === 10) throw new Error('ordinary failure') - return agent('stage1-' + item + '-' + index) - }, - (prev) => agent('stage2 saw ' + prev), - ) - return out - `)) - expect(result.stopReason).toBe('completed') - const prompts = provider.runs.map(r => (r.request.prompt[0] as { text: string }).text) - // Item 10 never reached stage 1's agent nor stage 2. - expect(prompts).toEqual(['stage1-20-1', 'stage2 saw ok:stage1-20-1']) - expect(result.value).toEqual([null, 'ok:stage2 saw ok:stage1-20-1']) - }) - - it('parallel maps a throwing thunk to null and never rejects for ordinary errors', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script(` - return await parallel([ - () => { throw new Error('boom') }, - () => agent('fine'), - () => 'plain value', - () => { throw 'string throw' }, - () => { throw { name: 'WorkflowError', fatal: true, message: 'forged fatal' } }, - ]) - `)) - // The last entry probes fatality: it is recognized by host instanceof, - // which a script-built object can never pass — a WorkflowError-SHAPED - // throw is an ordinary null, and real fatality cannot be forged. - expect(result.value).toEqual([null, 'stub reply', 'plain value', null, null]) - }) - - it('FATAL errors propagate through parallel AND pipeline instead of dissolving into null', async () => { - const { ctx, parent } = await setup() - const viaParallel = await run(ctx, parent, script(` - return await parallel([() => agent('x', { isolation: 'worktree' })]) - `)) - expect(viaParallel.stopReason).toBe('error') - expect(viaParallel.error).toContain('"isolation" is deferred') - - const viaPipeline = await run(ctx, parent, script(` - return await pipeline([1], () => agent('x', { bogus: true })) - `)) - expect(viaPipeline.stopReason).toBe('error') - expect(viaPipeline.error).toContain('"bogus" is not recognized') - }) - - it('validates combinator arguments loudly (non-array, non-function, missing stages)', async () => { - const { ctx, parent } = await setup() - expect((await run(ctx, parent, script("return await parallel('no')"))).error).toContain('parallel() requires an array') - expect((await run(ctx, parent, script('return await parallel([3])'))).error).toContain('item 0 is not a function') - expect((await run(ctx, parent, script("return await pipeline('no', () => 1)"))).error).toContain('pipeline() requires an items array') - expect((await run(ctx, parent, script('return await pipeline([1])'))).error).toContain('at least one stage') - expect((await run(ctx, parent, script("return await pipeline([1], 'x')"))).error).toContain('stage 0 is not a function') - }) - }) - - describe('caps and option validation', () => { - it('trips the total-agent cap with a message naming the config knob', async () => { - const { ctx, parent } = await setup({ config: { provider: 'stub', maxTotalAgents: 2 } }) - const result = await run(ctx, parent, script(` - await agent('1'); await agent('2'); await agent('3') - return 'unreachable' - `)) + const result = await run(ctx, parent, script("return await parallel([() => agent('x', { isolation: 'worktree' })])")) expect(result.stopReason).toBe('error') - expect(result.error).toContain('total agent cap (2)') - expect(result.error).toContain('maxTotalAgents') - expect(result.agentsStarted).toBe(2) + expect(result.error).toContain('"isolation" is deferred') }) - it('trips the per-call item cap for parallel and pipeline', async () => { - const { ctx, parent } = await setup({ config: { provider: 'stub', maxItemsPerCall: 2 } }) - expect((await run(ctx, parent, script('return await parallel([() => 1, () => 2, () => 3])'))).error) - .toContain('over the per-call cap (2)') - expect((await run(ctx, parent, script('return await pipeline([1, 2, 3], (x) => x)'))).error) - .toContain('maxItemsPerCall') - }) - - it('enforces the concurrency ceiling: never more than maxConcurrentAgents children in flight', async () => { - const { ctx, parent, provider } = await setup({ manual: true, config: { provider: 'stub', maxConcurrentAgents: 2 } }) - const handle = ctx.workflows.start({ - script: script("return await parallel([1, 2, 3, 4, 5].map((n) => () => agent('job ' + n)))"), - parent, - }) - // Only 2 children may exist until one settles. - await vi.waitFor(() => { expect(provider.runs.length).toBe(2) }) - await new Promise(resolve => setTimeout(resolve, 20)) - expect(provider.runs.length).toBe(2) - // Settle children in arrival order; after each settle at most ONE more - // child may enter — the window never exceeds the ceiling. - for (let index = 0; index < 5; index++) { - await vi.waitFor(() => { expect(provider.runs.length).toBeGreaterThan(index) }) - expect(provider.runs.length).toBeLessThanOrEqual(Math.min(index + 2, 5)) - provider.runs[index]!.settle(text(`r${index}`)) - } - const result = await handle.result - expect(result.stopReason).toBe('completed') - expect(result.agentsStarted).toBe(5) - expect(result.value).toEqual(['r0', 'r1', 'r2', 'r3', 'r4']) - await handle.dispose() - }) - - it('rejects malformed agent() arguments and option types loudly', async () => { - const { ctx, parent } = await setup() - expect((await run(ctx, parent, script('return await agent(42)'))).error).toContain('non-empty prompt string') - expect((await run(ctx, parent, script("return await agent('')"))).error).toContain('non-empty prompt string') - expect((await run(ctx, parent, script("return await agent('p', 'opts')"))).error).toContain('options must be an object') - expect((await run(ctx, parent, script("return await agent('p', { label: 3 })"))).error).toContain('"label" must be a string') - expect((await run(ctx, parent, script("return await agent('p', { effort: 'high' })"))).error).toContain('"effort" is deferred') - }) - - it('rejects options whose property reads throw (materialization is loud, not silent)', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script("return await agent('p', { get label() { throw new Error('read failed') } })")) - expect(result.stopReason).toBe('error') - expect(result.error).toContain('options must be plain JSON data') - expect(result.error).toContain('read failed') - }) - - it('validates phase() and log() arguments loudly', async () => { - const { ctx, parent } = await setup() - expect((await run(ctx, parent, script('phase(3)'))).error).toContain('phase() requires a non-empty title string') - expect((await run(ctx, parent, script("phase('')"))).error).toContain('phase() requires a non-empty title string') - expect((await run(ctx, parent, script('log(3)'))).error).toContain('log() requires a message string') - }) - - it('rejects an unsupported schema via the shared subset assertion (UNSUPPORTED_SCHEMA)', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script("return await agent('p', { schema: { type: 'object', oneOf: [] } })")) - expect(result.stopReason).toBe('error') - expect(result.error).toContain('outside the supported subset') - expect(result.error).toContain('oneOf') - }) - - it('wraps a provider start failure as a fatal AGENT_START error (a missing provider cannot dissolve into null)', async () => { + it('a provider start failure crosses back as a fatal AGENT_START error', async () => { const { ctx, parent } = await setup({ config: { provider: 'nonexistent' } }) const result = await run(ctx, parent, script("return await pipeline([1], () => agent('p'))")) expect(result.stopReason).toBe('error') - expect(result.error).toContain('could not start a child on provider "nonexistent"') - }) - }) - - describe('the value boundary', () => { - it('args are cloned at start: a script scribbling on them cannot mutate the caller\'s object', async () => { - const { ctx, parent } = await setup() - const hostArgs = { files: ['a.ts'], nested: { deep: [1, 2] } } - const result = await run(ctx, parent, script(` - args.files.push('b.ts') - return { count: args.files.length, deep: args.nested.deep[1] } - `), hostArgs) - expect(result.value).toEqual({ count: 2, deep: 2 }) - // The caller's object is untouched (the engine cloned args host-side). - expect(hostArgs.files).toEqual(['a.ts']) + expect(result.error).toContain('agent() could not start a child') }) - it('scalar/null args pass through directly; absent args leave the global undefined', async () => { - const { ctx, parent } = await setup() - expect((await run(ctx, parent, script('return args * 2'), 21)).value).toBe(42) - expect((await run(ctx, parent, script('return args === null'), null)).value).toBe(true) - expect((await run(ctx, parent, script('return typeof args'))).value).toBe('undefined') - }) - - it('hook failures reach the script as HOST WorkflowErrors: fields readable, in-realm instanceof Error is false', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script(` - try { - await agent('p', { bogus: true }) - return 'unreachable' - } catch (e) { - // The documented consequence of the trust premise: hook errors are - // host objects, so realm instanceof is false — read the fields. - return { isRealmError: e instanceof Error, name: e.name, code: e.code, fatal: e.fatal, message: e.message } - } - `)) - expect(result.stopReason).toBe('completed') - expect(result.value).toMatchObject({ isRealmError: false, name: 'WorkflowError', code: 'UNSUPPORTED_OPTION', fatal: true }) - expect((result.value as { message: string }).message).toContain('"bogus" is not recognized') - }) - - it('a rejecting provider result is an infrastructure fault: fatal AGENT_RESULT, agent-end paired, no combinator dissolve', async () => { + it('a child result REJECTION crosses back as a fatal AGENT_RESULT error (a broken provider is not a failed child)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) const provider: SubagentProvider = { @@ -465,184 +199,90 @@ describe('dsh-workflow-vm', () => { }), } ctx.subagents.registerProvider(provider) - await ctx.plugin(VmWorkflowEngine, { provider: 'rejecting' }) - const ends: unknown[] = [] - ctx.on('workflow/agent-end', (_info, agent) => { ends.push(agent) }) - // Direct await: the script reads the typed fields (a host object, so - // realm instanceof is false — same as every hook failure). - const direct = await run(ctx, fakeParent(), script(` + await ctx.plugin(WorkerWorkflowEngine, { provider: 'rejecting', maxConcurrentAgents: 2 }) + const result = await run(ctx, fakeParent(), script(` try { await agent('p'); return 'unreachable' } catch (e) { return { name: e.name, code: e.code, fatal: e.fatal, message: e.message } } `)) - expect(direct.value).toMatchObject({ name: 'WorkflowError', code: 'AGENT_RESULT', fatal: true }) - expect((direct.value as { message: string }).message).toContain('backend exploded') - // The child's lifecycle stays paired even though result never resolved. - expect(ends).toEqual([expect.objectContaining({ seq: 1, outcome: 'failed' })]) - // Through a combinator the fault PROPAGATES (fatal) — a broken provider - // must not dissolve into the per-item null and read as a failed child. - const throughParallel = await run(ctx, fakeParent(), script("return await parallel([() => agent('p')])")) - expect(throughParallel.stopReason).toBe('error') - expect(throughParallel.error).toContain('backend exploded') + expect(result.value).toMatchObject({ name: 'WorkflowError', code: 'AGENT_RESULT', fatal: true }) + expect((result.value as { message: string }).message).toContain('backend exploded') }) - it('phase()/log() throw host WorkflowErrors synchronously on misuse', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script(` - try { phase(3) } catch (e) { - if (e.name !== 'WorkflowError') throw e - } - try { log(3) } catch (e) { - return { name: e.name, message: e.message } - } - `)) - expect(result.value).toMatchObject({ name: 'WorkflowError' }) - expect((result.value as { message: string }).message).toContain('log() requires') + it('a child whose dispose() rejects cannot wedge the script (the host acks anyway)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const provider: SubagentProvider = { + name: 'bad-dispose', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true }, + inheritsParentContext: false, + start: () => ({ + id: AgentId('bad-dispose-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), + cancel: () => { /* settled already */ }, + dispose: () => Promise.reject(new Error('dispose exploded')), + }), + } + ctx.subagents.registerProvider(provider) + await ctx.plugin(WorkerWorkflowEngine, { provider: 'bad-dispose', maxConcurrentAgents: 2 }) + const result = await run(ctx, fakeParent(), script("return await agent('p')")) + expect(result.stopReason).toBe('completed') + expect(result.value).toBe('fine') }) - it('a returned value whose property reads throw fails loud as RESULT_UNSERIALIZABLE', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script(` - return { get a() { throw new Error('read failed') } } - `)) - expect(result.stopReason).toBe('error') - expect(result.error).toContain('not plain JSON data') - expect(result.error).toContain('read failed') - }) - - it('a non-JSON return value fails loud as RESULT_UNSERIALIZABLE', async () => { - const { ctx, parent } = await setup() - const withDate = await run(ctx, parent, script('return { when: new Date(0) }')) - expect(withDate.stopReason).toBe('error') - expect(withDate.error).toContain('not plain JSON data') - const withFn = await run(ctx, parent, script('return { fn: () => 1 }')) - expect(withFn.error).toContain('not plain JSON data') - }) - - it('kills a synchronous spin in the initial slice via the vm timeout', async () => { - const { ctx, parent } = await setup({ config: { provider: 'stub', syncTimeoutMs: 50 } }) - const result = await run(ctx, parent, script('while (true) {}')) - expect(result.stopReason).toBe('error') - expect(result.error?.toLowerCase()).toContain('timed out') + it('a child dispose() rejecting an UNRENDERABLE value still acks — the containment warn is total', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const provider: SubagentProvider = { + name: 'coercion-trap-dispose', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true }, + inheritsParentContext: false, + start: () => ({ + id: AgentId('trap-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), + cancel: () => { /* settled already */ }, + // The rejection VALUE's own coercion throws: a warn built with bare + // String(error) would itself throw, skipping the ChildDisposed ack + // and wedging the script's finally until the grace/terminate path. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- the non-Error rejection IS the scenario under test + dispose: () => Promise.reject({ toString: () => { throw new Error('coercion trap') } }), + }), + } + ctx.subagents.registerProvider(provider) + await ctx.plugin(WorkerWorkflowEngine, { provider: 'coercion-trap-dispose', maxConcurrentAgents: 2 }) + const result = await run(ctx, fakeParent(), script("return await agent('p')")) + expect(result.stopReason).toBe('completed') + expect(result.value).toBe('fine') }) }) - describe('lifecycle: parse errors, cancellation, disposal', () => { - it('start() throws synchronously for an unparseable script or invalid meta', async () => { + describe('lifecycle: parse errors, cancellation, termination, disposal', () => { + it('start() throws synchronously for an unparseable script or invalid meta (host-side pre-parse)', async () => { const { ctx, parent } = await setup() expect(() => ctx.workflows.start({ script: 'const x = 1', parent })).toThrow(/must begin with/) expect(() => ctx.workflows.start({ script: script('return ((('), parent })).toThrow(/does not parse/) }) - it('cancel() aborts in-flight children and settles the run cancelled', async () => { + it('cancel() aborts in-flight children (signal AND cancel RPC) and settles the run cancelled', async () => { const { ctx, parent, provider } = await setup({ manual: true }) - const ends: WorkflowResultInfo[] = [] - ctx.on('workflow/end', (_info, result) => { ends.push(result) }) + const ends: unknown[] = [] + ctx.on('workflow/agent-end', (_info, agent) => { ends.push(agent) }) + const runEnds: WorkflowResultInfo[] = [] + ctx.on('workflow/end', (_info, result) => { runEnds.push(result) }) const handle = ctx.workflows.start({ script: script("return await agent('long job')"), parent }) await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) handle.cancel('user stopped it') const result = await handle.result expect(result.stopReason).toBe('cancelled') expect(result.error).toContain('user stopped it') + await handle.dispose() expect(provider.runs[0]!.disposed).toBe(true) + expect(ends).toEqual([expect.objectContaining({ seq: 1, outcome: 'cancelled' })]) // workflow/end is an observer's only death signal: it fires for a // cancelled run too, mirroring the settled outcome data. - expect(ends).toEqual([{ stopReason: 'cancelled', error: result.error, agentsStarted: 1 }]) - await handle.dispose() + expect(runEnds).toEqual([{ stopReason: 'cancelled', error: result.error, agentsStarted: result.agentsStarted }]) }) - it('cancellation bridges to run.cancel() on every in-flight child, not just the request signal', async () => { - const { ctx, parent, provider } = await setup({ manual: true }) - const handle = ctx.workflows.start({ - script: script("return await parallel([() => agent('a'), () => agent('b')])"), - parent, - }) - await vi.waitFor(() => { expect(provider.runs.length).toBe(2) }) - handle.cancel('bridged') - expect((await handle.result).stopReason).toBe('cancelled') - // The seam leaves a provider free to honor run.cancel() rather than the - // request signal, so the engine must drive BOTH channels per child. - expect(provider.runs.map(r => r.cancelled)).toEqual(['bridged', 'bridged']) - await handle.dispose() - }) - - it('a provider whose result REJECTS on abort still gets a paired cancelled agent-end, and the run reports cancelled', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - // The seam allows result to reject for infrastructure faults; a backend - // that tears down uncleanly on abort exercises the rejection path WHILE - // the run is cancelled — which must stay a cancellation, not AGENT_RESULT. - const provider: SubagentProvider = { - name: 'reject-on-abort', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true }, - inheritsParentContext: false, - start: request => ({ - id: AgentId('crashing-child'), - result: new Promise((_, reject) => { - request.signal?.addEventListener('abort', () => { reject(new Error('backend crashed on abort')) }, { once: true }) - }), - cancel: () => { /* the signal listener above is the teardown */ }, - dispose: () => Promise.resolve(), - }), - } - ctx.subagents.registerProvider(provider) - await ctx.plugin(VmWorkflowEngine, { provider: 'reject-on-abort' }) - const starts: unknown[] = [] - const ends: unknown[] = [] - ctx.on('workflow/agent-start', (_info, agent) => { starts.push(agent) }) - ctx.on('workflow/agent-end', (_info, agent) => { ends.push(agent) }) - const handle = ctx.workflows.start({ script: script("return await agent('doomed')"), parent: fakeParent() }) - await vi.waitFor(() => { expect(starts.length).toBe(1) }) - handle.cancel('user aborted') - const result = await handle.result - expect(result.stopReason).toBe('cancelled') - expect(result.error).toContain('user aborted') - expect(ends).toEqual([expect.objectContaining({ seq: 1, outcome: 'cancelled' })]) - await handle.dispose() - }) - - it('after cancellation EVERY hook throws at entry — phase/log/parallel/pipeline, not just agent()', async () => { - const { ctx, parent, provider } = await setup({ manual: true }) - let cancelled = false - const postCancel: string[] = [] - ctx.on('workflow/phase', (_info, title) => { if (cancelled) postCancel.push(`phase:${title}`) }) - ctx.on('workflow/log', (_info, message) => { if (cancelled) postCancel.push(`log:${message}`) }) - const handle = ctx.workflows.start({ - // The script survives each throw by catching, so every guarded hook is - // actually ATTEMPTED after the cancel; the run still reports cancelled. - script: script(` - phase('before') - try { await agent('x') } catch (e) {} - try { phase('after') } catch (e) {} - try { log('after') } catch (e) {} - try { await parallel([() => 'ran']) } catch (e) {} - try { await pipeline(['item'], p => p) } catch (e) {} - return 'survived by catching' - `), - parent, - }) - await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) - cancelled = true - handle.cancel('stop everything') - const result = await handle.result - expect(result.stopReason).toBe('cancelled') - // No post-cancel progress ever reached observers, and no child started. - expect(postCancel).toEqual([]) - expect(provider.runs.length).toBe(1) - await handle.dispose() - }) - - it('an already-aborted request signal cancels before any child starts', async () => { - const { ctx, parent, provider } = await setup({ manual: true }) - const controller = new AbortController() - controller.abort() - const handle = ctx.workflows.start({ script: script("return await agent('never')"), parent, signal: controller.signal }) - const result = await handle.result - expect(result.stopReason).toBe('cancelled') - expect(provider.runs.length).toBe(0) - await handle.dispose() - }) - - it('an already-aborted signal cancels a HOOK-FREE script: the body never runs at all', async () => { - const { ctx, parent } = await setup() + it('an already-aborted request signal cancels before the body ever runs (the go handshake holds it)', async () => { + const { ctx, parent, provider } = await setup() const controller = new AbortController() controller.abort() const logs: string[] = [] @@ -652,151 +292,80 @@ describe('dsh-workflow-vm', () => { expect(result.stopReason).toBe('cancelled') expect(result.value).toBeNull() expect(logs).toEqual([]) + expect(provider.runs.length).toBe(0) await handle.dispose() }) - it('cancel() right after start() reports cancelled even when the script needed no hooks', async () => { - const { ctx, parent } = await setup() - const handle = ctx.workflows.start({ script: script('return 123'), parent }) - handle.cancel('changed my mind') - const result = await handle.result - expect(result.stopReason).toBe('cancelled') - expect(result.value).toBeNull() - expect(result.error).toContain('changed my mind') - await handle.dispose() - }) - - it('an agent() call AFTER a mid-run cancel rejects at entry — no child ever starts', async () => { + it('cancel() right after start() cancels before the body runs; the signal aborting mid-run cancels like cancel()', async () => { const { ctx, parent, provider } = await setup({ manual: true }) - const handle = ctx.workflows.start({ - script: script(` - await agent('first') - return await agent('second') - `), - parent, - }) - await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) - // Same synchronous block: the first child settles completed, then the - // cancel lands BEFORE the script's continuation can call agent() again. - provider.runs[0]!.settle(text('first done')) - handle.cancel('mid-run') - const result = await handle.result - expect(result.stopReason).toBe('cancelled') - expect(provider.runs.length).toBe(1) - await handle.dispose() - }) + const first = ctx.workflows.start({ script: script("return await agent('never')"), parent }) + // No-reason cancel: the canonical default reason must ride the result. + first.cancel() + const firstResult = await first.result + expect(firstResult.stopReason).toBe('cancelled') + expect(firstResult.error).toContain('workflow cancelled') + expect(provider.runs.length).toBe(0) + await first.dispose() - it('the signal aborting mid-run cancels like cancel()', async () => { - const { ctx, parent, provider } = await setup({ manual: true }) const controller = new AbortController() - const handle = ctx.workflows.start({ script: script("return await agent('job')"), parent, signal: controller.signal }) + const second = ctx.workflows.start({ script: script("return await agent('job')"), parent, signal: controller.signal }) await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) controller.abort() - const result = await handle.result - expect(result.stopReason).toBe('cancelled') - await handle.dispose() + expect((await second.result).stopReason).toBe('cancelled') + await second.dispose() }) - it('reports a non-Error script throw (a thrown string) faithfully', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script("throw 'plain string failure'")) - expect(result.stopReason).toBe('error') - expect(result.error).toContain('plain string failure') - }) - - it('a script Error surfaces its stack, carrying the script line numbers (lineOffset)', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script("throw new Error('with stack')")) - expect(result.stopReason).toBe('error') - // Line 1 is the blanked meta statement; the throw sits on line 2. - expect(result.error).toContain('workflow:test-flow:2') - }) - - it('an object throw with neither stack nor message stringifies', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script('throw { code: 42 }')) - expect(result.stopReason).toBe('error') - expect(result.error).toBe('[object Object]') - }) - - it('falls back to the message for an Error whose stack was stripped', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script(` - const e = new Error('stackless failure') - e.stack = undefined - throw e - `)) - expect(result.stopReason).toBe('error') - expect(result.error).toBe('stackless failure') - }) - - it('cancel() in the same frame as start(): the awaited slot tick cannot start a child', async () => { + it('a child-start racing the host cancel is refused: no child starts after cancellation', async () => { const { ctx, parent, provider } = await setup({ manual: true }) - // agent() enters during start()'s synchronous slice and suspends on the - // acquireSlot await (one microtask tick even with a free slot); the - // synchronous cancel below lands in that tick. Without the post-acquire - // re-check the continuation would start a child carrying an ALREADY- - // aborted signal — which the stub provider (subscribing only to future - // abort events, like a real backend) would never settle, leaking it. - const handle = ctx.workflows.start({ script: script("return await agent('never')"), parent }) - handle.cancel('immediately after start') + // Cancel from INSIDE the log listener: the worker has already posted + // its child-start (queued right behind the log message), so the host + // processes it with cancelReason set — the refusal arm no real-world + // timing can hit reliably. (The closure runs only after `handle` below + // is initialized — the listener fires on the worker's first message.) + ctx.on('workflow/log', () => { handle.cancel('cancelled from the log listener') }) + const handle = ctx.workflows.start({ script: script("log('mark')\nreturn await agent('late')"), parent }) const result = await handle.result expect(result.stopReason).toBe('cancelled') expect(provider.runs.length).toBe(0) await handle.dispose() }) - it('a waiter resumed by a release RACING a cancel still dies at the post-acquire check', async () => { - const { ctx, parent, provider } = await setup({ manual: true, config: { provider: 'stub', maxConcurrentAgents: 1 } }) + it('post-cancel narration is suppressed host-side, and completion racing a cancel reports cancelled', async () => { + const { ctx, parent } = await setup() + const narration: string[] = [] + ctx.on('workflow/log', (_info, message) => { narration.push(message) }) + ctx.on('workflow/phase', (_info, title) => { narration.push(`phase:${title}`) }) const handle = ctx.workflows.start({ - script: script("return await parallel([() => agent('a'), () => agent('b')])"), + // The sync spin keeps the worker's loop busy so the cancel message + // cannot be processed before the script settles `completed` — the + // worker posts a completed result that must LOSE to the in-flight + // host cancellation. The trailing narration exercises host-side + // suppression: posted pre-cancel-processing worker-side, arriving + // post-cancel host-side. + script: script(` + log('started') + const end = Date.now() + 1000 + while (Date.now() < end) {} + phase('late phase') + log('late log') + return 'done' + `), parent, }) - await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) - // Same synchronous block: b is still a QUEUED waiter when the cancel - // lands, so cancel() rejects it outright; together with the immediate- - // cancel test above (the resumed-waiter tick), no post-cancel path can - // reach subagents.start. - provider.runs[0]!.settle(text('a-done')) - handle.cancel('raced') + await vi.waitFor(() => { expect(narration).toContain('started') }) + handle.cancel('raced the completion') const result = await handle.result expect(result.stopReason).toBe('cancelled') - expect(provider.runs.length).toBe(1) + expect(result.error).toContain('raced the completion') + expect(narration).toEqual(['started']) await handle.dispose() - }) + }, 15_000) - it('a dropped agent() promise cannot become an unhandled rejection when cancellation lands', async () => { - const unhandled: unknown[] = [] - const onUnhandled = (reason: unknown): void => { unhandled.push(reason) } - process.on('unhandledRejection', onUnhandled) - try { - const { ctx, parent, provider } = await setup({ manual: true }) - const handle = ctx.workflows.start({ - script: script(` - agent('dropped, never awaited') - return await agent('awaited') - `), - parent, - }) - await vi.waitFor(() => { expect(provider.runs.length).toBe(2) }) - handle.cancel() - await handle.result - await handle.dispose() - // Let any stray rejection reach the process hook before asserting. - await new Promise(resolve => setTimeout(resolve, 20)) - expect(unhandled).toEqual([]) - } finally { - process.off('unhandledRejection', onUnhandled) - } - }) - - it('cancel() force-settles the result of a script parked on a promise no hook owns', async () => { - const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } }) - const ends: WorkflowResultInfo[] = [] - ctx.on('workflow/end', (_info, result) => { ends.push(result) }) + it('cancel() force-settles a script parked on a promise no hook owns, and TERMINATES its worker', async () => { + const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 50 } }) + const runEnds: WorkflowResultInfo[] = [] + ctx.on('workflow/end', (_info, result) => { runEnds.push(result) }) const handle = ctx.workflows.start({ - // No hooks involved: an unsettleable await cancellation cannot reject - // — the abandon grace is the only thing that can settle this run. script: script("await new Promise(() => {})\nreturn 'unreachable'"), parent, }) @@ -805,31 +374,20 @@ describe('dsh-workflow-vm', () => { expect(result.stopReason).toBe('cancelled') expect(result.error).toContain('user aborted') // The grace force-settle fires workflow/end exactly like an ordinary - // settlement — an abandoned script's death still reaches observers. - expect(ends).toEqual([{ stopReason: 'cancelled', error: result.error, agentsStarted: 0 }]) + // settlement — a terminated script's death still reaches observers. + expect(runEnds).toEqual([{ stopReason: 'cancelled', error: result.error, agentsStarted: 0 }]) await handle.dispose() }) - it('a never-settling returned thenable is abandoned the same way', async () => { - const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } }) - const handle = ctx.workflows.start({ script: script('return { then() {} }'), parent }) - handle.cancel() - expect((await handle.result).stopReason).toBe('cancelled') - await handle.dispose() - }) - - it('dispose() abandons a stuck script after the grace instead of hanging (result settles cancelled)', async () => { - const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } }) + it('dispose() on a stuck script returns within the grace instead of hanging (result settles cancelled)', async () => { + const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 50 } }) const handle = ctx.workflows.start({ script: script("await new Promise(() => {})\nreturn 'unreachable'"), parent, }) const before = Date.now() await handle.dispose() - expect(Date.now() - before).toBeLessThan(1000) - // The abandon that freed dispose() also settled result — a consumer - // still awaiting it (the tool does, before its disposing finally) is - // released rather than wedged forever. + expect(Date.now() - before).toBeLessThan(2000) const result = await handle.result expect(result.stopReason).toBe('cancelled') }) @@ -842,25 +400,28 @@ describe('dsh-workflow-vm', () => { await handle.dispose() }) - it('strays: children fired without await are aborted once the script settles', async () => { - const { ctx, parent, provider } = await setup({ manual: true }) - const handle = ctx.workflows.start({ - script: script(` - agent('stray') - return 'done without awaiting' - `), - parent, - }) - const result = await handle.result - expect(result.stopReason).toBe('completed') - await vi.waitFor(() => { - expect(provider.runs.length).toBe(1) - expect(provider.runs[0]!.disposed).toBe(true) - }) - await handle.dispose() + it('a settled run arms NO grace timer: disposing a completed run must not pin it for disposeGraceMs', async () => { + // A distinctive grace so the spy can tell the cancel-path grace timer + // apart from every other timeout in flight. + const GRACE = 44_444 + const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: GRACE } }) + const handle = ctx.workflows.start({ script: script('return 1'), parent }) + await handle.result + const spy = vi.spyOn(globalThis, 'setTimeout') + try { + await handle.dispose() + // dispose()'s own bounded-wait sleep is the ONLY grace-sized timer + // allowed here; before the settled guard, cancel() armed a second one + // that nothing would ever clear (the run was already settled), keeping + // the WorkerRun/Worker closure alive until the grace expired. + const graceTimers = spy.mock.calls.filter(call => call[1] === GRACE) + expect(graceTimers.length).toBe(1) + } finally { + spy.mockRestore() + } }) - it('dispose() waits for a stray child to FINISH disposing (quiescence), not just the script settle', async () => { + it('strays: children fired without await are aborted once the script settles, and dispose() waits for their disposal', async () => { const { ctx, parent, provider } = await setup({ manual: true, disposeDelayMs: 40 }) const handle = ctx.workflows.start({ script: script(` @@ -871,12 +432,222 @@ describe('dsh-workflow-vm', () => { }) const result = await handle.result expect(result.stopReason).toBe('completed') - expect(provider.runs.length).toBe(1) + await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) await handle.dispose() // Not a waitFor: by the time dispose() returns, the slow child disposal - // must already be complete. + // must already be complete (host-side registry quiescence). expect(provider.runs[0]!.disposed).toBe(true) }) + + it('the settle-reap fires the request signal too: a provider honoring ONLY the signal winds its stray down promptly', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const aborted: string[] = [] + const provider: SubagentProvider = { + name: 'signal-only', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true }, + inheritsParentContext: false, + start: (request) => { + let settle!: (result: SubagentResult) => void + const result = new Promise((resolve) => { settle = resolve }) + request.signal?.addEventListener('abort', () => { + aborted.push(String(request.signal?.reason)) + settle({ output: [], stopReason: 'aborted' }) + }, { once: true }) + return { + id: AgentId('signal-only-child'), + result, + // The seam leaves a provider free to honor EITHER cancel channel; + // this one deliberately ignores run.cancel() — only the request + // signal can wind it down. + cancel: () => { /* signal-only by design */ }, + dispose: () => Promise.resolve(), + } + }, + } + ctx.subagents.registerProvider(provider) + await ctx.plugin(WorkerWorkflowEngine, { provider: 'signal-only', maxConcurrentAgents: 2 }) + const handle = ctx.workflows.start({ + script: script(` + agent('stray, never awaited') + return 'done' + `), + parent: fakeParent(), + }) + const result = await handle.result + expect(result.stopReason).toBe('completed') + // BEFORE dispose(): the settlement itself must have aborted the signal — + // without it this child would stay live until dispose's terminate. + await vi.waitFor(() => { expect(aborted).toEqual(['workflow settled']) }) + await handle.dispose() + }) + + it("cancel() drives each child's explicit cancel() host-side: a wedged worker cannot delay it", async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + let starts = 0 + const cancelled: string[] = [] + const provider: SubagentProvider = { + name: 'cancel-only', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true }, + inheritsParentContext: false, + start: () => { + starts += 1 + return { + id: AgentId('cancel-only-child'), + result: new Promise(() => { /* only cancel() ends this child */ }), + // Deliberately ignores the request signal — the seam leaves a + // provider free to honor ONLY the explicit cancel() channel. + cancel: (reason?: string) => { cancelled.push(reason ?? 'cancelled') }, + dispose: () => Promise.resolve(), + } + }, + } + ctx.subagents.registerProvider(provider) + // A deliberately huge grace: if only the grace/terminate reap could + // reach this child, the assertion below would time out first. + await ctx.plugin(WorkerWorkflowEngine, { provider: 'cancel-only', maxConcurrentAgents: 2, disposeGraceMs: 30_000 }) + const handle = ctx.workflows.start({ + // The stray child's start RPC reaches the host, then the script wedges + // its own worker in a synchronous spin: the worker cannot process the + // Cancel message, so it can relay NO ChildCancel RPC — only the host's + // own children loop can deliver the explicit cancel in time. + script: script(` + agent('wedged child') + const end = Date.now() + 1500 + while (Date.now() < end) {} + return 'raced' + `), + parent: fakeParent(), + }) + await vi.waitFor(() => { expect(starts).toBe(1) }) + handle.cancel('stop now') + await vi.waitFor(() => { expect(cancelled).toEqual(['stop now']) }, { timeout: 800 }) + // The wedged worker's own completion loses to the in-flight cancel. + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + await handle.dispose() + }, 15_000) + }) + + describe('worker death', () => { + it('a worker that exits before settling reports an error result and reaps its children', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + // The child's dispose() REJECTS on top of the worker death: the reap + // must contain it (warn, not crash) while still emptying the registry. + const cancelled: string[] = [] + const provider: SubagentProvider = { + name: 'doomed', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true }, + inheritsParentContext: false, + start: () => ({ + id: AgentId('doomed-child'), + result: new Promise(() => { /* never settles; the reap is the teardown */ }), + cancel: (reason?: string) => { cancelled.push(reason ?? 'cancelled') }, + dispose: () => Promise.reject(new Error('dispose exploded during reap')), + }), + } + ctx.subagents.registerProvider(provider) + await ctx.plugin(WorkerWorkflowEngine, { provider: 'doomed', maxConcurrentAgents: 2 }) + const runEnds: WorkflowResultInfo[] = [] + ctx.on('workflow/end', (_info, result) => { runEnds.push(result) }) + const handle = ctx.workflows.start({ + // The stray child's start RPC reaches the host, then the script kills + // its own worker through the documented vm escape — the host must + // settle `error` with the exit diagnostics and wind the child down. + script: script(` + agent('doomed') + const proc = ${ESCAPE} + const st = globalThis.constructor.constructor('return setTimeout')() + await new Promise(resolve => st(resolve, 200)) + proc.exit(7) + `), + parent: fakeParent(), + }) + const result = await handle.result + expect(result.stopReason).toBe('error') + expect(result.error).toContain('exit code 7') + expect(result.agentsStarted).toBe(1) + // A worker death is a stop reason like any other: workflow/end fires + // with the error outcome — for a bus observer it is the only obituary. + expect(runEnds).toEqual([{ stopReason: 'error', error: result.error, agentsStarted: 1 }]) + await vi.waitFor(() => { expect(cancelled.length).toBe(1) }) + await handle.dispose() + }, 15_000) + + it('an uncaught exception inside the worker surfaces as an error result and reaps the in-flight child', async () => { + const { ctx, parent, provider } = await setup({ manual: true }) + const handle = ctx.workflows.start({ + script: script(` + agent('in flight when the worker dies') + const proc = ${ESCAPE} + const st = globalThis.constructor.constructor('return setTimeout')() + await new Promise(resolve => st(resolve, 200)) + proc.nextTick(() => { throw new Error('worker blew up') }) + await new Promise(() => {}) + `), + parent, + }) + const result = await handle.result + expect(result.stopReason).toBe('error') + expect(result.error).toContain('worker blew up') + // The reap wound the stray child down (cancel + a CLEAN dispose). + await vi.waitFor(() => { + expect(provider.runs.length).toBe(1) + expect(provider.runs[0]!.disposed).toBe(true) + }) + await handle.dispose() + }, 15_000) + + it('a dispose ack racing the worker death is dropped, not crashed (post after exit)', async () => { + // Slow child disposal: the ack resolves only AFTER the worker died, so + // it has nowhere to go and must be dropped silently (the workerGone + // guard in post()). + const { ctx, parent, provider } = await setup({ disposeDelayMs: 300 }) + const handle = ctx.workflows.start({ + // The STRAY child settles instantly, so its wrapper starts the slow + // host-side disposal concurrently while the script goes on to kill + // its own worker — the ack then resolves into a dead thread. + script: script(` + agent('stray, never awaited') + const proc = ${ESCAPE} + const st = globalThis.constructor.constructor('return setTimeout')() + await new Promise(resolve => st(resolve, 150)) + proc.exit(5) + `), + parent, + }) + const result = await handle.result + expect(result.stopReason).toBe('error') + expect(result.error).toContain('exit code 5') + await vi.waitFor(() => { expect(provider.runs[0]!.disposed).toBe(true) }) + await handle.dispose() + }, 15_000) + + it('a worker death AFTER a cancel reports cancelled, not error', async () => { + const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 60_000 } }) + const handle = ctx.workflows.start({ + script: script(` + const proc = ${ESCAPE} + const st = globalThis.constructor.constructor('return setTimeout')() + log('armed') + await new Promise(resolve => st(resolve, 400)) + proc.exit(3) + `), + parent, + }) + const logs: string[] = [] + ctx.on('workflow/log', (_info, message) => { logs.push(message) }) + await vi.waitFor(() => { expect(logs).toContain('armed') }) + handle.cancel('stop it') + // The grace is deliberately huge: only the worker's own death (exit 3, + // unreachable by the cancel — the script ignores hooks) settles this. + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + expect(result.error).toContain('stop it') + await handle.dispose() + }, 15_000) }) describe('service surface', () => { @@ -887,7 +658,6 @@ describe('dsh-workflow-vm', () => { const first = ctx.workflows.start({ script: script('return 1'), parent }) const second = ctx.workflows.start({ script: script('return 2'), parent }) expect(first.id).not.toBe(second.id) - // Mutating a listener's snapshot cannot corrupt the holder's view. eventMeta!.meta.name = 'corrupted' expect(second.meta.name).toBe('test-flow') await Promise.all([first.result, second.result]) @@ -895,38 +665,24 @@ describe('dsh-workflow-vm', () => { await second.dispose() }) - it('a listener mutating one event payload cannot corrupt later events (per-emission snapshots)', async () => { - const { ctx, parent } = await setup() - const ends: unknown[] = [] - let endInfo: WorkflowRunInfo | undefined - ctx.on('workflow/agent-start', (info, agent) => { - agent.seq = 999 - agent.label = 'HACKED' - info.meta.name = 'HACKED' - }) - ctx.on('workflow/agent-end', (info, agent) => { - ends.push(agent) - endInfo = info - }) - await run(ctx, parent, script("return await agent('job', { label: 'honest' })")) - expect(ends[0]).toMatchObject({ seq: 1, label: 'honest', outcome: 'completed' }) - expect(endInfo!.meta.name).toBe('test-flow') - }) - - it('unregisters ctx.workflows when the engine fiber is disposed (HMR safety)', async () => { + it('unregisters ctx.workflows when the engine fiber is disposed (HMR safety), and default config runs (auto concurrency)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) - const fiber = await ctx.plugin(VmWorkflowEngine, {}) + const fiber = await ctx.plugin(WorkerWorkflowEngine, {}) expect(ctx.get('workflows')).toBeDefined() + // A zero-agent run through the DEFAULT config exercises the auto + // concurrency resolution (cores - 2, capped) in start(). + const result = await run(ctx, fakeParent(), script('return 6 * 7')) + expect(result.value).toBe(42) await fiber.dispose() expect(ctx.get('workflows')).toBeUndefined() }) it('has the class-plugin export shape (default = the engine service class)', () => { - expect(vmEngineModule.default).toBe(VmWorkflowEngine) + expect(workerEngineModule.default).toBe(WorkerWorkflowEngine) const loader = Object.create(Loader.prototype) as Loader - const unwrapped: unknown = loader.unwrapExports(vmEngineModule) - expect(unwrapped).toBe(VmWorkflowEngine) + const unwrapped: unknown = loader.unwrapExports(workerEngineModule) + expect(unwrapped).toBe(WorkerWorkflowEngine) }) }) }) diff --git a/packages/workflow/workflow-vm/tests/workflow.e2e.ts b/packages/workflow/workflow-vm/tests/workflow.e2e.ts index c3f959d072..76cfd73d33 100644 --- a/packages/workflow/workflow-vm/tests/workflow.e2e.ts +++ b/packages/workflow/workflow-vm/tests/workflow.e2e.ts @@ -9,16 +9,14 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentService from '@deepseek-ai/dsh-subagent' import * as Spawn from '@deepseek-ai/dsh-subagent-spawn' -import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' -import { CallId } from '@deepseek-ai/dsh-llm' -import VmWorkflowEngine from '../src/index.ts' +import WorkerWorkflowEngine from '../src/index.ts' /** - * With-key e2e for the workflow engine: a REAL script drives REAL spawn - * children against the live DeepSeek API — one plain child and one schema'd - * child through the real structured-output runtime — and the run's value, - * events, and child sessions are asserted from the outside (never the - * script's self-report alone). Key-gated (self-skips without + * With-key e2e: a REAL script in a REAL worker thread + * drives REAL spawn children against the live DeepSeek API — one plain child + * and one schema'd child through the real structured-output runtime — and + * the run's value, events, and child sessions are asserted from the outside + * (never the script's self-report alone). Key-gated (self-skips without * DEEPSEEK_API_KEY). */ @@ -40,14 +38,13 @@ async function harness(): Promise { await built.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) await built.plugin(SubagentService) await built.plugin(Spawn, { providerName: 'spawn' }) - await built.plugin(VmWorkflowEngine, { provider: 'spawn' }) - await built.plugin(ToolWorkflow, {}) + await built.plugin(WorkerWorkflowEngine, { provider: 'spawn' }) return built } const SCRIPT = `export const meta = { - name: 'e2e-arithmetic', - description: 'two real children: one prose, one structured', + name: 'e2e-worker-arithmetic', + description: 'two real children through a worker thread: one prose, one structured', phases: [{ title: 'Ask' }, { title: 'Judge' }], } phase('Ask') @@ -61,12 +58,12 @@ const judged = await agent( ) return { prose, containsFour: judged === null ? null : judged.containsFour }` -describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workflow engine with-key e2e', () => { - it('runs a two-phase script over real children, one through the structured runtime', async () => { +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key e2e', () => { + it('runs a two-phase script in a worker thread over real children, one through the structured runtime', async () => { ctx = await harness() const parentHandle = ctx.agents.create({ - agentId: AgentId('wf-e2e-parent'), - sessionId: 'wf-e2e-session' as never, + agentId: AgentId('wf-worker-e2e-parent'), + sessionId: 'wf-worker-e2e-session' as never, agentOptions: { model: 'deepseek-v4-flash' }, }) @@ -102,30 +99,4 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workflow engine with-key e2e', ( } await parentHandle.dispose() }, 240_000) - - it('the workflow TOOL runs the same path through the real registry pipeline', async () => { - ctx = await harness() - const parentHandle = ctx.agents.create({ - agentId: AgentId('wf-e2e-tool-parent'), - sessionId: 'wf-e2e-tool-session' as never, - agentOptions: { model: 'deepseek-v4-flash' }, - }) - - const result = await ctx.tools.execute({ - callId: CallId('wf-e2e-call'), - name: 'workflow', - arguments: { - script: `export const meta = { name: 'e2e-tool', description: 'one real child via the tool' } -const answer = await agent('Reply with exactly one word: the capital of France.') -return { answer }`, - }, - agent: parentHandle.agent, - }) - - expect(result.isError).toBe(false) - const text = (result.content[0] as { text: string }).text - expect(text).toContain('workflow "e2e-tool" completed (1 agent)') - expect(text.toLowerCase()).toContain('paris') - await parentHandle.dispose() - }, 240_000) }) diff --git a/packages/workflow/workflow-vm/tsdown.config.ts b/packages/workflow/workflow-vm/tsdown.config.ts new file mode 100644 index 0000000000..3102a36c1c --- /dev/null +++ b/packages/workflow/workflow-vm/tsdown.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'tsdown' + +/** + * The engine ships two runtime entries: the engine service (index) and the + * worker-thread entry (worker) the engine spawns via `new Worker`. The + * entries are JS emitted by tsc under lib/types and are bundled as two + * single-entry passes so shared modules (realm, runtime, session) are inlined + * into each instead of split into a hash-named chunk (the worker entry must + * be a self-contained file the Worker constructor can load by path). + */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/worker.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f236c67bf2..e3e1892e35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1159,6 +1159,9 @@ importers: cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + tsx: + specifier: ^4.19.2 + version: 4.22.4 vendor/cordis: dependencies: diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index f579169ab0..2d12a66d8f 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -105,12 +105,25 @@ const dshBinPackageFiles = [ 'src', ] as const +const dshWorkerPackageFiles = [ + 'lib/index.js', + 'lib/worker.js', + 'lib/types/**/*.d.ts', + 'lib/types/**/*.d.ts.map', + 'src', +] as const + function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean { return !!actual && actual.length === expected.length && actual.every((value, index) => value === expected[index]) } function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { - return manifest.bin ? dshBinPackageFiles : dshPackageFiles + if (manifest.bin) return dshBinPackageFiles + // A declared "./worker" subpath export sanctions the one extra runtime + // bundle a worker-thread entry needs (and NodeNext/publint then validate + // that subpath's targets like any other export). + if (manifest.exports?.['./worker']) return dshWorkerPackageFiles + return dshPackageFiles } function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 6c844f842a..25e08cf8d2 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -310,6 +310,10 @@ function builtBinSmokeGate(): Gate { 'vitest.e2e.config.ts', 'packages/ui/stdio-agent/tests/built-bin.e2e.ts', 'packages/ui/acp-agent/tests/built-bin.e2e.ts', + // The workflow engine's built worker bundle: the only automated proof + // that lib/index.js resolves its sibling lib/worker.js under plain node + // (the e2e lane runs unbuilt, so this file self-skips there). + 'packages/workflow/workflow-vm/tests/built-worker.e2e.ts', ], { label: 'built-bin smoke', needs: ['build'], From d5c65e2b4c52271c9fb1a895b64d5457793bdd4c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:50:29 +0800 Subject: [PATCH 39/57] docs: describe the workflow engine as worker-thread first The outer ring catches up with the engine swap (the package's own README/JSDoc rode the port commit): - Seam module doc and README name the worker-thread engine as THE implementation, with isolated-vm/separate-process sandboxing as the deferred hardening; the seam service doc states the holder-owned-runs contract (engine-fiber disposal deliberately leaves live runs to their holders). - Seam contract precision: agentsStarted documents the termination-path degradation to the host-observed count; the events section scopes the agent-start/agent-end pair to calls that STARTED a child run; WorkflowRun wording drops the vm-era abandonment language. - The dynamic-workflows RFC is rewritten in place to the shipped mechanism (implemented-RFC rule): why worker threads, the thread's concrete buys, the in-process node:vm first cut recorded under alternatives considered; the tool section describes the usage policy as the tool's own prompt section. - gen-doc-graphs: six workflow/* DYNAMIC_EVENT_DISPATCHERS entries (the catalog no longer claims nothing dispatches them) and the seam-note wording; core-data-structures gains its workflow.md index row; packages/README + AGENTS.md layout line + example cordis.yml comments say worker-thread; catalogs regenerated. --- AGENTS.md | 2 +- docs/capability-seams.md | 2 +- docs/config-catalog.md | 8 +++---- docs/cordis-catalog/services.md | 3 ++- docs/core-data-structures/core.md | 1 + docs/core-data-structures/workflow.md | 4 ++-- docs/event-producer-consumer.md | 12 +++++------ .../feature/2026-07-05-dynamic-workflows.md | 17 ++++++++------- examples/acp-agent/cordis.yml | 8 +++---- examples/coding-agent/cordis.yml | 8 +++---- packages/README.md | 2 +- packages/workflow/README.md | 4 ++-- packages/workflow/workflow/README.md | 6 +++--- packages/workflow/workflow/src/index.ts | 13 ++++++++---- packages/workflow/workflow/src/types.ts | 21 ++++++++++++------- scripts/gen-doc-graphs.ts | 10 ++++++++- 16 files changed, 72 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eb34d404a0..5602b244ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ packages/ Harness packages at packages///, all named @deepseek-ai web/ web seam + search/fetch providers + model-facing web tools compact/ compaction seam + basic backend subagent/ subagent seam + spawn/fork/ACP backends + delegation tool - workflow/ workflow seam + node:vm script engine + the workflow tool + workflow/ workflow seam + worker-thread script engine + the workflow tool todo/ the todo_write tool hooks/ Claude Code / Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends diff --git a/docs/capability-seams.md b/docs/capability-seams.md index caa9905e46..1a13bd2060 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -145,6 +145,6 @@ flowchart LR | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-mock`](../packages/support/subagent-mock) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | -| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-vm`](../packages/workflow/workflow-vm) | [`tool-workflow`](../packages/workflow/tool-workflow) | - | One engine per context (bash shape, no named-provider registry); the vm engine fans agent() calls out through ctx.subagents. | +| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-vm`](../packages/workflow/workflow-vm) | [`tool-workflow`](../packages/workflow/tool-workflow) | - | One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents. | Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8ffaefdabd..300468dd70 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -815,18 +815,18 @@ export interface Config { maxTotalAgents?: number /** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */ maxItemsPerCall?: number - /** vm timeout for the script's initial synchronous slice AND the meta-literal evaluation (default 5000 ms). */ + /** vm timeout for the initial synchronous slice (inside the worker) AND the host-side meta evaluation (default 5000 ms). */ syncTimeoutMs?: number /** * How long after a cancellation an unsettled script may keep running before - * it is abandoned and `result` force-settles `cancelled` (default 5000 ms); - * also bounds `dispose()`. + * the run force-settles `cancelled` and its worker is TERMINATED (default + * 5000 ms); also bounds `dispose()`. */ disposeGraceMs?: number } ``` -Source: [`packages/workflow/workflow-vm/src/index.ts:58`](../packages/workflow/workflow-vm/src/index.ts) +Source: [`packages/workflow/workflow-vm/src/index.ts:69`](../packages/workflow/workflow-vm/src/index.ts) ## Loadable plugins with no config diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 027fa054c1..13c9f57733 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -238,12 +238,13 @@ Semantics every implementation must honor: - start throws synchronously for a request that cannot begin (an unparseable script, an invalid meta block). Once it returns a WorkflowRun, `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`) — and once the run is cancelled, `result` SETTLES within the implementation's bounded grace even if the script itself never settles (a consumer awaiting `result` must never be wedged past a cancellation). - The `workflow/*` events fire through emitWorkflowEvent (data snapshots, per-listener containment); `workflow/end` fires exactly once per started run, after `result` is settled or as it settles. - `dispose()` reaches quiescence within a bounded grace: it cancels, waits for the script to settle AND its started children to finish disposing, and abandons whatever is left rather than hanging its caller (the engine documents what abandonment leaves behind). +- Runs are HOLDER-OWNED: the engine hands control (`cancel`/`dispose`) to the `start()` caller and does not track its live runs — disposing the engine's own fiber mid-run deliberately leaves those runs to their holders' teardown, so an engine reload cannot yank a run out from under the consumer awaiting it. ```ts cordis-catalog abstract start(request: WorkflowStartRequest): WorkflowRun ``` -Source: [`packages/workflow/workflow/src/index.ts:202`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:207`](../../packages/workflow/workflow/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 7d1110d7a8..2f1e5031e9 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -24,6 +24,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface | | [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split | | [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider/capability status, `WebError` | +| [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | > Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts. diff --git a/docs/core-data-structures/workflow.md b/docs/core-data-structures/workflow.md index 8c7916a6c3..4d880ea948 100644 --- a/docs/core-data-structures/workflow.md +++ b/docs/core-data-structures/workflow.md @@ -2,7 +2,7 @@ The workflow seam — an agent running a model-written orchestration SCRIPT that fans out subagents. Like [subagent](subagent.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). Unlike the subagent registry it takes the bash shape: ONE engine implementation per context provides `ctx.workflows`; there is no named-provider registry (a second engine is a plugin swap, not a co-resident). -Interface: [dsh-workflow](../../packages/workflow/workflow) (`ctx.workflows` + the vocabulary below). The implementation is [dsh-workflow-vm](../../packages/workflow/workflow-vm) (an in-process `node:vm` engine); the model-facing consumer is [dsh-tool-workflow](../../packages/workflow/tool-workflow). The proposal and rationale: [the dynamic-workflows RFC](../rfc/implemented/feature/2026-07-05-dynamic-workflows.md). +Interface: [dsh-workflow](../../packages/workflow/workflow) (`ctx.workflows` + the vocabulary below). The implementation is [dsh-workflow-vm](../../packages/workflow/workflow-vm) (a `node:worker_threads` engine — one worker per run, the script's vm context inside it); the model-facing consumer is [dsh-tool-workflow](../../packages/workflow/tool-workflow). The proposal and rationale: [the dynamic-workflows RFC](../rfc/implemented/feature/2026-07-05-dynamic-workflows.md). Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts) @@ -47,7 +47,7 @@ interface WorkflowResult { ## A live run: `WorkflowRun` -The handle the consumer holds while a script executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path. `result` does NOT reject — a script failure resolves with `stopReason: 'error'` — and once the run is cancelled it SETTLES within the engine's bounded grace even if the script itself never settles (the engine abandons the script and reports `cancelled`), so a consumer awaiting `result` is never wedged past a cancellation. `dispose()` = cancel + that bounded settle + child quiescence (the engine documents what abandonment leaves behind); it never hangs on a stuck script. +The handle the consumer holds while a script executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path. `result` does NOT reject — a script failure resolves with `stopReason: 'error'` — and once the run is cancelled it SETTLES within the engine's bounded grace even if the script itself never settles (the engine force-settles `cancelled`; the worker-thread engine then terminates the script's worker), so a consumer awaiting `result` is never wedged past a cancellation. `dispose()` = cancel + that bounded settle + child quiescence; it never hangs on a stuck script. ```ts type-equiv interface WorkflowRun { diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ec1fc468e3..a8618022a5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -34,11 +34,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:97`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:92`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:76`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:93`](../packages/workflow/workflow/src/index.ts) | - | - | -| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:85`](../packages/workflow/workflow/src/index.ts) | - | - | -| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:103`](../packages/workflow/workflow/src/index.ts) | - | - | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:77`](../packages/workflow/workflow/src/index.ts) | - | - | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | - | - | -| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:62`](../packages/workflow/workflow/src/index.ts) | - | - | +| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:93`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:85`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:103`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:77`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:62`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | Maintenance mode: hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`. diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index f7749efa56..d0e436b17e 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -20,19 +20,19 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre `ctx.workflows` is an abstract `WorkflowService` in the bash shape — one engine per context, no named-provider registry (engines are deployment swaps, not co-residents). `start(request)` throws synchronously for a script that cannot begin; a returned `WorkflowRun`'s `result` NEVER rejects (failures resolve as `stopReason: 'error' | 'cancelled'`). The `workflow/*` events are observe-only emits carrying DATA SNAPSHOTS (id + meta; `workflow/end` omits the result value), per-listener contained, mirroring `subagent/start`/`subagent/end` — control stays with the run's holder. Vocabulary details: [core-data-structures/workflow.md](../../../core-data-structures/workflow.md). -### The engine (dsh-workflow-vm): in-process node:vm +### The engine (dsh-workflow-vm): one worker thread per run -**Trust premise (governs every engine decision below)**: workflow scripts are MODEL-WRITTEN — the same trust level as the model's existing bash access — so the engine defends against BUGGY scripts, never hostile ones. In scope: `result` never rejects, no unhandled rejections from dropped hook promises, loud rejection of values JSON cannot carry, fatal-vs-null hook discipline, cancellation that always frees the caller. Out of scope, deliberately: adversarial values (throwing/spinning accessors, proxies with hostile traps, prototype forgery, `prepareStackTrace` hijack) AND Node-API escape from the context — the vm context shares object machinery with the host, so a script can reach the host `Function` constructor (`globalThis.constructor.constructor`) and from it `process` and every Node builtin; the absent globals are API surface, not containment. Host code MAY run script code while reading script values, and that is accepted, because a hostile script can already occupy the event loop forever with a synchronous spin past its first await; containing its error VALUES while conceding it the event loop would be cost without a threat model. Genuine hardening is an engine swap behind the seam (worker/isolated-vm gets value isolation by serialization for free), not incremental host-side defenses. +**Trust premise (governs every engine decision below)**: workflow scripts are MODEL-WRITTEN — the same trust level as the model's existing bash access — so the engine defends against BUGGY scripts, never hostile ones. In scope: `result` never rejects, no unhandled rejections from dropped hook promises, loud rejection of values JSON cannot carry, fatal-vs-null hook discipline, cancellation that always frees the caller. Out of scope, deliberately: adversarial values (throwing/spinning accessors, proxies with hostile traps, prototype forgery, `prepareStackTrace` hijack) AND Node-API escape from the script's context — the vm context shares object machinery with its surrounding realm, so a script can reach the `Function` constructor (`globalThis.constructor.constructor`) and from it `process` and every Node builtin; the absent globals are API surface, not containment, and a worker thread is NOT a security boundary (an escapee holds process-wide privileges — Node's permission model is per-process). Worker-side code MAY run script code while reading script values, and that is accepted: a synchronous spin costs the script its OWN thread (terminated at the post-cancel grace), never the host loop, so containing error VALUES would be cost without a threat model. Genuine sandboxing (isolated-vm, a separate process) remains an engine swap behind the seam, not incremental defenses here. -**Why node:vm and not isolated-vm/worker threads**: isolated-vm is in maintenance mode, needs `--no-node-snapshot` on EVERY consumer process (including the published bins) on Node ≥ 20, and falls back to node-gyp source builds; a worker-thread engine turns every hook into RPC and complicates the per-file coverage gate. Under the trust premise, in-process is enough. Accepted, documented limitations: `start()` blocks the caller for the script's initial synchronous slice (bounded by the vm timeout); that timeout covers ONLY the initial slice, so a synchronous spin past it (an await continuation, a thenable's `then` invoked by promise resolution — a returned thenable resolves per JavaScript semantics, which is what makes an un-awaited `return agent('x')` work — or script code the host runs while rendering a thrown value) cannot be killed in-process; `dispose()` cancels, waits a bounded grace for the script to settle and its children to finish disposing, then abandons. +**Why node:worker_threads**: one run = one worker thread, no pooling — a run is heavyweight (many children), so thread spin-up (~tens of ms) is noise. The script runs in a vm context INSIDE the worker, keeping the script-visible surface exactly the hook contract above (a bare worker realm would leak `setTimeout`/`fetch`/`process` as accidental API), and every `agent()` bridges to `ctx.subagents` by message-port RPC — children are I/O-bound LLM loops and stay on the host loop; the thread isolates the SCRIPT, the only part that can spin. What the thread buys: `start()` never blocks the host (an in-process engine runs the initial synchronous slice inline and cannot kill a spin past the first await — it could only ABANDON such a script, leaving the spin on the host loop), the post-cancel grace ends in a REAL `worker.terminate()`, and the value boundary is serialization by construction. isolated-vm was rejected for actual sandboxing: maintenance mode, `--no-node-snapshot` on EVERY consumer process (including published bins) on Node ≥ 20, node-gyp source-build fallback. Key mechanics (details in the package README): meta extraction and a body pre-parse stay HOST-side (preserving the seam's synchronous throws), a ready→go handshake keeps a run cancelled before start from ever executing the body, `cancel()` drives both child-cancel channels host-side (the shared request signal AND each child's explicit `cancel()` — a wedged worker cannot relay its own cancel RPCs), a host-side child registry backs worker-death reaping and `dispose()` quiescence, the wire protocol is enum-keyed payload maps private to the package, and on a termination path `agentsStarted` degrades to the host-observed count. Coverage puts the worker-side session on an in-process `MessageChannel` (real-Worker code is invisible to main-process v8) and proves the built `lib/worker.js` — a second tsdown entry, sanctioned in the workspace-constraints gate by the `"./worker"` subpath export — under plain node in the built-bin smoke gate. **Meta extraction**: a string/comment-aware brace scanner (template interpolation rejected) finds the literal; it is evaluated ALONE in an empty, timed vm context; the result must materialize to plain JSON data and pass shape validation (unknown fields rejected loud); the statement is blanked line-preservingly so stacks keep script line numbers. -**Value boundary**: values entering the host (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation; getters are read ordinarily and their RESULT crosses (a throwing read fails loud). Values entering the realm (`args`, `agent()` results, hook promises and failures, combinator arrays) are handed over directly as host values — the script is trusted, so host prototypes are not a leak; `args` is host-`structuredClone`d once so a script cannot mutate the caller's object. Hook failures are host `WorkflowError`s: the combinators recognize fatality by host `instanceof` (unforgeable from the realm), and the script-visible consequence — in-script `instanceof Error` is `false` for hook errors; branch on `e.name`/`e.code` — is documented in the engine README. Realm functions (stages, thunks) are called, never materialized. Thrown script values are rendered by a total host-side renderer (stack → message → `String()`, fixed label if rendering throws), so `result` cannot reject. Caps (`maxConcurrentAgents` auto = `min(16, max(1, availableParallelism() - 2))`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. +**Value boundary**: values leaving the script (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation; getters are read ordinarily and their RESULT crosses (a throwing read fails loud) — which is also what makes every later postMessage hop total. Values entering the realm (`args`, `agent()` results, hook promises and failures, combinator arrays) are handed over directly as worker-realm values — the script is trusted, so outer prototypes are not a leak; `args` rides the `workerData` structured clone (the caller-isolation copy) and is cloned once more so a script scribbling on it cannot mutate the session's init object. Hook failures are `WorkflowError`s built OUTSIDE the script's context: the combinators recognize fatality by `instanceof` against the engine's own class (unforgeable from the script), and the script-visible consequence — in-script `instanceof Error` is `false` for hook errors; branch on `e.name`/`e.code` — is documented in the engine README. Realm functions (stages, thunks) are called, never materialized. Thrown script values are rendered by a total renderer (stack → message → `String()`, fixed label if rendering throws), so `result` cannot reject. Caps (`maxConcurrentAgents` auto = `min(16, max(1, availableParallelism() - 2))`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. ### The consumer (dsh-tool-workflow) -A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, await, `try/finally` dispose, abort-bridge `exec.signal`, non-`completed` → `isError`. Render intent: a `generic` card titled by a textual `meta.name` sniff (presentation is a pure function of args). The tool description IS the model-facing authoring spec. Examples load it with guidance to use workflows only on explicit user request — the harness has no ultracode-style effort gate. +A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, await, `try/finally` dispose, abort-bridge `exec.signal`, non-`completed` → `isError`. Render intent: a `generic` card titled by a textual `meta.name` sniff (presentation is a pure function of args). The tool description IS the model-facing authoring spec. The usage policy ships with the tool as its own `tool:` prompt section (explicit-ask-only guidance — tool guidance lives in tool plugins, never in the deployment persona); the harness has no ultracode-style effort gate. ### The foundation: structured output on the subagent seam @@ -45,13 +45,14 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai - **Saved/bundled workflows** (a `.deepseek/workflows/` registry, slash-command surface) and **script persistence to a run directory** (the tool-call event already records the script durably). - **Nested `workflow()`**, **token `budget`**, and the `effort`/`isolation`/`agentType` agent options (each rejects loud with a message naming it deferred). - **An overall run wall-clock timeout** — cancellation always frees the caller (result settles within the grace), so a cap on total run time is a policy knob for the background redesign, not a correctness need here. -- **Engine hardening**: a worker-thread or isolated-vm engine behind the same seam (kills synchronous spins; adds memory limits). +- **Engine hardening beyond worker threads**: an isolated-vm or separate-process engine behind the same seam (actual sandboxing; memory limits). - **ACP progress UI** over the `workflow/*` events (a `/workflows`-style view); the events exist for it. - **ACP-backend structured output** and **`toolFilter`** (both still capability-gated `false`). ## Alternatives considered -- **Hostile-value containment in the host** (trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition): an earlier revision built all of it, and review showed the cost was real while the threat model was not — every one of those defenses guards against an author the premise already trusts, who retains an accepted unkillable event-loop spin regardless. Removed in favor of the plain boundary above; the hardened engine deletes such machinery anyway (serialization by construction). +- **Hostile-value containment in the host** (trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition): an earlier revision built all of it, and review showed the cost was real while the threat model was not — every one of those defenses guards against an author the premise already trusts. Removed in favor of the plain boundary above; the thread boundary makes such machinery redundant anyway (serialization by construction). +- **In-process `node:vm` execution** (the first cut of this RFC shipped it): mechanically simplest — no RPC, no thread — but `start()` blocks the caller for the script's initial synchronous slice, a synchronous spin past the first await cannot be killed in-process (the vm `timeout` covers only that first slice), and `dispose()` could only ABANDON an unsettling script, leaving the spin on the host loop. Superseded by the worker-thread engine, which keeps the same vm-context script surface while unblocking the host and making termination real. - **Background execution as the default** (CC's shape): deferred; foreground-synchronous matches `dsh-tool-subagent`'s cut, and background semantics should be designed ONCE across bash/subagent/workflow rather than per-tool. - **Workflow-layer JSON parsing for `agent({schema})`**: duplicating a seam concern at one consumer while the seam's capability flag stayed dishonestly `false`. - **Meta as tool parameters instead of `export const meta`**: zero parsing, but scripts stop being self-contained artifacts and CC-authored scripts stop being drop-in. @@ -62,4 +63,4 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai ## Consequences -The harness gains CC-compatible script orchestration: fan-out plans live in a rerunnable artifact instead of the parent context, and the structured-output half of the subagent seam is now real (the vocabulary stopped lying about `outputSchema`). What it cost, all bounded by the trust premise: the in-process engine blocks its caller for a script's initial synchronous slice, cannot kill a synchronous spin past that slice, and does not isolate host values from the script — acceptable because scripts share the model's trust level, and each limitation names its exit (the engine swap behind the seam). The fatal-vs-null strictness divergence from CC means a CC-authored script that RELIES on option typos dissolving to `null` behaves differently here — judged worth it to keep the repo's no-accepted-then-ignored rule. Consumers must hold the run handle for control (`cancel`/`dispose`); observers get data snapshots only, so no listener can extend a run's lifetime or corrupt another's view. +The harness gains CC-compatible script orchestration: fan-out plans live in a rerunnable artifact instead of the parent context, and the structured-output half of the subagent seam is now real (the vocabulary stopped lying about `outputSchema`). What it cost, all bounded by the trust premise: a worker thread per run (~tens-of-ms spin-up), every hook crossing a message port as RPC, and a termination-path `agentsStarted` that degrades to the host-observed count; in exchange `start()` never blocks the host, a post-cancel grace ends in a real `worker.terminate()`, and the value boundary is serialization by construction. A worker thread is still NOT a security boundary — scripts share the model's trust level, and actual sandboxing names its exit (the isolated-vm/separate-process engine swap behind the seam). The fatal-vs-null strictness divergence from CC means a CC-authored script that RELIES on option typos dissolving to `null` behaves differently here — judged worth it to keep the repo's no-accepted-then-ignored rule. Consumers must hold the run handle for control (`cancel`/`dispose`); observers get data snapshots only, so no listener can extend a run's lifetime or corrupt another's view. diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index eeb78a7962..f2015bc7c2 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -82,10 +82,10 @@ toolName: subagent_fork -# Dynamic workflows: the node:vm engine (ctx.workflows) over the spawn subagent -# backend above, plus the model-facing `workflow` tool. The model writes a -# JavaScript orchestration script (meta + body); the engine runs it in-process -# and fans agent() calls out as spawn children. +# Dynamic workflows: the worker-thread engine (ctx.workflows) over the spawn +# subagent backend above, plus the model-facing `workflow` tool. The model +# writes a JavaScript orchestration script (meta + body); the engine runs it +# in its own worker thread and fans agent() calls out as spawn children. - id: workflow-vm name: '@deepseek-ai/dsh-workflow-vm' config: diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 28643e0d73..ee55bced0b 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -103,10 +103,10 @@ toolName: subagent_fork -# Dynamic workflows: the node:vm engine (ctx.workflows) over the spawn subagent -# backend above, plus the model-facing `workflow` tool. The model writes a -# JavaScript orchestration script (meta + body); the engine runs it in-process -# and fans agent() calls out as spawn children. +# Dynamic workflows: the worker-thread engine (ctx.workflows) over the spawn +# subagent backend above, plus the model-facing `workflow` tool. The model +# writes a JavaScript orchestration script (meta + body); the engine runs it +# in its own worker thread and fans agent() calls out as spawn children. - id: workflow-vm name: '@deepseek-ai/dsh-workflow-vm' config: diff --git a/packages/README.md b/packages/README.md index 3c38eda671..43b446eeb5 100644 --- a/packages/README.md +++ b/packages/README.md @@ -14,7 +14,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface | | [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface | | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | -| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, the node:vm engine, and the model-facing `workflow` tool | Product — stable surface | +| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, the worker-thread engine, and the model-facing `workflow` tool | Product — stable surface | | [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | | [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | diff --git a/packages/workflow/README.md b/packages/workflow/README.md index f28a325ea8..a0750d9fdf 100644 --- a/packages/workflow/README.md +++ b/packages/workflow/README.md @@ -5,9 +5,9 @@ The workflow seam: a model-written JavaScript orchestration script that fans out | Package | Role | ctx key | |---|---|---| | `workflow/` | Abstract workflow seam: service base class + run vocabulary + `workflow/*` events | `ctx.workflows` | -| `workflow-vm/` | In-process `node:vm` engine: parses the script, injects the hooks, drives `ctx.subagents` | (provides `ctx.workflows`) | +| `workflow-vm/` | `node:worker_threads` engine: one worker per run; the script's vm context lives inside the worker, `agent()` bridges to `ctx.subagents` over the message port | (provides `ctx.workflows`) | | `tool-workflow/` | Model-facing `workflow` tool over `ctx.workflows` | (registers on `ctx.tools`) | -The interface lives at `workflow/workflow/`. The engine's `agent()` hook rides the [subagent seam](../subagent/README.md) (any registered provider; the shipped examples use `spawn`), and `agent({ schema })` rides the structured-output support the in-process backends implement. The seam split exists for engine hardening: `node:vm` is in-process and cannot kill a pathological synchronous spin — a worker-thread or isolated-vm engine swaps in behind the same interface if that ever matters. +The interface lives at `workflow/workflow/`. The engine's `agent()` hook rides the [subagent seam](../subagent/README.md) (any registered provider; the shipped examples use `spawn`), and `agent({ schema })` rides the structured-output support the in-process backends implement. The worker thread isolates the SCRIPT — the host never blocks on it, and a cancelled run's post-grace termination is real — but it is NOT a security boundary; an isolated-vm/separate-process engine (actual sandboxing) swaps in behind the same interface if that ever matters. The proposal, decisions, and deferred work: [docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md](../../docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md). diff --git a/packages/workflow/workflow/README.md b/packages/workflow/workflow/README.md index 076f5443ac..96add5017e 100644 --- a/packages/workflow/workflow/README.md +++ b/packages/workflow/workflow/README.md @@ -1,10 +1,10 @@ # @deepseek-ai/dsh-workflow -The **workflow seam** (`ctx.workflows`): an abstract service defining WHAT a workflow engine does — execute a model-written orchestration script that fans out subagents — without saying HOW. The bash-shaped third of the [workflow family](../README.md): implementations subclass `WorkflowService` and register as the `workflows` service (one per context); [`dsh-workflow-vm`](../workflow-vm/README.md) is the first, and [`dsh-tool-workflow`](../tool-workflow/README.md) is the model-facing consumer. +The **workflow seam** (`ctx.workflows`): an abstract service defining WHAT a workflow engine does — execute a model-written orchestration script that fans out subagents — without saying HOW. The bash-shaped third of the [workflow family](../README.md): implementations subclass `WorkflowService` and register as the `workflows` service (one per context); [`dsh-workflow-vm`](../workflow-vm/README.md) (one worker thread per run) is the implementation, and [`dsh-tool-workflow`](../tool-workflow/README.md) is the model-facing consumer. ## Service: `WorkflowService` (abstract) -`start(request: WorkflowStartRequest): WorkflowRun` — parse and execute a script. Throws synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot begin; once a run is returned, its `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`) — and once the run is cancelled, `result` settles within the implementation's bounded grace even if the script itself never settles (a consumer awaiting `result` must never be wedged past a cancellation). `dispose()` must reach quiescence within a bounded grace (cancel → wait for the script to settle and its children to finish disposing → abandon), never hanging its caller. +`start(request: WorkflowStartRequest): WorkflowRun` — parse and execute a script. Throws synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot begin; once a run is returned, its `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`) — and once the run is cancelled, `result` settles within the implementation's bounded grace even if the script itself never settles (a consumer awaiting `result` must never be wedged past a cancellation). `dispose()` must reach quiescence within a bounded grace (cancel → wait for the script to settle and its children to finish disposing → abandon), never hanging its caller. Runs are HOLDER-owned: the engine does not track its live runs, so disposing the engine's fiber mid-run leaves each run to its holder's teardown. The protected `emitWorkflowEvent` helper dispatches the `workflow/*` events with PER-LISTENER containment and PER-LISTENER payload snapshots (a throwing subscriber is logged, never propagated, and cannot starve later listeners; each subscriber gets its own clone of the payload, so mutating it corrupts neither the engine nor other listeners) — the same containment guarantee as the subagent seam's lifecycle emits. @@ -22,7 +22,7 @@ All observe-only emits carrying DATA SNAPSHOTS (`WorkflowRunInfo` = id + meta) - `workflow/start`(info) / `workflow/end`(info, resultInfo) — run lifecycle; `resultInfo` deliberately omits the value. - `workflow/phase`(info, title) / `workflow/log`(info, message) — script narration. -- `workflow/agent-start`(info, agent) / `workflow/agent-end`(info, agent + outcome) — one pair per `agent()` call, correlated by `seq`. +- `workflow/agent-start`(info, agent) / `workflow/agent-end`(info, agent + outcome) — one pair per `agent()` call that STARTED a child run (a call rejected at validation or caps, refused at start, or cancelled while queued for a slot emits no pair), correlated by `seq`. ## Non-goals (this cut) diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index 0e5a03c438..2a57d143b6 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -4,10 +4,10 @@ * that fans out subagents — without saying HOW. Implementations subclass * {@link WorkflowService} and register as the `workflows` service (one * implementation per context, cordis' standard duplicate-service behavior); - * `@deepseek-ai/dsh-workflow-vm` (an in-process `node:vm` engine) is the - * first. Future engines (a worker-thread or isolated-vm sandbox) swap in - * without touching the model-facing tool that consumes them - * (`@deepseek-ai/dsh-tool-workflow`). + * the implementation is `@deepseek-ai/dsh-workflow-vm`, which runs each + * script in its own worker thread. Hardened engines (an isolated-vm or + * separate-process sandbox) swap in without touching the model-facing tool + * that consumes them (`@deepseek-ai/dsh-tool-workflow`). * * The `workflow/*` lifecycle events are OBSERVE-ONLY data snapshots: they * carry {@link WorkflowRunInfo} (id + meta), never the live {@link WorkflowRun} @@ -198,6 +198,11 @@ export function isFatalWorkflowError(error: unknown): boolean { * for the script to settle AND its started children to finish disposing, * and abandons whatever is left rather than hanging its caller (the engine * documents what abandonment leaves behind). + * - Runs are HOLDER-OWNED: the engine hands control (`cancel`/`dispose`) to + * the `start()` caller and does not track its live runs — disposing the + * engine's own fiber mid-run deliberately leaves those runs to their + * holders' teardown, so an engine reload cannot yank a run out from under + * the consumer awaiting it. */ export abstract class WorkflowService extends Service { constructor(ctx: Context) { diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index 767fb8257b..8552e32454 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -89,7 +89,13 @@ export interface WorkflowResult { stopReason: WorkflowStopReason /** The failure message (present iff `stopReason` is not `completed`). */ error?: string - /** How many `agent()` calls the run accepted (whole lifetime, including calls still queued for a slot when the run was cancelled). */ + /** + * How many `agent()` calls the run accepted over its whole lifetime. On a + * graceful settlement this is the script-side count (calls still queued for + * a concurrency slot included); on a termination path (grace force-settle, + * worker death) it degrades to the host-observed count — calls queued + * inside a terminated script are unknowable then. + */ agentsStarted: number } @@ -98,18 +104,19 @@ export interface WorkflowResult { * `result`, may `cancel` mid-flight, and MUST `dispose` on every path. * `result` does NOT reject — a script failure resolves with `stopReason: * 'error'` — and once the run is cancelled it SETTLES within the engine's - * bounded grace even if the script itself never settles (the engine abandons - * the script and reports `cancelled`), so a consumer awaiting `result` is - * never wedged past a cancellation. `dispose()` = cancel + that bounded - * settle + child quiescence; it never hangs on a stuck script and is safe to - * call on every path (idempotent). + * bounded grace even if the script itself never settles (the engine + * force-settles `cancelled`; what becomes of the script is engine-documented + * — the worker-thread engine terminates its worker), so a consumer awaiting + * `result` is never wedged past a cancellation. `dispose()` = cancel + that + * bounded settle + child quiescence; it never hangs on a stuck script and is + * safe to call on every path (idempotent). */ export interface WorkflowRun { readonly id: WorkflowRunId /** The validated meta block (available before the body runs). */ readonly meta: WorkflowMeta readonly result: Promise - /** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is abandoned at the grace). */ + /** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is force-settled at the grace). */ cancel(reason?: string): void /** Cancel + bounded-grace settle; safe to call on every path (idempotent). */ dispose(): Promise diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1fabb98707..0abe1a34cf 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -193,7 +193,7 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'seam', implementations: ['workflow-vm'], consumers: ['tool-workflow'], - note: 'One engine per context (bash shape, no named-provider registry); the vm engine fans agent() calls out through ctx.subagents.', + note: 'One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents.', }, ] @@ -203,6 +203,14 @@ const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: str // listeners or strand an already-started child run. { event: 'subagent/start', pkg: 'subagent', method: 'events.dispatch' }, { event: 'subagent/end', pkg: 'subagent', method: 'events.dispatch' }, + // The workflow/* lifecycle events dispatch the same way, for the same + // per-listener-containment reason (WorkflowService.emitWorkflowEvent). + { event: 'workflow/start', pkg: 'workflow', method: 'events.dispatch' }, + { event: 'workflow/phase', pkg: 'workflow', method: 'events.dispatch' }, + { event: 'workflow/log', pkg: 'workflow', method: 'events.dispatch' }, + { event: 'workflow/agent-start', pkg: 'workflow', method: 'events.dispatch' }, + { event: 'workflow/agent-end', pkg: 'workflow', method: 'events.dispatch' }, + { event: 'workflow/end', pkg: 'workflow', method: 'events.dispatch' }, ] function generatedHeader(title: string): string[] { From 0d11a3afc708a7fcfd867bfc1022798b0a90e44a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:06:55 +0800 Subject: [PATCH 40/57] workflow: rename dsh-workflow-vm to dsh-workflow-workerthread Pure mechanical rename now that the package's internals are the worker-thread engine: directory, package name, spec/e2e filenames, module tags and logger prefixes, tsconfig/knip/run-gates/AGENTS.md references, example cordis.yml plugin ids, doc links; catalogs regenerated and the lockfile refreshed. --- AGENTS.md | 4 ++-- docs/capability-seams.md | 6 +++--- docs/config-catalog.md | 4 ++-- docs/core-data-structures/workflow.md | 2 +- docs/module-graph.md | 16 ++++++++-------- .../feature/2026-07-05-dynamic-workflows.md | 2 +- examples/acp-agent/composition.md | 6 +++--- examples/acp-agent/cordis.yml | 4 ++-- examples/acp-agent/tests/acp.snapshot.ts | 2 +- examples/coding-agent/composition.md | 6 +++--- examples/coding-agent/cordis.yml | 4 ++-- knip.json | 2 +- packages/workflow/README.md | 2 +- packages/workflow/tool-workflow/package.json | 2 +- .../tool-workflow/tests/tool-workflow.spec.ts | 2 +- .../README.md | 2 +- .../package.json | 2 +- .../src/host.ts | 8 ++++---- .../src/index.ts | 2 +- .../src/meta.ts | 2 +- .../src/protocol.ts | 2 +- .../src/realm.ts | 2 +- .../src/runtime.ts | 2 +- .../src/session.ts | 2 +- .../src/types.ts | 2 +- .../src/worker.ts | 2 +- .../tests/built-worker.e2e.ts | 2 +- .../tests/integration.spec.ts | 2 +- .../tests/meta.spec.ts | 0 .../tests/realm.spec.ts | 0 .../tests/session.spec.ts | 0 .../tests/workflow-workerthread.e2e.ts} | 0 .../tests/workflow-workerthread.spec.ts} | 2 +- .../tsconfig.json | 0 .../tsdown.config.ts | 0 packages/workflow/workflow/README.md | 2 +- packages/workflow/workflow/src/index.ts | 2 +- pnpm-lock.yaml | 6 +++--- scripts/gen-doc-graphs.ts | 2 +- scripts/gen-tool-catalog.ts | 2 +- scripts/run-gates.ts | 2 +- tsconfig.build.json | 2 +- tsconfig.json | 2 +- 43 files changed, 59 insertions(+), 59 deletions(-) rename packages/workflow/{workflow-vm => workflow-workerthread}/README.md (99%) rename packages/workflow/{workflow-vm => workflow-workerthread}/package.json (97%) rename packages/workflow/{workflow-vm => workflow-workerthread}/src/host.ts (97%) rename packages/workflow/{workflow-vm => workflow-workerthread}/src/index.ts (99%) rename packages/workflow/{workflow-vm => workflow-workerthread}/src/meta.ts (99%) rename packages/workflow/{workflow-vm => workflow-workerthread}/src/protocol.ts (98%) rename packages/workflow/{workflow-vm => workflow-workerthread}/src/realm.ts (99%) rename packages/workflow/{workflow-vm => workflow-workerthread}/src/runtime.ts (99%) rename packages/workflow/{workflow-vm => workflow-workerthread}/src/session.ts (99%) rename packages/workflow/{workflow-vm => workflow-workerthread}/src/types.ts (98%) rename packages/workflow/{workflow-vm => workflow-workerthread}/src/worker.ts (93%) rename packages/workflow/{workflow-vm => workflow-workerthread}/tests/built-worker.e2e.ts (97%) rename packages/workflow/{workflow-vm => workflow-workerthread}/tests/integration.spec.ts (98%) rename packages/workflow/{workflow-vm => workflow-workerthread}/tests/meta.spec.ts (100%) rename packages/workflow/{workflow-vm => workflow-workerthread}/tests/realm.spec.ts (100%) rename packages/workflow/{workflow-vm => workflow-workerthread}/tests/session.spec.ts (100%) rename packages/workflow/{workflow-vm/tests/workflow.e2e.ts => workflow-workerthread/tests/workflow-workerthread.e2e.ts} (100%) rename packages/workflow/{workflow-vm/tests/workflow-vm.spec.ts => workflow-workerthread/tests/workflow-workerthread.spec.ts} (99%) rename packages/workflow/{workflow-vm => workflow-workerthread}/tsconfig.json (100%) rename packages/workflow/{workflow-vm => workflow-workerthread}/tsdown.config.ts (100%) diff --git a/AGENTS.md b/AGENTS.md index 5602b244ce..d0a7983b26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ packages/ Harness packages at packages///, all named @deepseek-ai web/ web seam + search/fetch providers + model-facing web tools compact/ compaction seam + basic backend subagent/ subagent seam + spawn/fork/ACP backends + delegation tool - workflow/ workflow seam + worker-thread script engine + the workflow tool + workflow/ workflow seam + worker-thread engine + the workflow tool todo/ the todo_write tool hooks/ Claude Code / Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends @@ -70,7 +70,7 @@ printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' ls .sessions/_no-cwd/main-session-*.jsonl >/dev/null rm -rf .sessions -pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts packages/workflow/workflow-vm/tests/built-worker.e2e.ts +pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts ``` `test:coverage`, not `test`, is the gating run ([why](docs/testing.md)); a sign-off counts only for commands actually run. diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 1a13bd2060..8f5f316916 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -62,7 +62,7 @@ flowchart LR pkg_web_fetch_local["web-fetch-local"] pkg_workflow["workflow"] svc_workflows["ctx.workflows
Workflow script engine"] - pkg_workflow_vm["workflow-vm"] + pkg_workflow_workerthread["workflow-workerthread"] pkg_tool_workflow["tool-workflow"] pkg_agent --> svc_agents pkg_agent_loop --> svc_agentLoop @@ -93,7 +93,7 @@ flowchart LR pkg_web_search_exa --> svc_web pkg_web_search_perplexity --> svc_web pkg_workflow --> svc_workflows - pkg_workflow_vm --> svc_workflows + pkg_workflow_workerthread --> svc_workflows svc_agentLoop --> pkg_agent_core svc_agents --> pkg_acp svc_agents --> pkg_agent_loop @@ -145,6 +145,6 @@ flowchart LR | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-mock`](../packages/support/subagent-mock) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | -| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-vm`](../packages/workflow/workflow-vm) | [`tool-workflow`](../packages/workflow/tool-workflow) | - | One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents. | +| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow) | - | One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents. | Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 300468dd70..1649ad4e26 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -800,7 +800,7 @@ export interface Config { Source: [`packages/web/web-search-perplexity/src/index.ts:33`](../packages/web/web-search-perplexity/src/index.ts) -## `@deepseek-ai/dsh-workflow-vm` +## `@deepseek-ai/dsh-workflow-workerthread` Requires: `subagents` @@ -826,7 +826,7 @@ export interface Config { } ``` -Source: [`packages/workflow/workflow-vm/src/index.ts:69`](../packages/workflow/workflow-vm/src/index.ts) +Source: [`packages/workflow/workflow-workerthread/src/index.ts:69`](../packages/workflow/workflow-workerthread/src/index.ts) ## Loadable plugins with no config diff --git a/docs/core-data-structures/workflow.md b/docs/core-data-structures/workflow.md index 4d880ea948..36ad3be330 100644 --- a/docs/core-data-structures/workflow.md +++ b/docs/core-data-structures/workflow.md @@ -2,7 +2,7 @@ The workflow seam — an agent running a model-written orchestration SCRIPT that fans out subagents. Like [subagent](subagent.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). Unlike the subagent registry it takes the bash shape: ONE engine implementation per context provides `ctx.workflows`; there is no named-provider registry (a second engine is a plugin swap, not a co-resident). -Interface: [dsh-workflow](../../packages/workflow/workflow) (`ctx.workflows` + the vocabulary below). The implementation is [dsh-workflow-vm](../../packages/workflow/workflow-vm) (a `node:worker_threads` engine — one worker per run, the script's vm context inside it); the model-facing consumer is [dsh-tool-workflow](../../packages/workflow/tool-workflow). The proposal and rationale: [the dynamic-workflows RFC](../rfc/implemented/feature/2026-07-05-dynamic-workflows.md). +Interface: [dsh-workflow](../../packages/workflow/workflow) (`ctx.workflows` + the vocabulary below). The implementation is [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread) (a `node:worker_threads` engine — one worker per run, the script's vm context inside it); the model-facing consumer is [dsh-tool-workflow](../../packages/workflow/tool-workflow). The proposal and rationale: [the dynamic-workflows RFC](../rfc/implemented/feature/2026-07-05-dynamic-workflows.md). Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts) diff --git a/docs/module-graph.md b/docs/module-graph.md index ae1d8ef63f..f084e7fe40 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -81,7 +81,7 @@ flowchart TD subgraph group_workflow["packages/workflow"] pkg_tool_workflow["tool-workflow"] pkg_workflow["workflow"] - pkg_workflow_vm["workflow-vm"] + pkg_workflow_workerthread["workflow-workerthread"] end pkg_llm --> pkg_brand pkg_bash --> pkg_brand @@ -199,12 +199,12 @@ flowchart TD pkg_subagent_mock --> pkg_agent pkg_subagent_mock --> pkg_llm pkg_subagent_mock --> pkg_subagent - pkg_workflow_vm --> pkg_agent - pkg_workflow_vm --> pkg_brand - pkg_workflow_vm --> pkg_llm - pkg_workflow_vm --> pkg_subagent - pkg_workflow_vm --> pkg_tools - pkg_workflow_vm --> pkg_workflow + pkg_workflow_workerthread --> pkg_agent + pkg_workflow_workerthread --> pkg_brand + pkg_workflow_workerthread --> pkg_llm + pkg_workflow_workerthread --> pkg_subagent + pkg_workflow_workerthread --> pkg_tools + pkg_workflow_workerthread --> pkg_workflow pkg_subagent_fork --> pkg_agent pkg_subagent_fork --> pkg_session pkg_subagent_fork --> pkg_subagent @@ -268,7 +268,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | -| [`workflow-vm`](../packages/workflow/workflow-vm) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index d0e436b17e..029e090cb1 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -20,7 +20,7 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre `ctx.workflows` is an abstract `WorkflowService` in the bash shape — one engine per context, no named-provider registry (engines are deployment swaps, not co-residents). `start(request)` throws synchronously for a script that cannot begin; a returned `WorkflowRun`'s `result` NEVER rejects (failures resolve as `stopReason: 'error' | 'cancelled'`). The `workflow/*` events are observe-only emits carrying DATA SNAPSHOTS (id + meta; `workflow/end` omits the result value), per-listener contained, mirroring `subagent/start`/`subagent/end` — control stays with the run's holder. Vocabulary details: [core-data-structures/workflow.md](../../../core-data-structures/workflow.md). -### The engine (dsh-workflow-vm): one worker thread per run +### The engine (dsh-workflow-workerthread): one worker thread per run **Trust premise (governs every engine decision below)**: workflow scripts are MODEL-WRITTEN — the same trust level as the model's existing bash access — so the engine defends against BUGGY scripts, never hostile ones. In scope: `result` never rejects, no unhandled rejections from dropped hook promises, loud rejection of values JSON cannot carry, fatal-vs-null hook discipline, cancellation that always frees the caller. Out of scope, deliberately: adversarial values (throwing/spinning accessors, proxies with hostile traps, prototype forgery, `prepareStackTrace` hijack) AND Node-API escape from the script's context — the vm context shares object machinery with its surrounding realm, so a script can reach the `Function` constructor (`globalThis.constructor.constructor`) and from it `process` and every Node builtin; the absent globals are API surface, not containment, and a worker thread is NOT a security boundary (an escapee holds process-wide privileges — Node's permission model is per-process). Worker-side code MAY run script code while reading script values, and that is accepted: a synchronous spin costs the script its OWN thread (terminated at the post-cancel grace), never the host loop, so containing error VALUES would be cost without a threat model. Genuine sandboxing (isolated-vm, a separate process) remains an engine swap behind the seam, not incremental defenses here. diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 57be4be05e..06290d326e 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -31,8 +31,8 @@ flowchart LR cfg --> plugin_acp_tool_subagent plugin_acp_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] cfg --> plugin_acp_tool_subagent_fork - plugin_acp_workflow_vm["workflow-vm
@deepseek-ai/dsh-workflow-vm"] - cfg --> plugin_acp_workflow_vm + plugin_acp_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] + cfg --> plugin_acp_workflow_workerthread plugin_acp_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] cfg --> plugin_acp_tool_workflow plugin_acp_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] @@ -59,7 +59,7 @@ flowchart LR | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | -| `workflow-vm` | `@deepseek-ai/dsh-workflow-vm` | +| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | | `tool-todo` | `@deepseek-ai/dsh-tool-todo` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index f2015bc7c2..3a0d177005 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -86,8 +86,8 @@ # subagent backend above, plus the model-facing `workflow` tool. The model # writes a JavaScript orchestration script (meta + body); the engine runs it # in its own worker thread and fans agent() calls out as spawn children. -- id: workflow-vm - name: '@deepseek-ai/dsh-workflow-vm' +- id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' config: provider: spawn diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 1c3e524903..944ccd9b5b 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -108,7 +108,7 @@ const SCENARIOS: Scenario[] = [ { name: 'subagent-fork', hasModelTurn: true, recorded: true, childSessions: 1 }, { name: 'subagent-mixed', hasModelTurn: true, recorded: true, childSessions: 2 }, // The workflow tool: the model writes a one-child orchestration script; the - // child runs as a spawn subagent inside the vm engine (its session is the + // child runs as a spawn subagent under the worker-thread engine (its session is the // child fixture), and the tool result carries the script's return value. { name: 'workflow-run', hasModelTurn: true, recorded: true, childSessions: 1 }, // Hook matrix — one scenario per hook point × its headline Decision outcome, diff --git a/examples/coding-agent/composition.md b/examples/coding-agent/composition.md index 4ebf4c838d..be9a457124 100644 --- a/examples/coding-agent/composition.md +++ b/examples/coding-agent/composition.md @@ -35,8 +35,8 @@ flowchart LR cfg --> plugin_coding_tool_subagent plugin_coding_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] cfg --> plugin_coding_tool_subagent_fork - plugin_coding_workflow_vm["workflow-vm
@deepseek-ai/dsh-workflow-vm"] - cfg --> plugin_coding_workflow_vm + plugin_coding_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] + cfg --> plugin_coding_workflow_workerthread plugin_coding_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] cfg --> plugin_coding_tool_workflow plugin_coding_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] @@ -61,7 +61,7 @@ flowchart LR | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | -| `workflow-vm` | `@deepseek-ai/dsh-workflow-vm` | +| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | | `tool-todo` | `@deepseek-ai/dsh-tool-todo` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index ee55bced0b..cf2e267e06 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -107,8 +107,8 @@ # subagent backend above, plus the model-facing `workflow` tool. The model # writes a JavaScript orchestration script (meta + body); the engine runs it # in its own worker thread and fans agent() calls out as spawn children. -- id: workflow-vm - name: '@deepseek-ai/dsh-workflow-vm' +- id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' config: provider: spawn diff --git a/knip.json b/knip.json index 717148a10a..5342aadc29 100644 --- a/knip.json +++ b/knip.json @@ -41,7 +41,7 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, - "packages/workflow/workflow-vm": { + "packages/workflow/workflow-workerthread": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, diff --git a/packages/workflow/README.md b/packages/workflow/README.md index a0750d9fdf..98fa3cfe04 100644 --- a/packages/workflow/README.md +++ b/packages/workflow/README.md @@ -5,7 +5,7 @@ The workflow seam: a model-written JavaScript orchestration script that fans out | Package | Role | ctx key | |---|---|---| | `workflow/` | Abstract workflow seam: service base class + run vocabulary + `workflow/*` events | `ctx.workflows` | -| `workflow-vm/` | `node:worker_threads` engine: one worker per run; the script's vm context lives inside the worker, `agent()` bridges to `ctx.subagents` over the message port | (provides `ctx.workflows`) | +| `workflow-workerthread/` | `node:worker_threads` engine: one worker per run; the script's vm context lives inside the worker, `agent()` bridges to `ctx.subagents` over the message port | (provides `ctx.workflows`) | | `tool-workflow/` | Model-facing `workflow` tool over `ctx.workflows` | (registers on `ctx.tools`) | The interface lives at `workflow/workflow/`. The engine's `agent()` hook rides the [subagent seam](../subagent/README.md) (any registered provider; the shipped examples use `spawn`), and `agent({ schema })` rides the structured-output support the in-process backends implement. The worker thread isolates the SCRIPT — the host never blocks on it, and a cancelled run's post-grace termination is real — but it is NOT a security boundary; an isolated-vm/separate-process engine (actual sandboxing) swaps in behind the same interface if that ever matters. diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 7fb0d1ec48..f5c2138d85 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -40,7 +40,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", - "@deepseek-ai/dsh-workflow-vm": "workspace:^", + "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 2b6503cd45..dafc39bf46 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -10,7 +10,7 @@ import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow' import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' import { CallId } from '@deepseek-ai/dsh-llm' import SubagentService from '@deepseek-ai/dsh-subagent' -import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-vm' +import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' import * as toolWorkflow from '../src/index.ts' /** A controllable engine standing in behind ctx.workflows (the tool's only seam). */ diff --git a/packages/workflow/workflow-vm/README.md b/packages/workflow/workflow-workerthread/README.md similarity index 99% rename from packages/workflow/workflow-vm/README.md rename to packages/workflow/workflow-workerthread/README.md index 9a0ab21fef..0e763bdee9 100644 --- a/packages/workflow/workflow-vm/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -1,4 +1,4 @@ -# @deepseek-ai/dsh-workflow-vm +# @deepseek-ai/dsh-workflow-workerthread The [`WorkflowService`](../workflow/README.md) implementation, on **`node:worker_threads`**: each run gets its OWN worker thread (one run = one worker, no pooling — a run is heavyweight, so the ~tens-of-ms thread spin-up is noise), the script executes in a vm context INSIDE that worker with the workflow hooks injected, and every `agent()` call bridges back over the message port to [`ctx.subagents`](../../subagent/README.md) on the host. Child agents are I/O-bound LLM loops and stay on the host event loop; the thread isolates the SCRIPT, the only part that can spin synchronously. diff --git a/packages/workflow/workflow-vm/package.json b/packages/workflow/workflow-workerthread/package.json similarity index 97% rename from packages/workflow/workflow-vm/package.json rename to packages/workflow/workflow-workerthread/package.json index a174893b9c..f86c74c2f7 100644 --- a/packages/workflow/workflow-vm/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -1,5 +1,5 @@ { - "name": "@deepseek-ai/dsh-workflow-vm", + "name": "@deepseek-ai/dsh-workflow-workerthread", "description": "worker-thread workflow engine: executes model-written orchestration scripts off the host event loop, bridging agent() calls back to ctx.subagents", "version": "0.0.1", "private": true, diff --git a/packages/workflow/workflow-vm/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts similarity index 97% rename from packages/workflow/workflow-vm/src/host.ts rename to packages/workflow/workflow-workerthread/src/host.ts index b329e0c7d2..9d98d00933 100644 --- a/packages/workflow/workflow-vm/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -22,7 +22,7 @@ * still queued worker-side for a concurrency slot are unknowable then; the * worker's own count rides the result message on every graceful path. * - * @module @deepseek-ai/dsh-workflow-vm/host + * @module @deepseek-ai/dsh-workflow-workerthread/host */ import { fileURLToPath } from 'node:url' @@ -190,7 +190,7 @@ export class WorkerRun implements WorkflowRun { // data, so serialization cannot fail); there is nothing left to // deliver to — log and move on. /* v8 ignore next -- postMessage teardown race (a throw between exit and its event): not constructible in-process */ - this.ctx.logger.warn(`workflow-vm: postMessage failed: ${renderThrown(error)}`) + this.ctx.logger.warn(`workflow-workerthread: postMessage failed: ${renderThrown(error)}`) } } @@ -293,7 +293,7 @@ export class WorkerRun implements WorkflowRun { // The subagent seam's dispose() is not supposed to reject; a backend // that does anyway must not wedge the script's finally (which awaits // the ack) — ack and move on. - this.ctx.logger.warn(`workflow-vm: child dispose failed: ${renderThrown(error)}`) + this.ctx.logger.warn(`workflow-workerthread: child dispose failed: ${renderThrown(error)}`) this.finishChild(callId) this.post(HostToWorkerType.ChildDisposed, { callId }) }, @@ -322,7 +322,7 @@ export class WorkerRun implements WorkflowRun { void run.dispose().then( () => { this.finishChild(callId) }, (error: unknown) => { - this.ctx.logger.warn(`workflow-vm: child dispose failed during reap: ${renderThrown(error)}`) + this.ctx.logger.warn(`workflow-workerthread: child dispose failed during reap: ${renderThrown(error)}`) this.finishChild(callId) }, ) diff --git a/packages/workflow/workflow-vm/src/index.ts b/packages/workflow/workflow-workerthread/src/index.ts similarity index 99% rename from packages/workflow/workflow-vm/src/index.ts rename to packages/workflow/workflow-workerthread/src/index.ts index 1b65381bfb..538252d90a 100644 --- a/packages/workflow/workflow-vm/src/index.ts +++ b/packages/workflow/workflow-workerthread/src/index.ts @@ -36,7 +36,7 @@ * Plugin export shape: a default-exported {@link WorkflowService} subclass * (the class-based service form, like `dsh-bash-local`). * - * @module @deepseek-ai/dsh-workflow-vm + * @module @deepseek-ai/dsh-workflow-workerthread */ import { randomUUID } from 'node:crypto' diff --git a/packages/workflow/workflow-vm/src/meta.ts b/packages/workflow/workflow-workerthread/src/meta.ts similarity index 99% rename from packages/workflow/workflow-vm/src/meta.ts rename to packages/workflow/workflow-workerthread/src/meta.ts index 75958ba6f1..e818bc6f00 100644 --- a/packages/workflow/workflow-vm/src/meta.ts +++ b/packages/workflow/workflow-workerthread/src/meta.ts @@ -14,7 +14,7 @@ * result — not the source — is the contract: it must materialize to plain * JSON data and pass the shape validation). * - * @module @deepseek-ai/dsh-workflow-vm/meta + * @module @deepseek-ai/dsh-workflow-workerthread/meta */ import * as vm from 'node:vm' diff --git a/packages/workflow/workflow-vm/src/protocol.ts b/packages/workflow/workflow-workerthread/src/protocol.ts similarity index 98% rename from packages/workflow/workflow-vm/src/protocol.ts rename to packages/workflow/workflow-workerthread/src/protocol.ts index 293676706e..ed6b54950a 100644 --- a/packages/workflow/workflow-vm/src/protocol.ts +++ b/packages/workflow/workflow-workerthread/src/protocol.ts @@ -13,7 +13,7 @@ * `post(type, payload)` whose payload parameter is looked up from the map, * so a tag/payload mismatch is a compile error at the call site. * - * @module @deepseek-ai/dsh-workflow-vm/protocol + * @module @deepseek-ai/dsh-workflow-workerthread/protocol */ import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult } from '@deepseek-ai/dsh-workflow' diff --git a/packages/workflow/workflow-vm/src/realm.ts b/packages/workflow/workflow-workerthread/src/realm.ts similarity index 99% rename from packages/workflow/workflow-vm/src/realm.ts rename to packages/workflow/workflow-workerthread/src/realm.ts index 017de76069..393716c279 100644 --- a/packages/workflow/workflow-vm/src/realm.ts +++ b/packages/workflow/workflow-workerthread/src/realm.ts @@ -27,7 +27,7 @@ * thrown by a hook is built OUTSIDE the script's vm context, so an in-script * `instanceof Error` check is false; read `name`/`code`/`message` instead. * - * @module @deepseek-ai/dsh-workflow-vm/realm + * @module @deepseek-ai/dsh-workflow-workerthread/realm */ /** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */ diff --git a/packages/workflow/workflow-vm/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts similarity index 99% rename from packages/workflow/workflow-vm/src/runtime.ts rename to packages/workflow/workflow-workerthread/src/runtime.ts index 3005520fa0..28450977c3 100644 --- a/packages/workflow/workflow-vm/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -33,7 +33,7 @@ * the settles-within-grace guarantee by force-settling `cancelled` and * terminating the worker — the real kill an in-process engine could not have. * - * @module @deepseek-ai/dsh-workflow-vm/runtime + * @module @deepseek-ai/dsh-workflow-workerthread/runtime */ import * as vm from 'node:vm' diff --git a/packages/workflow/workflow-vm/src/session.ts b/packages/workflow/workflow-workerthread/src/session.ts similarity index 99% rename from packages/workflow/workflow-vm/src/session.ts rename to packages/workflow/workflow-workerthread/src/session.ts index 131f760ff9..718b8ac54d 100644 --- a/packages/workflow/workflow-vm/src/session.ts +++ b/packages/workflow/workflow-workerthread/src/session.ts @@ -14,7 +14,7 @@ * A `cancel` arriving instead of `go` still releases the gate: `drive()` * sees the cancelled state and settles without running the body. * - * @module @deepseek-ai/dsh-workflow-vm/session + * @module @deepseek-ai/dsh-workflow-workerthread/session */ import type { MessagePort } from 'node:worker_threads' diff --git a/packages/workflow/workflow-vm/src/types.ts b/packages/workflow/workflow-workerthread/src/types.ts similarity index 98% rename from packages/workflow/workflow-vm/src/types.ts rename to packages/workflow/workflow-workerthread/src/types.ts index c87fc4668c..329523ffc8 100644 --- a/packages/workflow/workflow-vm/src/types.ts +++ b/packages/workflow/workflow-workerthread/src/types.ts @@ -6,7 +6,7 @@ * JSON data by construction, so the structured-clone hop never meets a value * it cannot carry. Types only, per the package convention. * - * @module @deepseek-ai/dsh-workflow-vm/types + * @module @deepseek-ai/dsh-workflow-workerthread/types */ import type { ContentBlock } from '@deepseek-ai/dsh-llm' diff --git a/packages/workflow/workflow-vm/src/worker.ts b/packages/workflow/workflow-workerthread/src/worker.ts similarity index 93% rename from packages/workflow/workflow-vm/src/worker.ts rename to packages/workflow/workflow-workerthread/src/worker.ts index 3b20600d7d..f468ad9a53 100644 --- a/packages/workflow/workflow-vm/src/worker.ts +++ b/packages/workflow/workflow-workerthread/src/worker.ts @@ -6,7 +6,7 @@ * coverage); loading this module on the main thread throws via * `requireParentPort`, which is how the suite covers the file itself. * - * @module @deepseek-ai/dsh-workflow-vm/worker + * @module @deepseek-ai/dsh-workflow-workerthread/worker */ import { parentPort, workerData } from 'node:worker_threads' diff --git a/packages/workflow/workflow-vm/tests/built-worker.e2e.ts b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts similarity index 97% rename from packages/workflow/workflow-vm/tests/built-worker.e2e.ts rename to packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts index 9bdd3865bb..d541b4c0ff 100644 --- a/packages/workflow/workflow-vm/tests/built-worker.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts @@ -28,7 +28,7 @@ describe.skipIf(!existsSync(builtIndex) || !existsSync(builtWorker))('built work await writeFile(driver, ` import { Context } from 'cordis' import SubagentService from '@deepseek-ai/dsh-subagent' -import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-vm' +import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' const ctx = new Context() await ctx.plugin(SubagentService) diff --git a/packages/workflow/workflow-vm/tests/integration.spec.ts b/packages/workflow/workflow-workerthread/tests/integration.spec.ts similarity index 98% rename from packages/workflow/workflow-vm/tests/integration.spec.ts rename to packages/workflow/workflow-workerthread/tests/integration.spec.ts index e53ea518bc..ae58a536f8 100644 --- a/packages/workflow/workflow-vm/tests/integration.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/integration.spec.ts @@ -41,7 +41,7 @@ async function setup(script: Script) { return { ctx, parent, adapter } } -describe('dsh-workflow-vm over the real in-process stack', () => { +describe('dsh-workflow-workerthread over the real in-process stack', () => { it('runs a two-stage workflow: a plain child, then a schema child through the structured runtime', async () => { const { ctx, parent } = await setup([ textResponse('the file list is a.ts'), diff --git a/packages/workflow/workflow-vm/tests/meta.spec.ts b/packages/workflow/workflow-workerthread/tests/meta.spec.ts similarity index 100% rename from packages/workflow/workflow-vm/tests/meta.spec.ts rename to packages/workflow/workflow-workerthread/tests/meta.spec.ts diff --git a/packages/workflow/workflow-vm/tests/realm.spec.ts b/packages/workflow/workflow-workerthread/tests/realm.spec.ts similarity index 100% rename from packages/workflow/workflow-vm/tests/realm.spec.ts rename to packages/workflow/workflow-workerthread/tests/realm.spec.ts diff --git a/packages/workflow/workflow-vm/tests/session.spec.ts b/packages/workflow/workflow-workerthread/tests/session.spec.ts similarity index 100% rename from packages/workflow/workflow-vm/tests/session.spec.ts rename to packages/workflow/workflow-workerthread/tests/session.spec.ts diff --git a/packages/workflow/workflow-vm/tests/workflow.e2e.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts similarity index 100% rename from packages/workflow/workflow-vm/tests/workflow.e2e.ts rename to packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts diff --git a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts similarity index 99% rename from packages/workflow/workflow-vm/tests/workflow-vm.spec.ts rename to packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 7264877f48..9e2218c167 100644 --- a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -119,7 +119,7 @@ async function run(ctx: Context, parent: Agent, source: string, args?: unknown): } } -describe('dsh-workflow-vm', () => { +describe('dsh-workflow-workerthread', () => { describe('script execution over a real worker thread', () => { it('runs a script end-to-end: agent() text results, phases, log, args, return value, events', async () => { const { ctx, parent, provider } = await setup({ reply: (_request, index) => text(`answer-${index}`) }) diff --git a/packages/workflow/workflow-vm/tsconfig.json b/packages/workflow/workflow-workerthread/tsconfig.json similarity index 100% rename from packages/workflow/workflow-vm/tsconfig.json rename to packages/workflow/workflow-workerthread/tsconfig.json diff --git a/packages/workflow/workflow-vm/tsdown.config.ts b/packages/workflow/workflow-workerthread/tsdown.config.ts similarity index 100% rename from packages/workflow/workflow-vm/tsdown.config.ts rename to packages/workflow/workflow-workerthread/tsdown.config.ts diff --git a/packages/workflow/workflow/README.md b/packages/workflow/workflow/README.md index 96add5017e..317aaf9f81 100644 --- a/packages/workflow/workflow/README.md +++ b/packages/workflow/workflow/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-workflow -The **workflow seam** (`ctx.workflows`): an abstract service defining WHAT a workflow engine does — execute a model-written orchestration script that fans out subagents — without saying HOW. The bash-shaped third of the [workflow family](../README.md): implementations subclass `WorkflowService` and register as the `workflows` service (one per context); [`dsh-workflow-vm`](../workflow-vm/README.md) (one worker thread per run) is the implementation, and [`dsh-tool-workflow`](../tool-workflow/README.md) is the model-facing consumer. +The **workflow seam** (`ctx.workflows`): an abstract service defining WHAT a workflow engine does — execute a model-written orchestration script that fans out subagents — without saying HOW. The bash-shaped third of the [workflow family](../README.md): implementations subclass `WorkflowService` and register as the `workflows` service (one per context); [`dsh-workflow-workerthread`](../workflow-workerthread/README.md) (one worker thread per run) is the implementation, and [`dsh-tool-workflow`](../tool-workflow/README.md) is the model-facing consumer. ## Service: `WorkflowService` (abstract) diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index 2a57d143b6..6c2b797362 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -4,7 +4,7 @@ * that fans out subagents — without saying HOW. Implementations subclass * {@link WorkflowService} and register as the `workflows` service (one * implementation per context, cordis' standard duplicate-service behavior); - * the implementation is `@deepseek-ai/dsh-workflow-vm`, which runs each + * the implementation is `@deepseek-ai/dsh-workflow-workerthread`, which runs each * script in its own worker thread. Hardened engines (an isolated-vm or * separate-process sandbox) swap in without touching the model-facing tool * that consumes them (`@deepseek-ai/dsh-tool-workflow`). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3e1892e35..c3ce677f83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1092,9 +1092,9 @@ importers: '@deepseek-ai/dsh-workflow': specifier: workspace:^ version: link:../workflow - '@deepseek-ai/dsh-workflow-vm': + '@deepseek-ai/dsh-workflow-workerthread': specifier: workspace:^ - version: link:../workflow-vm + version: link:../workflow-workerthread cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -1117,7 +1117,7 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/workflow/workflow-vm: + packages/workflow/workflow-workerthread: dependencies: schemastery: specifier: ^3.18.0 diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0abe1a34cf..31e010f55c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -191,7 +191,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'workflow', title: 'Workflow script engine', mode: 'seam', - implementations: ['workflow-vm'], + implementations: ['workflow-workerthread'], consumers: ['tool-workflow'], note: 'One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents.', }, diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 27481a6304..42795ec00e 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -51,7 +51,7 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' import * as ToolWeb from '@deepseek-ai/dsh-tool-web' -import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-vm' +import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 25e08cf8d2..3b4a3568cb 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -313,7 +313,7 @@ function builtBinSmokeGate(): Gate { // The workflow engine's built worker bundle: the only automated proof // that lib/index.js resolves its sibling lib/worker.js under plain node // (the e2e lane runs unbuilt, so this file self-skips there). - 'packages/workflow/workflow-vm/tests/built-worker.e2e.ts', + 'packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts', ], { label: 'built-bin smoke', needs: ['build'], diff --git a/tsconfig.build.json b/tsconfig.build.json index 93cce8f4b9..018c29ec37 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -52,7 +52,7 @@ { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, { "path": "./packages/workflow/workflow" }, - { "path": "./packages/workflow/workflow-vm" }, + { "path": "./packages/workflow/workflow-workerthread" }, { "path": "./packages/workflow/tool-workflow" }, { "path": "./packages/todo/tool-todo" }, { "path": "./packages/hooks/hook-protocol" }, diff --git a/tsconfig.json b/tsconfig.json index ba5ccbac26..0cad85791c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -63,7 +63,7 @@ { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, { "path": "./packages/workflow/workflow" }, - { "path": "./packages/workflow/workflow-vm" }, + { "path": "./packages/workflow/workflow-workerthread" }, { "path": "./packages/workflow/tool-workflow" }, { "path": "./packages/todo/tool-todo" }, { "path": "./packages/hooks/hook-protocol" }, From af9616f47d87f317d5bc07b90015b3bdaee7524c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:12:58 +0800 Subject: [PATCH 41/57] test: let the wedged-worker regression post its child-start first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regression's script spun immediately after calling agent(), but the agent() continuation (which posts the child-start RPC) only runs on a microtask tick — the spin seized the worker's loop before the post, so the host never saw a child inside the waitFor window. A few await-null yields before the spin let the RPC out; the posted message needs no further worker-loop turns to reach the host, so the wedge still holds for the Cancel message the test is about. --- .../tests/workflow-workerthread.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 9e2218c167..20911d052e 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -511,9 +511,13 @@ describe('dsh-workflow-workerthread', () => { // The stray child's start RPC reaches the host, then the script wedges // its own worker in a synchronous spin: the worker cannot process the // Cancel message, so it can relay NO ChildCancel RPC — only the host's - // own children loop can deliver the explicit cancel in time. + // own children loop can deliver the explicit cancel in time. The + // microtask yields let the agent() continuation POST its child-start + // before the spin seizes the worker's loop (the posted message needs + // no further worker-loop turns to reach the host). script: script(` agent('wedged child') + for (let i = 0; i < 20; i++) await null const end = Date.now() + 1500 while (Date.now() < end) {} return 'raced' From 0d0f0204f206fbea27530919f49c48cf0f3245d6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:09:10 +0800 Subject: [PATCH 42/57] =?UTF-8?q?workflow:=20meta=20rides=20the=20seam=20a?= =?UTF-8?q?s=20data=20=E2=80=94=20the=20engine=20never=20evaluates=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 review finding: extractMeta timed only the literal's vm evaluation; materializing the RESULT then read properties ordinarily on the HOST stack, so a meta literal smuggling a getter (get name() { while(true){} }) could wedge the host outside any timeout — defeating the exact spin isolation the worker thread exists for. Rather than harden the evaluator (descriptor walks, AST validation), delete the mechanism: the workflow's identity now reaches the seam as a plain JSON field (WorkflowStartRequest.meta), carried by the tool as a schema-validated `meta` object parameter the model fills directly. The engine only shape-validates data (validateMeta, every violation named) and pre-parses the body; the scanner, the vm evaluation, and the host-side materialization are gone, and with them the hole. A body still opening with a Claude Code-style `export const meta` statement gets a pointed SCRIPT_PARSE message (the likeliest authoring slip; a CC script's body stays drop-in, only its meta header moves into the parameter). syncTimeoutMs now governs exactly one thing: the initial synchronous slice inside the worker. The RFC's decision section is rewritten in place (implemented-RFC rule); the embedded-meta format moves to alternatives-considered with the hole as the reason. Tool description, presentation (title now reads meta.name directly — the textual sniff is gone), seam vocabulary docs, and catalogs follow. --- docs/config-catalog.md | 4 +- docs/core-data-structures/workflow.md | 7 +- .../feature/2026-07-05-dynamic-workflows.md | 10 +- docs/tool-catalog.md | 53 +++- packages/workflow/tool-workflow/src/index.ts | 53 ++-- .../tool-workflow/tests/tool-workflow.spec.ts | 46 ++-- .../workflow/workflow-workerthread/README.md | 8 +- .../workflow-workerthread/src/index.ts | 35 ++- .../workflow-workerthread/src/meta.ts | 196 ++------------- .../workflow-workerthread/src/runtime.ts | 2 +- .../workflow-workerthread/src/types.ts | 4 +- .../tests/built-worker.e2e.ts | 3 +- .../tests/integration.spec.ts | 8 +- .../workflow-workerthread/tests/meta.spec.ts | 228 +++++------------- .../tests/workflow-workerthread.e2e.ts | 6 +- .../tests/workflow-workerthread.spec.ts | 79 +++--- packages/workflow/workflow/README.md | 2 +- packages/workflow/workflow/src/types.ts | 22 +- 18 files changed, 304 insertions(+), 462 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1649ad4e26..65ef893528 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -685,7 +685,7 @@ export interface Config { } ``` -Source: [`packages/workflow/tool-workflow/src/index.ts:40`](../packages/workflow/tool-workflow/src/index.ts) +Source: [`packages/workflow/tool-workflow/src/index.ts:39`](../packages/workflow/tool-workflow/src/index.ts) ## `@deepseek-ai/dsh-web` @@ -815,7 +815,7 @@ export interface Config { maxTotalAgents?: number /** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */ maxItemsPerCall?: number - /** vm timeout for the initial synchronous slice (inside the worker) AND the host-side meta evaluation (default 5000 ms). */ + /** vm timeout for the script's initial synchronous slice, inside the worker (default 5000 ms). */ syncTimeoutMs?: number /** * How long after a cancellation an unsettled script may keep running before diff --git a/docs/core-data-structures/workflow.md b/docs/core-data-structures/workflow.md index 36ad3be330..4354105e70 100644 --- a/docs/core-data-structures/workflow.md +++ b/docs/core-data-structures/workflow.md @@ -8,20 +8,21 @@ Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/work ## The start request -What a caller asks for when starting a run. The tool layer builds this from the model's `{ script, args }` plus the calling agent; the engine validates the script's meta block BEFORE the body runs. `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)). `args` must be plain host-realm JSON data; the engine exposes it to the script as the `args` global. +What a caller asks for when starting a run. The tool layer builds this from the model's `{ script, meta, args }` call plus the calling agent; `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)). ```ts type-equiv interface WorkflowStartRequest { script: string + meta: WorkflowMeta args?: unknown parent: Agent signal?: AbortSignal } ``` -## The script's identity: `WorkflowMeta` +## The workflow's identity: `WorkflowMeta` -The validated `export const meta` block (Claude Code dynamic-workflows format — a PURE object literal heading the script). `phases` is progress vocabulary only: `phase()` calls match titles for observers; no execution structure is implied. +The identity block carried as data on the start request (the tool's `meta` parameter; the field vocabulary matches the Claude Code dynamic-workflows meta block). `phases` is progress vocabulary only: `phase()` calls match titles for observers; no execution structure is implied. ```ts type-equiv interface WorkflowMeta { diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index 029e090cb1..2f711651f6 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -12,7 +12,7 @@ A workflow capability family at `packages/workflow/` in the bash seam shape (int ### The script contract (Claude Code-compatible) -A script is `export const meta = {...}` (a PURE object literal: `name`, `description`, optional `whenToUse`/`phases`) followed by a plain-JS body with top-level `await`, ending in `return `. The body sees exactly: `agent(prompt, {label, phase, schema, model})`, `parallel(thunks)`, `pipeline(items, ...stages)` (NO cross-stage barrier; `(prev, item, index)` callbacks), `phase(title)`, `log(message)`, and `args`. CC semantics are preserved where they matter to script authors: a failed child resolves `null` (scripts `.filter(Boolean)`); an ordinary stage throw nulls the ITEM and skips its remaining stages. CC's determinism bans (`Date.now()`/`Math.random()`/argless `new Date()` throwing) are NOT enforced — they exist for CC's journaling/resume, which this cut defers — so a CC-authored script runs unchanged while scripts written here may freely read the clock. +A workflow call is two parts: a `meta` JSON parameter (the identity block — `name`, `description`, optional `whenToUse`/`phases`; the field vocabulary matches Claude Code's meta block) and a `script` — a plain-JS body with top-level `await`, ending in `return `. Meta is DATA, never code: the engine shape-validates it and evaluates no script text to obtain it (a body still opening with a CC-style `export const meta` statement is rejected with a pointed message). The body sees exactly: `agent(prompt, {label, phase, schema, model})`, `parallel(thunks)`, `pipeline(items, ...stages)` (NO cross-stage barrier; `(prev, item, index)` callbacks), `phase(title)`, `log(message)`, and `args`. CC semantics are preserved where they matter to script authors: a failed child resolves `null` (scripts `.filter(Boolean)`); an ordinary stage throw nulls the ITEM and skips its remaining stages. CC's determinism bans (`Date.now()`/`Math.random()`/argless `new Date()` throwing) are NOT enforced — they exist for CC's journaling/resume, which this cut defers — so a CC-authored BODY runs unchanged (its meta header moves into the parameter) while scripts written here may freely read the clock. One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferred options (`effort`/`isolation`/`agentType`), malformed arguments, schemas outside the supported subset, tripped caps, seam start failures — throws a `WorkflowError` with `fatal: true`, and the combinators RE-THROW fatal errors instead of nulling the item. Without this, a typo'd option dissolves into a `null` indistinguishable from a child failure — the accepted-then-ignored failure mode this repo bans. One addition: the tool's `args` parameter is a JSON OBJECT (a bare list is wrapped as a field) so the wire schema stays honest. @@ -24,15 +24,15 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre **Trust premise (governs every engine decision below)**: workflow scripts are MODEL-WRITTEN — the same trust level as the model's existing bash access — so the engine defends against BUGGY scripts, never hostile ones. In scope: `result` never rejects, no unhandled rejections from dropped hook promises, loud rejection of values JSON cannot carry, fatal-vs-null hook discipline, cancellation that always frees the caller. Out of scope, deliberately: adversarial values (throwing/spinning accessors, proxies with hostile traps, prototype forgery, `prepareStackTrace` hijack) AND Node-API escape from the script's context — the vm context shares object machinery with its surrounding realm, so a script can reach the `Function` constructor (`globalThis.constructor.constructor`) and from it `process` and every Node builtin; the absent globals are API surface, not containment, and a worker thread is NOT a security boundary (an escapee holds process-wide privileges — Node's permission model is per-process). Worker-side code MAY run script code while reading script values, and that is accepted: a synchronous spin costs the script its OWN thread (terminated at the post-cancel grace), never the host loop, so containing error VALUES would be cost without a threat model. Genuine sandboxing (isolated-vm, a separate process) remains an engine swap behind the seam, not incremental defenses here. -**Why node:worker_threads**: one run = one worker thread, no pooling — a run is heavyweight (many children), so thread spin-up (~tens of ms) is noise. The script runs in a vm context INSIDE the worker, keeping the script-visible surface exactly the hook contract above (a bare worker realm would leak `setTimeout`/`fetch`/`process` as accidental API), and every `agent()` bridges to `ctx.subagents` by message-port RPC — children are I/O-bound LLM loops and stay on the host loop; the thread isolates the SCRIPT, the only part that can spin. What the thread buys: `start()` never blocks the host (an in-process engine runs the initial synchronous slice inline and cannot kill a spin past the first await — it could only ABANDON such a script, leaving the spin on the host loop), the post-cancel grace ends in a REAL `worker.terminate()`, and the value boundary is serialization by construction. isolated-vm was rejected for actual sandboxing: maintenance mode, `--no-node-snapshot` on EVERY consumer process (including published bins) on Node ≥ 20, node-gyp source-build fallback. Key mechanics (details in the package README): meta extraction and a body pre-parse stay HOST-side (preserving the seam's synchronous throws), a ready→go handshake keeps a run cancelled before start from ever executing the body, `cancel()` drives both child-cancel channels host-side (the shared request signal AND each child's explicit `cancel()` — a wedged worker cannot relay its own cancel RPCs), a host-side child registry backs worker-death reaping and `dispose()` quiescence, the wire protocol is enum-keyed payload maps private to the package, and on a termination path `agentsStarted` degrades to the host-observed count. Coverage puts the worker-side session on an in-process `MessageChannel` (real-Worker code is invisible to main-process v8) and proves the built `lib/worker.js` — a second tsdown entry, sanctioned in the workspace-constraints gate by the `"./worker"` subpath export — under plain node in the built-bin smoke gate. +**Why node:worker_threads**: one run = one worker thread, no pooling — a run is heavyweight (many children), so thread spin-up (~tens of ms) is noise. The script runs in a vm context INSIDE the worker, keeping the script-visible surface exactly the hook contract above (a bare worker realm would leak `setTimeout`/`fetch`/`process` as accidental API), and every `agent()` bridges to `ctx.subagents` by message-port RPC — children are I/O-bound LLM loops and stay on the host loop; the thread isolates the SCRIPT, the only part that can spin. What the thread buys: `start()` never blocks the host (an in-process engine runs the initial synchronous slice inline and cannot kill a spin past the first await — it could only ABANDON such a script, leaving the spin on the host loop), the post-cancel grace ends in a REAL `worker.terminate()`, and the value boundary is serialization by construction. isolated-vm was rejected for actual sandboxing: maintenance mode, `--no-node-snapshot` on EVERY consumer process (including published bins) on Node ≥ 20, node-gyp source-build fallback. Key mechanics (details in the package README): meta shape-validation and a body pre-parse stay HOST-side (preserving the seam's synchronous throws), a ready→go handshake keeps a run cancelled before start from ever executing the body, `cancel()` drives both child-cancel channels host-side (the shared request signal AND each child's explicit `cancel()` — a wedged worker cannot relay its own cancel RPCs), a host-side child registry backs worker-death reaping and `dispose()` quiescence, the wire protocol is enum-keyed payload maps private to the package, and on a termination path `agentsStarted` degrades to the host-observed count. Coverage puts the worker-side session on an in-process `MessageChannel` (real-Worker code is invisible to main-process v8) and proves the built `lib/worker.js` — a second tsdown entry, sanctioned in the workspace-constraints gate by the `"./worker"` subpath export — under plain node in the built-bin smoke gate. -**Meta extraction**: a string/comment-aware brace scanner (template interpolation rejected) finds the literal; it is evaluated ALONE in an empty, timed vm context; the result must materialize to plain JSON data and pass shape validation (unknown fields rejected loud); the statement is blanked line-preservingly so stacks keep script line numbers. +**Meta as data, never evaluated**: the meta block reaches the seam as a plain JSON request field (the tool's schema-validated `meta` parameter) and the engine only shape-validates it, every violation named. This is a host-isolation invariant, not a convenience: evaluating a meta literal host-side — even one contractually "pure", in an empty timed vm context — hands script-controlled getters a host stack with no timeout the moment the result is READ, defeating the exact spin isolation the worker thread buys. **Value boundary**: values leaving the script (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation; getters are read ordinarily and their RESULT crosses (a throwing read fails loud) — which is also what makes every later postMessage hop total. Values entering the realm (`args`, `agent()` results, hook promises and failures, combinator arrays) are handed over directly as worker-realm values — the script is trusted, so outer prototypes are not a leak; `args` rides the `workerData` structured clone (the caller-isolation copy) and is cloned once more so a script scribbling on it cannot mutate the session's init object. Hook failures are `WorkflowError`s built OUTSIDE the script's context: the combinators recognize fatality by `instanceof` against the engine's own class (unforgeable from the script), and the script-visible consequence — in-script `instanceof Error` is `false` for hook errors; branch on `e.name`/`e.code` — is documented in the engine README. Realm functions (stages, thunks) are called, never materialized. Thrown script values are rendered by a total renderer (stack → message → `String()`, fixed label if rendering throws), so `result` cannot reject. Caps (`maxConcurrentAgents` auto = `min(16, max(1, availableParallelism() - 2))`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. ### The consumer (dsh-tool-workflow) -A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, await, `try/finally` dispose, abort-bridge `exec.signal`, non-`completed` → `isError`. Render intent: a `generic` card titled by a textual `meta.name` sniff (presentation is a pure function of args). The tool description IS the model-facing authoring spec. The usage policy ships with the tool as its own `tool:` prompt section (explicit-ask-only guidance — tool guidance lives in tool plugins, never in the deployment persona); the harness has no ultracode-style effort gate. +A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, await, `try/finally` dispose, abort-bridge `exec.signal`, non-`completed` → `isError`. Render intent: a `generic` card titled by the call's `meta.name` parameter (presentation is a pure function of args). The tool description IS the model-facing authoring spec. The usage policy ships with the tool as its own `tool:` prompt section (explicit-ask-only guidance — tool guidance lives in tool plugins, never in the deployment persona); the harness has no ultracode-style effort gate. ### The foundation: structured output on the subagent seam @@ -55,7 +55,7 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai - **In-process `node:vm` execution** (the first cut of this RFC shipped it): mechanically simplest — no RPC, no thread — but `start()` blocks the caller for the script's initial synchronous slice, a synchronous spin past the first await cannot be killed in-process (the vm `timeout` covers only that first slice), and `dispose()` could only ABANDON an unsettling script, leaving the spin on the host loop. Superseded by the worker-thread engine, which keeps the same vm-context script surface while unblocking the host and making termination real. - **Background execution as the default** (CC's shape): deferred; foreground-synchronous matches `dsh-tool-subagent`'s cut, and background semantics should be designed ONCE across bash/subagent/workflow rather than per-tool. - **Workflow-layer JSON parsing for `agent({schema})`**: duplicating a seam concern at one consumer while the seam's capability flag stayed dishonestly `false`. -- **Meta as tool parameters instead of `export const meta`**: zero parsing, but scripts stop being self-contained artifacts and CC-authored scripts stop being drop-in. +- **Meta embedded in the script as `export const meta = {...}`** (CC's exact format; the first cut shipped it): keeps scripts self-contained and CC scripts drop-in, but obtaining meta means evaluating model-written text on the HOST — the shipped extractor ran the literal in an empty timed vm context, yet reading the RESULT still executed script-controlled getters on the host stack outside any timeout, re-opening the host-spin hole the worker thread exists to close. A JSON parameter deletes the scanner, the evaluation, and the hole outright; the cost is that a CC script's meta header must move into the parameter (the body stays drop-in). - **`SchemaSpec` as the outputSchema type**: the author-facing DSL cannot express what arrives as data and cannot be validated against without conversion loss. - **A schema-object library (zod, or the repo's schemastery) for the structured-output subset**: the schema is wire data — plain JSON that crosses the vm realm boundary in `agent({schema})` and lands verbatim in the forced tool's parameters — exactly where live schema objects cannot sit; consuming raw JSON Schema at runtime would need a third-party converter on top (zod core only emits JSON Schema, not the reverse), and it would put a second schema language beside schemastery's config role. - **ajv for value validation**: it validates FULL JSON Schema, so the subset gate — the module's actual point, since every accepted keyword must be one the harness enforces — would remain hand-written regardless; it compiles validators through `new Function`; and it would be dsh-tools' first runtime dependency, all to replace the ~70-line value walker while the path-qualified, every-violation error reporting stays custom either way. diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 3794b1d7a7..088147b799 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -283,7 +283,7 @@ todo_write is session-owned state; UIs render the latest todo/write event as a c Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. -The script MUST begin with `export const meta = {...}` — a PURE object literal (no variables, calls, or template interpolation) with required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The body after it is plain JavaScript (NOT TypeScript) running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. +The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. @@ -301,7 +301,53 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim "properties": { "script": { "type": "string", - "description": "The complete workflow script: `export const meta = {...}` followed by the plain-JS body (top-level await allowed; end with `return `)." + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] }, "args": { "type": "object", @@ -309,7 +355,8 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim } }, "required": [ - "script" + "script", + "meta" ] } ``` diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 22ab21ffc6..7cd1606b47 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -13,9 +13,8 @@ * collection is deferred to the cross-tool background redesign. * * Render intent (decided up front, per the render-intent RFC): a `generic` - * card whose title carries the script's `meta.name`, sniffed textually from - * the args — presentation must be a pure function of `args`, so it cannot ask - * the engine to parse. + * card whose title carries the workflow's `meta.name`, read directly from the + * call's `meta` parameter — presentation is a pure function of `args`. * * Usage policy ships with the tool as a `tool:` system-prompt * section (explicit-ask-only guidance) — tool guidance lives in tool plugins, @@ -58,7 +57,7 @@ type ResolvedConfig = Required */ const DESCRIPTION = `Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. -The script MUST begin with \`export const meta = {...}\` — a PURE object literal (no variables, calls, or template interpolation) with required \`name\` (short kebab-case) and \`description\` strings, optional \`whenToUse\` string and \`phases\` array (\`{title, detail?, model?}\`). The body after it is plain JavaScript (NOT TypeScript) running with top-level await; end with \`return \` — the value must be JSON-serializable and is this tool's result. +The workflow's identity rides the \`meta\` parameter as JSON: required \`name\` (short kebab-case) and \`description\` strings, optional \`whenToUse\` string and \`phases\` array (\`{title, detail?, model?}\`). The \`script\` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO \`export const meta\` statement — meta is a parameter, not code), running with top-level await; end with \`return \` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - \`agent(prompt, opts?): Promise\` — run one subagent to completion. Without \`opts.schema\` it resolves to the child's final text; with \`opts.schema\` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves \`null\` when the child fails (filter with \`.filter(Boolean)\`). Other opts: \`label\` (display), \`phase\` (progress group), \`model\` (override). Anything else (\`effort\`/\`isolation\`/\`agentType\`) is rejected loudly. @@ -70,20 +69,17 @@ Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.` -type WorkflowCallArgs = { script: string; args?: Record } - -/** Best-effort meta.name sniff for presentation (pure textual; no evaluation). */ -function sniffMetaName(script: string): string | undefined { - const match = /export\s+const\s+meta\s*=\s*\{[^{}]*?name\s*:\s*(['"`])([^'"`\n]{1,64})\1/.exec(script) - return match?.[2] +type WorkflowCallArgs = { + script: string + meta: { name: string; description: string; whenToUse?: string; phases?: { title: string; detail?: string; model?: string }[] } + args?: Record } -/** The pending-state card: a generic card titled by the script's meta name. */ +/** The pending-state card: a generic card titled by the workflow's meta name. */ function presentWorkflowCall(args: WorkflowCallArgs): ToolCallView { - const name = sniffMetaName(args.script) return { card: 'generic', - title: name !== undefined ? `workflow: ${name}` : 'workflow', + title: `workflow: ${args.meta.name}`, rawInput: args.script, } } @@ -139,7 +135,29 @@ export function apply(ctx: Context, config: Config): void { script: { type: 'string', required: true, - description: 'The complete workflow script: `export const meta = {...}` followed by the plain-JS body (top-level await allowed; end with `return `).', + description: 'The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `).', + }, + meta: { + type: 'object', + required: true, + description: 'The workflow identity block (plain JSON — never code).', + properties: { + name: { type: 'string', required: true, description: 'Short kebab-case workflow name.' }, + description: { type: 'string', required: true, description: 'One-line description of what the workflow does.' }, + whenToUse: { type: 'string', description: 'Optional guidance on when this workflow applies.' }, + phases: { + type: 'array', + description: 'Optional phase declarations matched by phase() calls.', + items: { + type: 'object', + properties: { + title: { type: 'string', required: true, description: 'The phase title phase() calls match by exact string.' }, + detail: { type: 'string', description: 'Optional one-line description of the phase.' }, + model: { type: 'string', description: 'Optional model override this phase is expected to use.' }, + }, + }, + }, + }, }, args: { type: 'object', @@ -155,11 +173,12 @@ export function apply(ctx: Context, config: Config): void { throw new Error('workflow tool requires a calling agent (exec.agent was undefined)') } - // Parse failures (SCRIPT_PARSE/META_INVALID) throw synchronously here - // and become isError results via the registry — the model sees the - // violation list and can correct the script. + // Meta/body validation failures (META_INVALID/SCRIPT_PARSE) throw + // synchronously here and become isError results via the registry — the + // model sees the violation list and can correct the call. const run: WorkflowRun = ctx.workflows.start({ script: args.script, + meta: args.meta, ...args.args !== undefined ? { args: args.args } : {}, parent, ...exec.signal ? { signal: exec.signal } : {}, diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index dafc39bf46..0585889617 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -55,7 +55,8 @@ async function setup(config?: { toolName?: string; maxResultChars?: number }) { return { ctx, engine, parent } } -const SCRIPT = "export const meta = { name: 'audit', description: 'd' }\nreturn 1" +const SCRIPT = 'return 1' +const META = { name: 'audit', description: 'd' } function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise { return ctx.tools.execute({ @@ -71,9 +72,9 @@ describe('dsh-tool-workflow', () => { it('starts a run with the script/args/parent/signal and renders the completed value', async () => { const { ctx, engine, parent } = await setup() const controller = new AbortController() - const pending = execute(ctx, { script: SCRIPT, args: { files: ['a.ts'] } }, { agent: parent, signal: controller.signal }) + const pending = execute(ctx, { script: SCRIPT, meta: META, args: { files: ['a.ts'] } }, { agent: parent, signal: controller.signal }) await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) - expect(engine.requests[0]).toMatchObject({ script: SCRIPT, args: { files: ['a.ts'] }, parent }) + expect(engine.requests[0]).toMatchObject({ script: SCRIPT, meta: META, args: { files: ['a.ts'] }, parent }) expect(engine.requests[0]!.signal).toBe(controller.signal) engine.settle({ value: { findings: [1, 2] }, stopReason: 'completed', agentsStarted: 7 }) const result = await pending @@ -86,7 +87,7 @@ describe('dsh-tool-workflow', () => { it('maps a non-completed stop reason to an isError result (and still disposes)', async () => { const { ctx, engine, parent } = await setup() - const pending = execute(ctx, { script: SCRIPT }, { agent: parent }) + const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) engine.settle({ value: null, stopReason: 'error', error: 'script threw: boom', agentsStarted: 2 }) const result = await pending @@ -97,14 +98,14 @@ describe('dsh-tool-workflow', () => { it('reports a cancelled run distinctly (with and without a reason)', async () => { const { ctx, engine, parent } = await setup() - const pending = execute(ctx, { script: SCRIPT }, { agent: parent }) + const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) engine.settle({ value: null, stopReason: 'cancelled', error: 'user', agentsStarted: 0 }) const result = await pending expect(result.isError).toBe(true) expect((result.content[0] as { text: string }).text).toContain('workflow run was cancelled (user)') - const bare = execute(ctx, { script: SCRIPT }, { agent: parent }) + const bare = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) await vi.waitFor(() => { expect(engine.requests.length).toBe(2) }) engine.settle({ value: null, stopReason: 'cancelled', agentsStarted: 0 }) expect(((await bare).content[0] as { text: string }).text.trim().endsWith('cancelled')).toBe(true) @@ -112,7 +113,7 @@ describe('dsh-tool-workflow', () => { it('an error result without a message renders the unknown-error fallback', async () => { const { ctx, engine, parent } = await setup() - const pending = execute(ctx, { script: SCRIPT }, { agent: parent }) + const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) engine.settle({ value: null, stopReason: 'error', agentsStarted: 0 }) expect(((await pending).content[0] as { text: string }).text).toContain('unknown error') @@ -121,7 +122,7 @@ describe('dsh-tool-workflow', () => { it('cancels the run when exec.signal aborts MID-FLIGHT (the abort bridge)', async () => { const { ctx, engine, parent } = await setup() const controller = new AbortController() - const pending = execute(ctx, { script: SCRIPT }, { agent: parent, signal: controller.signal }) + const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent, signal: controller.signal }) await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) controller.abort() const result = await pending @@ -130,17 +131,17 @@ describe('dsh-tool-workflow', () => { expect(engine.disposed).toBe(1) }) - it('a synchronous engine start throw (parse/meta failure) becomes an isError result', async () => { + it('a synchronous engine start throw (meta/parse failure) becomes an isError result', async () => { const { ctx, engine, parent } = await setup() - engine.startError = new Error('script must begin with `export const meta = {...}`') - const result = await execute(ctx, { script: 'nope' }, { agent: parent }) + engine.startError = new Error('invalid meta: meta.name must be a non-empty string') + const result = await execute(ctx, { script: 'nope', meta: { name: '', description: 'd' } }, { agent: parent }) expect(result.isError).toBe(true) - expect((result.content[0] as { text: string }).text).toContain('must begin with') + expect((result.content[0] as { text: string }).text).toContain('meta.name must be a non-empty string') }) it('requires a calling agent (fails loud without exec.agent)', async () => { const { ctx, engine } = await setup() - const result = await execute(ctx, { script: SCRIPT }) + const result = await execute(ctx, { script: SCRIPT, meta: META }) expect(result.isError).toBe(true) expect((result.content[0] as { text: string }).text).toContain('requires a calling agent') expect(engine.requests.length).toBe(0) @@ -157,7 +158,7 @@ describe('dsh-tool-workflow', () => { const { ctx, engine, parent } = await setup() const controller = new AbortController() controller.abort() - const result = await execute(ctx, { script: SCRIPT }, { agent: parent, signal: controller.signal }) + const result = await execute(ctx, { script: SCRIPT, meta: META }, { agent: parent, signal: controller.signal }) expect(result.isError).toBe(true) expect(engine.cancels).toContain('parent step aborted') expect(engine.disposed).toBe(1) @@ -165,7 +166,7 @@ describe('dsh-tool-workflow', () => { it('truncates an oversized rendered value with a notice (maxResultChars)', async () => { const { ctx, engine, parent } = await setup({ maxResultChars: 40 }) - const pending = execute(ctx, { script: SCRIPT }, { agent: parent }) + const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) engine.settle({ value: { blob: 'x'.repeat(500) }, stopReason: 'completed', agentsStarted: 1 }) const rendered = ((await pending).content[0] as { text: string }).text @@ -193,22 +194,22 @@ describe('dsh-tool-workflow', () => { expect((await ctx.systemPrompt.assemble()).sections.some(s => s.name === 'tool:orchestrate')).toBe(false) }) - it('presents a generic pending card titled by the sniffed meta name, with the script as rawInput', async () => { + it('presents a generic pending card titled by the meta name, with the script as rawInput', async () => { const { ctx } = await setup() const tool = ctx.tools.get('workflow')! - const view = tool.presentCall!({ script: SCRIPT }) + const view = tool.presentCall!({ script: SCRIPT, meta: META }) expect(view).toMatchObject({ card: 'generic', title: 'workflow: audit', rawInput: SCRIPT }) - const anonymous = tool.presentCall!({ script: 'export const meta = {}\nreturn 1' }) - expect(anonymous).toMatchObject({ card: 'generic', title: 'workflow' }) }) it('presentResult keeps the generic card; presentation is pure and replay-safe on malformed args', async () => { const { ctx } = await setup() const tool = ctx.tools.get('workflow')! - expect(tool.presentResult!({ script: SCRIPT }, { content: [], isError: false })).toEqual({ card: 'generic' }) + expect(tool.presentResult!({ script: SCRIPT, meta: META }, { content: [], isError: false })).toEqual({ card: 'generic' }) // defineTool soft-validates presentation args: a malformed logged shape - // falls back to undefined instead of throwing mid-replay. + // (wrong fields entirely, or a call missing its meta) falls back to + // undefined instead of throwing mid-replay. expect(tool.presentCall!({ not: 'the schema' })).toBeUndefined() + expect(tool.presentCall!({ script: SCRIPT })).toBeUndefined() }) it('has the namespace-plugin export shape (no stray default)', () => { @@ -239,7 +240,8 @@ describe('dsh-tool-workflow', () => { const parent = { id: AgentId('caller'), options: {} } as unknown as Agent const controller = new AbortController() const pending = execute(ctx, { - script: "export const meta = { name: 'stuck', description: 'parks forever' }\nawait new Promise(() => {})\nreturn 1", + script: 'await new Promise(() => {})\nreturn 1', + meta: { name: 'stuck', description: 'parks forever' }, }, { agent: parent, signal: controller.signal }) // Give the run a beat to start (past its synchronous slice), then abort. await new Promise(resolve => setTimeout(resolve, 20)) diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index 0e763bdee9..25c79ad09c 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -14,19 +14,19 @@ What the seam guarantees regardless, because benign scripts hit these constantly ## The script contract it executes -- **Meta extraction** (`extractMeta`, host-side): a string/comment-aware brace scanner finds the leading `export const meta` literal (template interpolation rejected — the literal must be pure), evaluates it ALONE in an empty timed vm context, materializes the result to plain JSON data, validates the shape (`name`/`description` required; unknown fields rejected loud), and blanks the statement line-preservingly so error stacks keep the script's own line numbers. +- **Meta as DATA** (`validateMeta`, host-side): the workflow's identity arrives on the start request as plain JSON (the tool carries it as its schema-validated `meta` parameter — never as script text) and is shape-validated loud, every violation named (`name`/`description` required; unknown fields rejected). The engine deliberately evaluates NO script text to obtain meta: an evaluated meta literal could smuggle getters that run on the host outside any vm timeout — the exact spin the worker thread exists to isolate. A body that still opens with a Claude Code-style `export const meta` statement is rejected with a pointed `SCRIPT_PARSE` message. - **Hooks**: `agent(prompt, {label, phase, schema, model})` (schema = the [structured-output subset](../../core/tools/README.md), forwarded as `outputSchema`; result = validated object, or final text without a schema; a failed child resolves `null`), `parallel(thunks)`, `pipeline(items, ...stages)` with NO cross-stage barrier and `(prev, item, index)` stage callbacks, `phase(title)`, `log(message)`, and the `args` global. Anything else — `effort`/`isolation`/`agentType`, unknown options, malformed arguments, schemas outside the subset — throws a FATAL `WorkflowError` that `parallel`/`pipeline` re-throw rather than nulling (see the seam README's failure discipline). - **No ambient APIs**: no timers, filesystem, or Node APIs are injected into the context (absence is API surface, not containment — see the trust premise). ## How a run executes -`start()` extracts and validates the meta HOST-side and parse-checks the body with the identical wrapper the worker compiles (`new vm.Script`, discarded), preserving the seam's synchronous `SCRIPT_PARSE`/`META_INVALID` throws; one redundant parse per run is the deliberate price. It then spawns the worker (`src/worker.ts` unbuilt via an explicit tsx `execArgv`; the sibling `lib/worker.js` bundle when built) with the meta, blanked body, `args`, and worker-side limits as `workerData`. +`start()` shape-validates the meta DATA host-side and parse-checks the body with the identical wrapper the worker compiles (`new vm.Script`, discarded), preserving the seam's synchronous `META_INVALID`/`SCRIPT_PARSE` throws; one redundant parse per run is the deliberate price. It then spawns the worker (`src/worker.ts` unbuilt via an explicit tsx `execArgv`; the sibling `lib/worker.js` bundle when built) with the meta, body, `args`, and worker-side limits as `workerData`. Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**: `agent()` sends `child-start` and the host starts the child on `ctx.subagents` (parent attribution, the shared per-run abort signal, `outputSchema`/`model` pass-through), replying with the child id, its settlement (a JSON projection; an infrastructure REJECTION crosses as `child-failed` and stays the fatal `AGENT_RESULT`), and dispose acks. Observer narration (`phase`/`log`/`agent-start`/`agent-end`) crosses as messages and re-emits as the seam's `workflow/*` events. A **ready→go handshake** gates the body: a cancellation racing worker boot arrives before `go`, so a run cancelled before start never executes the body at all. ## The value boundary -Values LEAVING the script (the meta literal, hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying into plain containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Getters are read ordinarily — the RESULT is what crosses; a read that throws fails loud. Values ENTERING the realm (`args`, `agent()` results, hook promises and their failures, combinator arrays) are handed over directly as worker-realm values — the script is trusted, so outer prototypes are not a leak; `args` is cloned once at start so a script scribbling on it cannot mutate the caller's object. One script-visible consequence: an error thrown by a hook is built OUTSIDE the script's vm context, so `e instanceof Error` inside the script is `false` — branch on `e.name`/`e.code` instead (the combinators recognize fatality by `instanceof` against their own realm's class, which a script-built object can never pass, so fatal-vs-null cannot be forged or dissolved). +Values LEAVING the script (hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying into plain containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Getters are read ordinarily — the RESULT is what crosses; a read that throws fails loud; both run in the WORKER, never on the host. Values ENTERING the realm (`args`, `agent()` results, hook promises and their failures, combinator arrays) are handed over directly as worker-realm values — the script is trusted, so outer prototypes are not a leak; `args` is cloned once at start so a script scribbling on it cannot mutate the caller's object. One script-visible consequence: an error thrown by a hook is built OUTSIDE the script's vm context, so `e instanceof Error` inside the script is `false` — branch on `e.name`/`e.code` instead (the combinators recognize fatality by `instanceof` against their own realm's class, which a script-built object can never pass, so fatal-vs-null cannot be forged or dissolved). ## Cancellation, death, disposal @@ -44,5 +44,5 @@ A worker that dies unexpectedly (an OOM, a script reaching `process.exit` throug | `maxConcurrentAgents` | `0` (auto) | Concurrent `agent()` ceiling; `0` resolves to `min(16, max(1, cores - 2))`. | | `maxTotalAgents` | `1000` | Total `agent()` calls one run may start (runaway-loop backstop). | | `maxItemsPerCall` | `4096` | Items accepted by one `parallel()`/`pipeline()` call. | -| `syncTimeoutMs` | `5000` | vm timeout for the initial synchronous slice (in the worker) and the host-side meta evaluation. | +| `syncTimeoutMs` | `5000` | vm timeout for the script's initial synchronous slice (in the worker). | | `disposeGraceMs` | `5000` | How long a cancelled run may stay unsettled before force-settle + terminate; also bounds `dispose()`. | diff --git a/packages/workflow/workflow-workerthread/src/index.ts b/packages/workflow/workflow-workerthread/src/index.ts index 538252d90a..e0b8caf3fb 100644 --- a/packages/workflow/workflow-workerthread/src/index.ts +++ b/packages/workflow/workflow-workerthread/src/index.ts @@ -47,10 +47,10 @@ import z from 'schemastery' import WorkflowService, { WorkflowError, WorkflowRunId } from '@deepseek-ai/dsh-workflow' import type { WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' import { WorkerRun } from './host.ts' -import { extractMeta } from './meta.ts' +import { validateMeta } from './meta.ts' import type { WorkerInit, WorkerLimits } from './types.ts' -export { extractMeta, type ExtractedScript } from './meta.ts' +export { validateMeta } from './meta.ts' export { HostToWorkerType, WorkerToHostType } from './protocol.ts' export type { HostToWorkerMessage, HostToWorkerPayloads, WorkerToHostMessage, WorkerToHostPayloads } from './protocol.ts' export { materializeFromRealm, MaterializeError } from './realm.ts' @@ -75,7 +75,7 @@ export interface Config { maxTotalAgents?: number /** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */ maxItemsPerCall?: number - /** vm timeout for the initial synchronous slice (inside the worker) AND the host-side meta evaluation (default 5000 ms). */ + /** vm timeout for the script's initial synchronous slice, inside the worker (default 5000 ms). */ syncTimeoutMs?: number /** * How long after a cancellation an unsettled script may keep running before @@ -87,13 +87,21 @@ export interface Config { type ResolvedConfig = Required +/** A body that still carries the Claude Code-style meta header (meta rides the seam as data here). */ +const META_STATEMENT = /^\s*export\s+const\s+meta\b/ + /** * Parse-check the body with the SAME wrapper the worker-side runtime * compiles, so `start()` keeps the seam's synchronous `SCRIPT_PARSE` throw * (the worker's own compile happens a thread away, after `start()` returned). - * One redundant parse per run, bought deliberately for the contract. + * One redundant parse per run, bought deliberately for the contract. A body + * opening with `export const meta` gets a pointed message instead of the + * wrapper's bare SyntaxError — the model's likeliest authoring slip. */ function assertBodyParses(body: string, name: string): void { + if (META_STATEMENT.test(body)) { + throw new WorkflowError('workflow meta rides the `meta` request field, not the script: remove the `export const meta = {...}` statement from the body', 'SCRIPT_PARSE') + } try { // Parse only — the script object is discarded, nothing executes. void new vm.Script(`(async () => {\n${body}\n})()`, { filename: `workflow:${name}`, lineOffset: -1 }) @@ -130,17 +138,18 @@ export class WorkerWorkflowEngine extends WorkflowService { } /** - * Parse and execute a workflow script in a fresh worker thread. Throws - * {@link WorkflowError} synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a - * script that cannot begin; once a run is returned, every failure resolves - * through `result.stopReason` instead. - * @param request - the script, its `args`, the parent agent, and an - * optional cancel signal. + * Validate and execute a workflow script in a fresh worker thread. Throws + * {@link WorkflowError} synchronously (`META_INVALID` for a malformed meta + * block, `SCRIPT_PARSE` for a body that does not compile) for a request + * that cannot begin; once a run is returned, every failure resolves through + * `result.stopReason` instead. + * @param request - the script body, its meta data and `args`, the parent + * agent, and an optional cancel signal. * @returns the live run (its `result` resolves when the script settles). */ start(request: WorkflowStartRequest): WorkflowRun { - const { meta, body } = extractMeta(request.script, this.config.syncTimeoutMs) - assertBodyParses(body, meta.name) + const meta = validateMeta(request.meta) + assertBodyParses(request.script, meta.name) const id = WorkflowRunId(randomUUID()) // The event payloads and the run handle get SEPARATE meta clones: a // listener mutating its snapshot must not corrupt the holder's view. @@ -155,7 +164,7 @@ export class WorkerWorkflowEngine extends WorkflowService { } const init: WorkerInit = { meta, - body, + body: request.script, ...request.args !== undefined ? { args: request.args } : {}, limits, } diff --git a/packages/workflow/workflow-workerthread/src/meta.ts b/packages/workflow/workflow-workerthread/src/meta.ts index e818bc6f00..848a4fc9b1 100644 --- a/packages/workflow/workflow-workerthread/src/meta.ts +++ b/packages/workflow/workflow-workerthread/src/meta.ts @@ -1,100 +1,24 @@ /** - * Meta-block extraction: turn a Claude Code-format workflow script — - * `export const meta = {...}` followed by a plain-JS body — into a validated - * {@link WorkflowMeta} plus the body with the meta statement blanked - * line-preservingly (error stacks keep the script's own line numbers). - * - * The scanner is a small string/comment-aware brace matcher, not a JS parser: - * it only has to find the END of the meta object literal, and the literal is - * contractually PURE (no interpolation, no computed values). Template strings - * are tolerated as plain quotes but `${` inside one is rejected up front — - * interpolation is where "literal" stops being checkable by evaluation. The - * extracted text is then evaluated ALONE in an empty, timed vm context (a - * non-literal reference throws there; an expression can still RUN, so the - * result — not the source — is the contract: it must materialize to plain - * JSON data and pass the shape validation). + * Meta validation: check the caller-provided {@link WorkflowMeta} DATA against + * the shape contract and reject everything else loud, every violation named. + * Meta arrives as plain JSON through the seam (the model-facing tool carries + * it as a schema-validated object parameter) — the engine never evaluates + * script text to obtain it, so no script-controlled code can run on the host + * here (an evaluated meta literal could smuggle getters that spin the host + * outside any vm timeout, the exact escape the worker thread exists to + * prevent). * * @module @deepseek-ai/dsh-workflow-workerthread/meta */ -import * as vm from 'node:vm' import { WorkflowError } from '@deepseek-ai/dsh-workflow' import type { WorkflowMeta, WorkflowPhase } from '@deepseek-ai/dsh-workflow' -import { materializeFromRealm, MaterializeError, renderThrown } from './realm.ts' -/** The result of {@link extractMeta}: the validated meta and the runnable body. */ -export interface ExtractedScript { - meta: WorkflowMeta - /** The script with the meta statement blanked (newlines preserved). */ - body: string -} - -/** - * Scan `source` from `start` (an opening `{`) to its matching `}`, aware of - * string literals (`'`/`"`/backtick, with escapes) and comments. Returns the - * index AFTER the closing brace. Throws `SCRIPT_PARSE` on template - * interpolation (`${` inside a backtick string) or an unterminated literal. - */ -function scanObjectLiteral(source: string, start: number): number { - let depth = 0 - let index = start - while (index < source.length) { - const ch = source.charAt(index) - if (ch === '/' && source[index + 1] === '/') { - const end = source.indexOf('\n', index) - index = end === -1 ? source.length : end + 1 - continue - } - if (ch === '/' && source[index + 1] === '*') { - const end = source.indexOf('*/', index + 2) - if (end === -1) throw new WorkflowError('meta block has an unterminated comment', 'SCRIPT_PARSE') - index = end + 2 - continue - } - if (ch === '\'' || ch === '"' || ch === '`') { - index = scanString(source, index, ch) - continue - } - if (ch === '{' || ch === '[') depth += 1 - if (ch === '}' || ch === ']') { - depth -= 1 - if (depth === 0) return index + 1 - } - index += 1 - } - throw new WorkflowError('meta block is not a balanced object literal', 'SCRIPT_PARSE') -} - -/** Scan past one string literal starting at `start` (the quote char); returns the index after the closing quote. */ -function scanString(source: string, start: number, quote: string): number { - let index = start + 1 - while (index < source.length) { - const ch = source.charAt(index) - if (ch === '\\') { - index += 2 - continue - } - if (quote === '`' && ch === '$' && source[index + 1] === '{') { - throw new WorkflowError('template interpolation (`${...}`) is not allowed in the meta block — meta must be a pure literal', 'SCRIPT_PARSE') - } - if (ch === quote) return index + 1 - index += 1 - } - throw new WorkflowError('meta block has an unterminated string literal', 'SCRIPT_PARSE') -} - -/** Replace `[from, to)` of `source` with whitespace, preserving every newline (line numbers survive). */ -function blankSpan(source: string, from: number, to: number): string { - const blanked = source.slice(from, to).replace(/[^\n]/g, ' ') - return source.slice(0, from) + blanked + source.slice(to) -} - -/** Collect shape violations for an evaluated meta value (already materialized to host JSON data). */ +/** Collect shape violations for a meta value (plain JSON data by the seam contract). */ function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: string[] } { const violations: string[] = [] - /* v8 ignore next 3 -- defensive: the scanner only extracts a brace-delimited literal, which always evaluates to a plain object */ if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) { - return { violations: ['meta must be an object literal'] } + return { violations: ['meta must be an object'] } } const record = meta as Record const known = new Set(['name', 'description', 'whenToUse', 'phases']) @@ -143,97 +67,19 @@ function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: st } } -/** `export const meta =`, anchored AFTER {@link skipLeadingTrivia} — its quantifiers cannot backtrack ambiguously. */ -const META_HEAD = /^export\s+const\s+meta\s*=\s*/ - /** - * Index just past the leading trivia: whitespace and `//` / `/*`-style - * comments. A hand-rolled character scan, NOT a prefix regex — an - * all-alternation prefix (`\s*(?:comment|\s+)*`) partitions a whitespace run - * ambiguously and backtracks EXPONENTIALLY when the match ultimately fails, - * so a near-miss script (a comment header, then a forgotten `export`) would - * spin the host synchronously inside `start()`, where no vm timeout applies. - * The near-miss must fail fast into `SCRIPT_PARSE` instead — that error is - * the model's retry signal. + * Validate a caller-provided meta value against the {@link WorkflowMeta} + * contract. Throws `META_INVALID` naming every violation (unknown fields, + * missing/mistyped `name`/`description`, malformed `phases`); the returned + * meta is a NORMALIZED copy built from the validated fields, so the engine + * never aliases the caller's object. + * @param value - the meta data from the start request (plain JSON by the seam contract). + * @returns the validated, normalized meta block. */ -function skipLeadingTrivia(source: string): number { - let index = 0 - while (index < source.length) { - const ch = source.charAt(index) - if (/\s/.test(ch)) { - index += 1 - continue - } - if (ch === '/' && source[index + 1] === '/') { - const end = source.indexOf('\n', index) - if (end === -1) return source.length - index = end + 1 - continue - } - if (ch === '/' && source[index + 1] === '*') { - const end = source.indexOf('*/', index + 2) - if (end === -1) throw new WorkflowError('script has an unterminated comment before the meta block', 'SCRIPT_PARSE') - index = end + 2 - continue - } - break - } - return index -} - -/** - * Extract and validate the leading `export const meta = {...}` statement. - * Throws {@link WorkflowError} — `SCRIPT_PARSE` when the statement is missing - * or unscannable, `META_INVALID` when the literal evaluates to something - * outside the meta contract (non-JSON data, wrong shape, unknown fields). - * @param script - the full script text. - * @param evalTimeoutMs - the vm timeout for evaluating the extracted literal. - * @returns the validated meta and the line-preservingly blanked body. - */ -export function extractMeta(script: string, evalTimeoutMs: number): ExtractedScript { - const triviaEnd = skipLeadingTrivia(script) - const match = META_HEAD.exec(script.slice(triviaEnd)) - if (!match) { - throw new WorkflowError('script must begin with `export const meta = {...}` (leading comments allowed)', 'SCRIPT_PARSE') - } - const literalStart = triviaEnd + match[0].length - if (script[literalStart] !== '{') { - throw new WorkflowError('`export const meta =` must be followed by an object literal', 'SCRIPT_PARSE') - } - const literalEnd = scanObjectLiteral(script, literalStart) - const literal = script.slice(literalStart, literalEnd) - - let evaluated: unknown - try { - // An EMPTY context: any non-literal reference (a variable, a call) throws - // here. The result — data only — is what the contract checks; a getter or - // IIFE can still run, which is why the timeout and the materialization - // below are part of the same boundary. - evaluated = vm.runInNewContext(`(${literal})`, undefined, { timeout: evalTimeoutMs }) - } catch (error: unknown) { - throw new WorkflowError( - `meta block failed to evaluate as a pure literal: ${renderThrown(error)}`, - 'META_INVALID', - { cause: error }, - ) - } - let data: unknown - try { - data = materializeFromRealm(evaluated, 'meta') - } catch (error: unknown) { - /* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */ - if (!(error instanceof MaterializeError)) throw error - throw new WorkflowError(`meta block is not pure JSON data — ${error.message}`, 'META_INVALID', { cause: error }) - } - const { meta, violations } = validateMetaShape(data) +export function validateMeta(value: unknown): WorkflowMeta { + const { meta, violations } = validateMetaShape(value) if (meta === undefined) { - throw new WorkflowError(`invalid meta block: ${violations.join('; ')}`, 'META_INVALID') + throw new WorkflowError(`invalid meta: ${violations.join('; ')}`, 'META_INVALID') } - - // Blank the whole statement (including a trailing semicolon, if any) so the - // body compiles standalone with its original line numbers. - let statementEnd = literalEnd - while (statementEnd < script.length && (script[statementEnd] === ' ' || script[statementEnd] === '\t')) statementEnd += 1 - if (script[statementEnd] === ';') statementEnd += 1 - return { meta, body: blankSpan(script, 0, statementEnd) } + return meta } diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index 28450977c3..645a61b6c4 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -109,7 +109,7 @@ export class WorkflowExecution { // wrapper, so under one Node version this throw is unreachable in // production — the session still maps it to an error result defensively. // lineOffset compensates for the wrapper line, so stack traces carry the - // script's own line numbers (the meta statement was blanked, not removed). + // script's own line numbers. try { this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, { filename: `workflow:${meta.name}`, diff --git a/packages/workflow/workflow-workerthread/src/types.ts b/packages/workflow/workflow-workerthread/src/types.ts index 329523ffc8..a80b126a2d 100644 --- a/packages/workflow/workflow-workerthread/src/types.ts +++ b/packages/workflow/workflow-workerthread/src/types.ts @@ -30,9 +30,9 @@ export interface WorkerLimits { /** The `workerData` payload one run is initialized with (host → worker, once, at spawn). */ export interface WorkerInit { - /** The validated meta block (extracted host-side). */ + /** The validated meta block (plain data off the start request, validated host-side). */ meta: WorkflowMeta - /** The script body with the meta statement blanked (host-side `extractMeta`). */ + /** The plain-JS script body, exactly as the start request carried it. */ body: string /** The run's `args` value; the workerData structured clone is the copy that isolates the caller. */ args?: unknown diff --git a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts index d541b4c0ff..545be831f6 100644 --- a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts @@ -34,7 +34,8 @@ const ctx = new Context() await ctx.plugin(SubagentService) await ctx.plugin(WorkerWorkflowEngine, {}) const run = ctx.workflows.start({ - script: "export const meta = { name: 'built-smoke', description: 'built worker smoke' }\\nreturn 6 * 7", + script: 'return 6 * 7', + meta: { name: 'built-smoke', description: 'built worker smoke' }, // A zero-agent script never touches the provider, so a bare id suffices. parent: { id: 'built-smoke-parent', options: {} }, }) diff --git a/packages/workflow/workflow-workerthread/tests/integration.spec.ts b/packages/workflow/workflow-workerthread/tests/integration.spec.ts index ae58a536f8..d6e8237804 100644 --- a/packages/workflow/workflow-workerthread/tests/integration.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/integration.spec.ts @@ -50,8 +50,8 @@ describe('dsh-workflow-workerthread over the real in-process stack', () => { const childIds: string[] = [] ctx.on('workflow/agent-start', (_info, agent) => { childIds.push(agent.childId) }) const run = ctx.workflows.start({ - script: `export const meta = { name: 'integration', description: 'plain + structured children' } -phase('Read') + meta: { name: 'integration', description: 'plain + structured children' }, + script: `phase('Read') const prose = await agent('read the repo') phase('Judge') const judged = await agent('judge: ' + prose, { @@ -78,8 +78,8 @@ return { prose, verdict: judged.verdict, confidence: judged.confidence }`, textResponse('still prose after the nudge'), ]) const run = ctx.workflows.start({ - script: `export const meta = { name: 'null-path', description: 'schema failure maps to null' } -const judged = await agent('judge it', { schema: { type: 'object', properties: { v: { type: 'string' } } } }) + meta: { name: 'null-path', description: 'schema failure maps to null' }, + script: `const judged = await agent('judge it', { schema: { type: 'object', properties: { v: { type: 'string' } } } }) return { got: judged === null ? 'null' : 'value' }`, parent, }) diff --git a/packages/workflow/workflow-workerthread/tests/meta.spec.ts b/packages/workflow/workflow-workerthread/tests/meta.spec.ts index a1c622e3fc..37b86440be 100644 --- a/packages/workflow/workflow-workerthread/tests/meta.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/meta.spec.ts @@ -1,182 +1,88 @@ import { describe, expect, it } from 'vitest' import { WorkflowError } from '@deepseek-ai/dsh-workflow' -import { extractMeta } from '../src/meta.ts' +import { validateMeta } from '../src/meta.ts' -const TIMEOUT = 1000 - -/** Extract and expect success. */ -function ok(script: string) { - return extractMeta(script, TIMEOUT) -} - -/** The WorkflowError a bad script produces (throws if it extracts cleanly). */ -function bad(script: string): WorkflowError { +/** Assert a META_INVALID throw whose message matches every given fragment. */ +function expectInvalid(value: unknown, ...fragments: string[]): void { + let thrown: unknown try { - extractMeta(script, TIMEOUT) + validateMeta(value) } catch (error: unknown) { - if (error instanceof WorkflowError) return error - throw error + thrown = error + } + expect(thrown).toBeInstanceOf(WorkflowError) + expect((thrown as WorkflowError).code).toBe('META_INVALID') + for (const fragment of fragments) { + expect((thrown as WorkflowError).message).toContain(fragment) } - throw new Error('expected extraction to fail') } -describe('extractMeta', () => { - it('extracts a full meta block and blanks the statement line-preservingly', () => { - const script = `export const meta = { - name: 'audit-routes', - description: 'Audit every route', - whenToUse: 'when auditing', - phases: [{ title: 'Scan', detail: 'find files' }, { title: 'Fix', model: 'deepseek-v4-pro' }], -} -const x = 1 -return x` - const { meta, body } = ok(script) - expect(meta).toEqual({ - name: 'audit-routes', - description: 'Audit every route', - whenToUse: 'when auditing', - phases: [{ title: 'Scan', detail: 'find files' }, { title: 'Fix', model: 'deepseek-v4-pro' }], +describe('validateMeta', () => { + it('accepts a minimal meta and returns a normalized copy (no aliasing of the input)', () => { + const input = { name: 'audit', description: 'audit the repo' } + const meta = validateMeta(input) + expect(meta).toEqual({ name: 'audit', description: 'audit the repo' }) + expect(meta).not.toBe(input) + input.name = 'mutated' + expect(meta.name).toBe('audit') + }) + + it('accepts the full shape and rebuilds phases entry by entry', () => { + const meta = validateMeta({ + name: 'migrate', + description: 'migrate call sites', + whenToUse: 'large mechanical sweeps', + phases: [ + { title: 'Discover' }, + { title: 'Transform', detail: 'one agent per file', model: 'deepseek-v4-pro' }, + ], + }) + expect(meta).toEqual({ + name: 'migrate', + description: 'migrate call sites', + whenToUse: 'large mechanical sweeps', + phases: [ + { title: 'Discover' }, + { title: 'Transform', detail: 'one agent per file', model: 'deepseek-v4-pro' }, + ], }) - // Same line count; the statement's characters blanked; the body intact. - expect(body.split('\n').length).toBe(script.split('\n').length) - expect(body.split('\n')[6]).toBe('const x = 1') - expect(body).not.toContain('export') }) - it('allows leading line and block comments before the meta statement', () => { - const script = `// a workflow -/* multi - line */ -export const meta = { name: 'x', description: 'y' } -return 1` - expect(ok(script).meta.name).toBe('x') + it('rejects non-object values loud', () => { + expectInvalid(undefined, 'meta must be an object') + expectInvalid('a string', 'meta must be an object') + expectInvalid(null, 'meta must be an object') + expectInvalid([{ name: 'x', description: 'd' }], 'meta must be an object') }) - it('handles braces inside strings and comments while scanning', () => { - const script = `export const meta = { - name: 'tricky', // } not a close { - /* } also not } */ - description: "has { braces } and 'quotes'", -} -return 2` - expect(ok(script).meta.description).toBe("has { braces } and 'quotes'") + it('rejects unknown fields by name (accepted-then-ignored is banned)', () => { + expectInvalid({ name: 'x', description: 'd', color: 'red' }, 'meta.color is not a recognized field') }) - it('tolerates template-quoted strings WITHOUT interpolation, escapes included', () => { - const script = 'export const meta = { name: `plain`, description: `esc \\` tick` }\nreturn 1' - expect(ok(script).meta.name).toBe('plain') + it('rejects missing or mistyped name/description/whenToUse', () => { + expectInvalid({ description: 'd' }, 'meta.name must be a non-empty string') + expectInvalid({ name: '', description: 'd' }, 'meta.name must be a non-empty string') + expectInvalid({ name: 'x' }, 'meta.description must be a non-empty string') + expectInvalid({ name: 'x', description: 42 }, 'meta.description must be a non-empty string') + expectInvalid({ name: 'x', description: 'd', whenToUse: 3 }, 'meta.whenToUse must be a string') }) - it('consumes a trailing semicolon after the literal, spaces included', () => { - const { body } = ok("export const meta = { name: 'x', description: 'y' };\nreturn 1") - expect(body).not.toContain(';') - expect(body.split('\n')[1]).toBe('return 1') - const spaced = ok("export const meta = { name: 'x', description: 'y' } ;\nreturn 1") - expect(spaced.body).not.toContain(';') + it('rejects malformed phases, entry by entry', () => { + expectInvalid({ name: 'x', description: 'd', phases: 'Scan' }, 'meta.phases must be an array') + expectInvalid({ name: 'x', description: 'd', phases: ['Scan'] }, 'meta.phases[0] must be an object') + expectInvalid({ name: 'x', description: 'd', phases: [{ title: '' }] }, 'meta.phases[0].title must be a non-empty string') + expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', order: 1 }] }, 'meta.phases[0].order is not a recognized field') + expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', detail: 9 }] }, 'meta.phases[0].detail must be a string') + expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', model: 9 }] }, 'meta.phases[0].model must be a string') }) - it('rejects a script that does not begin with the meta statement (SCRIPT_PARSE)', () => { - expect(bad('const a = 1').code).toBe('SCRIPT_PARSE') - expect(bad('').code).toBe('SCRIPT_PARSE') - expect(bad('export const meta = [1]').code).toBe('SCRIPT_PARSE') - }) - - it('a near-miss prefix (comment header + whitespace, then no `export`) fails FAST as SCRIPT_PARSE', () => { - // Regression: the previous all-alternation prefix regex backtracked - // exponentially on exactly this shape (~×2 per extra whitespace char once - // the match fails), spinning the host synchronously inside start(). The - // linear trivia scan must reject it in effectively zero time. - const nearMiss = `// deep-audit workflow: reviews every route handler\n${' \n'.repeat(40)}/* second header block */\n${' '.repeat(200)}\nconst meta = { name: 'x', description: 'y' }\n` - const started = Date.now() - expect(bad(nearMiss).code).toBe('SCRIPT_PARSE') - expect(Date.now() - started).toBeLessThan(1000) - }) - - it('an unterminated block comment BEFORE the meta statement is SCRIPT_PARSE', () => { - const error = bad('/* never closed\nexport const meta = { name: "x", description: "y" }') - expect(error.code).toBe('SCRIPT_PARSE') - expect(error.message).toContain('unterminated comment') - }) - - it('a line comment running to EOF leaves no meta statement (SCRIPT_PARSE)', () => { - expect(bad('// only a comment, no newline').code).toBe('SCRIPT_PARSE') - }) - - it('rejects template interpolation in the meta block as impure (SCRIPT_PARSE)', () => { - const error = bad('export const meta = { name: `w-${1}`, description: "d" }\nreturn 1') - expect(error.code).toBe('SCRIPT_PARSE') - expect(error.message).toContain('pure literal') - }) - - it('rejects unbalanced literals, unterminated strings, and unterminated comments (SCRIPT_PARSE)', () => { - expect(bad('export const meta = { name: "x", description: "y"').code).toBe('SCRIPT_PARSE') - expect(bad('export const meta = { name: "x').code).toBe('SCRIPT_PARSE') - expect(bad('export const meta = { /* open').code).toBe('SCRIPT_PARSE') - // A line comment running to EOF (no newline) leaves the literal unbalanced. - expect(bad('export const meta = { name: "x" // eof comment').code).toBe('SCRIPT_PARSE') - }) - - it('rejects a literal referencing variables or calls (META_INVALID via the empty realm)', () => { - const error = bad('export const meta = { name: someVariable, description: "d" }\nreturn 1') - expect(error.code).toBe('META_INVALID') - expect(error.message).toContain('pure literal') - expect(bad('export const meta = { name: compute(), description: "d" }').code).toBe('META_INVALID') - }) - - it('rejects a literal evaluating to non-JSON data (META_INVALID via materialization)', () => { - const error = bad('export const meta = { name: "x", description: "d", whenToUse: () => 1 }') - expect(error.code).toBe('META_INVALID') - expect(error.message).toContain('JSON data') - }) - - it('a meta expression that THROWS maps to META_INVALID carrying the rendered value', () => { - const error = bad('export const meta = { name: (() => { throw "nope" })(), description: "d" }\nreturn 1') - expect(error.code).toBe('META_INVALID') - expect(error.message).toContain('pure literal') - expect(error.message).toContain('nope') - }) - - it('a spinning meta expression dies by the eval timeout', () => { - try { - extractMeta('export const meta = { name: (() => { while (true) {} })(), description: "d" }', 50) - throw new Error('expected the extraction to time out') - } catch (error: unknown) { - expect(error).toBeInstanceOf(WorkflowError) - expect((error as WorkflowError).code).toBe('META_INVALID') - expect((error as WorkflowError).message.toLowerCase()).toContain('timed out') - } - }) - - it('rejects shape violations with EVERY violation listed (META_INVALID)', () => { - const error = bad('export const meta = { description: 7, bogus: 1 }\nreturn 1') - expect(error.code).toBe('META_INVALID') - expect(error.message).toContain('meta.name must be a non-empty string') - expect(error.message).toContain('meta.description must be a non-empty string') - expect(error.message).toContain('meta.bogus is not a recognized field') - }) - - it('rejects malformed whenToUse and phases shapes precisely', () => { - expect(bad('export const meta = { name: "x", description: "d", whenToUse: 3 }').message) - .toContain('meta.whenToUse must be a string') - expect(bad('export const meta = { name: "x", description: "d", phases: "no" }').message) - .toContain('meta.phases must be an array') - expect(bad('export const meta = { name: "x", description: "d", phases: [3] }').message) - .toContain('meta.phases[0] must be an object') - expect(bad('export const meta = { name: "x", description: "d", phases: [{}] }').message) - .toContain('meta.phases[0].title must be a non-empty string') - expect(bad('export const meta = { name: "x", description: "d", phases: [{ title: "t", extra: 1 }] }').message) - .toContain('meta.phases[0].extra is not a recognized field') - expect(bad('export const meta = { name: "x", description: "d", phases: [{ title: "t", detail: 1 }] }').message) - .toContain('meta.phases[0].detail must be a string') - expect(bad('export const meta = { name: "x", description: "d", phases: [{ title: "t", model: 1 }] }').message) - .toContain('meta.phases[0].model must be a string') - }) - - it('stops scanning at the balanced literal — trailing expression text stays in the body', () => { - // The scanner extracts exactly `{ valueOf: null }`; the ` && 3` is body - // text (which would fail compilation later, but extraction sees only the - // literal and reports its unknown field). - expect(bad('export const meta = { valueOf: null } && 3').message) - .toContain('meta.valueOf is not a recognized field') + it('names EVERY violation in one throw, not just the first', () => { + expectInvalid( + { description: 7, extra: true, phases: [{ title: 'Scan' }, 'bad'] }, + 'meta.extra is not a recognized field', + 'meta.name must be a non-empty string', + 'meta.description must be a non-empty string', + 'meta.phases[1] must be an object', + ) }) }) diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts index 76cfd73d33..6353868a02 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts @@ -42,12 +42,12 @@ async function harness(): Promise { return built } -const SCRIPT = `export const meta = { +const META = { name: 'e2e-worker-arithmetic', description: 'two real children through a worker thread: one prose, one structured', phases: [{ title: 'Ask' }, { title: 'Judge' }], } -phase('Ask') +const SCRIPT = `phase('Ask') log('asking the prose child') const prose = await agent('Reply with exactly one short sentence: what is 2 + 2?') phase('Judge') @@ -76,7 +76,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key }) } - const run = ctx.workflows.start({ script: SCRIPT, parent: parentHandle.agent }) + const run = ctx.workflows.start({ script: SCRIPT, meta: META, parent: parentHandle.agent }) const result = await run.result await run.dispose() diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 20911d052e..378dd6ecf6 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -5,7 +5,7 @@ import { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import type { WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' +import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import * as workerEngineModule from '../src/index.ts' import WorkerWorkflowEngine, { type Config } from '../src/index.ts' @@ -104,14 +104,14 @@ async function setup(options?: SetupOptions) { return { ctx, provider, parent: fakeParent() } } -/** Wrap a body in the minimal valid meta header. */ -function script(body: string, metaExtra = ''): string { - return `export const meta = { name: 'test-flow', description: 'a test workflow'${metaExtra} }\n${body}` +/** The standard test meta plus a body, spread into a start request. */ +function scripted(body: string, metaExtra?: Partial): { script: string; meta: WorkflowMeta } { + return { script: body, meta: { name: 'test-flow', description: 'a test workflow', ...metaExtra } } } /** Start + await one run, disposing on the way out. */ -async function run(ctx: Context, parent: Agent, source: string, args?: unknown): Promise { - const handle = ctx.workflows.start({ script: source, parent, ...args !== undefined ? { args } : {} }) +async function run(ctx: Context, parent: Agent, source: { script: string; meta: WorkflowMeta }, args?: unknown): Promise { + const handle = ctx.workflows.start({ ...source, parent, ...args !== undefined ? { args } : {} }) try { return await handle.result } finally { @@ -127,13 +127,13 @@ describe('dsh-workflow-workerthread', () => { for (const name of ['workflow/start', 'workflow/phase', 'workflow/log', 'workflow/agent-start', 'workflow/agent-end', 'workflow/end'] as const) { ctx.on(name, (...payload: unknown[]) => { events.push([name, payload]) }) } - const result = await run(ctx, parent, script(` + const result = await run(ctx, parent, scripted(` phase('Scan') log('starting with ' + args.files.length + ' files') const answers = await pipeline(args.files, (prev, item) => agent('read ' + item)) phase('Report') return { answers, count: args.files.length } - `, ", phases: [{ title: 'Scan' }, { title: 'Report' }]"), { files: ['a.ts', 'b.ts'] }) + `, { phases: [{ title: 'Scan' }, { title: 'Report' }] }), { files: ['a.ts', 'b.ts'] }) expect(result.stopReason).toBe('completed') expect(result.agentsStarted).toBe(2) @@ -156,7 +156,7 @@ describe('dsh-workflow-workerthread', () => { const { ctx, parent, provider } = await setup({ reply: () => ({ output: [], structured: { files: ['x.ts', 'y.ts'] }, stopReason: 'completed' }), }) - const result = await run(ctx, parent, script(` + const result = await run(ctx, parent, scripted(` const found = await agent('list files', { model: 'deepseek-v4-pro', schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } }, required: ['files'] } }) return { first: found.files[0], count: found.files.length } `)) @@ -172,14 +172,14 @@ describe('dsh-workflow-workerthread', () => { it('a fatal hook error inside the worker kills the script and reports the error', async () => { const { ctx, parent } = await setup() - const result = await run(ctx, parent, script("return await parallel([() => agent('x', { isolation: 'worktree' })])")) + const result = await run(ctx, parent, scripted("return await parallel([() => agent('x', { isolation: 'worktree' })])")) expect(result.stopReason).toBe('error') expect(result.error).toContain('"isolation" is deferred') }) it('a provider start failure crosses back as a fatal AGENT_START error', async () => { const { ctx, parent } = await setup({ config: { provider: 'nonexistent' } }) - const result = await run(ctx, parent, script("return await pipeline([1], () => agent('p'))")) + const result = await run(ctx, parent, scripted("return await pipeline([1], () => agent('p'))")) expect(result.stopReason).toBe('error') expect(result.error).toContain('agent() could not start a child') }) @@ -200,7 +200,7 @@ describe('dsh-workflow-workerthread', () => { } ctx.subagents.registerProvider(provider) await ctx.plugin(WorkerWorkflowEngine, { provider: 'rejecting', maxConcurrentAgents: 2 }) - const result = await run(ctx, fakeParent(), script(` + const result = await run(ctx, fakeParent(), scripted(` try { await agent('p'); return 'unreachable' } catch (e) { return { name: e.name, code: e.code, fatal: e.fatal, message: e.message } } `)) expect(result.value).toMatchObject({ name: 'WorkflowError', code: 'AGENT_RESULT', fatal: true }) @@ -223,7 +223,7 @@ describe('dsh-workflow-workerthread', () => { } ctx.subagents.registerProvider(provider) await ctx.plugin(WorkerWorkflowEngine, { provider: 'bad-dispose', maxConcurrentAgents: 2 }) - const result = await run(ctx, fakeParent(), script("return await agent('p')")) + const result = await run(ctx, fakeParent(), scripted("return await agent('p')")) expect(result.stopReason).toBe('completed') expect(result.value).toBe('fine') }) @@ -248,17 +248,22 @@ describe('dsh-workflow-workerthread', () => { } ctx.subagents.registerProvider(provider) await ctx.plugin(WorkerWorkflowEngine, { provider: 'coercion-trap-dispose', maxConcurrentAgents: 2 }) - const result = await run(ctx, fakeParent(), script("return await agent('p')")) + const result = await run(ctx, fakeParent(), scripted("return await agent('p')")) expect(result.stopReason).toBe('completed') expect(result.value).toBe('fine') }) }) describe('lifecycle: parse errors, cancellation, termination, disposal', () => { - it('start() throws synchronously for an unparseable script or invalid meta (host-side pre-parse)', async () => { + it('start() throws synchronously for invalid meta data or an unparseable body (host-side pre-checks)', async () => { const { ctx, parent } = await setup() - expect(() => ctx.workflows.start({ script: 'const x = 1', parent })).toThrow(/must begin with/) - expect(() => ctx.workflows.start({ script: script('return ((('), parent })).toThrow(/does not parse/) + // Meta is DATA — shape violations reject loud, every one named. + expect(() => ctx.workflows.start({ script: 'return 1', meta: { name: '', description: 'd' }, parent })).toThrow(/meta\.name must be a non-empty string/) + expect(() => ctx.workflows.start({ script: 'return 1', meta: { name: 'x', description: 'd', extra: 1 } as unknown as WorkflowMeta, parent })).toThrow(/META_INVALID|not a recognized field/) + expect(() => ctx.workflows.start({ ...scripted('return ((('), parent })).toThrow(/does not parse/) + // The likeliest authoring slip — a Claude Code-style meta header in the + // body — gets a pointed message, not a bare SyntaxError. + expect(() => ctx.workflows.start({ ...scripted("export const meta = { name: 'x', description: 'd' }\nreturn 1"), parent })).toThrow(/meta rides the `meta` request field/) }) it('cancel() aborts in-flight children (signal AND cancel RPC) and settles the run cancelled', async () => { @@ -267,7 +272,7 @@ describe('dsh-workflow-workerthread', () => { ctx.on('workflow/agent-end', (_info, agent) => { ends.push(agent) }) const runEnds: WorkflowResultInfo[] = [] ctx.on('workflow/end', (_info, result) => { runEnds.push(result) }) - const handle = ctx.workflows.start({ script: script("return await agent('long job')"), parent }) + const handle = ctx.workflows.start({ ...scripted("return await agent('long job')"), parent }) await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) handle.cancel('user stopped it') const result = await handle.result @@ -287,7 +292,7 @@ describe('dsh-workflow-workerthread', () => { controller.abort() const logs: string[] = [] ctx.on('workflow/log', (_info, message) => { logs.push(message) }) - const handle = ctx.workflows.start({ script: script("log('ran')\nreturn 123"), parent, signal: controller.signal }) + const handle = ctx.workflows.start({ ...scripted("log('ran')\nreturn 123"), parent, signal: controller.signal }) const result = await handle.result expect(result.stopReason).toBe('cancelled') expect(result.value).toBeNull() @@ -298,7 +303,7 @@ describe('dsh-workflow-workerthread', () => { it('cancel() right after start() cancels before the body runs; the signal aborting mid-run cancels like cancel()', async () => { const { ctx, parent, provider } = await setup({ manual: true }) - const first = ctx.workflows.start({ script: script("return await agent('never')"), parent }) + const first = ctx.workflows.start({ ...scripted("return await agent('never')"), parent }) // No-reason cancel: the canonical default reason must ride the result. first.cancel() const firstResult = await first.result @@ -308,7 +313,7 @@ describe('dsh-workflow-workerthread', () => { await first.dispose() const controller = new AbortController() - const second = ctx.workflows.start({ script: script("return await agent('job')"), parent, signal: controller.signal }) + const second = ctx.workflows.start({ ...scripted("return await agent('job')"), parent, signal: controller.signal }) await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) controller.abort() expect((await second.result).stopReason).toBe('cancelled') @@ -323,7 +328,7 @@ describe('dsh-workflow-workerthread', () => { // timing can hit reliably. (The closure runs only after `handle` below // is initialized — the listener fires on the worker's first message.) ctx.on('workflow/log', () => { handle.cancel('cancelled from the log listener') }) - const handle = ctx.workflows.start({ script: script("log('mark')\nreturn await agent('late')"), parent }) + const handle = ctx.workflows.start({ ...scripted("log('mark')\nreturn await agent('late')"), parent }) const result = await handle.result expect(result.stopReason).toBe('cancelled') expect(provider.runs.length).toBe(0) @@ -342,7 +347,7 @@ describe('dsh-workflow-workerthread', () => { // host cancellation. The trailing narration exercises host-side // suppression: posted pre-cancel-processing worker-side, arriving // post-cancel host-side. - script: script(` + ...scripted(` log('started') const end = Date.now() + 1000 while (Date.now() < end) {} @@ -366,7 +371,7 @@ describe('dsh-workflow-workerthread', () => { const runEnds: WorkflowResultInfo[] = [] ctx.on('workflow/end', (_info, result) => { runEnds.push(result) }) const handle = ctx.workflows.start({ - script: script("await new Promise(() => {})\nreturn 'unreachable'"), + ...scripted("await new Promise(() => {})\nreturn 'unreachable'"), parent, }) handle.cancel('user aborted') @@ -382,7 +387,7 @@ describe('dsh-workflow-workerthread', () => { it('dispose() on a stuck script returns within the grace instead of hanging (result settles cancelled)', async () => { const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 50 } }) const handle = ctx.workflows.start({ - script: script("await new Promise(() => {})\nreturn 'unreachable'"), + ...scripted("await new Promise(() => {})\nreturn 'unreachable'"), parent, }) const before = Date.now() @@ -394,7 +399,7 @@ describe('dsh-workflow-workerthread', () => { it('dispose() is idempotent and settles cleanly after a completed run', async () => { const { ctx, parent } = await setup() - const handle = ctx.workflows.start({ script: script('return 1'), parent }) + const handle = ctx.workflows.start({ ...scripted('return 1'), parent }) await handle.result await handle.dispose() await handle.dispose() @@ -405,7 +410,7 @@ describe('dsh-workflow-workerthread', () => { // apart from every other timeout in flight. const GRACE = 44_444 const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: GRACE } }) - const handle = ctx.workflows.start({ script: script('return 1'), parent }) + const handle = ctx.workflows.start({ ...scripted('return 1'), parent }) await handle.result const spy = vi.spyOn(globalThis, 'setTimeout') try { @@ -424,7 +429,7 @@ describe('dsh-workflow-workerthread', () => { it('strays: children fired without await are aborted once the script settles, and dispose() waits for their disposal', async () => { const { ctx, parent, provider } = await setup({ manual: true, disposeDelayMs: 40 }) const handle = ctx.workflows.start({ - script: script(` + ...scripted(` agent('stray') return 'done without awaiting' `), @@ -468,7 +473,7 @@ describe('dsh-workflow-workerthread', () => { ctx.subagents.registerProvider(provider) await ctx.plugin(WorkerWorkflowEngine, { provider: 'signal-only', maxConcurrentAgents: 2 }) const handle = ctx.workflows.start({ - script: script(` + ...scripted(` agent('stray, never awaited') return 'done' `), @@ -515,7 +520,7 @@ describe('dsh-workflow-workerthread', () => { // microtask yields let the agent() continuation POST its child-start // before the spin seizes the worker's loop (the posted message needs // no further worker-loop turns to reach the host). - script: script(` + ...scripted(` agent('wedged child') for (let i = 0; i < 20; i++) await null const end = Date.now() + 1500 @@ -560,7 +565,7 @@ describe('dsh-workflow-workerthread', () => { // The stray child's start RPC reaches the host, then the script kills // its own worker through the documented vm escape — the host must // settle `error` with the exit diagnostics and wind the child down. - script: script(` + ...scripted(` agent('doomed') const proc = ${ESCAPE} const st = globalThis.constructor.constructor('return setTimeout')() @@ -583,7 +588,7 @@ describe('dsh-workflow-workerthread', () => { it('an uncaught exception inside the worker surfaces as an error result and reaps the in-flight child', async () => { const { ctx, parent, provider } = await setup({ manual: true }) const handle = ctx.workflows.start({ - script: script(` + ...scripted(` agent('in flight when the worker dies') const proc = ${ESCAPE} const st = globalThis.constructor.constructor('return setTimeout')() @@ -613,7 +618,7 @@ describe('dsh-workflow-workerthread', () => { // The STRAY child settles instantly, so its wrapper starts the slow // host-side disposal concurrently while the script goes on to kill // its own worker — the ack then resolves into a dead thread. - script: script(` + ...scripted(` agent('stray, never awaited') const proc = ${ESCAPE} const st = globalThis.constructor.constructor('return setTimeout')() @@ -632,7 +637,7 @@ describe('dsh-workflow-workerthread', () => { it('a worker death AFTER a cancel reports cancelled, not error', async () => { const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 60_000 } }) const handle = ctx.workflows.start({ - script: script(` + ...scripted(` const proc = ${ESCAPE} const st = globalThis.constructor.constructor('return setTimeout')() log('armed') @@ -659,8 +664,8 @@ describe('dsh-workflow-workerthread', () => { const { ctx, parent } = await setup() let eventMeta: WorkflowRunInfo | undefined ctx.on('workflow/start', (info) => { eventMeta = info }) - const first = ctx.workflows.start({ script: script('return 1'), parent }) - const second = ctx.workflows.start({ script: script('return 2'), parent }) + const first = ctx.workflows.start({ ...scripted('return 1'), parent }) + const second = ctx.workflows.start({ ...scripted('return 2'), parent }) expect(first.id).not.toBe(second.id) eventMeta!.meta.name = 'corrupted' expect(second.meta.name).toBe('test-flow') @@ -676,7 +681,7 @@ describe('dsh-workflow-workerthread', () => { expect(ctx.get('workflows')).toBeDefined() // A zero-agent run through the DEFAULT config exercises the auto // concurrency resolution (cores - 2, capped) in start(). - const result = await run(ctx, fakeParent(), script('return 6 * 7')) + const result = await run(ctx, fakeParent(), scripted('return 6 * 7')) expect(result.value).toBe(42) await fiber.dispose() expect(ctx.get('workflows')).toBeUndefined() diff --git a/packages/workflow/workflow/README.md b/packages/workflow/workflow/README.md index 317aaf9f81..5ff8028b0d 100644 --- a/packages/workflow/workflow/README.md +++ b/packages/workflow/workflow/README.md @@ -11,7 +11,7 @@ The protected `emitWorkflowEvent` helper dispatches the `workflow/*` events with ## Vocabulary - `WorkflowStartRequest` — `{ script, args?, parent: Agent, signal? }`. `parent` is REQUIRED: every child the script spawns is attributed to it. `args` must be plain host-realm JSON data. -- `WorkflowMeta` / `WorkflowPhase` — the script's validated `export const meta` block (Claude Code format: required `name`/`description`, optional `whenToUse`/`phases`). +- `WorkflowMeta` / `WorkflowPhase` — the workflow's identity block, carried as plain JSON data on the start request (Claude Code meta vocabulary: required `name`/`description`, optional `whenToUse`/`phases`) and shape-validated by the engine. - `WorkflowRun` — `{ id, meta, result, cancel(reason?), dispose() }`; the consumer awaits `result` and MUST `dispose` on every path. - `WorkflowResult` — `{ value, stopReason: 'completed'|'cancelled'|'error', error?, agentsStarted }`; `value` is the script's materialized return (plain JSON data; `null` for no return). - `WorkflowError` — `HarnessError` with a `WorkflowErrorCode` and a `fatal` flag driving the combinator discipline: a fatal error (bad hook arguments, unsupported options/schemas, tripped caps, seam start failures, cancellation) always propagates through `parallel()`/`pipeline()` instead of dissolving into a per-item `null`. `isFatalWorkflowError(error)` is the catch-site predicate. diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index 8552e32454..66da4f1268 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -35,9 +35,11 @@ export interface WorkflowPhase { } /** - * The script's `export const meta` block, validated by the engine before the - * body runs. `name`/`description` are required; the rest is optional - * annotation. Matches the Claude Code dynamic-workflows script format. + * The script's identity block, provided as plain JSON data alongside the + * script body (the model-facing tool carries it as its `meta` parameter) and + * validated by the engine before the body runs. `name`/`description` are + * required; the rest is optional annotation. The field vocabulary matches the + * Claude Code dynamic-workflows meta block. */ export interface WorkflowMeta { /** Short kebab-case workflow name (display + persistence key). */ @@ -51,14 +53,18 @@ export interface WorkflowMeta { } /** - * What a caller asks for when starting a workflow run. `parent` is REQUIRED — - * every `agent()` the script spawns is attributed to it (cwd, lineage, depth - * flow through the subagent seam). `args` must be plain host-realm JSON data; - * the engine exposes it to the script as the `args` global. + * What a caller asks for when starting a workflow run. `meta` and `args` are + * plain JSON DATA by the seam contract (the tool builds both from the model's + * schema-validated call; the engine validates `meta`'s shape and rejects loud + * before anything runs) — an engine never evaluates script text to obtain + * them. `parent` is REQUIRED — every `agent()` the script spawns is + * attributed to it (cwd, lineage, depth flow through the subagent seam). */ export interface WorkflowStartRequest { - /** The full script text: `export const meta = {...}` + a plain-JS body. */ + /** The plain-JS script body (top-level await allowed; ends with `return `). */ script: string + /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ + meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown /** The agent on whose behalf the run executes (parent of every child). */ From f91bfc1fcfadb370a38bc1a3463cd7ee23df5276 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:25:59 +0800 Subject: [PATCH 43/57] test: re-record the header pin and workflow-run for the meta-parameter schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow tool's wire schema changed (required meta object parameter; body-only script). Request-header content is pinned by exactly ONE scenario (text-turn) and scrubbed to {{system}}/{{tools}} tokens everywhere else, so the schema change re-records exactly two fixtures: - text-turn — the pinned header itself (the one committed copy of the tool schemas; every other scenario's live header is asserted equal to this pin by the uniformity guard). - workflow-run — its recorded interaction used the removed call shape (meta embedded in the script), which the engine now rejects; the authored prompt is updated to dictate the new shape (meta as a parameter, body-only script) and the scenario re-recorded to a clean single call. Every other fixture stays byte-identical and replays green against the new pin. Known pre-existing exception: fs-policy-reject's recording carries a GNU-only sed -i fallback that fails BSD/macOS replay — kept out of this change deliberately (the snapshot CI lane is ubuntu). --- .../tests/snapshots/text-turn/session.jsonl | 69 ++-- .../snapshots/text-turn/stdout.golden.jsonl | 9 +- .../tests/snapshots/workflow-run/input.json | 2 +- .../snapshots/workflow-run/session.1.jsonl | 75 ++-- .../snapshots/workflow-run/session.jsonl | 359 ++++++++++-------- .../workflow-run/stdout.golden.jsonl | 100 +++-- 6 files changed, 366 insertions(+), 248 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index 39898c5003..8475c97896 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -1,33 +1,36 @@ -{"type":"session","version":0,"id":"423e2c78-075e-4286-8027-85b0e64da45d","createdAt":1783437535685,"cwd":"/tmp/acp-snap-cwd-XUOYdd"} -{"type":"turn/start","seq":0,"time":1783437535688,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783437535689,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783437535690,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783437535690,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-XUOYdd.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe script MUST begin with `export const meta = {...}` — a PURE object literal (no variables, calls, or template interpolation) with required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The body after it is plain JavaScript (NOT TypeScript) running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The complete workflow script: `export const meta = {...}` followed by the plain-JS body (top-level await allowed; end with `return `)."},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783437536390,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783437536390,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783437536568,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783437536591,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783437536592,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783437536592,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783437536592,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":11,"time":1783437536592,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":12,"time":1783437536615,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":13,"time":1783437536616,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":14,"time":1783437536647,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":15,"time":1783437536648,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONG"}}} -{"type":"assistant/chunk","seq":16,"time":1783437536648,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} -{"type":"assistant/chunk","seq":17,"time":1783437536674,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":18,"time":1783437536675,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} -{"type":"assistant/chunk","seq":19,"time":1783437536675,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} -{"type":"assistant/chunk","seq":20,"time":1783437536675,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":21,"time":1783437536675,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":22,"time":1783437536675,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} -{"type":"assistant/chunk","seq":23,"time":1783437536702,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} -{"type":"assistant/chunk","seq":24,"time":1783437536703,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"."}}} -{"type":"assistant/chunk","seq":25,"time":1783437536704,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"PONG.\" and no tools."}}}} -{"type":"assistant/chunk","seq":26,"time":1783437536704,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG."}}}} -{"type":"assistant/chunk","seq":27,"time":1783437536704,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2867,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":16}}}} -{"type":"assistant/chunk","seq":28,"time":1783437536704,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":29,"time":1783437536706,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"PONG.\" and no tools."},{"type":"text","text":"PONG."}],"usage":{"inputTokens":2867,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":16}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"} -{"type":"step/end","seq":30,"time":1783437536706,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":31,"time":1783437536706,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w"} +{"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783600629542,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783600630820,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1783600630885,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1783600630886,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":13,"time":1783600630926,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":14,"time":1783600630926,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":15,"time":1783600630926,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1783600630926,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":17,"time":1783600630926,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONG"}}} +{"type":"assistant/chunk","seq":18,"time":1783600630926,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":19,"time":1783600630944,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1783600630944,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":21,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":22,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} +{"type":"assistant/chunk","seq":23,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":24,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":25,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":26,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":27,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":28,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"type":"assistant/chunk","seq":29,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} +{"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":32,"time":1783600631011,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1783600631011,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":34,"time":1783600631011,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl index bc3582f027..dda3afb9c5 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl @@ -8,15 +8,18 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONG"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" no"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" not"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" any"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"P"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONG"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/input.json b/examples/acp-agent/tests/snapshots/workflow-run/input.json index 2c402dc49e..e5deb7edd0 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/input.json +++ b/examples/acp-agent/tests/snapshots/workflow-run/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Use the workflow tool exactly once, with args omitted and this EXACT script (copy it verbatim):\nexport const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool." } + { "op": "prompt", "text": "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool." } ] } diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl index 34f6710ee1..a8c1d6018b 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl @@ -1,39 +1,36 @@ -{"type":"session","version":0,"id":"ba8789c0-cfec-4ec0-9f19-d30e086c3428","createdAt":1783352155100,"cwd":"/tmp/acp-snap-cwd-Z6uc79","parentSession":"322d3a3d-add3-4926-a507-9a6a70a2123a"} -{"type":"turn/start","seq":0,"time":1783352155101,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352155101,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352155102,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352155102,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352156006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352156006,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352156119,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352156148,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":8,"time":1783352156148,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} -{"type":"assistant/chunk","seq":9,"time":1783352156148,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":10,"time":1783352156148,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":11,"time":1783352156148,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":12,"time":1783352156176,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":13,"time":1783352156176,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":14,"time":1783352156176,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":15,"time":1783352156205,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":16,"time":1783352156205,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":17,"time":1783352156205,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} -{"type":"assistant/chunk","seq":18,"time":1783352156205,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} -{"type":"assistant/chunk","seq":19,"time":1783352156205,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} -{"type":"assistant/chunk","seq":20,"time":1783352156205,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":21,"time":1783352156234,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":22,"time":1783352156234,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":23,"time":1783352156234,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} -{"type":"assistant/chunk","seq":24,"time":1783352156234,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} -{"type":"assistant/chunk","seq":25,"time":1783352156234,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":26,"time":1783352156263,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":27,"time":1783352156263,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WF"}}} -{"type":"assistant/chunk","seq":28,"time":1783352156264,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_CH"}}} -{"type":"assistant/chunk","seq":29,"time":1783352156264,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ILD"}}} -{"type":"assistant/chunk","seq":30,"time":1783352156264,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} -{"type":"assistant/chunk","seq":31,"time":1783352156264,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to reply with exactly the word \"WF_CHILD_OK\" and nothing else."}}}} -{"type":"assistant/chunk","seq":32,"time":1783352156264,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":33,"time":1783352156264,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2865,"outputTokens":26,"cacheReadTokens":0,"reasoningTokens":21}}}} -{"type":"assistant/chunk","seq":34,"time":1783352156264,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":35,"time":1783352156264,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user is asking me to reply with exactly the word \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"usage":{"inputTokens":2865,"outputTokens":26,"cacheReadTokens":0,"reasoningTokens":21}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":1783352156265,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":37,"time":1783352156265,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8"} +{"type":"turn/start","seq":0,"time":1783600636316,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783600636316,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783600636316,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783600636317,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783600638073,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783600638073,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783600638173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":13,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":14,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} +{"type":"assistant/chunk","seq":15,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} +{"type":"assistant/chunk","seq":16,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":17,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":18,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":19,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":21,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":22,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":23,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WF"}}} +{"type":"assistant/chunk","seq":25,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_CH"}}} +{"type":"assistant/chunk","seq":26,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ILD"}}} +{"type":"assistant/chunk","seq":27,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} +{"type":"assistant/chunk","seq":28,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}}} +{"type":"assistant/chunk","seq":29,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":30,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":31,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":32,"time":1783600638281,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1783600638281,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":34,"time":1783600638281,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl index 927e1408bd..ab53f20550 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl @@ -1,150 +1,209 @@ -{"type":"session","version":0,"id":"322d3a3d-add3-4926-a507-9a6a70a2123a","createdAt":1783352153441,"cwd":"/tmp/acp-snap-cwd-Z6uc79"} -{"type":"turn/start","seq":0,"time":1783352153445,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352153446,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted and this EXACT script (copy it verbatim):\nexport const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352153447,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352153447,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352154106,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352154106,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352154306,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352154335,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352154335,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352154335,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352154336,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":11,"time":1783352154363,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":12,"time":1783352154363,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exact"}}} -{"type":"assistant/chunk","seq":13,"time":1783352154363,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} -{"type":"assistant/chunk","seq":14,"time":1783352154363,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" script"}}} -{"type":"assistant/chunk","seq":15,"time":1783352154391,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" provided"}}} -{"type":"assistant/chunk","seq":16,"time":1783352154392,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":17,"time":1783352154392,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":18,"time":1783352154421,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":19,"time":1783352154421,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":20,"time":1783352154421,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":21,"time":1783352154450,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":22,"time":1783352154450,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":23,"time":1783352154450,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" WORK"}}} -{"type":"assistant/chunk","seq":24,"time":1783352154450,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}} -{"type":"assistant/chunk","seq":25,"time":1783352154451,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}} -{"type":"assistant/chunk","seq":26,"time":1783352154451,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":27,"time":1783352154478,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":28,"time":1783352154478,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":29,"time":1783352154478,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":30,"time":1783352154509,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":31,"time":1783352154509,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":32,"time":1783352154509,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":33,"time":1783352154510,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":34,"time":1783352154536,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":35,"time":1783352154536,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} -{"type":"assistant/chunk","seq":36,"time":1783352154575,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":37,"time":1783352154626,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":38,"time":1783352154626,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":39,"time":1783352154656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":40,"time":1783352154656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":41,"time":1783352154656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"script"}}} -{"type":"assistant/chunk","seq":42,"time":1783352154683,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":43,"time":1783352154683,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":44,"time":1783352154683,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1783352154683,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"export"}}} -{"type":"assistant/chunk","seq":46,"time":1783352154713,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" const"}}} -{"type":"assistant/chunk","seq":47,"time":1783352154713,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" meta"}}} -{"type":"assistant/chunk","seq":48,"time":1783352154713,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":49,"time":1783352154713,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" {"}}} -{"type":"assistant/chunk","seq":50,"time":1783352154714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" name"}}} -{"type":"assistant/chunk","seq":51,"time":1783352154741,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":52,"time":1783352154741,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" '"}}} -{"type":"assistant/chunk","seq":53,"time":1783352154741,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"sn"}}} -{"type":"assistant/chunk","seq":54,"time":1783352154741,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"apshot"}}} -{"type":"assistant/chunk","seq":55,"time":1783352154741,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"-flow"}}} -{"type":"assistant/chunk","seq":56,"time":1783352154741,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"',"}}} -{"type":"assistant/chunk","seq":57,"time":1783352154773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" description"}}} -{"type":"assistant/chunk","seq":58,"time":1783352154773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":59,"time":1783352154773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" '"}}} -{"type":"assistant/chunk","seq":60,"time":1783352154773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"one"}}} -{"type":"assistant/chunk","seq":61,"time":1783352154773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" child"}}} -{"type":"assistant/chunk","seq":62,"time":1783352154798,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" for"}}} -{"type":"assistant/chunk","seq":63,"time":1783352154799,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":64,"time":1783352154799,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" snapshot"}}} -{"type":"assistant/chunk","seq":65,"time":1783352154799,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"'"}}} -{"type":"assistant/chunk","seq":66,"time":1783352154799,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" }\\n"}}} -{"type":"assistant/chunk","seq":67,"time":1783352154799,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"phase"}}} -{"type":"assistant/chunk","seq":68,"time":1783352154827,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"('"}}} -{"type":"assistant/chunk","seq":69,"time":1783352154827,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"Run"}}} -{"type":"assistant/chunk","seq":70,"time":1783352154856,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"')\\n"}}} -{"type":"assistant/chunk","seq":71,"time":1783352154856,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"const"}}} -{"type":"assistant/chunk","seq":72,"time":1783352154856,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" reply"}}} -{"type":"assistant/chunk","seq":73,"time":1783352154884,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":74,"time":1783352154885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":75,"time":1783352154885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" agent"}}} -{"type":"assistant/chunk","seq":76,"time":1783352154885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"('"}}} -{"type":"assistant/chunk","seq":77,"time":1783352154914,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"Reply"}}} -{"type":"assistant/chunk","seq":78,"time":1783352154914,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":79,"time":1783352154915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" exactly"}}} -{"type":"assistant/chunk","seq":80,"time":1783352154915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":81,"time":1783352154942,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" word"}}} -{"type":"assistant/chunk","seq":82,"time":1783352154942,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" WF"}}} -{"type":"assistant/chunk","seq":83,"time":1783352154943,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"_CH"}}} -{"type":"assistant/chunk","seq":84,"time":1783352154943,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"ILD"}}} -{"type":"assistant/chunk","seq":85,"time":1783352154971,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":86,"time":1783352154971,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":87,"time":1783352154971,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" nothing"}}} -{"type":"assistant/chunk","seq":88,"time":1783352154972,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" else"}}} -{"type":"assistant/chunk","seq":89,"time":1783352154972,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":".')\\n"}}} -{"type":"assistant/chunk","seq":90,"time":1783352154972,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"return"}}} -{"type":"assistant/chunk","seq":91,"time":1783352155000,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" {"}}} -{"type":"assistant/chunk","seq":92,"time":1783352155000,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" reply"}}} -{"type":"assistant/chunk","seq":93,"time":1783352155000,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":" }"}}} -{"type":"assistant/chunk","seq":94,"time":1783352155001,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":95,"time":1783352155029,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":96,"time":1783352155091,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run the exact workflow script provided and then reply with the single word WORKFLOW_DONE. Let me do this exactly as instructed."}}}} -{"type":"assistant/chunk","seq":97,"time":1783352155092,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","arguments":"{\"script\": \"export const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\"}"}}}} -{"type":"assistant/chunk","seq":98,"time":1783352155092,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2947,"outputTokens":124,"cacheReadTokens":0,"reasoningTokens":32}}}} -{"type":"assistant/chunk","seq":99,"time":1783352155092,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":100,"time":1783352155094,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run the exact workflow script provided and then reply with the single word WORKFLOW_DONE. Let me do this exactly as instructed."},{"type":"tool-call","id":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","arguments":"{\"script\": \"export const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\"}"}],"usage":{"inputTokens":2947,"outputTokens":124,"cacheReadTokens":0,"reasoningTokens":32}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99],"surfaceOp":"append"} -{"type":"tool/call","seq":101,"time":1783352155094,"data":{"turn":1,"step":1,"callId":"call_00_Vp9f0l50KeeHRfcBezz08251","name":"workflow","arguments":"{\"script\": \"export const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\"}"}} -{"type":"tool/result","seq":102,"time":1783352156271,"data":{"turn":1,"step":1,"callId":"call_00_Vp9f0l50KeeHRfcBezz08251","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[101],"surfaceOp":"append"} -{"type":"step/end","seq":103,"time":1783352156271,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":104,"time":1783352156272,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":105,"time":1783352156660,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":106,"time":1783352156660,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":107,"time":1783352156787,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} -{"type":"assistant/chunk","seq":108,"time":1783352156823,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":109,"time":1783352156842,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":110,"time":1783352156842,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":111,"time":1783352156870,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":112,"time":1783352156871,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":113,"time":1783352156899,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":114,"time":1783352156899,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} -{"type":"assistant/chunk","seq":115,"time":1783352156900,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} -{"type":"assistant/chunk","seq":116,"time":1783352156900,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} -{"type":"assistant/chunk","seq":117,"time":1783352156900,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":118,"time":1783352156900,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":119,"time":1783352156927,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":120,"time":1783352156928,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":121,"time":1783352156928,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":122,"time":1783352156928,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":123,"time":1783352156928,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":124,"time":1783352156956,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":125,"time":1783352156956,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":126,"time":1783352156956,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":127,"time":1783352156956,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":128,"time":1783352156956,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" WORK"}}} -{"type":"assistant/chunk","seq":129,"time":1783352156985,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}} -{"type":"assistant/chunk","seq":130,"time":1783352156985,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}} -{"type":"assistant/chunk","seq":131,"time":1783352156985,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":132,"time":1783352156985,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":133,"time":1783352156986,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":134,"time":1783352156986,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} -{"type":"assistant/chunk","seq":135,"time":1783352157015,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":136,"time":1783352157016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":137,"time":1783352157016,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WORK"}}} -{"type":"assistant/chunk","seq":138,"time":1783352157016,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"FL"}}} -{"type":"assistant/chunk","seq":139,"time":1783352157016,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OW"}}} -{"type":"assistant/chunk","seq":140,"time":1783352157016,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} -{"type":"assistant/chunk","seq":141,"time":1783352157044,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":142,"time":1783352157044,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with the single word WORKFLOW_DONE as instructed."}}}} -{"type":"assistant/chunk","seq":143,"time":1783352157045,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} -{"type":"assistant/chunk","seq":144,"time":1783352157045,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":165,"outputTokens":36,"cacheReadTokens":2944,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":145,"time":1783352157045,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":146,"time":1783352157045,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with the single word WORKFLOW_DONE as instructed."},{"type":"text","text":"WORKFLOW_DONE"}],"usage":{"inputTokens":165,"outputTokens":36,"cacheReadTokens":2944,"reasoningTokens":30}},"sourceEventSeqs":[105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145],"surfaceOp":"append"} -{"type":"step/end","seq":147,"time":1783352157045,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":148,"time":1783352157045,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz"} +{"type":"turn/start","seq":0,"time":1783600631838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783600631838,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783600631839,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783600631839,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783600634643,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783600634643,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":11,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} +{"type":"assistant/chunk","seq":13,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":14,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":15,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":16,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":17,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":18,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" parameters"}}} +{"type":"assistant/chunk","seq":19,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":20,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":21,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":22,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" carefully"}}} +{"type":"assistant/chunk","seq":23,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" follow"}}} +{"type":"assistant/chunk","seq":24,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":25,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" instructions"}}} +{"type":"assistant/chunk","seq":26,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":27,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":28,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":29,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" args"}}} +{"type":"assistant/chunk","seq":30,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" omitted"}}} +{"type":"assistant/chunk","seq":31,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":32,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"so"}}} +{"type":"assistant/chunk","seq":33,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":34,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" don"}}} +{"type":"assistant/chunk","seq":35,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":36,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" include"}}} +{"type":"assistant/chunk","seq":37,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":38,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":")\n"}}} +{"type":"assistant/chunk","seq":39,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":40,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":41,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" meta"}}} +{"type":"assistant/chunk","seq":42,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ="}}} +{"type":"assistant/chunk","seq":43,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" {"}}} +{"type":"assistant/chunk","seq":44,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":45,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"name"}}} +{"type":"assistant/chunk","seq":46,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\":"}}} +{"type":"assistant/chunk","seq":47,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":48,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"sn"}}} +{"type":"assistant/chunk","seq":49,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"apshot"}}} +{"type":"assistant/chunk","seq":50,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-flow"}}} +{"type":"assistant/chunk","seq":51,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} +{"type":"assistant/chunk","seq":52,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":53,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"description"}}} +{"type":"assistant/chunk","seq":54,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\":"}}} +{"type":"assistant/chunk","seq":55,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":56,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"one"}}} +{"type":"assistant/chunk","seq":57,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":58,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} +{"type":"assistant/chunk","seq":59,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":60,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" snapshot"}}} +{"type":"assistant/chunk","seq":61,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":62,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" }\n"}}} +{"type":"assistant/chunk","seq":63,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} +{"type":"assistant/chunk","seq":64,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":65,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" script"}}} +{"type":"assistant/chunk","seq":66,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ="}}} +{"type":"assistant/chunk","seq":67,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":68,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" given"}}} +{"type":"assistant/chunk","seq":69,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":70,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":71,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} +{"type":"assistant/chunk","seq":72,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":73,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":74,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} +{"type":"assistant/chunk","seq":75,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":76,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":77,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":78,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":79,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":80,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":81,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WORK"}}} +{"type":"assistant/chunk","seq":82,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}} +{"type":"assistant/chunk","seq":83,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}} +{"type":"assistant/chunk","seq":84,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":85,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":86,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n\n"}}} +{"type":"assistant/chunk","seq":87,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":88,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":89,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":90,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":91,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":92,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":93,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":94,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":95,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":96,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":97,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"meta"}}} +{"type":"assistant/chunk","seq":98,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":99,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":100,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"{\""}}} +{"type":"assistant/chunk","seq":101,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"name"}}} +{"type":"assistant/chunk","seq":102,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":103,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":104,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"sn"}}} +{"type":"assistant/chunk","seq":105,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"apshot"}}} +{"type":"assistant/chunk","seq":106,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"-flow"}}} +{"type":"assistant/chunk","seq":107,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":108,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":109,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":110,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":111,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":112,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"one"}}} +{"type":"assistant/chunk","seq":113,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" child"}}} +{"type":"assistant/chunk","seq":114,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" for"}}} +{"type":"assistant/chunk","seq":115,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":116,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" snapshot"}}} +{"type":"assistant/chunk","seq":117,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":118,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":119,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":120,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":121,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"script"}}} +{"type":"assistant/chunk","seq":122,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":123,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":124,"time":1783600635764,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":125,"time":1783600635764,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\\n"}}} +{"type":"assistant/chunk","seq":126,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"phase"}}} +{"type":"assistant/chunk","seq":127,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"('"}}} +{"type":"assistant/chunk","seq":128,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"Run"}}} +{"type":"assistant/chunk","seq":129,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"')\\n"}}} +{"type":"assistant/chunk","seq":130,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":131,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" reply"}}} +{"type":"assistant/chunk","seq":132,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":133,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":134,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" agent"}}} +{"type":"assistant/chunk","seq":135,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"('"}}} +{"type":"assistant/chunk","seq":136,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":137,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":138,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":139,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":140,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":141,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" WF"}}} +{"type":"assistant/chunk","seq":142,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"_CH"}}} +{"type":"assistant/chunk","seq":143,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"ILD"}}} +{"type":"assistant/chunk","seq":144,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":145,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":146,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":147,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":148,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":".')\\n"}}} +{"type":"assistant/chunk","seq":149,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":150,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" {"}}} +{"type":"assistant/chunk","seq":151,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" reply"}}} +{"type":"assistant/chunk","seq":152,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" }\\n"}}} +{"type":"assistant/chunk","seq":153,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":154,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":155,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."}}}} +{"type":"assistant/chunk","seq":156,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}}}} +{"type":"assistant/chunk","seq":157,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}}}} +{"type":"assistant/chunk","seq":158,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":159,"time":1783600636247,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."},{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}],"usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158],"surfaceOp":"append"} +{"type":"tool/call","seq":160,"time":1783600636247,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}} +{"type":"tool/result","seq":161,"time":1783600638304,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[160],"surfaceOp":"append"} +{"type":"step/end","seq":162,"time":1783600638304,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":163,"time":1783600638305,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":164,"time":1783600640028,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":165,"time":1783600640028,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":166,"time":1783600640134,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} +{"type":"assistant/chunk","seq":167,"time":1783600640162,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":168,"time":1783600640195,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":169,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":170,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":171,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":172,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":173,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} +{"type":"assistant/chunk","seq":174,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} +{"type":"assistant/chunk","seq":175,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":176,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":177,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":178,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":179,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":180,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":181,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":182,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":183,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":184,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":185,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":186,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WORK"}}} +{"type":"assistant/chunk","seq":187,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}} +{"type":"assistant/chunk","seq":188,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}} +{"type":"assistant/chunk","seq":189,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":190,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":191,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":192,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":193,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":194,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":195,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":196,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WORK"}}} +{"type":"assistant/chunk","seq":197,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"FL"}}} +{"type":"assistant/chunk","seq":198,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OW"}}} +{"type":"assistant/chunk","seq":199,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":200,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":201,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":202,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} +{"type":"assistant/chunk","seq":203,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":204,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":205,"time":1783600640865,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"} +{"type":"step/end","seq":206,"time":1783600640865,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":207,"time":1783600640865,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl index 49eeb48918..63af2375d5 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl @@ -5,35 +5,91 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exact"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" workflow"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specific"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" parameters"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" carefully"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" follow"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instructions"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" args"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" omitted"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"so"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" don"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'t"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" include"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":")\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" meta"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ="}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" {"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"name"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"sn"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"apshot"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-flow"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"description"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"one"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" for"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" snapshot"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" }\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" script"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" provided"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ="}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" given"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"4"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" After"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" WORK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WORK"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OW"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" do"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instructed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Vp9f0l50KeeHRfcBezz08251","title":"workflow: snapshot-flow","kind":"other","status":"in_progress","rawInput":"export const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Vp9f0l50KeeHRfcBezz08251","status":"completed","content":[{"type":"content","content":{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","title":"workflow: snapshot-flow","kind":"other","status":"in_progress","rawInput":"\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\n"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","status":"completed","content":[{"type":"content","content":{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" workflow"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} @@ -53,16 +109,16 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" WORK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WORK"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OW"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instructed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"WORK"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"FL"}}}} From 2c03b2bc29d0bbea7a4d0bfd3a63db3496015492 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:56:44 +0800 Subject: [PATCH 44/57] feat: surface the run_code program in the ACP tool-call card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated program rode only rawInput — the detail/expanded slot many ACP clients never open — so the code a run executed was invisible in the UI stream. presentCall now also carries it as a fenced ts block in the card's content, which the bridge already forwards as tool_call content. The two code-mode snapshot goldens are re-recorded live and replay green; the presentation unit test pins the fenced block. --- .../snapshots/both-mode-turn/session.jsonl | 232 ++++++----- .../both-mode-turn/stdout.golden.jsonl | 15 +- .../snapshots/code-mode-turn/session.jsonl | 377 +++++++++--------- .../code-mode-turn/stdout.golden.jsonl | 65 ++- packages/core/tools/src/code-mode.ts | 12 +- packages/core/tools/tests/code-mode.spec.ts | 10 +- 6 files changed, 359 insertions(+), 352 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl index a2b0e9921b..c7bf7c3eba 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -1,110 +1,122 @@ -{"type":"session","version":0,"id":"55c51419-0ee3-4c06-8199-cc69eef57a45","createdAt":1783484575071,"cwd":"/tmp/acp-snap-cwd-lORmOD"} -{"type":"turn/start","seq":0,"time":1783484575075,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783484575076,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783484575078,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783484575079,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-lORmOD.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783484575489,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783484575489,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783484575561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783484575587,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783484575587,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783484575588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783484575588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} -{"type":"assistant/chunk","seq":11,"time":1783484575588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":12,"time":1783484575613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} -{"type":"assistant/chunk","seq":13,"time":1783484575614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":14,"time":1783484575614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":15,"time":1783484575614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} -{"type":"assistant/chunk","seq":16,"time":1783484575639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":17,"time":1783484575639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" runs"}}} -{"type":"assistant/chunk","seq":18,"time":1783484575639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":19,"time":1783484575662,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":20,"time":1783484575663,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" B"}}} -{"type":"assistant/chunk","seq":21,"time":1783484575663,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OTH"}}} -{"type":"assistant/chunk","seq":22,"time":1783484575663,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":23,"time":1783484575688,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":24,"time":1783484575688,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" via"}}} -{"type":"assistant/chunk","seq":25,"time":1783484575689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} -{"type":"assistant/chunk","seq":26,"time":1783484575689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".b"}}} -{"type":"assistant/chunk","seq":27,"time":1783484575689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ash"}}} -{"type":"assistant/chunk","seq":28,"time":1783484575689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":29,"time":1783484575713,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} -{"type":"assistant/chunk","seq":30,"time":1783484575714,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":31,"time":1783484575714,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":32,"time":1783484575714,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":33,"time":1783484575714,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":34,"time":1783484575739,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":35,"time":1783484575739,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":36,"time":1783484575739,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":37,"time":1783484575740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":38,"time":1783484575815,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":39,"time":1783484575815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":40,"time":1783484575840,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":41,"time":1783484575841,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":42,"time":1783484575841,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"code"}}} -{"type":"assistant/chunk","seq":43,"time":1783484575841,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":44,"time":1783484575841,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":45,"time":1783484575865,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":46,"time":1783484575865,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"return"}}} -{"type":"assistant/chunk","seq":47,"time":1783484575865,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":48,"time":1783484575865,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" tools"}}} -{"type":"assistant/chunk","seq":49,"time":1783484575890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":".b"}}} -{"type":"assistant/chunk","seq":50,"time":1783484575891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"ash"}}} -{"type":"assistant/chunk","seq":51,"time":1783484575891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"({"}}} -{"type":"assistant/chunk","seq":52,"time":1783484575891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" command"}}} -{"type":"assistant/chunk","seq":53,"time":1783484575891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":54,"time":1783484575891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":55,"time":1783484575916,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":56,"time":1783484575916,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" B"}}} -{"type":"assistant/chunk","seq":57,"time":1783484575916,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"OTH"}}} -{"type":"assistant/chunk","seq":58,"time":1783484575916,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":59,"time":1783484575916,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"\\\","}}} -{"type":"assistant/chunk","seq":60,"time":1783484575916,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" description"}}} -{"type":"assistant/chunk","seq":61,"time":1783484575942,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":62,"time":1783484575942,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":63,"time":1783484575942,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":64,"time":1783484576004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":65,"time":1783484576004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" B"}}} -{"type":"assistant/chunk","seq":66,"time":1783484576004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"OTH"}}} -{"type":"assistant/chunk","seq":67,"time":1783484576004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":68,"time":1783484576004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":69,"time":1783484576004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":" }"}}} -{"type":"assistant/chunk","seq":70,"time":1783484576019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":");"}}} -{"type":"assistant/chunk","seq":71,"time":1783484576019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":72,"time":1783484576044,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":73,"time":1783484576075,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call run_code with a program that runs `echo BOTH_OK` via tools.bash and returns the output. Let me do that."}}}} -{"type":"assistant/chunk","seq":74,"time":1783484576075,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","arguments":"{\"code\": \"return await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\"}"}}}} -{"type":"assistant/chunk","seq":75,"time":1783484576075,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3733,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":33}}}} -{"type":"assistant/chunk","seq":76,"time":1783484576075,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":77,"time":1783484576078,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to call run_code with a program that runs `echo BOTH_OK` via tools.bash and returns the output. Let me do that."},{"type":"tool-call","id":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","arguments":"{\"code\": \"return await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\"}"}],"usage":{"inputTokens":3733,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":33}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"} -{"type":"tool/call","seq":78,"time":1783484576078,"data":{"turn":1,"step":1,"callId":"call_00_fwre7Dvt0ZU6p96bEBdk6728","name":"run_code","arguments":"{\"code\": \"return await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\"}"}} -{"type":"tool/code-dispatch","seq":79,"time":1783484576205,"data":{"parentCallId":"call_00_fwre7Dvt0ZU6p96bEBdk6728","subCallId":"call_00_fwre7Dvt0ZU6p96bEBdk6728:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Echo BOTH_OK"},"isError":false,"resultSummary":"BOTH_OK\n"}} -{"type":"tool/result","seq":80,"time":1783484576208,"data":{"turn":1,"step":1,"callId":"call_00_fwre7Dvt0ZU6p96bEBdk6728","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[78],"surfaceOp":"append"} -{"type":"step/end","seq":81,"time":1783484576208,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":82,"time":1783484576209,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":83,"time":1783484576645,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":84,"time":1783484576645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":85,"time":1783484576758,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":86,"time":1783484576782,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":87,"time":1783484576783,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":88,"time":1783484576783,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} -{"type":"assistant/chunk","seq":89,"time":1783484576783,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OTH"}}} -{"type":"assistant/chunk","seq":90,"time":1783484576783,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":91,"time":1783484576783,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":92,"time":1783484576810,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":93,"time":1783484576835,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":94,"time":1783484576836,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":95,"time":1783484576836,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":96,"time":1783484576836,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":97,"time":1783484576860,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":98,"time":1783484576894,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":99,"time":1783484576894,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"B"}}} -{"type":"assistant/chunk","seq":100,"time":1783484576894,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OTH"}}} -{"type":"assistant/chunk","seq":101,"time":1783484576894,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} -{"type":"assistant/chunk","seq":102,"time":1783484576894,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output is \"BOTH_OK\". Let me reply with that."}}}} -{"type":"assistant/chunk","seq":103,"time":1783484576894,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BOTH_OK"}}}} -{"type":"assistant/chunk","seq":104,"time":1783484576895,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":138,"outputTokens":18,"cacheReadTokens":3712,"reasoningTokens":14}}}} -{"type":"assistant/chunk","seq":105,"time":1783484576895,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":106,"time":1783484576895,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output is \"BOTH_OK\". Let me reply with that."},{"type":"text","text":"BOTH_OK"}],"usage":{"inputTokens":138,"outputTokens":18,"cacheReadTokens":3712,"reasoningTokens":14}},"sourceEventSeqs":[83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105],"surfaceOp":"append"} -{"type":"step/end","seq":107,"time":1783484576895,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":108,"time":1783484576895,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"14b611ba-2cfd-46d6-bdcb-4f12a261f651","createdAt":1783600817605,"cwd":"/tmp/acp-snap-cwd-f5yZEg"} +{"type":"turn/start","seq":0,"time":1783600817609,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783600817610,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783600817612,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783600817613,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-f5yZEg.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783600818106,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783600818107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783600818317,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783600818345,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783600818346,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783600818346,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783600818347,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":11,"time":1783600818347,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":12,"time":1783600818347,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} +{"type":"assistant/chunk","seq":13,"time":1783600818374,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":14,"time":1783600818375,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":15,"time":1783600818375,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} +{"type":"assistant/chunk","seq":16,"time":1783600818375,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":17,"time":1783600818375,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" runs"}}} +{"type":"assistant/chunk","seq":18,"time":1783600818375,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":19,"time":1783600818407,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":20,"time":1783600818407,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" B"}}} +{"type":"assistant/chunk","seq":21,"time":1783600818407,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OTH"}}} +{"type":"assistant/chunk","seq":22,"time":1783600818408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":23,"time":1783600818432,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":24,"time":1783600818433,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" via"}}} +{"type":"assistant/chunk","seq":25,"time":1783600818433,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":26,"time":1783600818433,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".b"}}} +{"type":"assistant/chunk","seq":27,"time":1783600818433,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ash"}}} +{"type":"assistant/chunk","seq":28,"time":1783600818434,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":29,"time":1783600818461,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":30,"time":1783600818462,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" its"}}} +{"type":"assistant/chunk","seq":31,"time":1783600818462,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":32,"time":1783600818462,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":33,"time":1783600818462,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":34,"time":1783600818491,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":35,"time":1783600818491,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":36,"time":1783600818492,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":37,"time":1783600818492,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":38,"time":1783600818583,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":39,"time":1783600818583,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":40,"time":1783600818611,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":41,"time":1783600818611,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783600818611,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"code"}}} +{"type":"assistant/chunk","seq":43,"time":1783600818611,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783600818611,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":45,"time":1783600818643,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1783600818643,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":47,"time":1783600818643,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" result"}}} +{"type":"assistant/chunk","seq":48,"time":1783600818643,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":49,"time":1783600818643,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":50,"time":1783600818670,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":51,"time":1783600818670,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":52,"time":1783600818670,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":53,"time":1783600818670,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"({\\n"}}} +{"type":"assistant/chunk","seq":54,"time":1783600818670,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" "}}} +{"type":"assistant/chunk","seq":55,"time":1783600818698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":56,"time":1783600818698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":57,"time":1783600818698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":58,"time":1783600818698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":59,"time":1783600818698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" B"}}} +{"type":"assistant/chunk","seq":60,"time":1783600818698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"OTH"}}} +{"type":"assistant/chunk","seq":61,"time":1783600818728,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":62,"time":1783600818728,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"\\\",\\n"}}} +{"type":"assistant/chunk","seq":63,"time":1783600818728,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" "}}} +{"type":"assistant/chunk","seq":64,"time":1783600818728,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":65,"time":1783600818728,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":66,"time":1783600818728,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":67,"time":1783600818757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":68,"time":1783600818785,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":69,"time":1783600818786,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" B"}}} +{"type":"assistant/chunk","seq":70,"time":1783600818786,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"OTH"}}} +{"type":"assistant/chunk","seq":71,"time":1783600818786,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":72,"time":1783600818786,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" string"}}} +{"type":"assistant/chunk","seq":73,"time":1783600818815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"\\\"\\n"}}} +{"type":"assistant/chunk","seq":74,"time":1783600818815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"});\\n"}}} +{"type":"assistant/chunk","seq":75,"time":1783600818815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":76,"time":1783600818815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" result"}}} +{"type":"assistant/chunk","seq":77,"time":1783600818815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":";"}}} +{"type":"assistant/chunk","seq":78,"time":1783600818815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":79,"time":1783600818845,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":80,"time":1783600818906,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call run_code with a program that runs `echo BOTH_OK` via tools.bash and returns its output. Let me do that."}}}} +{"type":"assistant/chunk","seq":81,"time":1783600818906,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({\\n command: \\\"echo BOTH_OK\\\",\\n description: \\\"Echo BOTH_OK string\\\"\\n});\\nreturn result;\"}"}}}} +{"type":"assistant/chunk","seq":82,"time":1783600818906,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3734,"outputTokens":107,"cacheReadTokens":0,"reasoningTokens":33}}}} +{"type":"assistant/chunk","seq":83,"time":1783600818906,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":84,"time":1783600818909,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to call run_code with a program that runs `echo BOTH_OK` via tools.bash and returns its output. Let me do that."},{"type":"tool-call","id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({\\n command: \\\"echo BOTH_OK\\\",\\n description: \\\"Echo BOTH_OK string\\\"\\n});\\nreturn result;\"}"}],"usage":{"inputTokens":3734,"outputTokens":107,"cacheReadTokens":0,"reasoningTokens":33}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83],"surfaceOp":"append"} +{"type":"tool/call","seq":85,"time":1783600818909,"data":{"turn":1,"step":1,"callId":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({\\n command: \\\"echo BOTH_OK\\\",\\n description: \\\"Echo BOTH_OK string\\\"\\n});\\nreturn result;\"}"}} +{"type":"tool/code-dispatch","seq":86,"time":1783600819019,"data":{"parentCallId":"call_00_Kv45KGQIqVt8nuaRebYh2326","subCallId":"call_00_Kv45KGQIqVt8nuaRebYh2326:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Echo BOTH_OK string"},"isError":false,"resultSummary":"BOTH_OK\n"}} +{"type":"tool/result","seq":87,"time":1783600819021,"data":{"turn":1,"step":1,"callId":"call_00_Kv45KGQIqVt8nuaRebYh2326","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[85],"surfaceOp":"append"} +{"type":"step/end","seq":88,"time":1783600819022,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":89,"time":1783600819022,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":90,"time":1783600819418,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":91,"time":1783600819418,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":92,"time":1783600819515,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":93,"time":1783600819543,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":94,"time":1783600819544,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":95,"time":1783600819574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} +{"type":"assistant/chunk","seq":96,"time":1783600819574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OTH"}}} +{"type":"assistant/chunk","seq":97,"time":1783600819574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":98,"time":1783600819574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":99,"time":1783600819574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":100,"time":1783600819575,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":101,"time":1783600819602,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":102,"time":1783600819603,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":103,"time":1783600819603,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":104,"time":1783600819603,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":105,"time":1783600819634,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":106,"time":1783600819635,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" only"}}} +{"type":"assistant/chunk","seq":107,"time":1783600819635,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":108,"time":1783600819664,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":109,"time":1783600819665,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":110,"time":1783600819665,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":111,"time":1783600819665,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"B"}}} +{"type":"assistant/chunk","seq":112,"time":1783600819665,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OTH"}}} +{"type":"assistant/chunk","seq":113,"time":1783600819665,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} +{"type":"assistant/chunk","seq":114,"time":1783600819693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output is \"BOTH_OK\". I need to reply with that output only and stop."}}}} +{"type":"assistant/chunk","seq":115,"time":1783600819693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BOTH_OK"}}}} +{"type":"assistant/chunk","seq":116,"time":1783600819693,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3840,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":117,"time":1783600819693,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":118,"time":1783600819694,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output is \"BOTH_OK\". I need to reply with that output only and stop."},{"type":"text","text":"BOTH_OK"}],"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3840,"reasoningTokens":19}},"sourceEventSeqs":[90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117],"surfaceOp":"append"} +{"type":"step/end","seq":119,"time":1783600819694,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":120,"time":1783600819694,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl index d307e1d60f..4a959143d7 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl @@ -25,7 +25,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ash"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" its"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} @@ -33,8 +33,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" do"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_fwre7Dvt0ZU6p96bEBdk6728","title":"Run code","kind":"execute","status":"in_progress","rawInput":"return await tools.bash({ command: \"echo BOTH_OK\", description: \"Echo BOTH_OK\" });"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_fwre7Dvt0ZU6p96bEBdk6728","status":"completed","content":[{"type":"content","content":{"type":"text","text":"BOTH_OK\n"}}],"title":"Run code (1 tool call)"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Kv45KGQIqVt8nuaRebYh2326","title":"Run code","kind":"execute","status":"in_progress","rawInput":"const result = await tools.bash({\n command: \"echo BOTH_OK\",\n description: \"Echo BOTH_OK string\"\n});\nreturn result;","content":[{"type":"content","content":{"type":"text","text":"```ts\nconst result = await tools.bash({\n command: \"echo BOTH_OK\",\n description: \"Echo BOTH_OK string\"\n});\nreturn result;\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Kv45KGQIqVt8nuaRebYh2326","status":"completed","content":[{"type":"content","content":{"type":"text","text":"BOTH_OK\n"}}],"title":"Run code (1 tool call)"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} @@ -43,11 +43,16 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OTH"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" only"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"B"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OTH"}}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl index dd9c0bed28..97d73709fc 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl @@ -1,196 +1,181 @@ -{"type":"session","version":0,"id":"92c80cd8-dddc-4cd6-a05a-9676ef54af5e","createdAt":1783484558135,"cwd":"/tmp/acp-snap-cwd-zej9wx"} -{"type":"turn/start","seq":0,"time":1783484558139,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783484558139,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO` — and return the two outputs joined with a plus sign. Then reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783484558142,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783484558142,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-zej9wx.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783484558789,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783484558789,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783484558877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783484558904,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783484558905,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783484558905,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783484558905,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} -{"type":"assistant/chunk","seq":11,"time":1783484558933,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":12,"time":1783484558933,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":13,"time":1783484558934,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":14,"time":1783484558934,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} -{"type":"assistant/chunk","seq":15,"time":1783484558934,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} -{"type":"assistant/chunk","seq":16,"time":1783484558957,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":17,"time":1783484558957,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} -{"type":"assistant/chunk","seq":18,"time":1783484558958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} -{"type":"assistant/chunk","seq":19,"time":1783484558958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":20,"time":1783484558958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Calls"}}} -{"type":"assistant/chunk","seq":21,"time":1783484558982,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":22,"time":1783484558982,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":23,"time":1783484558982,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":24,"time":1783484559009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":25,"time":1783484559009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} -{"type":"assistant/chunk","seq":26,"time":1783484559009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} -{"type":"assistant/chunk","seq":27,"time":1783484559009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":28,"time":1783484559035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`\n"}}} -{"type":"assistant/chunk","seq":29,"time":1783484559035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} -{"type":"assistant/chunk","seq":30,"time":1783484559035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":31,"time":1783484559036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Calls"}}} -{"type":"assistant/chunk","seq":32,"time":1783484559036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":33,"time":1783484559036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":34,"time":1783484559036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":35,"time":1783484559060,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":36,"time":1783484559061,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} -{"type":"assistant/chunk","seq":37,"time":1783484559061,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} -{"type":"assistant/chunk","seq":38,"time":1783484559061,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} -{"type":"assistant/chunk","seq":39,"time":1783484559061,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`\n"}}} -{"type":"assistant/chunk","seq":40,"time":1783484559061,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} -{"type":"assistant/chunk","seq":41,"time":1783484559088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":42,"time":1783484559088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Returns"}}} -{"type":"assistant/chunk","seq":43,"time":1783484559088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":44,"time":1783484559088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} -{"type":"assistant/chunk","seq":45,"time":1783484559088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" outputs"}}} -{"type":"assistant/chunk","seq":46,"time":1783484559089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} -{"type":"assistant/chunk","seq":47,"time":1783484559114,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":48,"time":1783484559115,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":49,"time":1783484559115,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" plus"}}} -{"type":"assistant/chunk","seq":50,"time":1783484559115,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sign"}}} -{"type":"assistant/chunk","seq":51,"time":1783484559115,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n\n"}}} -{"type":"assistant/chunk","seq":52,"time":1783484559115,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Then"}}} -{"type":"assistant/chunk","seq":53,"time":1783484559186,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":54,"time":1783484559187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":55,"time":1783484559187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":56,"time":1783484559201,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":57,"time":1783484559201,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} -{"type":"assistant/chunk","seq":58,"time":1783484559201,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} -{"type":"assistant/chunk","seq":59,"time":1783484559227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} -{"type":"assistant/chunk","seq":60,"time":1783484559227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} -{"type":"assistant/chunk","seq":61,"time":1783484559227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":62,"time":1783484559227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} -{"type":"assistant/chunk","seq":63,"time":1783484559227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":64,"time":1783484559251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" code"}}} -{"type":"assistant/chunk","seq":65,"time":1783484559252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":66,"time":1783484559330,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":67,"time":1783484559330,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":68,"time":1783484559330,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":69,"time":1783484559330,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":70,"time":1783484559357,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"code"}}} -{"type":"assistant/chunk","seq":71,"time":1783484559357,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":72,"time":1783484559357,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":73,"time":1783484559357,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":74,"time":1783484559382,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"const"}}} -{"type":"assistant/chunk","seq":75,"time":1783484559383,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":76,"time":1783484559383,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"1"}}} -{"type":"assistant/chunk","seq":77,"time":1783484559383,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":78,"time":1783484559383,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":79,"time":1783484559383,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" tools"}}} -{"type":"assistant/chunk","seq":80,"time":1783484559410,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":".b"}}} -{"type":"assistant/chunk","seq":81,"time":1783484559410,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"ash"}}} -{"type":"assistant/chunk","seq":82,"time":1783484559410,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"({"}}} -{"type":"assistant/chunk","seq":83,"time":1783484559410,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" command"}}} -{"type":"assistant/chunk","seq":84,"time":1783484559410,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":85,"time":1783484559410,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":86,"time":1783484559435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":87,"time":1783484559435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" CODE"}}} -{"type":"assistant/chunk","seq":88,"time":1783484559435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"_"}}} -{"type":"assistant/chunk","seq":89,"time":1783484559435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"ONE"}}} -{"type":"assistant/chunk","seq":90,"time":1783484559435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\\\","}}} -{"type":"assistant/chunk","seq":91,"time":1783484559435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" description"}}} -{"type":"assistant/chunk","seq":92,"time":1783484559461,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":93,"time":1783484559461,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":94,"time":1783484559461,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":95,"time":1783484559489,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":96,"time":1783484559489,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" CODE"}}} -{"type":"assistant/chunk","seq":97,"time":1783484559489,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"_"}}} -{"type":"assistant/chunk","seq":98,"time":1783484559515,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"ONE"}}} -{"type":"assistant/chunk","seq":99,"time":1783484559515,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":100,"time":1783484559516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" });\\n"}}} -{"type":"assistant/chunk","seq":101,"time":1783484559516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"const"}}} -{"type":"assistant/chunk","seq":102,"time":1783484559516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":103,"time":1783484559516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"2"}}} -{"type":"assistant/chunk","seq":104,"time":1783484559541,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":105,"time":1783484559541,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":106,"time":1783484559542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" tools"}}} -{"type":"assistant/chunk","seq":107,"time":1783484559542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":".b"}}} -{"type":"assistant/chunk","seq":108,"time":1783484559542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"ash"}}} -{"type":"assistant/chunk","seq":109,"time":1783484559542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"({"}}} -{"type":"assistant/chunk","seq":110,"time":1783484559566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" command"}}} -{"type":"assistant/chunk","seq":111,"time":1783484559567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":112,"time":1783484559567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":113,"time":1783484559567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":114,"time":1783484559567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" CODE"}}} -{"type":"assistant/chunk","seq":115,"time":1783484559567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"_T"}}} -{"type":"assistant/chunk","seq":116,"time":1783484559592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"WO"}}} -{"type":"assistant/chunk","seq":117,"time":1783484559592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\\\","}}} -{"type":"assistant/chunk","seq":118,"time":1783484559593,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" description"}}} -{"type":"assistant/chunk","seq":119,"time":1783484559593,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":120,"time":1783484559593,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":121,"time":1783484559593,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":122,"time":1783484559617,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":123,"time":1783484559617,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" CODE"}}} -{"type":"assistant/chunk","seq":124,"time":1783484559618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"_T"}}} -{"type":"assistant/chunk","seq":125,"time":1783484559618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"WO"}}} -{"type":"assistant/chunk","seq":126,"time":1783484559618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":127,"time":1783484559618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" });\\n"}}} -{"type":"assistant/chunk","seq":128,"time":1783484559643,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"return"}}} -{"type":"assistant/chunk","seq":129,"time":1783484559644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":130,"time":1783484559669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"1"}}} -{"type":"assistant/chunk","seq":131,"time":1783484559669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":".trim"}}} -{"type":"assistant/chunk","seq":132,"time":1783484559669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"()"}}} -{"type":"assistant/chunk","seq":133,"time":1783484559669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" +"}}} -{"type":"assistant/chunk","seq":134,"time":1783484559669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" \\\"+"}}} -{"type":"assistant/chunk","seq":135,"time":1783484559697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":136,"time":1783484559698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" +"}}} -{"type":"assistant/chunk","seq":137,"time":1783484559698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":138,"time":1783484559698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"2"}}} -{"type":"assistant/chunk","seq":139,"time":1783484559698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":".trim"}}} -{"type":"assistant/chunk","seq":140,"time":1783484559698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"();"}}} -{"type":"assistant/chunk","seq":141,"time":1783484559724,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":142,"time":1783484559725,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":143,"time":1783484559777,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash with `echo CODE_ONE`\n2. Calls bash with `echo CODE_TWO`\n3. Returns the two outputs joined with a plus sign\n\nThen reply with just that joined string.\n\nLet me write the code."}}}} -{"type":"assistant/chunk","seq":144,"time":1783484559777,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}}}} -{"type":"assistant/chunk","seq":145,"time":1783484559777,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2121,"outputTokens":171,"cacheReadTokens":0,"reasoningTokens":61}}}} -{"type":"assistant/chunk","seq":146,"time":1783484559777,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":147,"time":1783484559780,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash with `echo CODE_ONE`\n2. Calls bash with `echo CODE_TWO`\n3. Returns the two outputs joined with a plus sign\n\nThen reply with just that joined string.\n\nLet me write the code."},{"type":"tool-call","id":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}],"usage":{"inputTokens":2121,"outputTokens":171,"cacheReadTokens":0,"reasoningTokens":61}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146],"surfaceOp":"append"} -{"type":"tool/call","seq":148,"time":1783484559780,"data":{"turn":1,"step":1,"callId":"call_00_TBMd5LxIFwxqRBHOErfg0279","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}} -{"type":"tool/code-dispatch","seq":149,"time":1783484559896,"data":{"parentCallId":"call_00_TBMd5LxIFwxqRBHOErfg0279","subCallId":"call_00_TBMd5LxIFwxqRBHOErfg0279:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Echo CODE_ONE"},"isError":false,"resultSummary":"CODE_ONE\n"}} -{"type":"tool/code-dispatch","seq":150,"time":1783484559908,"data":{"parentCallId":"call_00_TBMd5LxIFwxqRBHOErfg0279","subCallId":"call_00_TBMd5LxIFwxqRBHOErfg0279:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Echo CODE_TWO"},"isError":false,"resultSummary":"CODE_TWO\n"}} -{"type":"tool/result","seq":151,"time":1783484559913,"data":{"turn":1,"step":1,"callId":"call_00_TBMd5LxIFwxqRBHOErfg0279","content":[{"type":"text","text":"CODE_ONE+CODE_TWO"}],"isError":false,"meta":{"logs":[],"dispatches":2}},"sourceEventSeqs":[148],"surfaceOp":"append"} -{"type":"step/end","seq":152,"time":1783484559913,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":153,"time":1783484559914,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":154,"time":1783484560545,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":155,"time":1783484560545,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":156,"time":1783484560716,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":157,"time":1783484560744,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":158,"time":1783484560744,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":159,"time":1783484560769,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} -{"type":"assistant/chunk","seq":160,"time":1783484560770,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} -{"type":"assistant/chunk","seq":161,"time":1783484560795,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requested"}}} -{"type":"assistant/chunk","seq":162,"time":1783484560822,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":163,"time":1783484560823,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":164,"time":1783484560823,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} -{"type":"assistant/chunk","seq":165,"time":1783484560823,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} -{"type":"assistant/chunk","seq":166,"time":1783484560847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":167,"time":1783484560847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"+"}}} -{"type":"assistant/chunk","seq":168,"time":1783484560848,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} -{"type":"assistant/chunk","seq":169,"time":1783484560848,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} -{"type":"assistant/chunk","seq":170,"time":1783484560848,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} -{"type":"assistant/chunk","seq":171,"time":1783484560848,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"`."}}} -{"type":"assistant/chunk","seq":172,"time":1783484560872,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":173,"time":1783484560872,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} -{"type":"assistant/chunk","seq":174,"time":1783484560898,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":175,"time":1783484560898,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":176,"time":1783484560898,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":177,"time":1783484560924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":178,"time":1783484560924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} -{"type":"assistant/chunk","seq":179,"time":1783484560924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":180,"time":1783484560924,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":181,"time":1783484560925,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} -{"type":"assistant/chunk","seq":182,"time":1783484560925,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_"}}} -{"type":"assistant/chunk","seq":183,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":184,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"+"}}} -{"type":"assistant/chunk","seq":185,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} -{"type":"assistant/chunk","seq":186,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_T"}}} -{"type":"assistant/chunk","seq":187,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} -{"type":"assistant/chunk","seq":188,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The result is exactly what was requested: `CODE_ONE+CODE_TWO`. I'll reply with just that string."}}}} -{"type":"assistant/chunk","seq":189,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} -{"type":"assistant/chunk","seq":190,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":135,"outputTokens":33,"cacheReadTokens":2176,"reasoningTokens":25}}}} -{"type":"assistant/chunk","seq":191,"time":1783484560950,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":192,"time":1783484560951,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The result is exactly what was requested: `CODE_ONE+CODE_TWO`. I'll reply with just that string."},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"usage":{"inputTokens":135,"outputTokens":33,"cacheReadTokens":2176,"reasoningTokens":25}},"sourceEventSeqs":[154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191],"surfaceOp":"append"} -{"type":"step/end","seq":193,"time":1783484560951,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":194,"time":1783484560951,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"9423eeec-62a7-46ea-8b05-abd52ac1e703","createdAt":1783600811133,"cwd":"/tmp/acp-snap-cwd-bdz41V"} +{"type":"turn/start","seq":0,"time":1783600811137,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783600811138,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO` — and return the two outputs joined with a plus sign. Then reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783600811141,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783600811141,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-bdz41V.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783600811872,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783600811872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783600812033,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783600812066,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783600812067,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783600812067,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783600812067,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":11,"time":1783600812067,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783600812068,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":13,"time":1783600812091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":14,"time":1783600812091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" twice"}}} +{"type":"assistant/chunk","seq":15,"time":1783600812091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":16,"time":1783600812091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":17,"time":1783600812121,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":18,"time":1783600812121,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":19,"time":1783600812121,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":20,"time":1783600812151,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":21,"time":1783600812151,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":22,"time":1783600812151,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":23,"time":1783600812151,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":24,"time":1783600812151,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":25,"time":1783600812151,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":26,"time":1783600812179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":27,"time":1783600812179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":28,"time":1783600812179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`,"}}} +{"type":"assistant/chunk","seq":29,"time":1783600812179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":30,"time":1783600812179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" return"}}} +{"type":"assistant/chunk","seq":31,"time":1783600812179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":32,"time":1783600812208,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} +{"type":"assistant/chunk","seq":33,"time":1783600812208,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" outputs"}}} +{"type":"assistant/chunk","seq":34,"time":1783600812209,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} +{"type":"assistant/chunk","seq":35,"time":1783600812209,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":36,"time":1783600812209,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":37,"time":1783600812209,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" plus"}}} +{"type":"assistant/chunk","seq":38,"time":1783600812238,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sign"}}} +{"type":"assistant/chunk","seq":39,"time":1783600812238,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":40,"time":1783600812238,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":41,"time":1783600812238,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":42,"time":1783600812238,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":43,"time":1783600812267,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":44,"time":1783600812267,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":45,"time":1783600812267,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":46,"time":1783600812296,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} +{"type":"assistant/chunk","seq":47,"time":1783600812296,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} +{"type":"assistant/chunk","seq":48,"time":1783600812296,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":49,"time":1783600812388,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":50,"time":1783600812389,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":51,"time":1783600812418,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":52,"time":1783600812418,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1783600812418,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"code"}}} +{"type":"assistant/chunk","seq":54,"time":1783600812418,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":55,"time":1783600812418,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":56,"time":1783600812448,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":57,"time":1783600812448,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":58,"time":1783600812448,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":59,"time":1783600812448,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":60,"time":1783600812448,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":61,"time":1783600812448,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":62,"time":1783600812479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":63,"time":1783600812479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":64,"time":1783600812479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":65,"time":1783600812479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":66,"time":1783600812506,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":67,"time":1783600812506,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":68,"time":1783600812506,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":69,"time":1783600812506,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":70,"time":1783600812506,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":71,"time":1783600812507,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"_"}}} +{"type":"assistant/chunk","seq":72,"time":1783600812536,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"ONE"}}} +{"type":"assistant/chunk","seq":73,"time":1783600812537,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":74,"time":1783600812537,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":75,"time":1783600812537,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":76,"time":1783600812537,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":77,"time":1783600812537,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":78,"time":1783600812566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":79,"time":1783600812566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" first"}}} +{"type":"assistant/chunk","seq":80,"time":1783600812566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" code"}}} +{"type":"assistant/chunk","seq":81,"time":1783600812595,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":82,"time":1783600812596,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":83,"time":1783600812596,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":84,"time":1783600812596,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":85,"time":1783600812596,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":86,"time":1783600812596,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":87,"time":1783600812628,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":88,"time":1783600812628,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":89,"time":1783600812628,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":90,"time":1783600812628,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":91,"time":1783600812628,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":92,"time":1783600812628,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":93,"time":1783600812656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":94,"time":1783600812656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":95,"time":1783600812656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":96,"time":1783600812656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":97,"time":1783600812656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"_T"}}} +{"type":"assistant/chunk","seq":98,"time":1783600812656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"WO"}}} +{"type":"assistant/chunk","seq":99,"time":1783600812686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":100,"time":1783600812686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":101,"time":1783600812686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":102,"time":1783600812686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":103,"time":1783600812686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":104,"time":1783600812686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":105,"time":1783600812714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" second"}}} +{"type":"assistant/chunk","seq":106,"time":1783600812714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" code"}}} +{"type":"assistant/chunk","seq":107,"time":1783600812715,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":108,"time":1783600812715,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":109,"time":1783600812715,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":110,"time":1783600812715,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":111,"time":1783600812744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":112,"time":1783600812744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":".trim"}}} +{"type":"assistant/chunk","seq":113,"time":1783600812744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"()"}}} +{"type":"assistant/chunk","seq":114,"time":1783600812744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" +"}}} +{"type":"assistant/chunk","seq":115,"time":1783600812744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" \\\"+"}}} +{"type":"assistant/chunk","seq":116,"time":1783600812772,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":117,"time":1783600812773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" +"}}} +{"type":"assistant/chunk","seq":118,"time":1783600812773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":119,"time":1783600812773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":120,"time":1783600812773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":".trim"}}} +{"type":"assistant/chunk","seq":121,"time":1783600812773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"();"}}} +{"type":"assistant/chunk","seq":122,"time":1783600812802,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":123,"time":1783600812802,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":124,"time":1783600812863,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call the bash tool twice with `echo CODE_ONE` and `echo CODE_TWO`, then return the two outputs joined with a plus sign. Let me write a single run_code program."}}}} +{"type":"assistant/chunk","seq":125,"time":1783600812863,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo first code\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo second code\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}}}} +{"type":"assistant/chunk","seq":126,"time":1783600812864,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2121,"outputTokens":152,"cacheReadTokens":0,"reasoningTokens":44}}}} +{"type":"assistant/chunk","seq":127,"time":1783600812864,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":128,"time":1783600812866,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to call the bash tool twice with `echo CODE_ONE` and `echo CODE_TWO`, then return the two outputs joined with a plus sign. Let me write a single run_code program."},{"type":"tool-call","id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo first code\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo second code\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}],"usage":{"inputTokens":2121,"outputTokens":152,"cacheReadTokens":0,"reasoningTokens":44}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127],"surfaceOp":"append"} +{"type":"tool/call","seq":129,"time":1783600812866,"data":{"turn":1,"step":1,"callId":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo first code\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo second code\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}} +{"type":"tool/code-dispatch","seq":130,"time":1783600812976,"data":{"parentCallId":"call_00_RJaLT7yuWS9RqjyD9wP85417","subCallId":"call_00_RJaLT7yuWS9RqjyD9wP85417:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Echo first code"},"isError":false,"resultSummary":"CODE_ONE\n"}} +{"type":"tool/code-dispatch","seq":131,"time":1783600812986,"data":{"parentCallId":"call_00_RJaLT7yuWS9RqjyD9wP85417","subCallId":"call_00_RJaLT7yuWS9RqjyD9wP85417:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Echo second code"},"isError":false,"resultSummary":"CODE_TWO\n"}} +{"type":"tool/result","seq":132,"time":1783600812988,"data":{"turn":1,"step":1,"callId":"call_00_RJaLT7yuWS9RqjyD9wP85417","content":[{"type":"text","text":"CODE_ONE+CODE_TWO"}],"isError":false,"meta":{"logs":[],"dispatches":2}},"sourceEventSeqs":[129],"surfaceOp":"append"} +{"type":"step/end","seq":133,"time":1783600812989,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":134,"time":1783600812989,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":135,"time":1783600813658,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":136,"time":1783600813658,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":137,"time":1783600813840,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} +{"type":"assistant/chunk","seq":138,"time":1783600813843,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":139,"time":1783600813843,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":140,"time":1783600813871,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":141,"time":1783600813900,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":142,"time":1783600813929,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requested"}}} +{"type":"assistant/chunk","seq":143,"time":1783600813929,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":144,"time":1783600813929,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":145,"time":1783600813958,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":146,"time":1783600813958,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":147,"time":1783600813958,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":148,"time":1783600813958,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"+"}}} +{"type":"assistant/chunk","seq":149,"time":1783600813958,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":150,"time":1783600813959,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":151,"time":1783600813987,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":152,"time":1783600813987,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"`."}}} +{"type":"assistant/chunk","seq":153,"time":1783600813987,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":154,"time":1783600813987,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":155,"time":1783600814016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":156,"time":1783600814017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":157,"time":1783600814017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":158,"time":1783600814017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":159,"time":1783600814045,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} +{"type":"assistant/chunk","seq":160,"time":1783600814045,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} +{"type":"assistant/chunk","seq":161,"time":1783600814046,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" only"}}} +{"type":"assistant/chunk","seq":162,"time":1783600814046,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":163,"time":1783600814131,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":164,"time":1783600814131,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":165,"time":1783600814132,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":166,"time":1783600814132,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":167,"time":1783600814132,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_"}}} +{"type":"assistant/chunk","seq":168,"time":1783600814132,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":169,"time":1783600814136,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"+"}}} +{"type":"assistant/chunk","seq":170,"time":1783600814136,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":171,"time":1783600814136,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_T"}}} +{"type":"assistant/chunk","seq":172,"time":1783600814137,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} +{"type":"assistant/chunk","seq":173,"time":1783600814137,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The program returned exactly what was requested: `CODE_ONE+CODE_TWO`. I need to reply with that joined string only and stop."}}}} +{"type":"assistant/chunk","seq":174,"time":1783600814137,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} +{"type":"assistant/chunk","seq":175,"time":1783600814137,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":116,"outputTokens":37,"cacheReadTokens":2176,"reasoningTokens":29}}}} +{"type":"assistant/chunk","seq":176,"time":1783600814137,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":177,"time":1783600814137,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The program returned exactly what was requested: `CODE_ONE+CODE_TWO`. I need to reply with that joined string only and stop."},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"usage":{"inputTokens":116,"outputTokens":37,"cacheReadTokens":2176,"reasoningTokens":29}},"sourceEventSeqs":[135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176],"surfaceOp":"append"} +{"type":"step/end","seq":178,"time":1783600814137,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":179,"time":1783600814138,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl index 3660515e4c..916f20126c 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl @@ -5,39 +5,27 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_code"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" program"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Calls"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" call"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" twice"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Calls"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_T"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Returns"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`,"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" return"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" two"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" outputs"}}}} @@ -46,26 +34,21 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" plus"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sign"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Then"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" joined"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" string"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" program"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_TBMd5LxIFwxqRBHOErfg0279","title":"Run code","kind":"execute","status":"in_progress","rawInput":"const out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo CODE_ONE\" });\nconst out2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo CODE_TWO\" });\nreturn out1.trim() + \"+\" + out2.trim();"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_TBMd5LxIFwxqRBHOErfg0279","status":"completed","content":[{"type":"content","content":{"type":"text","text":"CODE_ONE+CODE_TWO"}}],"title":"Run code (2 tool calls)"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_RJaLT7yuWS9RqjyD9wP85417","title":"Run code","kind":"execute","status":"in_progress","rawInput":"const out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo first code\" });\nconst out2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo second code\" });\nreturn out1.trim() + \"+\" + out2.trim();","content":[{"type":"content","content":{"type":"text","text":"```ts\nconst out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo first code\" });\nconst out2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo second code\" });\nreturn out1.trim() + \"+\" + out2.trim();\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_RJaLT7yuWS9RqjyD9wP85417","status":"completed","content":[{"type":"content","content":{"type":"text","text":"CODE_ONE+CODE_TWO"}}],"title":"Run code (2 tool calls)"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" program"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} @@ -81,12 +64,16 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'ll"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" joined"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" string"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" only"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CODE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_"}}}} diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 404364d57c..1ae26a5eb9 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -289,7 +289,17 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => exec.signal?.removeEventListener('abort', onOuterAbort) } }, - presentCall: args => ({ card: 'generic', title: 'Run code', kind: 'execute', rawInput: args.code }), + // The program IS the call: surface it as an always-visible fenced block in + // the card body (rawInput alone lands in detail/expanded views many + // clients never open). Fence collisions are impossible to break rendering + // — a backtick run inside the program at worst ends the block early. + presentCall: args => ({ + card: 'generic', + title: 'Run code', + kind: 'execute', + rawInput: args.code, + content: [{ type: 'text', text: `\`\`\`ts\n${args.code}\n\`\`\`` }], + }), presentResult: (_args, result) => { const meta = asRunCodeMeta(result.meta) if (!meta) return undefined diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 03afc260ee..092e0ed90e 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -447,7 +447,15 @@ describe('the run_code dispatch bridge', () => { it('presents the pending call as a generic execute card carrying the program, and the result with the captured output', async () => { const { ctx } = await setup({ mode: 'code' }) const tool = ctx.tools.get(RUN_CODE_NAME)! - expect(tool.presentCall?.({ code: 'return 1' })).toEqual({ card: 'generic', title: 'Run code', kind: 'execute', rawInput: 'return 1' }) + expect(tool.presentCall?.({ code: 'return 1' })).toEqual({ + card: 'generic', + title: 'Run code', + kind: 'execute', + rawInput: 'return 1', + // The program rides the card BODY as a fenced block — visible in ACP + // clients that never open the rawInput detail view. + content: [{ type: 'text', text: '```ts\nreturn 1\n```' }], + }) const view = tool.presentResult?.({ code: 'return 1' }, { content: [{ type: 'text', text: 'model-facing' }], isError: false, From 6f86f6081b1818614a48a87765d394dada689781 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:14:30 +0800 Subject: [PATCH 45/57] workflow: dispose() host-drives child disposal inside the grace --- .../workflow/workflow-workerthread/README.md | 2 +- .../workflow-workerthread/src/host.ts | 90 ++++++++++++------- .../tests/workflow-workerthread.spec.ts | 62 ++++++++++++- 3 files changed, 119 insertions(+), 35 deletions(-) diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index 25c79ad09c..dc8859d351 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -32,7 +32,7 @@ Values LEAVING the script (hook options/schemas, the script's return) are materi Per-run limits: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` posts the cancel to the worker (its hooks start throwing `CANCELLED`; the script dies at its next await) and cancels every host-side child NOW on **both seam channels** — the shared request signal aborts AND each registered child's explicit `cancel()` is called host-side, because the seam leaves a provider free to honor either channel and a worker wedged in a synchronous spin could not relay its own per-child cancel RPCs (those later land as idempotent no-ops). The grace then arms: a run still unsettled `disposeGraceMs` later force-settles `cancelled` and the worker is **terminated**. A cancellation that lands before the body runs (the ready→go handshake) reports `cancelled` without executing anything; a worker `result` racing an in-flight host cancellation reports `cancelled` too (first-wins settlement — the seam-visible result had not settled when cancellation was requested); post-cancel `phase`/`log` narration is suppressed host-side, while cancelled children still deliver their paired `agent-end`. -A worker that dies unexpectedly (an OOM, a script reaching `process.exit` through the documented vm escape) settles the run `stopReason: 'error'` with the exit diagnostics — or `'cancelled'` when a cancel was in flight — and the host-side child registry is what winds every surviving child down. `dispose()` = cancel + bounded wait (result, then child-registry quiescence, capped by the grace) + unconditional `worker.terminate()`: the thread never outlives its run. Once a run settles, stray children a script fired without awaiting are cancelled too, and `dispose()` waits for their disposal (bounded by the grace) before returning. +A worker that dies unexpectedly (an OOM, a script reaching `process.exit` through the documented vm escape) settles the run `stopReason: 'error'` with the exit diagnostics — or `'cancelled'` when a cancel was in flight — and the host-side child registry is what winds every surviving child down. `dispose()` = cancel + immediate host-driven disposal of every registered child (a wedged worker can relay no dispose RPC, so child teardown overlaps the grace instead of starting after it; the worker's own dispose RPCs join the same per-child disposal) + bounded wait (result, then child-registry quiescence, capped by the grace) + unconditional `worker.terminate()`: the thread never outlives its run. Once a run settles, stray children a script fired without awaiting are cancelled too, and `dispose()` waits for their disposal (bounded by the grace) before returning. **Engine-specific limitations**: worker startup is paid per run; on a termination path `agentsStarted` reports the HOST-observed count (accepted `child-start`s — calls still queued worker-side for a concurrency slot are unknowable then); and a returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited `return agent('x')` work — with the value-boundary guard applying to the resolution. diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 9d98d00933..c512c15dee 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -15,9 +15,14 @@ * terminated — the real kill an in-process engine could not perform). * * Children live in a host-side registry (callId → run): the worker drives - * their disposal by RPC on the graceful path, and the registry is what lets - * the host abort and dispose every survivor when the worker dies or is - * terminated mid-flight. On a termination path `agentsStarted` reports the + * their disposal by RPC on the graceful path, `dispose()` host-drives every + * registered child's disposal immediately (a wedged worker can relay no + * dispose RPC, and child teardown must overlap the grace, not start after + * it), and the registry is what lets the host abort and dispose every + * survivor when the worker dies or is terminated mid-flight. The three + * paths share ONE disposal per child (memoized by callId; the seam's + * dispose() is idempotent anyway, the memo keeps the bookkeeping and the + * containment warn single). On a termination path `agentsStarted` reports the * HOST-observed count (accepted `child-start` messages) — `agent()` calls * still queued worker-side for a concurrency slot are unknowable then; the * worker's own count rides the result message on every graceful path. @@ -85,6 +90,8 @@ export class WorkerRun implements WorkflowRun { private hostStarted = 0 /** Live children by callId; an entry leaves ONLY after its dispose settles (quiescence = empty). */ private readonly children = new Map() + /** In-flight child disposals by callId — the memo that gives every path (worker RPC, dispose(), reap) ONE shared disposal per child. */ + private readonly childDisposals = new Map>() private readonly quiescenceWaiters: (() => void)[] = [] /** The per-run abort fanout every child start request carries. */ private readonly controller = new AbortController() @@ -156,17 +163,24 @@ export class WorkerRun implements WorkflowRun { } /** - * Cancel + bounded settle + termination. Waits (at most the grace) for the - * result and child quiescence, then terminates the worker unconditionally - * — the thread never outlives its run — and reaps whatever children - * remain (their disposal is contained, not awaited past the grace, the - * same abandonment the seam documents for a slow-disposing child). - * Idempotent; safe on every path. + * Cancel + bounded settle + termination. Host-drives every registered + * child's disposal IMMEDIATELY — a wedged worker can relay no dispose RPC, + * and deferring child teardown to the post-terminate reap would spend the + * whole grace waiting for a quiescence that cannot start, then return with + * the disposals still in flight — so child disposal overlaps the same + * grace the worker gets to settle (the worker's own dispose RPCs join the + * shared per-child disposal). Waits (at most the grace) for the result and + * child quiescence, then terminates the worker unconditionally — the + * thread never outlives its run — and reaps whatever children remain + * (their disposal is contained, not awaited past the grace, the same + * abandonment the seam documents for a slow-disposing child). Idempotent; + * safe on every path. * @returns resolves when the run's resources are released or abandoned. */ dispose(): Promise { this.disposed ??= (async () => { this.cancel('workflow disposed') + for (const [callId, run] of [...this.children]) void this.disposeChild(callId, run) await Promise.race([ (async () => { await this.result @@ -278,31 +292,47 @@ export class WorkerRun implements WorkflowRun { private onChildDispose(callId: number): void { const run = this.children.get(callId) - /* v8 ignore next 5 -- dispose RPC for an already-reaped child: only a worker-death race can produce it, not orderable in-process */ if (run === undefined) { - // Already reaped — the ack is still owed (the worker-side wrapper awaits it). + // Already disposed host-side (a dispose() drive or a death reap beat + // the RPC) — the ack is still owed (the worker-side wrapper awaits it). this.post(HostToWorkerType.ChildDisposed, { callId }) return } - void run.dispose().then( - () => { - this.finishChild(callId) - this.post(HostToWorkerType.ChildDisposed, { callId }) - }, - (error: unknown) => { - // The subagent seam's dispose() is not supposed to reject; a backend - // that does anyway must not wedge the script's finally (which awaits - // the ack) — ack and move on. - this.ctx.logger.warn(`workflow-workerthread: child dispose failed: ${renderThrown(error)}`) - this.finishChild(callId) - this.post(HostToWorkerType.ChildDisposed, { callId }) - }, - ) + // disposeChild never rejects (containment is inside), so the ack always follows. + void this.disposeChild(callId, run).then(() => { this.post(HostToWorkerType.ChildDisposed, { callId }) }) } - /** Drop a child from the registry, releasing quiescence waiters at zero. */ + /** + * Start (or join) one registered child's disposal; the registry entry + * leaves when it settles. Memoized per callId: the worker's dispose RPC, + * the dispose() host drive, and the reap can all land on the same child — + * the child's `dispose()` runs once and every caller awaits that one + * settlement. A rejection is contained (the subagent seam's dispose() is + * not supposed to reject, but a backend that does anyway must not break + * quiescence): logged, and the child still leaves the registry. + * @param callId - the child's registry key. + * @param run - the registered child (the caller looked it up). + * @returns resolves when the disposal settled either way; never rejects. + */ + private disposeChild(callId: number, run: SubagentRun): Promise { + let disposal = this.childDisposals.get(callId) + if (disposal === undefined) { + disposal = run.dispose().then( + () => { this.finishChild(callId) }, + (error: unknown) => { + this.ctx.logger.warn(`workflow-workerthread: child dispose failed: ${renderThrown(error)}`) + this.finishChild(callId) + }, + ) + this.childDisposals.set(callId, disposal) + } + return disposal + } + + /** Drop a child from the registry (and its disposal memo), releasing quiescence waiters at zero. */ private finishChild(callId: number): void { this.children.delete(callId) + this.childDisposals.delete(callId) if (this.children.size === 0) { for (const waiter of this.quiescenceWaiters.splice(0)) waiter() } @@ -319,13 +349,7 @@ export class WorkerRun implements WorkflowRun { this.controller.abort(this.cancelReason ?? reason) for (const [callId, run] of [...this.children]) { run.cancel(this.cancelReason ?? reason) - void run.dispose().then( - () => { this.finishChild(callId) }, - (error: unknown) => { - this.ctx.logger.warn(`workflow-workerthread: child dispose failed during reap: ${renderThrown(error)}`) - this.finishChild(callId) - }, - ) + void this.disposeChild(callId, run) } } diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 378dd6ecf6..c1c26dcf0f 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -23,6 +23,7 @@ interface ControlledRun { settle(result: SubagentResult): void cancelled: string | undefined disposed: boolean + disposeCalls: number } /** @@ -45,7 +46,7 @@ class StubProvider implements SubagentProvider { start(request: SubagentStartRequest): SubagentRun { let settle!: (result: SubagentResult) => void const result = new Promise((resolve) => { settle = resolve }) - const controlled: ControlledRun = { request, settle, cancelled: undefined, disposed: false } + const controlled: ControlledRun = { request, settle, cancelled: undefined, disposed: false, disposeCalls: 0 } this.runs.push(controlled) const index = this.runs.length - 1 request.signal?.addEventListener('abort', () => { settle({ output: [], stopReason: 'aborted' }) }, { once: true }) @@ -61,6 +62,7 @@ class StubProvider implements SubagentProvider { settle({ output: [], stopReason: 'aborted' }) }, dispose: () => { + controlled.disposeCalls += 1 if (this.disposeDelayMs === 0) { controlled.disposed = true return Promise.resolve() @@ -537,6 +539,64 @@ describe('dsh-workflow-workerthread', () => { expect(result.stopReason).toBe('cancelled') await handle.dispose() }, 15_000) + + it('dispose() on a wedged worker host-drives child disposal inside the grace: it returns with the children DISPOSED, not with their teardown still in flight', async () => { + const { ctx, parent, provider } = await setup({ + manual: true, + disposeDelayMs: 40, + config: { provider: 'stub', maxConcurrentAgents: 8, disposeGraceMs: 400 }, + }) + const handle = ctx.workflows.start({ + // Same shape as the wedged-cancel test above: the child's start RPC + // reaches the host, then the script seizes its worker's loop, so the + // worker can relay NO dispose RPC — the host's own dispose() drive is + // the only thing that can start (and finish) this child's disposal + // before the grace runs out. + ...scripted(` + agent('wedged child') + for (let i = 0; i < 20; i++) await null + const end = Date.now() + 1500 + while (Date.now() < end) {} + return 'raced' + `), + parent, + }) + await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) + const before = Date.now() + await handle.dispose() + // Bounded by the grace (plus the terminate), never by the 1.5s spin. + expect(Date.now() - before).toBeLessThan(1200) + // Not a waitFor: dispose() resolving IS the quiescence claim — the slow + // child disposal must be complete, not merely started (before the + // host-driven drive, disposal only STARTED at the post-terminate reap, + // so dispose() returned with it still in flight). + expect(provider.runs[0]!.disposed).toBe(true) + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + }, 15_000) + + it('a live child disposed by the dispose() drive is disposed ONCE, and the worker\'s late dispose RPC still gets its ack (the script settles, not the grace)', async () => { + const { ctx, parent, provider } = await setup({ manual: true }) + const handle = ctx.workflows.start({ + ...scripted(` + await agent('long child') + return 'unreachable' + `), + parent, + }) + await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) + const handleDispose = handle.dispose() + const result = await handle.result + // The script itself settled (the wrapper's own dispose RPC found the + // child already reaped host-side and was acked) — a missing ack would + // wedge the wrapper's finally until the 5s default grace force-settle. + expect(result.stopReason).toBe('cancelled') + expect(result.error).toContain('workflow disposed') + await handleDispose + expect(provider.runs[0]!.disposed).toBe(true) + // The memo: the host drive and the worker's RPC share one disposal. + expect(provider.runs[0]!.disposeCalls).toBe(1) + }) }) describe('worker death', () => { From 387f19c7f607f5e350e3ffd28ec2516b3a70c878 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:23:45 +0800 Subject: [PATCH 46/57] docs: regenerate the module graph for the ask-user merge --- docs/module-graph.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 5ef8313b4e..5bee74fae6 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -124,7 +124,9 @@ flowchart TD pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session pkg_tools --> pkg_agent + pkg_tools --> pkg_code_runtime pkg_tools --> pkg_llm + pkg_tools --> pkg_session pkg_tools --> pkg_system_prompt pkg_compact_basic --> pkg_agent pkg_compact_basic --> pkg_compact @@ -224,6 +226,7 @@ flowchart TD pkg_acp_agent --> pkg_agent_core pkg_acp_agent --> pkg_app_boot pkg_acp_agent --> pkg_session_persistence_jsonl + pkg_acp_agent --> pkg_tools pkg_acp_agent --> pkg_user_interaction pkg_stdio_agent --> pkg_agent pkg_stdio_agent --> pkg_agent_core @@ -232,6 +235,7 @@ flowchart TD pkg_stdio_agent --> pkg_session pkg_stdio_agent --> pkg_session_persistence_jsonl pkg_stdio_agent --> pkg_tool_ask_user + pkg_stdio_agent --> pkg_tools pkg_stdio_agent --> pkg_user_interaction ``` @@ -263,7 +267,7 @@ flowchart TD | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt) | +| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | @@ -288,5 +292,5 @@ flowchart TD | [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`user-interaction`](../packages/ui/user-interaction) | -| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`user-interaction`](../packages/ui/user-interaction) | +| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | From 27f9c45e6a43dc13c52674e23b95e1bcbcddb6a8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:38:49 +0800 Subject: [PATCH 47/57] docs: update budget --- scripts/doc-budgets.manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 337fc57763..dd7f2c3e74 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1691, + "AGENTS.md": 1693, "docs/AGENTS.md": 1315, "docs/architecture.md": 1640, "docs/cordis-primer.md": 550, @@ -7,5 +7,5 @@ "docs/testing.md": 800, "examples/AGENTS.md": 610, "packages/AGENTS.md": 450, - "packages/README.md": 610 + "packages/README.md": 632 } From f505776eee9b1d157086338e19bdd60699b804f5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:04:52 +0800 Subject: [PATCH 48/57] fix: keep the program on the COMPLETED run_code card (agent review follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit put the fenced program only on the pending card — but an ACP tool_call_update's content REPLACES the card content (Zed truncates to the new list, crates/acp_thread update_fields), so the code vanished the moment the run completed and was effectively never visible. presentResult now re-carries the fenced program before the captured output via a shared fencedProgram helper; the completed card body is program + output, rendered by Zed as syntax-highlighted markdown behind the card disclosure. Goldens re-recorded (filtered this time: DSH_SNAPSHOT=record vitest -u -t mode-turn); unit test pins the two-block result content. --- .../snapshots/both-mode-turn/session.jsonl | 239 ++++++----- .../both-mode-turn/stdout.golden.jsonl | 43 +- .../snapshots/code-mode-turn/session.jsonl | 376 +++++++++--------- .../code-mode-turn/stdout.golden.jsonl | 104 ++--- packages/core/tools/src/code-mode.ts | 30 +- packages/core/tools/tests/code-mode.spec.ts | 13 +- 6 files changed, 422 insertions(+), 383 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl index c7bf7c3eba..e3f28fa3c6 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -1,122 +1,117 @@ -{"type":"session","version":0,"id":"14b611ba-2cfd-46d6-bdcb-4f12a261f651","createdAt":1783600817605,"cwd":"/tmp/acp-snap-cwd-f5yZEg"} -{"type":"turn/start","seq":0,"time":1783600817609,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783600817610,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783600817612,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783600817613,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-f5yZEg.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783600818106,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783600818107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783600818317,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783600818345,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783600818346,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783600818346,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783600818347,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} -{"type":"assistant/chunk","seq":11,"time":1783600818347,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":12,"time":1783600818347,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} -{"type":"assistant/chunk","seq":13,"time":1783600818374,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":14,"time":1783600818375,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":15,"time":1783600818375,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} -{"type":"assistant/chunk","seq":16,"time":1783600818375,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":17,"time":1783600818375,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" runs"}}} -{"type":"assistant/chunk","seq":18,"time":1783600818375,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":19,"time":1783600818407,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":20,"time":1783600818407,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" B"}}} -{"type":"assistant/chunk","seq":21,"time":1783600818407,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OTH"}}} -{"type":"assistant/chunk","seq":22,"time":1783600818408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":23,"time":1783600818432,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":24,"time":1783600818433,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" via"}}} -{"type":"assistant/chunk","seq":25,"time":1783600818433,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} -{"type":"assistant/chunk","seq":26,"time":1783600818433,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".b"}}} -{"type":"assistant/chunk","seq":27,"time":1783600818433,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ash"}}} -{"type":"assistant/chunk","seq":28,"time":1783600818434,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":29,"time":1783600818461,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} -{"type":"assistant/chunk","seq":30,"time":1783600818462,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" its"}}} -{"type":"assistant/chunk","seq":31,"time":1783600818462,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":32,"time":1783600818462,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":33,"time":1783600818462,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":34,"time":1783600818491,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":35,"time":1783600818491,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":36,"time":1783600818492,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":37,"time":1783600818492,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":38,"time":1783600818583,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":39,"time":1783600818583,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":40,"time":1783600818611,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":41,"time":1783600818611,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":42,"time":1783600818611,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"code"}}} -{"type":"assistant/chunk","seq":43,"time":1783600818611,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":44,"time":1783600818611,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":45,"time":1783600818643,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":46,"time":1783600818643,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"const"}}} -{"type":"assistant/chunk","seq":47,"time":1783600818643,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" result"}}} -{"type":"assistant/chunk","seq":48,"time":1783600818643,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":49,"time":1783600818643,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":50,"time":1783600818670,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" tools"}}} -{"type":"assistant/chunk","seq":51,"time":1783600818670,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":".b"}}} -{"type":"assistant/chunk","seq":52,"time":1783600818670,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"ash"}}} -{"type":"assistant/chunk","seq":53,"time":1783600818670,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"({\\n"}}} -{"type":"assistant/chunk","seq":54,"time":1783600818670,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" "}}} -{"type":"assistant/chunk","seq":55,"time":1783600818698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" command"}}} -{"type":"assistant/chunk","seq":56,"time":1783600818698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":57,"time":1783600818698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":58,"time":1783600818698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":59,"time":1783600818698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" B"}}} -{"type":"assistant/chunk","seq":60,"time":1783600818698,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"OTH"}}} -{"type":"assistant/chunk","seq":61,"time":1783600818728,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":62,"time":1783600818728,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"\\\",\\n"}}} -{"type":"assistant/chunk","seq":63,"time":1783600818728,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" "}}} -{"type":"assistant/chunk","seq":64,"time":1783600818728,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" description"}}} -{"type":"assistant/chunk","seq":65,"time":1783600818728,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":66,"time":1783600818728,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":67,"time":1783600818757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":68,"time":1783600818785,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":69,"time":1783600818786,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" B"}}} -{"type":"assistant/chunk","seq":70,"time":1783600818786,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"OTH"}}} -{"type":"assistant/chunk","seq":71,"time":1783600818786,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":72,"time":1783600818786,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" string"}}} -{"type":"assistant/chunk","seq":73,"time":1783600818815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"\\\"\\n"}}} -{"type":"assistant/chunk","seq":74,"time":1783600818815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"});\\n"}}} -{"type":"assistant/chunk","seq":75,"time":1783600818815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"return"}}} -{"type":"assistant/chunk","seq":76,"time":1783600818815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":" result"}}} -{"type":"assistant/chunk","seq":77,"time":1783600818815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":";"}}} -{"type":"assistant/chunk","seq":78,"time":1783600818815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":79,"time":1783600818845,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":80,"time":1783600818906,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call run_code with a program that runs `echo BOTH_OK` via tools.bash and returns its output. Let me do that."}}}} -{"type":"assistant/chunk","seq":81,"time":1783600818906,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({\\n command: \\\"echo BOTH_OK\\\",\\n description: \\\"Echo BOTH_OK string\\\"\\n});\\nreturn result;\"}"}}}} -{"type":"assistant/chunk","seq":82,"time":1783600818906,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3734,"outputTokens":107,"cacheReadTokens":0,"reasoningTokens":33}}}} -{"type":"assistant/chunk","seq":83,"time":1783600818906,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":84,"time":1783600818909,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to call run_code with a program that runs `echo BOTH_OK` via tools.bash and returns its output. Let me do that."},{"type":"tool-call","id":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({\\n command: \\\"echo BOTH_OK\\\",\\n description: \\\"Echo BOTH_OK string\\\"\\n});\\nreturn result;\"}"}],"usage":{"inputTokens":3734,"outputTokens":107,"cacheReadTokens":0,"reasoningTokens":33}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83],"surfaceOp":"append"} -{"type":"tool/call","seq":85,"time":1783600818909,"data":{"turn":1,"step":1,"callId":"call_00_Kv45KGQIqVt8nuaRebYh2326","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({\\n command: \\\"echo BOTH_OK\\\",\\n description: \\\"Echo BOTH_OK string\\\"\\n});\\nreturn result;\"}"}} -{"type":"tool/code-dispatch","seq":86,"time":1783600819019,"data":{"parentCallId":"call_00_Kv45KGQIqVt8nuaRebYh2326","subCallId":"call_00_Kv45KGQIqVt8nuaRebYh2326:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Echo BOTH_OK string"},"isError":false,"resultSummary":"BOTH_OK\n"}} -{"type":"tool/result","seq":87,"time":1783600819021,"data":{"turn":1,"step":1,"callId":"call_00_Kv45KGQIqVt8nuaRebYh2326","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[85],"surfaceOp":"append"} -{"type":"step/end","seq":88,"time":1783600819022,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":89,"time":1783600819022,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":90,"time":1783600819418,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":91,"time":1783600819418,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":92,"time":1783600819515,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":93,"time":1783600819543,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":94,"time":1783600819544,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":95,"time":1783600819574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} -{"type":"assistant/chunk","seq":96,"time":1783600819574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OTH"}}} -{"type":"assistant/chunk","seq":97,"time":1783600819574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":98,"time":1783600819574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":99,"time":1783600819574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":100,"time":1783600819575,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":101,"time":1783600819602,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":102,"time":1783600819603,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":103,"time":1783600819603,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":104,"time":1783600819603,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":105,"time":1783600819634,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":106,"time":1783600819635,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" only"}}} -{"type":"assistant/chunk","seq":107,"time":1783600819635,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":108,"time":1783600819664,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":109,"time":1783600819665,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":110,"time":1783600819665,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":111,"time":1783600819665,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"B"}}} -{"type":"assistant/chunk","seq":112,"time":1783600819665,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OTH"}}} -{"type":"assistant/chunk","seq":113,"time":1783600819665,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} -{"type":"assistant/chunk","seq":114,"time":1783600819693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output is \"BOTH_OK\". I need to reply with that output only and stop."}}}} -{"type":"assistant/chunk","seq":115,"time":1783600819693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BOTH_OK"}}}} -{"type":"assistant/chunk","seq":116,"time":1783600819693,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3840,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":117,"time":1783600819693,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":118,"time":1783600819694,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output is \"BOTH_OK\". I need to reply with that output only and stop."},{"type":"text","text":"BOTH_OK"}],"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3840,"reasoningTokens":19}},"sourceEventSeqs":[90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117],"surfaceOp":"append"} -{"type":"step/end","seq":119,"time":1783600819694,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":120,"time":1783600819694,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"7354d242-c6f9-4c36-9040-54c1fb295a6c","createdAt":1783604835700,"cwd":"/tmp/acp-snap-cwd-JyIozV"} +{"type":"turn/start","seq":0,"time":1783604835703,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783604835704,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783604835706,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783604835707,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-JyIozV.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783604836078,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783604836079,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":6,"time":1783604836174,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":7,"time":1783604836203,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":8,"time":1783604836204,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":9,"time":1783604836204,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" simple"}}} +{"type":"assistant/chunk","seq":10,"time":1783604836233,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Type"}}} +{"type":"assistant/chunk","seq":11,"time":1783604836234,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Script"}}} +{"type":"assistant/chunk","seq":12,"time":1783604836234,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} +{"type":"assistant/chunk","seq":13,"time":1783604836234,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":14,"time":1783604836234,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" calls"}}} +{"type":"assistant/chunk","seq":15,"time":1783604836262,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":16,"time":1783604836291,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".b"}}} +{"type":"assistant/chunk","seq":17,"time":1783604836292,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ash"}}} +{"type":"assistant/chunk","seq":18,"time":1783604836292,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":19,"time":1783604836321,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":20,"time":1783604836321,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":21,"time":1783604836350,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":22,"time":1783604836350,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" B"}}} +{"type":"assistant/chunk","seq":23,"time":1783604836350,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OTH"}}} +{"type":"assistant/chunk","seq":24,"time":1783604836350,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":25,"time":1783604836350,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":26,"time":1783604836350,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":27,"time":1783604836382,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":28,"time":1783604836383,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":29,"time":1783604836437,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":30,"time":1783604836437,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":31,"time":1783604836499,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":32,"time":1783604836499,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":33,"time":1783604836499,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":34,"time":1783604836499,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1783604836527,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"code"}}} +{"type":"assistant/chunk","seq":36,"time":1783604836528,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":37,"time":1783604836528,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":38,"time":1783604836528,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1783604836556,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":40,"time":1783604836557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" result"}}} +{"type":"assistant/chunk","seq":41,"time":1783604836557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":42,"time":1783604836557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":43,"time":1783604836557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":44,"time":1783604836557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":45,"time":1783604836602,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":46,"time":1783604836602,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"({\\n"}}} +{"type":"assistant/chunk","seq":47,"time":1783604836615,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" "}}} +{"type":"assistant/chunk","seq":48,"time":1783604836615,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":49,"time":1783604836615,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":50,"time":1783604836615,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":51,"time":1783604836615,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":52,"time":1783604836615,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" B"}}} +{"type":"assistant/chunk","seq":53,"time":1783604836644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"OTH"}}} +{"type":"assistant/chunk","seq":54,"time":1783604836644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":55,"time":1783604836644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"\\\",\\n"}}} +{"type":"assistant/chunk","seq":56,"time":1783604836644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" "}}} +{"type":"assistant/chunk","seq":57,"time":1783604836644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":58,"time":1783604836644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":59,"time":1783604836674,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":60,"time":1783604836675,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":61,"time":1783604836703,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":62,"time":1783604836704,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" B"}}} +{"type":"assistant/chunk","seq":63,"time":1783604836704,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"OTH"}}} +{"type":"assistant/chunk","seq":64,"time":1783604836704,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":65,"time":1783604836704,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" message"}}} +{"type":"assistant/chunk","seq":66,"time":1783604836704,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"\\\",\\n"}}} +{"type":"assistant/chunk","seq":67,"time":1783604836732,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"});\\n"}}} +{"type":"assistant/chunk","seq":68,"time":1783604836733,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":69,"time":1783604836733,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" result"}}} +{"type":"assistant/chunk","seq":70,"time":1783604836733,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":";\\n"}}} +{"type":"assistant/chunk","seq":71,"time":1783604836733,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":72,"time":1783604836762,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":73,"time":1783604836823,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Let me write a simple TypeScript program that calls tools.bash to run `echo BOTH_OK` and returns the output."}}}} +{"type":"assistant/chunk","seq":74,"time":1783604836823,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({\\n command: \\\"echo BOTH_OK\\\",\\n description: \\\"Echo BOTH_OK message\\\",\\n});\\nreturn result;\\n\"}"}}}} +{"type":"assistant/chunk","seq":75,"time":1783604836823,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3734,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":26}}}} +{"type":"assistant/chunk","seq":76,"time":1783604836823,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":77,"time":1783604836825,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Let me write a simple TypeScript program that calls tools.bash to run `echo BOTH_OK` and returns the output."},{"type":"tool-call","id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({\\n command: \\\"echo BOTH_OK\\\",\\n description: \\\"Echo BOTH_OK message\\\",\\n});\\nreturn result;\\n\"}"}],"usage":{"inputTokens":3734,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":26}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"} +{"type":"tool/call","seq":78,"time":1783604836825,"data":{"turn":1,"step":1,"callId":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({\\n command: \\\"echo BOTH_OK\\\",\\n description: \\\"Echo BOTH_OK message\\\",\\n});\\nreturn result;\\n\"}"}} +{"type":"tool/code-dispatch","seq":79,"time":1783604836929,"data":{"parentCallId":"call_00_7FUi2qEmyE8bzRWZPbQI6485","subCallId":"call_00_7FUi2qEmyE8bzRWZPbQI6485:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Echo BOTH_OK message"},"isError":false,"resultSummary":"BOTH_OK\n"}} +{"type":"tool/result","seq":80,"time":1783604836932,"data":{"turn":1,"step":1,"callId":"call_00_7FUi2qEmyE8bzRWZPbQI6485","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[78],"surfaceOp":"append"} +{"type":"step/end","seq":81,"time":1783604836932,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":82,"time":1783604836933,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":83,"time":1783604837401,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":84,"time":1783604837401,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":85,"time":1783604837526,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":86,"time":1783604837554,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":87,"time":1783604837555,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":88,"time":1783604837583,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} +{"type":"assistant/chunk","seq":89,"time":1783604837583,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OTH"}}} +{"type":"assistant/chunk","seq":90,"time":1783604837583,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":91,"time":1783604837583,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":92,"time":1783604837583,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":93,"time":1783604837583,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":94,"time":1783604837612,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":95,"time":1783604837612,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":96,"time":1783604837613,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":97,"time":1783604837613,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":98,"time":1783604837641,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":99,"time":1783604837643,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":100,"time":1783604837643,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":101,"time":1783604837643,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" only"}}} +{"type":"assistant/chunk","seq":102,"time":1783604837644,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":103,"time":1783604837670,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":104,"time":1783604837671,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":105,"time":1783604837671,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":106,"time":1783604837671,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"B"}}} +{"type":"assistant/chunk","seq":107,"time":1783604837671,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OTH"}}} +{"type":"assistant/chunk","seq":108,"time":1783604837671,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} +{"type":"assistant/chunk","seq":109,"time":1783604837701,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The result is \"BOTH_OK\". The user asked me to reply with that output only and stop."}}}} +{"type":"assistant/chunk","seq":110,"time":1783604837701,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BOTH_OK"}}}} +{"type":"assistant/chunk","seq":111,"time":1783604837701,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":139,"outputTokens":25,"cacheReadTokens":3712,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":112,"time":1783604837701,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":113,"time":1783604837702,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The result is \"BOTH_OK\". The user asked me to reply with that output only and stop."},{"type":"text","text":"BOTH_OK"}],"usage":{"inputTokens":139,"outputTokens":25,"cacheReadTokens":3712,"reasoningTokens":21}},"sourceEventSeqs":[83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112],"surfaceOp":"append"} +{"type":"step/end","seq":114,"time":1783604837702,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":115,"time":1783604837702,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl index 4a959143d7..d5a8135569 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl @@ -1,50 +1,45 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" call"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_code"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" simple"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Type"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Script"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" program"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" runs"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" calls"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".b"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" B"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OTH"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" via"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".b"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ash"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" its"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" do"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Kv45KGQIqVt8nuaRebYh2326","title":"Run code","kind":"execute","status":"in_progress","rawInput":"const result = await tools.bash({\n command: \"echo BOTH_OK\",\n description: \"Echo BOTH_OK string\"\n});\nreturn result;","content":[{"type":"content","content":{"type":"text","text":"```ts\nconst result = await tools.bash({\n command: \"echo BOTH_OK\",\n description: \"Echo BOTH_OK string\"\n});\nreturn result;\n```"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Kv45KGQIqVt8nuaRebYh2326","status":"completed","content":[{"type":"content","content":{"type":"text","text":"BOTH_OK\n"}}],"title":"Run code (1 tool call)"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_7FUi2qEmyE8bzRWZPbQI6485","title":"Run code","kind":"execute","status":"in_progress","rawInput":"const result = await tools.bash({\n command: \"echo BOTH_OK\",\n description: \"Echo BOTH_OK message\",\n});\nreturn result;\n","content":[{"type":"content","content":{"type":"text","text":"```ts\nconst result = await tools.bash({\n command: \"echo BOTH_OK\",\n description: \"Echo BOTH_OK message\",\n});\nreturn result;\n\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_7FUi2qEmyE8bzRWZPbQI6485","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```ts\nconst result = await tools.bash({\n command: \"echo BOTH_OK\",\n description: \"Echo BOTH_OK message\",\n});\nreturn result;\n\n```"}}],"title":"Run code (1 tool call)"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"B"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OTH"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl index 97d73709fc..69cf7d1825 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl @@ -1,181 +1,195 @@ -{"type":"session","version":0,"id":"9423eeec-62a7-46ea-8b05-abd52ac1e703","createdAt":1783600811133,"cwd":"/tmp/acp-snap-cwd-bdz41V"} -{"type":"turn/start","seq":0,"time":1783600811137,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783600811138,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO` — and return the two outputs joined with a plus sign. Then reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783600811141,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783600811141,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-bdz41V.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783600811872,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783600811872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783600812033,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783600812066,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783600812067,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783600812067,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783600812067,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} -{"type":"assistant/chunk","seq":11,"time":1783600812067,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":12,"time":1783600812068,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":13,"time":1783600812091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":14,"time":1783600812091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" twice"}}} -{"type":"assistant/chunk","seq":15,"time":1783600812091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":16,"time":1783600812091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":17,"time":1783600812121,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":18,"time":1783600812121,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} -{"type":"assistant/chunk","seq":19,"time":1783600812121,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} -{"type":"assistant/chunk","seq":20,"time":1783600812151,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":21,"time":1783600812151,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":22,"time":1783600812151,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":23,"time":1783600812151,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":24,"time":1783600812151,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":25,"time":1783600812151,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} -{"type":"assistant/chunk","seq":26,"time":1783600812179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} -{"type":"assistant/chunk","seq":27,"time":1783600812179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} -{"type":"assistant/chunk","seq":28,"time":1783600812179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`,"}}} -{"type":"assistant/chunk","seq":29,"time":1783600812179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":30,"time":1783600812179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" return"}}} -{"type":"assistant/chunk","seq":31,"time":1783600812179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":32,"time":1783600812208,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} -{"type":"assistant/chunk","seq":33,"time":1783600812208,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" outputs"}}} -{"type":"assistant/chunk","seq":34,"time":1783600812209,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} -{"type":"assistant/chunk","seq":35,"time":1783600812209,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":36,"time":1783600812209,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":37,"time":1783600812209,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" plus"}}} -{"type":"assistant/chunk","seq":38,"time":1783600812238,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sign"}}} -{"type":"assistant/chunk","seq":39,"time":1783600812238,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":40,"time":1783600812238,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":41,"time":1783600812238,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":42,"time":1783600812238,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} -{"type":"assistant/chunk","seq":43,"time":1783600812267,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":44,"time":1783600812267,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":45,"time":1783600812267,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":46,"time":1783600812296,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} -{"type":"assistant/chunk","seq":47,"time":1783600812296,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} -{"type":"assistant/chunk","seq":48,"time":1783600812296,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":49,"time":1783600812388,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":50,"time":1783600812389,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":51,"time":1783600812418,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":52,"time":1783600812418,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":53,"time":1783600812418,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"code"}}} -{"type":"assistant/chunk","seq":54,"time":1783600812418,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":55,"time":1783600812418,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":56,"time":1783600812448,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":57,"time":1783600812448,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"const"}}} -{"type":"assistant/chunk","seq":58,"time":1783600812448,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":59,"time":1783600812448,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"1"}}} -{"type":"assistant/chunk","seq":60,"time":1783600812448,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":61,"time":1783600812448,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":62,"time":1783600812479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" tools"}}} -{"type":"assistant/chunk","seq":63,"time":1783600812479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":".b"}}} -{"type":"assistant/chunk","seq":64,"time":1783600812479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"ash"}}} -{"type":"assistant/chunk","seq":65,"time":1783600812479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"({"}}} -{"type":"assistant/chunk","seq":66,"time":1783600812506,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" command"}}} -{"type":"assistant/chunk","seq":67,"time":1783600812506,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":68,"time":1783600812506,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":69,"time":1783600812506,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":70,"time":1783600812506,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" CODE"}}} -{"type":"assistant/chunk","seq":71,"time":1783600812507,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"_"}}} -{"type":"assistant/chunk","seq":72,"time":1783600812536,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"ONE"}}} -{"type":"assistant/chunk","seq":73,"time":1783600812537,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\\\","}}} -{"type":"assistant/chunk","seq":74,"time":1783600812537,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" description"}}} -{"type":"assistant/chunk","seq":75,"time":1783600812537,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":76,"time":1783600812537,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":77,"time":1783600812537,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":78,"time":1783600812566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":79,"time":1783600812566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" first"}}} -{"type":"assistant/chunk","seq":80,"time":1783600812566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" code"}}} -{"type":"assistant/chunk","seq":81,"time":1783600812595,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":82,"time":1783600812596,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" });\\n"}}} -{"type":"assistant/chunk","seq":83,"time":1783600812596,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"const"}}} -{"type":"assistant/chunk","seq":84,"time":1783600812596,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":85,"time":1783600812596,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"2"}}} -{"type":"assistant/chunk","seq":86,"time":1783600812596,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":87,"time":1783600812628,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":88,"time":1783600812628,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" tools"}}} -{"type":"assistant/chunk","seq":89,"time":1783600812628,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":".b"}}} -{"type":"assistant/chunk","seq":90,"time":1783600812628,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"ash"}}} -{"type":"assistant/chunk","seq":91,"time":1783600812628,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"({"}}} -{"type":"assistant/chunk","seq":92,"time":1783600812628,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" command"}}} -{"type":"assistant/chunk","seq":93,"time":1783600812656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":94,"time":1783600812656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":95,"time":1783600812656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":96,"time":1783600812656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" CODE"}}} -{"type":"assistant/chunk","seq":97,"time":1783600812656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"_T"}}} -{"type":"assistant/chunk","seq":98,"time":1783600812656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"WO"}}} -{"type":"assistant/chunk","seq":99,"time":1783600812686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\\\","}}} -{"type":"assistant/chunk","seq":100,"time":1783600812686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" description"}}} -{"type":"assistant/chunk","seq":101,"time":1783600812686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":102,"time":1783600812686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":103,"time":1783600812686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":104,"time":1783600812686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":105,"time":1783600812714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" second"}}} -{"type":"assistant/chunk","seq":106,"time":1783600812714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" code"}}} -{"type":"assistant/chunk","seq":107,"time":1783600812715,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":108,"time":1783600812715,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" });\\n"}}} -{"type":"assistant/chunk","seq":109,"time":1783600812715,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"return"}}} -{"type":"assistant/chunk","seq":110,"time":1783600812715,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":111,"time":1783600812744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"1"}}} -{"type":"assistant/chunk","seq":112,"time":1783600812744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":".trim"}}} -{"type":"assistant/chunk","seq":113,"time":1783600812744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"()"}}} -{"type":"assistant/chunk","seq":114,"time":1783600812744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" +"}}} -{"type":"assistant/chunk","seq":115,"time":1783600812744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" \\\"+"}}} -{"type":"assistant/chunk","seq":116,"time":1783600812772,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":117,"time":1783600812773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" +"}}} -{"type":"assistant/chunk","seq":118,"time":1783600812773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":119,"time":1783600812773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"2"}}} -{"type":"assistant/chunk","seq":120,"time":1783600812773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":".trim"}}} -{"type":"assistant/chunk","seq":121,"time":1783600812773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"();"}}} -{"type":"assistant/chunk","seq":122,"time":1783600812802,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":123,"time":1783600812802,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":124,"time":1783600812863,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call the bash tool twice with `echo CODE_ONE` and `echo CODE_TWO`, then return the two outputs joined with a plus sign. Let me write a single run_code program."}}}} -{"type":"assistant/chunk","seq":125,"time":1783600812863,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo first code\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo second code\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}}}} -{"type":"assistant/chunk","seq":126,"time":1783600812864,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2121,"outputTokens":152,"cacheReadTokens":0,"reasoningTokens":44}}}} -{"type":"assistant/chunk","seq":127,"time":1783600812864,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":128,"time":1783600812866,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to call the bash tool twice with `echo CODE_ONE` and `echo CODE_TWO`, then return the two outputs joined with a plus sign. Let me write a single run_code program."},{"type":"tool-call","id":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo first code\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo second code\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}],"usage":{"inputTokens":2121,"outputTokens":152,"cacheReadTokens":0,"reasoningTokens":44}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127],"surfaceOp":"append"} -{"type":"tool/call","seq":129,"time":1783600812866,"data":{"turn":1,"step":1,"callId":"call_00_RJaLT7yuWS9RqjyD9wP85417","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo first code\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo second code\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}} -{"type":"tool/code-dispatch","seq":130,"time":1783600812976,"data":{"parentCallId":"call_00_RJaLT7yuWS9RqjyD9wP85417","subCallId":"call_00_RJaLT7yuWS9RqjyD9wP85417:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Echo first code"},"isError":false,"resultSummary":"CODE_ONE\n"}} -{"type":"tool/code-dispatch","seq":131,"time":1783600812986,"data":{"parentCallId":"call_00_RJaLT7yuWS9RqjyD9wP85417","subCallId":"call_00_RJaLT7yuWS9RqjyD9wP85417:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Echo second code"},"isError":false,"resultSummary":"CODE_TWO\n"}} -{"type":"tool/result","seq":132,"time":1783600812988,"data":{"turn":1,"step":1,"callId":"call_00_RJaLT7yuWS9RqjyD9wP85417","content":[{"type":"text","text":"CODE_ONE+CODE_TWO"}],"isError":false,"meta":{"logs":[],"dispatches":2}},"sourceEventSeqs":[129],"surfaceOp":"append"} -{"type":"step/end","seq":133,"time":1783600812989,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":134,"time":1783600812989,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":135,"time":1783600813658,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":136,"time":1783600813658,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":137,"time":1783600813840,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} -{"type":"assistant/chunk","seq":138,"time":1783600813843,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":139,"time":1783600813843,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":140,"time":1783600813871,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} -{"type":"assistant/chunk","seq":141,"time":1783600813900,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} -{"type":"assistant/chunk","seq":142,"time":1783600813929,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requested"}}} -{"type":"assistant/chunk","seq":143,"time":1783600813929,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":144,"time":1783600813929,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":145,"time":1783600813958,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} -{"type":"assistant/chunk","seq":146,"time":1783600813958,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} -{"type":"assistant/chunk","seq":147,"time":1783600813958,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":148,"time":1783600813958,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"+"}}} -{"type":"assistant/chunk","seq":149,"time":1783600813958,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} -{"type":"assistant/chunk","seq":150,"time":1783600813959,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} -{"type":"assistant/chunk","seq":151,"time":1783600813987,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} -{"type":"assistant/chunk","seq":152,"time":1783600813987,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"`."}}} -{"type":"assistant/chunk","seq":153,"time":1783600813987,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":154,"time":1783600813987,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":155,"time":1783600814016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":156,"time":1783600814017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":157,"time":1783600814017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":158,"time":1783600814017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":159,"time":1783600814045,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} -{"type":"assistant/chunk","seq":160,"time":1783600814045,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} -{"type":"assistant/chunk","seq":161,"time":1783600814046,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" only"}}} -{"type":"assistant/chunk","seq":162,"time":1783600814046,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":163,"time":1783600814131,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":164,"time":1783600814131,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":165,"time":1783600814132,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":166,"time":1783600814132,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} -{"type":"assistant/chunk","seq":167,"time":1783600814132,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_"}}} -{"type":"assistant/chunk","seq":168,"time":1783600814132,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":169,"time":1783600814136,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"+"}}} -{"type":"assistant/chunk","seq":170,"time":1783600814136,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} -{"type":"assistant/chunk","seq":171,"time":1783600814136,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_T"}}} -{"type":"assistant/chunk","seq":172,"time":1783600814137,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} -{"type":"assistant/chunk","seq":173,"time":1783600814137,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The program returned exactly what was requested: `CODE_ONE+CODE_TWO`. I need to reply with that joined string only and stop."}}}} -{"type":"assistant/chunk","seq":174,"time":1783600814137,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} -{"type":"assistant/chunk","seq":175,"time":1783600814137,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":116,"outputTokens":37,"cacheReadTokens":2176,"reasoningTokens":29}}}} -{"type":"assistant/chunk","seq":176,"time":1783600814137,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":177,"time":1783600814137,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The program returned exactly what was requested: `CODE_ONE+CODE_TWO`. I need to reply with that joined string only and stop."},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"usage":{"inputTokens":116,"outputTokens":37,"cacheReadTokens":2176,"reasoningTokens":29}},"sourceEventSeqs":[135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176],"surfaceOp":"append"} -{"type":"step/end","seq":178,"time":1783600814137,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":179,"time":1783600814138,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"80586ac0-3af1-4291-aef4-908d65fd3585","createdAt":1783604829168,"cwd":"/tmp/acp-snap-cwd-7XHEGB"} +{"type":"turn/start","seq":0,"time":1783604829173,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783604829174,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO` — and return the two outputs joined with a plus sign. Then reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783604829176,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783604829176,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-7XHEGB.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783604829821,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783604829821,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783604829991,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783604830022,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783604830022,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783604830022,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783604830023,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":11,"time":1783604830053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1783604830053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":13,"time":1783604830053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":14,"time":1783604830053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} +{"type":"assistant/chunk","seq":15,"time":1783604830053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} +{"type":"assistant/chunk","seq":16,"time":1783604830080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":17,"time":1783604830080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} +{"type":"assistant/chunk","seq":18,"time":1783604830080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":19,"time":1783604830080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":20,"time":1783604830080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Calls"}}} +{"type":"assistant/chunk","seq":21,"time":1783604830109,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":22,"time":1783604830109,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":23,"time":1783604830138,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":24,"time":1783604830139,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":25,"time":1783604830167,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":26,"time":1783604830167,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":27,"time":1783604830167,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":28,"time":1783604830168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":29,"time":1783604830168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":30,"time":1783604830215,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`\n"}}} +{"type":"assistant/chunk","seq":31,"time":1783604830215,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":32,"time":1783604830215,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":33,"time":1783604830215,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Calls"}}} +{"type":"assistant/chunk","seq":34,"time":1783604830215,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":35,"time":1783604830215,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":36,"time":1783604830216,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":37,"time":1783604830226,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":38,"time":1783604830227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":39,"time":1783604830227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":40,"time":1783604830227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":41,"time":1783604830227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":42,"time":1783604830227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":43,"time":1783604830257,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`\n"}}} +{"type":"assistant/chunk","seq":44,"time":1783604830258,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} +{"type":"assistant/chunk","seq":45,"time":1783604830258,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":46,"time":1783604830258,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Jo"}}} +{"type":"assistant/chunk","seq":47,"time":1783604830289,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ins"}}} +{"type":"assistant/chunk","seq":48,"time":1783604830289,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":49,"time":1783604830289,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} +{"type":"assistant/chunk","seq":50,"time":1783604830289,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" outputs"}}} +{"type":"assistant/chunk","seq":51,"time":1783604830289,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":52,"time":1783604830290,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":53,"time":1783604830320,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" plus"}}} +{"type":"assistant/chunk","seq":54,"time":1783604830320,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sign"}}} +{"type":"assistant/chunk","seq":55,"time":1783604830320,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} +{"type":"assistant/chunk","seq":56,"time":1783604830320,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":57,"time":1783604830321,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":58,"time":1783604830321,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Returns"}}} +{"type":"assistant/chunk","seq":59,"time":1783604830348,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":60,"time":1783604830348,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} +{"type":"assistant/chunk","seq":61,"time":1783604830348,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} +{"type":"assistant/chunk","seq":62,"time":1783604830377,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n\n"}}} +{"type":"assistant/chunk","seq":63,"time":1783604830378,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":64,"time":1783604830378,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":65,"time":1783604830378,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":66,"time":1783604830378,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":67,"time":1783604830378,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":68,"time":1783604830466,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":69,"time":1783604830467,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":70,"time":1783604830496,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":71,"time":1783604830497,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":72,"time":1783604830497,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"code"}}} +{"type":"assistant/chunk","seq":73,"time":1783604830497,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":74,"time":1783604830525,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":75,"time":1783604830526,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":76,"time":1783604830526,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":77,"time":1783604830526,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":78,"time":1783604830555,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":79,"time":1783604830555,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":80,"time":1783604830555,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":81,"time":1783604830555,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":82,"time":1783604830555,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":83,"time":1783604830555,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":84,"time":1783604830584,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":85,"time":1783604830584,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":86,"time":1783604830584,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":87,"time":1783604830584,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":88,"time":1783604830584,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":89,"time":1783604830585,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":90,"time":1783604830613,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"_"}}} +{"type":"assistant/chunk","seq":91,"time":1783604830614,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"ONE"}}} +{"type":"assistant/chunk","seq":92,"time":1783604830614,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":93,"time":1783604830614,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":94,"time":1783604830614,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":95,"time":1783604830614,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":96,"time":1783604830646,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":97,"time":1783604830646,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":98,"time":1783604830673,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":99,"time":1783604830673,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"_"}}} +{"type":"assistant/chunk","seq":100,"time":1783604830673,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"ONE"}}} +{"type":"assistant/chunk","seq":101,"time":1783604830674,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":102,"time":1783604830674,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":103,"time":1783604830674,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":104,"time":1783604830705,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":105,"time":1783604830705,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":106,"time":1783604830705,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":107,"time":1783604830705,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":108,"time":1783604830705,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":109,"time":1783604830705,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":110,"time":1783604830735,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":111,"time":1783604830736,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":112,"time":1783604830736,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":113,"time":1783604830736,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":114,"time":1783604830736,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":115,"time":1783604830736,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":116,"time":1783604830761,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":117,"time":1783604830761,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"_T"}}} +{"type":"assistant/chunk","seq":118,"time":1783604830761,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"WO"}}} +{"type":"assistant/chunk","seq":119,"time":1783604830761,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":120,"time":1783604830761,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":121,"time":1783604830761,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":122,"time":1783604830790,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":123,"time":1783604830790,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":124,"time":1783604830790,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":125,"time":1783604830790,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":126,"time":1783604830790,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"_T"}}} +{"type":"assistant/chunk","seq":127,"time":1783604830791,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"WO"}}} +{"type":"assistant/chunk","seq":128,"time":1783604830821,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":129,"time":1783604830821,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":130,"time":1783604830821,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":131,"time":1783604830821,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":132,"time":1783604830821,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":133,"time":1783604830850,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":".trim"}}} +{"type":"assistant/chunk","seq":134,"time":1783604830850,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"()"}}} +{"type":"assistant/chunk","seq":135,"time":1783604830850,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" +"}}} +{"type":"assistant/chunk","seq":136,"time":1783604830850,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" \\\"+"}}} +{"type":"assistant/chunk","seq":137,"time":1783604830882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":138,"time":1783604830882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" +"}}} +{"type":"assistant/chunk","seq":139,"time":1783604830882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":140,"time":1783604830882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":141,"time":1783604830882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":".trim"}}} +{"type":"assistant/chunk","seq":142,"time":1783604830882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"();"}}} +{"type":"assistant/chunk","seq":143,"time":1783604830921,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":144,"time":1783604830922,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":145,"time":1783604830974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash tool to run `echo CODE_ONE`\n2. Calls bash tool to run `echo CODE_TWO`\n3. Joins the two outputs with a plus sign\n4. Returns that joined string\n\nLet me write this."}}}} +{"type":"assistant/chunk","seq":146,"time":1783604830974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}}}} +{"type":"assistant/chunk","seq":147,"time":1783604830974,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2122,"outputTokens":173,"cacheReadTokens":0,"reasoningTokens":63}}}} +{"type":"assistant/chunk","seq":148,"time":1783604830974,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":149,"time":1783604830977,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash tool to run `echo CODE_ONE`\n2. Calls bash tool to run `echo CODE_TWO`\n3. Joins the two outputs with a plus sign\n4. Returns that joined string\n\nLet me write this."},{"type":"tool-call","id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}],"usage":{"inputTokens":2122,"outputTokens":173,"cacheReadTokens":0,"reasoningTokens":63}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148],"surfaceOp":"append"} +{"type":"tool/call","seq":150,"time":1783604830977,"data":{"turn":1,"step":1,"callId":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}} +{"type":"tool/code-dispatch","seq":151,"time":1783604831079,"data":{"parentCallId":"call_00_EvAw7ZWOeySn2jCErZPo6450","subCallId":"call_00_EvAw7ZWOeySn2jCErZPo6450:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Echo CODE_ONE"},"isError":false,"resultSummary":"CODE_ONE\n"}} +{"type":"tool/code-dispatch","seq":152,"time":1783604831089,"data":{"parentCallId":"call_00_EvAw7ZWOeySn2jCErZPo6450","subCallId":"call_00_EvAw7ZWOeySn2jCErZPo6450:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Echo CODE_TWO"},"isError":false,"resultSummary":"CODE_TWO\n"}} +{"type":"tool/result","seq":153,"time":1783604831093,"data":{"turn":1,"step":1,"callId":"call_00_EvAw7ZWOeySn2jCErZPo6450","content":[{"type":"text","text":"CODE_ONE+CODE_TWO"}],"isError":false,"meta":{"logs":[],"dispatches":2}},"sourceEventSeqs":[150],"surfaceOp":"append"} +{"type":"step/end","seq":154,"time":1783604831093,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":155,"time":1783604831094,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":156,"time":1783604831685,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":157,"time":1783604831685,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":158,"time":1783604831830,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":159,"time":1783604831859,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":160,"time":1783604831859,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":161,"time":1783604831859,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":162,"time":1783604831890,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":163,"time":1783604831890,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requested"}}} +{"type":"assistant/chunk","seq":164,"time":1783604831890,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":165,"time":1783604831920,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":166,"time":1783604831920,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":167,"time":1783604831920,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":168,"time":1783604831920,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"+"}}} +{"type":"assistant/chunk","seq":169,"time":1783604831921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":170,"time":1783604831921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":171,"time":1783604831945,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":172,"time":1783604831945,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":173,"time":1783604831946,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":174,"time":1783604831946,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} +{"type":"assistant/chunk","seq":175,"time":1783604831974,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":176,"time":1783604831974,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":177,"time":1783604832003,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":178,"time":1783604832003,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":179,"time":1783604832004,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":180,"time":1783604832004,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":181,"time":1783604832032,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_"}}} +{"type":"assistant/chunk","seq":182,"time":1783604832032,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":183,"time":1783604832032,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"+"}}} +{"type":"assistant/chunk","seq":184,"time":1783604832032,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":185,"time":1783604832032,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_T"}}} +{"type":"assistant/chunk","seq":186,"time":1783604832032,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} +{"type":"assistant/chunk","seq":187,"time":1783604832068,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output is exactly what was requested: CODE_ONE+CODE_TWO. I'll reply with that."}}}} +{"type":"assistant/chunk","seq":188,"time":1783604832069,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} +{"type":"assistant/chunk","seq":189,"time":1783604832069,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":138,"outputTokens":30,"cacheReadTokens":2176,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":190,"time":1783604832069,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":191,"time":1783604832069,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output is exactly what was requested: CODE_ONE+CODE_TWO. I'll reply with that."},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"usage":{"inputTokens":138,"outputTokens":30,"cacheReadTokens":2176,"reasoningTokens":22}},"sourceEventSeqs":[156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190],"surfaceOp":"append"} +{"type":"step/end","seq":192,"time":1783604832069,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":193,"time":1783604832069,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl index 916f20126c..ef6a5502a5 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl @@ -5,75 +5,87 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" call"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" twice"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_T"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`,"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" return"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" two"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" outputs"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" joined"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" plus"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sign"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_code"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" program"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_RJaLT7yuWS9RqjyD9wP85417","title":"Run code","kind":"execute","status":"in_progress","rawInput":"const out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo first code\" });\nconst out2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo second code\" });\nreturn out1.trim() + \"+\" + out2.trim();","content":[{"type":"content","content":{"type":"text","text":"```ts\nconst out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo first code\" });\nconst out2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo second code\" });\nreturn out1.trim() + \"+\" + out2.trim();\n```"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_RJaLT7yuWS9RqjyD9wP85417","status":"completed","content":[{"type":"content","content":{"type":"text","text":"CODE_ONE+CODE_TWO"}}],"title":"Run code (2 tool calls)"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Calls"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Calls"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_T"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Jo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ins"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" two"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" outputs"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" plus"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sign"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"4"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Returns"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" joined"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" string"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_EvAw7ZWOeySn2jCErZPo6450","title":"Run code","kind":"execute","status":"in_progress","rawInput":"const out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo CODE_ONE\" });\nconst out2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo CODE_TWO\" });\nreturn out1.trim() + \"+\" + out2.trim();","content":[{"type":"content","content":{"type":"text","text":"```ts\nconst out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo CODE_ONE\" });\nconst out2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo CODE_TWO\" });\nreturn out1.trim() + \"+\" + out2.trim();\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_EvAw7ZWOeySn2jCErZPo6450","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```ts\nconst out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo CODE_ONE\" });\nconst out2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo CODE_TWO\" });\nreturn out1.trim() + \"+\" + out2.trim();\n```"}}],"title":"Run code (2 tool calls)"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" program"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" requested"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"+"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_T"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'ll"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" joined"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" string"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" only"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CODE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_"}}}} diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 1ae26a5eb9..0175e5811e 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -135,6 +135,15 @@ function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined { return m as unknown as RunCodeMeta } +/** + * Render a program as the markdown block the tool-call cards carry. + * @param code - the program text. + * @returns the ts-fenced markdown block. + */ +function fencedProgram(code: string): string { + return `\`\`\`ts\n${code}\n\`\`\`` +} + /** * Build the `run_code` {@link ToolDefinition}: one required `code` parameter, * executed through the dispatch bridge described in the module doc. The @@ -289,25 +298,32 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => exec.signal?.removeEventListener('abort', onOuterAbort) } }, - // The program IS the call: surface it as an always-visible fenced block in - // the card body (rawInput alone lands in detail/expanded views many - // clients never open). Fence collisions are impossible to break rendering - // — a backtick run inside the program at worst ends the block early. + // The program IS the call: surface it as a fenced block in the card body + // (rawInput alone lands in detail/expanded views many clients never + // open). Fence collisions are impossible to break rendering — a backtick + // run inside the program at worst ends the block early. presentCall: args => ({ card: 'generic', title: 'Run code', kind: 'execute', rawInput: args.code, - content: [{ type: 'text', text: `\`\`\`ts\n${args.code}\n\`\`\`` }], + content: [{ type: 'text', text: fencedProgram(args.code) }], }), - presentResult: (_args, result) => { + // The result re-carries the program BEFORE the captured output: an ACP + // tool_call_update's `content` REPLACES the pending card's (clients + // truncate to the new list), so a result without the program would wipe + // it the moment the run completes. + presentResult: (args, result) => { const meta = asRunCodeMeta(result.meta) if (!meta) return undefined const output = meta.logs.map(entry => entry.text).join('\n') return { card: 'generic', title: `Run code (${meta.dispatches} tool call${meta.dispatches === 1 ? '' : 's'})`, - ...output.length > 0 ? { content: [{ type: 'text', text: output }] } : {}, + content: [ + { type: 'text', text: fencedProgram(args.code) }, + ...output.length > 0 ? [{ type: 'text' as const, text: output }] : [], + ], } }, }) diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 092e0ed90e..73e4ffc946 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -461,10 +461,17 @@ describe('the run_code dispatch bridge', () => { isError: false, meta: { logs: [{ source: 'console', level: 'log', text: 'printed' }], dispatches: 1 }, }) - expect(view).toEqual({ card: 'generic', title: 'Run code (1 tool call)', content: [{ type: 'text', text: 'printed' }] }) - // Plural title, and no content when the program printed nothing. + // The result re-carries the fenced program before the output: the ACP + // update's content REPLACES the pending card's, so omitting it would + // wipe the code from the card the moment the run completes. + expect(view).toEqual({ + card: 'generic', + title: 'Run code (1 tool call)', + content: [{ type: 'text', text: '```ts\nreturn 1\n```' }, { type: 'text', text: 'printed' }], + }) + // Plural title, and the program alone when it printed nothing. expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [], dispatches: 2 } })) - .toEqual({ card: 'generic', title: 'Run code (2 tool calls)' }) + .toEqual({ card: 'generic', title: 'Run code (2 tool calls)', content: [{ type: 'text', text: '```ts\nx\n```' }] }) // Replay with an unrecognizable meta falls back to the generic rendering. expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { other: true } })).toBeUndefined() expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false })).toBeUndefined() From 30bc7f6a1d04783b4969b0b290f3246bb12ef6e9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:41:57 +0800 Subject: [PATCH 49/57] fix: the run_code program IS the execute-card title (root cause: Zed shows nothing else) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Systematic trace through Zed (crates/agent_ui thread_view.rs + crates/acp_thread): kind:execute routes a tool call onto the terminal-card layout, whose header (render_collapsible_command) has NO disclosure toggle, whose body content renders only when is_open — a flag only a real terminal entity can ever set — and which suppresses the Raw Input view outright. Every prior attempt (rawInput, pending content, completed content) targeted slots that layout structurally never renders; the one slot it always shows is the TITLE, which said "Run code". codex-acp confirms the idiom: execute cards are titled with the command itself. presentCall now titles the card with the program (rawInput kept as the canonical input slot); presentResult omits the title — an update replaces only provided fields, so the program header persists — and carries the captured output as content. Goldens re-recorded; the unit test pins title-carries-program on both frames. --- .../snapshots/both-mode-turn/session.jsonl | 233 +++++------ .../both-mode-turn/stdout.golden.jsonl | 44 +- .../snapshots/code-mode-turn/session.jsonl | 395 +++++++++--------- .../code-mode-turn/stdout.golden.jsonl | 51 ++- packages/core/tools/src/code-mode.ts | 38 +- packages/core/tools/tests/code-mode.spec.ts | 23 +- 6 files changed, 391 insertions(+), 393 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl index e3f28fa3c6..afd1fa8e07 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -1,117 +1,116 @@ -{"type":"session","version":0,"id":"7354d242-c6f9-4c36-9040-54c1fb295a6c","createdAt":1783604835700,"cwd":"/tmp/acp-snap-cwd-JyIozV"} -{"type":"turn/start","seq":0,"time":1783604835703,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783604835704,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783604835706,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783604835707,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-JyIozV.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783604836078,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783604836079,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} -{"type":"assistant/chunk","seq":6,"time":1783604836174,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":7,"time":1783604836203,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} -{"type":"assistant/chunk","seq":8,"time":1783604836204,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":9,"time":1783604836204,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" simple"}}} -{"type":"assistant/chunk","seq":10,"time":1783604836233,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Type"}}} -{"type":"assistant/chunk","seq":11,"time":1783604836234,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Script"}}} -{"type":"assistant/chunk","seq":12,"time":1783604836234,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} -{"type":"assistant/chunk","seq":13,"time":1783604836234,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":14,"time":1783604836234,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" calls"}}} -{"type":"assistant/chunk","seq":15,"time":1783604836262,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} -{"type":"assistant/chunk","seq":16,"time":1783604836291,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".b"}}} -{"type":"assistant/chunk","seq":17,"time":1783604836292,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ash"}}} -{"type":"assistant/chunk","seq":18,"time":1783604836292,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":19,"time":1783604836321,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":20,"time":1783604836321,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":21,"time":1783604836350,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":22,"time":1783604836350,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" B"}}} -{"type":"assistant/chunk","seq":23,"time":1783604836350,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OTH"}}} -{"type":"assistant/chunk","seq":24,"time":1783604836350,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":25,"time":1783604836350,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":26,"time":1783604836350,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":27,"time":1783604836382,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} -{"type":"assistant/chunk","seq":28,"time":1783604836383,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":29,"time":1783604836437,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":30,"time":1783604836437,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":31,"time":1783604836499,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":32,"time":1783604836499,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":33,"time":1783604836499,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":34,"time":1783604836499,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":35,"time":1783604836527,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"code"}}} -{"type":"assistant/chunk","seq":36,"time":1783604836528,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":37,"time":1783604836528,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":38,"time":1783604836528,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":39,"time":1783604836556,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"const"}}} -{"type":"assistant/chunk","seq":40,"time":1783604836557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" result"}}} -{"type":"assistant/chunk","seq":41,"time":1783604836557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":42,"time":1783604836557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":43,"time":1783604836557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" tools"}}} -{"type":"assistant/chunk","seq":44,"time":1783604836557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":".b"}}} -{"type":"assistant/chunk","seq":45,"time":1783604836602,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"ash"}}} -{"type":"assistant/chunk","seq":46,"time":1783604836602,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"({\\n"}}} -{"type":"assistant/chunk","seq":47,"time":1783604836615,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" "}}} -{"type":"assistant/chunk","seq":48,"time":1783604836615,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" command"}}} -{"type":"assistant/chunk","seq":49,"time":1783604836615,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":50,"time":1783604836615,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":51,"time":1783604836615,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":52,"time":1783604836615,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" B"}}} -{"type":"assistant/chunk","seq":53,"time":1783604836644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"OTH"}}} -{"type":"assistant/chunk","seq":54,"time":1783604836644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":55,"time":1783604836644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"\\\",\\n"}}} -{"type":"assistant/chunk","seq":56,"time":1783604836644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" "}}} -{"type":"assistant/chunk","seq":57,"time":1783604836644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" description"}}} -{"type":"assistant/chunk","seq":58,"time":1783604836644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":59,"time":1783604836674,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":60,"time":1783604836675,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":61,"time":1783604836703,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":62,"time":1783604836704,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" B"}}} -{"type":"assistant/chunk","seq":63,"time":1783604836704,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"OTH"}}} -{"type":"assistant/chunk","seq":64,"time":1783604836704,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":65,"time":1783604836704,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" message"}}} -{"type":"assistant/chunk","seq":66,"time":1783604836704,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"\\\",\\n"}}} -{"type":"assistant/chunk","seq":67,"time":1783604836732,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"});\\n"}}} -{"type":"assistant/chunk","seq":68,"time":1783604836733,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"return"}}} -{"type":"assistant/chunk","seq":69,"time":1783604836733,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":" result"}}} -{"type":"assistant/chunk","seq":70,"time":1783604836733,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":";\\n"}}} -{"type":"assistant/chunk","seq":71,"time":1783604836733,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":72,"time":1783604836762,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":73,"time":1783604836823,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Let me write a simple TypeScript program that calls tools.bash to run `echo BOTH_OK` and returns the output."}}}} -{"type":"assistant/chunk","seq":74,"time":1783604836823,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({\\n command: \\\"echo BOTH_OK\\\",\\n description: \\\"Echo BOTH_OK message\\\",\\n});\\nreturn result;\\n\"}"}}}} -{"type":"assistant/chunk","seq":75,"time":1783604836823,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3734,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":26}}}} -{"type":"assistant/chunk","seq":76,"time":1783604836823,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":77,"time":1783604836825,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Let me write a simple TypeScript program that calls tools.bash to run `echo BOTH_OK` and returns the output."},{"type":"tool-call","id":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({\\n command: \\\"echo BOTH_OK\\\",\\n description: \\\"Echo BOTH_OK message\\\",\\n});\\nreturn result;\\n\"}"}],"usage":{"inputTokens":3734,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":26}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"} -{"type":"tool/call","seq":78,"time":1783604836825,"data":{"turn":1,"step":1,"callId":"call_00_7FUi2qEmyE8bzRWZPbQI6485","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({\\n command: \\\"echo BOTH_OK\\\",\\n description: \\\"Echo BOTH_OK message\\\",\\n});\\nreturn result;\\n\"}"}} -{"type":"tool/code-dispatch","seq":79,"time":1783604836929,"data":{"parentCallId":"call_00_7FUi2qEmyE8bzRWZPbQI6485","subCallId":"call_00_7FUi2qEmyE8bzRWZPbQI6485:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Echo BOTH_OK message"},"isError":false,"resultSummary":"BOTH_OK\n"}} -{"type":"tool/result","seq":80,"time":1783604836932,"data":{"turn":1,"step":1,"callId":"call_00_7FUi2qEmyE8bzRWZPbQI6485","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[78],"surfaceOp":"append"} -{"type":"step/end","seq":81,"time":1783604836932,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":82,"time":1783604836933,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":83,"time":1783604837401,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":84,"time":1783604837401,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":85,"time":1783604837526,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":86,"time":1783604837554,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":87,"time":1783604837555,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":88,"time":1783604837583,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} -{"type":"assistant/chunk","seq":89,"time":1783604837583,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OTH"}}} -{"type":"assistant/chunk","seq":90,"time":1783604837583,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":91,"time":1783604837583,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":92,"time":1783604837583,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":93,"time":1783604837583,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":94,"time":1783604837612,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":95,"time":1783604837612,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":96,"time":1783604837613,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":97,"time":1783604837613,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":98,"time":1783604837641,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":99,"time":1783604837643,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":100,"time":1783604837643,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":101,"time":1783604837643,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" only"}}} -{"type":"assistant/chunk","seq":102,"time":1783604837644,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":103,"time":1783604837670,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":104,"time":1783604837671,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":105,"time":1783604837671,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":106,"time":1783604837671,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"B"}}} -{"type":"assistant/chunk","seq":107,"time":1783604837671,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OTH"}}} -{"type":"assistant/chunk","seq":108,"time":1783604837671,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} -{"type":"assistant/chunk","seq":109,"time":1783604837701,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The result is \"BOTH_OK\". The user asked me to reply with that output only and stop."}}}} -{"type":"assistant/chunk","seq":110,"time":1783604837701,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BOTH_OK"}}}} -{"type":"assistant/chunk","seq":111,"time":1783604837701,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":139,"outputTokens":25,"cacheReadTokens":3712,"reasoningTokens":21}}}} -{"type":"assistant/chunk","seq":112,"time":1783604837701,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":113,"time":1783604837702,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The result is \"BOTH_OK\". The user asked me to reply with that output only and stop."},{"type":"text","text":"BOTH_OK"}],"usage":{"inputTokens":139,"outputTokens":25,"cacheReadTokens":3712,"reasoningTokens":21}},"sourceEventSeqs":[83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112],"surfaceOp":"append"} -{"type":"step/end","seq":114,"time":1783604837702,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":115,"time":1783604837702,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"53db4e4d-46fb-444c-aba2-8809cf609f07","createdAt":1783607331385,"cwd":"/tmp/acp-snap-cwd-iMVFx2"} +{"type":"turn/start","seq":0,"time":1783607331389,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783607331390,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783607331392,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783607331393,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-iMVFx2.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783607331860,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783607331860,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783607331943,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783607331972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783607331972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783607331972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783607331973,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":11,"time":1783607331973,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":12,"time":1783607331973,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"run"}}} +{"type":"assistant/chunk","seq":13,"time":1783607332000,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} +{"type":"assistant/chunk","seq":14,"time":1783607332000,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":15,"time":1783607332000,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":16,"time":1783607332000,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":17,"time":1783607332000,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":18,"time":1783607332026,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"tools"}}} +{"type":"assistant/chunk","seq":19,"time":1783607332026,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".b"}}} +{"type":"assistant/chunk","seq":20,"time":1783607332026,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ash"}}} +{"type":"assistant/chunk","seq":21,"time":1783607332027,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":22,"time":1783607332027,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":23,"time":1783607332027,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":24,"time":1783607332055,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":25,"time":1783607332056,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":26,"time":1783607332056,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":27,"time":1783607332056,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" B"}}} +{"type":"assistant/chunk","seq":28,"time":1783607332056,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OTH"}}} +{"type":"assistant/chunk","seq":29,"time":1783607332056,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":30,"time":1783607332081,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":31,"time":1783607332082,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":32,"time":1783607332082,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" return"}}} +{"type":"assistant/chunk","seq":33,"time":1783607332111,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" its"}}} +{"type":"assistant/chunk","seq":34,"time":1783607332111,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":35,"time":1783607332111,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":36,"time":1783607332232,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":37,"time":1783607332232,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":38,"time":1783607332232,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":39,"time":1783607332233,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783607332233,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"code"}}} +{"type":"assistant/chunk","seq":41,"time":1783607332233,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783607332233,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":43,"time":1783607332255,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783607332255,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":45,"time":1783607332255,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":" result"}}} +{"type":"assistant/chunk","seq":46,"time":1783607332283,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":47,"time":1783607332283,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":48,"time":1783607332283,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":49,"time":1783607332283,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":50,"time":1783607332283,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":51,"time":1783607332284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":52,"time":1783607332312,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":53,"time":1783607332313,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":54,"time":1783607332313,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":55,"time":1783607332313,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":56,"time":1783607332313,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":" B"}}} +{"type":"assistant/chunk","seq":57,"time":1783607332313,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"OTH"}}} +{"type":"assistant/chunk","seq":58,"time":1783607332339,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":59,"time":1783607332339,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":60,"time":1783607332339,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":61,"time":1783607332339,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":62,"time":1783607332339,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":63,"time":1783607332339,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":64,"time":1783607332367,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":65,"time":1783607332367,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":" B"}}} +{"type":"assistant/chunk","seq":66,"time":1783607332367,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"OTH"}}} +{"type":"assistant/chunk","seq":67,"time":1783607332367,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":68,"time":1783607332401,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":69,"time":1783607332402,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":70,"time":1783607332402,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":71,"time":1783607332402,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":" result"}}} +{"type":"assistant/chunk","seq":72,"time":1783607332430,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":";"}}} +{"type":"assistant/chunk","seq":73,"time":1783607332431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":74,"time":1783607332431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":75,"time":1783607332495,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use `run_code` to call `tools.bash` with the command `echo BOTH_OK` and return its output."}}}} +{"type":"assistant/chunk","seq":76,"time":1783607332495,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\\nreturn result;\"}"}}}} +{"type":"assistant/chunk","seq":77,"time":1783607332495,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3733,"outputTokens":102,"cacheReadTokens":0,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":78,"time":1783607332495,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":79,"time":1783607332497,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use `run_code` to call `tools.bash` with the command `echo BOTH_OK` and return its output."},{"type":"tool-call","id":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\\nreturn result;\"}"}],"usage":{"inputTokens":3733,"outputTokens":102,"cacheReadTokens":0,"reasoningTokens":31}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78],"surfaceOp":"append"} +{"type":"tool/call","seq":80,"time":1783607332497,"data":{"turn":1,"step":1,"callId":"call_00_eZXVwOupAyCOXGgrtxXw7528","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\\nreturn result;\"}"}} +{"type":"tool/code-dispatch","seq":81,"time":1783607332597,"data":{"parentCallId":"call_00_eZXVwOupAyCOXGgrtxXw7528","subCallId":"call_00_eZXVwOupAyCOXGgrtxXw7528:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Echo BOTH_OK"},"isError":false,"resultSummary":"BOTH_OK\n"}} +{"type":"tool/result","seq":82,"time":1783607332599,"data":{"turn":1,"step":1,"callId":"call_00_eZXVwOupAyCOXGgrtxXw7528","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[80],"surfaceOp":"append"} +{"type":"step/end","seq":83,"time":1783607332599,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":84,"time":1783607332600,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":85,"time":1783607333261,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":86,"time":1783607333261,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":87,"time":1783607333471,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":88,"time":1783607333501,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":89,"time":1783607333501,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":90,"time":1783607333530,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} +{"type":"assistant/chunk","seq":91,"time":1783607333530,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OTH"}}} +{"type":"assistant/chunk","seq":92,"time":1783607333530,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":93,"time":1783607333530,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":94,"time":1783607333530,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":95,"time":1783607333530,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":96,"time":1783607333558,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} +{"type":"assistant/chunk","seq":97,"time":1783607333587,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":98,"time":1783607333587,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":99,"time":1783607333587,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":100,"time":1783607333615,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":101,"time":1783607333616,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":102,"time":1783607333616,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" only"}}} +{"type":"assistant/chunk","seq":103,"time":1783607333616,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":104,"time":1783607333616,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":105,"time":1783607333616,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"B"}}} +{"type":"assistant/chunk","seq":106,"time":1783607333644,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OTH"}}} +{"type":"assistant/chunk","seq":107,"time":1783607333645,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} +{"type":"assistant/chunk","seq":108,"time":1783607333645,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output is \"BOTH_OK\". The user said to reply with that output only."}}}} +{"type":"assistant/chunk","seq":109,"time":1783607333645,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BOTH_OK"}}}} +{"type":"assistant/chunk","seq":110,"time":1783607333645,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":139,"outputTokens":22,"cacheReadTokens":3712,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":111,"time":1783607333645,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":112,"time":1783607333646,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output is \"BOTH_OK\". The user said to reply with that output only."},{"type":"text","text":"BOTH_OK"}],"usage":{"inputTokens":139,"outputTokens":22,"cacheReadTokens":3712,"reasoningTokens":18}},"sourceEventSeqs":[85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111],"surfaceOp":"append"} +{"type":"step/end","seq":113,"time":1783607333646,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":114,"time":1783607333646,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl index d5a8135569..41a2a751fe 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl @@ -1,20 +1,25 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" simple"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Type"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Script"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" program"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" calls"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" call"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"tools"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".b"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" B"}}}} @@ -22,14 +27,14 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" return"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" its"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_7FUi2qEmyE8bzRWZPbQI6485","title":"Run code","kind":"execute","status":"in_progress","rawInput":"const result = await tools.bash({\n command: \"echo BOTH_OK\",\n description: \"Echo BOTH_OK message\",\n});\nreturn result;\n","content":[{"type":"content","content":{"type":"text","text":"```ts\nconst result = await tools.bash({\n command: \"echo BOTH_OK\",\n description: \"Echo BOTH_OK message\",\n});\nreturn result;\n\n```"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_7FUi2qEmyE8bzRWZPbQI6485","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```ts\nconst result = await tools.bash({\n command: \"echo BOTH_OK\",\n description: \"Echo BOTH_OK message\",\n});\nreturn result;\n\n```"}}],"title":"Run code (1 tool call)"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_eZXVwOupAyCOXGgrtxXw7528","title":"const result = await tools.bash({ command: \"echo BOTH_OK\", description: \"Echo BOTH_OK\" });\nreturn result;","kind":"execute","status":"in_progress","rawInput":"const result = await tools.bash({ command: \"echo BOTH_OK\", description: \"Echo BOTH_OK\" });\nreturn result;"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_eZXVwOupAyCOXGgrtxXw7528","status":"completed","content":[{"type":"content","content":{"type":"text","text":"BOTH_OK\n"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"B"}}}} @@ -38,16 +43,13 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" said"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" only"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"B"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OTH"}}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl index 69cf7d1825..acc807da41 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl @@ -1,195 +1,200 @@ -{"type":"session","version":0,"id":"80586ac0-3af1-4291-aef4-908d65fd3585","createdAt":1783604829168,"cwd":"/tmp/acp-snap-cwd-7XHEGB"} -{"type":"turn/start","seq":0,"time":1783604829173,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783604829174,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO` — and return the two outputs joined with a plus sign. Then reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783604829176,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783604829176,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-7XHEGB.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783604829821,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783604829821,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783604829991,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783604830022,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783604830022,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783604830022,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783604830023,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} -{"type":"assistant/chunk","seq":11,"time":1783604830053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":12,"time":1783604830053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":13,"time":1783604830053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":14,"time":1783604830053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} -{"type":"assistant/chunk","seq":15,"time":1783604830053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} -{"type":"assistant/chunk","seq":16,"time":1783604830080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":17,"time":1783604830080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} -{"type":"assistant/chunk","seq":18,"time":1783604830080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} -{"type":"assistant/chunk","seq":19,"time":1783604830080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":20,"time":1783604830080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Calls"}}} -{"type":"assistant/chunk","seq":21,"time":1783604830109,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":22,"time":1783604830109,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":23,"time":1783604830138,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":24,"time":1783604830139,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":25,"time":1783604830167,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":26,"time":1783604830167,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":27,"time":1783604830167,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} -{"type":"assistant/chunk","seq":28,"time":1783604830168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} -{"type":"assistant/chunk","seq":29,"time":1783604830168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":30,"time":1783604830215,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`\n"}}} -{"type":"assistant/chunk","seq":31,"time":1783604830215,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} -{"type":"assistant/chunk","seq":32,"time":1783604830215,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":33,"time":1783604830215,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Calls"}}} -{"type":"assistant/chunk","seq":34,"time":1783604830215,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":35,"time":1783604830215,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":36,"time":1783604830216,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":37,"time":1783604830226,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":38,"time":1783604830227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":39,"time":1783604830227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":40,"time":1783604830227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} -{"type":"assistant/chunk","seq":41,"time":1783604830227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} -{"type":"assistant/chunk","seq":42,"time":1783604830227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} -{"type":"assistant/chunk","seq":43,"time":1783604830257,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`\n"}}} -{"type":"assistant/chunk","seq":44,"time":1783604830258,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} -{"type":"assistant/chunk","seq":45,"time":1783604830258,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":46,"time":1783604830258,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Jo"}}} -{"type":"assistant/chunk","seq":47,"time":1783604830289,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ins"}}} -{"type":"assistant/chunk","seq":48,"time":1783604830289,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":49,"time":1783604830289,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} -{"type":"assistant/chunk","seq":50,"time":1783604830289,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" outputs"}}} -{"type":"assistant/chunk","seq":51,"time":1783604830289,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":52,"time":1783604830290,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":53,"time":1783604830320,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" plus"}}} -{"type":"assistant/chunk","seq":54,"time":1783604830320,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sign"}}} -{"type":"assistant/chunk","seq":55,"time":1783604830320,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} -{"type":"assistant/chunk","seq":56,"time":1783604830320,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} -{"type":"assistant/chunk","seq":57,"time":1783604830321,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":58,"time":1783604830321,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Returns"}}} -{"type":"assistant/chunk","seq":59,"time":1783604830348,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":60,"time":1783604830348,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} -{"type":"assistant/chunk","seq":61,"time":1783604830348,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} -{"type":"assistant/chunk","seq":62,"time":1783604830377,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n\n"}}} -{"type":"assistant/chunk","seq":63,"time":1783604830378,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} -{"type":"assistant/chunk","seq":64,"time":1783604830378,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":65,"time":1783604830378,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} -{"type":"assistant/chunk","seq":66,"time":1783604830378,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":67,"time":1783604830378,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":68,"time":1783604830466,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":69,"time":1783604830467,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":70,"time":1783604830496,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":71,"time":1783604830497,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":72,"time":1783604830497,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"code"}}} -{"type":"assistant/chunk","seq":73,"time":1783604830497,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":74,"time":1783604830525,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":75,"time":1783604830526,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":76,"time":1783604830526,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"const"}}} -{"type":"assistant/chunk","seq":77,"time":1783604830526,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":78,"time":1783604830555,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"1"}}} -{"type":"assistant/chunk","seq":79,"time":1783604830555,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":80,"time":1783604830555,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":81,"time":1783604830555,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" tools"}}} -{"type":"assistant/chunk","seq":82,"time":1783604830555,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":".b"}}} -{"type":"assistant/chunk","seq":83,"time":1783604830555,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"ash"}}} -{"type":"assistant/chunk","seq":84,"time":1783604830584,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"({"}}} -{"type":"assistant/chunk","seq":85,"time":1783604830584,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" command"}}} -{"type":"assistant/chunk","seq":86,"time":1783604830584,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":87,"time":1783604830584,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":88,"time":1783604830584,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":89,"time":1783604830585,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" CODE"}}} -{"type":"assistant/chunk","seq":90,"time":1783604830613,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"_"}}} -{"type":"assistant/chunk","seq":91,"time":1783604830614,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"ONE"}}} -{"type":"assistant/chunk","seq":92,"time":1783604830614,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\\\","}}} -{"type":"assistant/chunk","seq":93,"time":1783604830614,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" description"}}} -{"type":"assistant/chunk","seq":94,"time":1783604830614,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":95,"time":1783604830614,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":96,"time":1783604830646,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":97,"time":1783604830646,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":98,"time":1783604830673,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" CODE"}}} -{"type":"assistant/chunk","seq":99,"time":1783604830673,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"_"}}} -{"type":"assistant/chunk","seq":100,"time":1783604830673,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"ONE"}}} -{"type":"assistant/chunk","seq":101,"time":1783604830674,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":102,"time":1783604830674,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" });\\n"}}} -{"type":"assistant/chunk","seq":103,"time":1783604830674,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"const"}}} -{"type":"assistant/chunk","seq":104,"time":1783604830705,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":105,"time":1783604830705,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"2"}}} -{"type":"assistant/chunk","seq":106,"time":1783604830705,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":107,"time":1783604830705,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":108,"time":1783604830705,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" tools"}}} -{"type":"assistant/chunk","seq":109,"time":1783604830705,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":".b"}}} -{"type":"assistant/chunk","seq":110,"time":1783604830735,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"ash"}}} -{"type":"assistant/chunk","seq":111,"time":1783604830736,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"({"}}} -{"type":"assistant/chunk","seq":112,"time":1783604830736,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" command"}}} -{"type":"assistant/chunk","seq":113,"time":1783604830736,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":114,"time":1783604830736,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":115,"time":1783604830736,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":116,"time":1783604830761,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" CODE"}}} -{"type":"assistant/chunk","seq":117,"time":1783604830761,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"_T"}}} -{"type":"assistant/chunk","seq":118,"time":1783604830761,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"WO"}}} -{"type":"assistant/chunk","seq":119,"time":1783604830761,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\\\","}}} -{"type":"assistant/chunk","seq":120,"time":1783604830761,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" description"}}} -{"type":"assistant/chunk","seq":121,"time":1783604830761,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":122,"time":1783604830790,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":123,"time":1783604830790,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":124,"time":1783604830790,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":125,"time":1783604830790,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" CODE"}}} -{"type":"assistant/chunk","seq":126,"time":1783604830790,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"_T"}}} -{"type":"assistant/chunk","seq":127,"time":1783604830791,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"WO"}}} -{"type":"assistant/chunk","seq":128,"time":1783604830821,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":129,"time":1783604830821,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" });\\n"}}} -{"type":"assistant/chunk","seq":130,"time":1783604830821,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"return"}}} -{"type":"assistant/chunk","seq":131,"time":1783604830821,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":132,"time":1783604830821,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"1"}}} -{"type":"assistant/chunk","seq":133,"time":1783604830850,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":".trim"}}} -{"type":"assistant/chunk","seq":134,"time":1783604830850,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"()"}}} -{"type":"assistant/chunk","seq":135,"time":1783604830850,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" +"}}} -{"type":"assistant/chunk","seq":136,"time":1783604830850,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" \\\"+"}}} -{"type":"assistant/chunk","seq":137,"time":1783604830882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":138,"time":1783604830882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" +"}}} -{"type":"assistant/chunk","seq":139,"time":1783604830882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":140,"time":1783604830882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"2"}}} -{"type":"assistant/chunk","seq":141,"time":1783604830882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":".trim"}}} -{"type":"assistant/chunk","seq":142,"time":1783604830882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"();"}}} -{"type":"assistant/chunk","seq":143,"time":1783604830921,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":144,"time":1783604830922,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":145,"time":1783604830974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash tool to run `echo CODE_ONE`\n2. Calls bash tool to run `echo CODE_TWO`\n3. Joins the two outputs with a plus sign\n4. Returns that joined string\n\nLet me write this."}}}} -{"type":"assistant/chunk","seq":146,"time":1783604830974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}}}} -{"type":"assistant/chunk","seq":147,"time":1783604830974,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2122,"outputTokens":173,"cacheReadTokens":0,"reasoningTokens":63}}}} -{"type":"assistant/chunk","seq":148,"time":1783604830974,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":149,"time":1783604830977,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash tool to run `echo CODE_ONE`\n2. Calls bash tool to run `echo CODE_TWO`\n3. Joins the two outputs with a plus sign\n4. Returns that joined string\n\nLet me write this."},{"type":"tool-call","id":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}],"usage":{"inputTokens":2122,"outputTokens":173,"cacheReadTokens":0,"reasoningTokens":63}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148],"surfaceOp":"append"} -{"type":"tool/call","seq":150,"time":1783604830977,"data":{"turn":1,"step":1,"callId":"call_00_EvAw7ZWOeySn2jCErZPo6450","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}} -{"type":"tool/code-dispatch","seq":151,"time":1783604831079,"data":{"parentCallId":"call_00_EvAw7ZWOeySn2jCErZPo6450","subCallId":"call_00_EvAw7ZWOeySn2jCErZPo6450:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Echo CODE_ONE"},"isError":false,"resultSummary":"CODE_ONE\n"}} -{"type":"tool/code-dispatch","seq":152,"time":1783604831089,"data":{"parentCallId":"call_00_EvAw7ZWOeySn2jCErZPo6450","subCallId":"call_00_EvAw7ZWOeySn2jCErZPo6450:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Echo CODE_TWO"},"isError":false,"resultSummary":"CODE_TWO\n"}} -{"type":"tool/result","seq":153,"time":1783604831093,"data":{"turn":1,"step":1,"callId":"call_00_EvAw7ZWOeySn2jCErZPo6450","content":[{"type":"text","text":"CODE_ONE+CODE_TWO"}],"isError":false,"meta":{"logs":[],"dispatches":2}},"sourceEventSeqs":[150],"surfaceOp":"append"} -{"type":"step/end","seq":154,"time":1783604831093,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":155,"time":1783604831094,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":156,"time":1783604831685,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":157,"time":1783604831685,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":158,"time":1783604831830,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":159,"time":1783604831859,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":160,"time":1783604831859,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":161,"time":1783604831859,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} -{"type":"assistant/chunk","seq":162,"time":1783604831890,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} -{"type":"assistant/chunk","seq":163,"time":1783604831890,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requested"}}} -{"type":"assistant/chunk","seq":164,"time":1783604831890,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":165,"time":1783604831920,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} -{"type":"assistant/chunk","seq":166,"time":1783604831920,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} -{"type":"assistant/chunk","seq":167,"time":1783604831920,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":168,"time":1783604831920,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"+"}}} -{"type":"assistant/chunk","seq":169,"time":1783604831921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} -{"type":"assistant/chunk","seq":170,"time":1783604831921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} -{"type":"assistant/chunk","seq":171,"time":1783604831945,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} -{"type":"assistant/chunk","seq":172,"time":1783604831945,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":173,"time":1783604831946,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":174,"time":1783604831946,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} -{"type":"assistant/chunk","seq":175,"time":1783604831974,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":176,"time":1783604831974,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":177,"time":1783604832003,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":178,"time":1783604832003,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":179,"time":1783604832004,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":180,"time":1783604832004,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} -{"type":"assistant/chunk","seq":181,"time":1783604832032,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_"}}} -{"type":"assistant/chunk","seq":182,"time":1783604832032,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":183,"time":1783604832032,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"+"}}} -{"type":"assistant/chunk","seq":184,"time":1783604832032,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} -{"type":"assistant/chunk","seq":185,"time":1783604832032,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_T"}}} -{"type":"assistant/chunk","seq":186,"time":1783604832032,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} -{"type":"assistant/chunk","seq":187,"time":1783604832068,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output is exactly what was requested: CODE_ONE+CODE_TWO. I'll reply with that."}}}} -{"type":"assistant/chunk","seq":188,"time":1783604832069,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} -{"type":"assistant/chunk","seq":189,"time":1783604832069,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":138,"outputTokens":30,"cacheReadTokens":2176,"reasoningTokens":22}}}} -{"type":"assistant/chunk","seq":190,"time":1783604832069,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":191,"time":1783604832069,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output is exactly what was requested: CODE_ONE+CODE_TWO. I'll reply with that."},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"usage":{"inputTokens":138,"outputTokens":30,"cacheReadTokens":2176,"reasoningTokens":22}},"sourceEventSeqs":[156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190],"surfaceOp":"append"} -{"type":"step/end","seq":192,"time":1783604832069,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":193,"time":1783604832069,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"687d082e-f208-45cb-a4e7-83235750c4a5","createdAt":1783607324790,"cwd":"/tmp/acp-snap-cwd-yWlMC5"} +{"type":"turn/start","seq":0,"time":1783607324794,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783607324794,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO` — and return the two outputs joined with a plus sign. Then reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783607324797,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783607324797,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-yWlMC5.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783607325519,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783607325519,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783607325693,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783607325721,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783607325722,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783607325722,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783607325722,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":11,"time":1783607325753,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1783607325753,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":13,"time":1783607325753,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":14,"time":1783607325779,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"run"}}} +{"type":"assistant/chunk","seq":15,"time":1783607325779,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} +{"type":"assistant/chunk","seq":16,"time":1783607325779,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":17,"time":1783607325779,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} +{"type":"assistant/chunk","seq":18,"time":1783607325779,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":19,"time":1783607325805,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} +{"type":"assistant/chunk","seq":20,"time":1783607325805,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":21,"time":1783607325806,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":22,"time":1783607325806,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Calls"}}} +{"type":"assistant/chunk","seq":23,"time":1783607325806,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":24,"time":1783607325833,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"bash"}}} +{"type":"assistant/chunk","seq":25,"time":1783607325834,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":26,"time":1783607325862,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" twice"}}} +{"type":"assistant/chunk","seq":27,"time":1783607325862,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":28,"time":1783607325890,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":29,"time":1783607325890,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":30,"time":1783607325890,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":31,"time":1783607325891,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":32,"time":1783607325891,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":33,"time":1783607325891,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":34,"time":1783607325919,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":35,"time":1783607325919,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":36,"time":1783607325919,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":37,"time":1783607325919,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":38,"time":1783607325919,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":39,"time":1783607325920,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":40,"time":1783607325948,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":41,"time":1783607325948,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":42,"time":1783607325948,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":43,"time":1783607325948,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":44,"time":1783607325948,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`\n"}}} +{"type":"assistant/chunk","seq":45,"time":1783607325948,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":46,"time":1783607325975,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":47,"time":1783607325975,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Jo"}}} +{"type":"assistant/chunk","seq":48,"time":1783607325975,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ins"}}} +{"type":"assistant/chunk","seq":49,"time":1783607325975,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":50,"time":1783607325976,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} +{"type":"assistant/chunk","seq":51,"time":1783607325976,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" outputs"}}} +{"type":"assistant/chunk","seq":52,"time":1783607326003,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":53,"time":1783607326004,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":54,"time":1783607326004,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" plus"}}} +{"type":"assistant/chunk","seq":55,"time":1783607326004,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sign"}}} +{"type":"assistant/chunk","seq":56,"time":1783607326004,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} +{"type":"assistant/chunk","seq":57,"time":1783607326004,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} +{"type":"assistant/chunk","seq":58,"time":1783607326032,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":59,"time":1783607326032,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Returns"}}} +{"type":"assistant/chunk","seq":60,"time":1783607326032,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":61,"time":1783607326060,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} +{"type":"assistant/chunk","seq":62,"time":1783607326060,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} +{"type":"assistant/chunk","seq":63,"time":1783607326091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n\n"}}} +{"type":"assistant/chunk","seq":64,"time":1783607326092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":65,"time":1783607326092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":66,"time":1783607326092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":67,"time":1783607326092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":68,"time":1783607326092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" code"}}} +{"type":"assistant/chunk","seq":69,"time":1783607326119,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":70,"time":1783607326176,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":71,"time":1783607326176,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":72,"time":1783607326203,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":73,"time":1783607326204,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":74,"time":1783607326204,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"code"}}} +{"type":"assistant/chunk","seq":75,"time":1783607326233,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":76,"time":1783607326233,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":77,"time":1783607326233,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":78,"time":1783607326233,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":79,"time":1783607326260,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" r"}}} +{"type":"assistant/chunk","seq":80,"time":1783607326289,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":81,"time":1783607326289,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":82,"time":1783607326289,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":83,"time":1783607326289,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":84,"time":1783607326289,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":85,"time":1783607326290,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":86,"time":1783607326317,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":87,"time":1783607326318,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":88,"time":1783607326318,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":89,"time":1783607326318,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":90,"time":1783607326318,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":91,"time":1783607326318,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":92,"time":1783607326346,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"_"}}} +{"type":"assistant/chunk","seq":93,"time":1783607326346,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"ONE"}}} +{"type":"assistant/chunk","seq":94,"time":1783607326346,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":95,"time":1783607326346,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":96,"time":1783607326346,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":97,"time":1783607326346,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":98,"time":1783607326374,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":99,"time":1783607326405,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":100,"time":1783607326405,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":101,"time":1783607326405,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"_"}}} +{"type":"assistant/chunk","seq":102,"time":1783607326405,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"ONE"}}} +{"type":"assistant/chunk","seq":103,"time":1783607326431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":104,"time":1783607326431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":105,"time":1783607326431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":106,"time":1783607326431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" r"}}} +{"type":"assistant/chunk","seq":107,"time":1783607326431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":108,"time":1783607326431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":109,"time":1783607326462,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":110,"time":1783607326462,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":111,"time":1783607326462,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":112,"time":1783607326462,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":113,"time":1783607326462,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":114,"time":1783607326463,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":115,"time":1783607326487,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":116,"time":1783607326487,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":117,"time":1783607326487,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":118,"time":1783607326488,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":119,"time":1783607326488,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"_T"}}} +{"type":"assistant/chunk","seq":120,"time":1783607326488,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"WO"}}} +{"type":"assistant/chunk","seq":121,"time":1783607326516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":122,"time":1783607326516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":123,"time":1783607326516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":124,"time":1783607326516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":125,"time":1783607326516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":126,"time":1783607326516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":127,"time":1783607326543,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":128,"time":1783607326544,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"_T"}}} +{"type":"assistant/chunk","seq":129,"time":1783607326544,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"WO"}}} +{"type":"assistant/chunk","seq":130,"time":1783607326544,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":131,"time":1783607326544,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":132,"time":1783607326544,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":133,"time":1783607326572,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" r"}}} +{"type":"assistant/chunk","seq":134,"time":1783607326572,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":135,"time":1783607326572,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":".trim"}}} +{"type":"assistant/chunk","seq":136,"time":1783607326600,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"()"}}} +{"type":"assistant/chunk","seq":137,"time":1783607326601,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" +"}}} +{"type":"assistant/chunk","seq":138,"time":1783607326601,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" \\\"+"}}} +{"type":"assistant/chunk","seq":139,"time":1783607326630,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":140,"time":1783607326630,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" +"}}} +{"type":"assistant/chunk","seq":141,"time":1783607326630,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":" r"}}} +{"type":"assistant/chunk","seq":142,"time":1783607326630,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":143,"time":1783607326630,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":".trim"}}} +{"type":"assistant/chunk","seq":144,"time":1783607326630,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"();"}}} +{"type":"assistant/chunk","seq":145,"time":1783607326660,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":146,"time":1783607326660,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":147,"time":1783607326724,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Calls `bash` twice - once with `echo CODE_ONE` and once with `echo CODE_TWO`\n2. Joins the two outputs with a plus sign\n3. Returns that joined string\n\nLet me write the code."}}}} +{"type":"assistant/chunk","seq":148,"time":1783607326725,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","arguments":"{\"code\": \"const r1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE\\\" });\\nconst r2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO\\\" });\\nreturn r1.trim() + \\\"+\\\" + r2.trim();\"}"}}}} +{"type":"assistant/chunk","seq":149,"time":1783607326725,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2122,"outputTokens":175,"cacheReadTokens":0,"reasoningTokens":65}}}} +{"type":"assistant/chunk","seq":150,"time":1783607326725,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":151,"time":1783607326727,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Calls `bash` twice - once with `echo CODE_ONE` and once with `echo CODE_TWO`\n2. Joins the two outputs with a plus sign\n3. Returns that joined string\n\nLet me write the code."},{"type":"tool-call","id":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","arguments":"{\"code\": \"const r1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE\\\" });\\nconst r2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO\\\" });\\nreturn r1.trim() + \\\"+\\\" + r2.trim();\"}"}],"usage":{"inputTokens":2122,"outputTokens":175,"cacheReadTokens":0,"reasoningTokens":65}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150],"surfaceOp":"append"} +{"type":"tool/call","seq":152,"time":1783607326727,"data":{"turn":1,"step":1,"callId":"call_00_Km1jhimv1ZVR4qbgjxAo9869","name":"run_code","arguments":"{\"code\": \"const r1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE\\\" });\\nconst r2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO\\\" });\\nreturn r1.trim() + \\\"+\\\" + r2.trim();\"}"}} +{"type":"tool/code-dispatch","seq":153,"time":1783607326846,"data":{"parentCallId":"call_00_Km1jhimv1ZVR4qbgjxAo9869","subCallId":"call_00_Km1jhimv1ZVR4qbgjxAo9869:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Echo CODE_ONE"},"isError":false,"resultSummary":"CODE_ONE\n"}} +{"type":"tool/code-dispatch","seq":154,"time":1783607326855,"data":{"parentCallId":"call_00_Km1jhimv1ZVR4qbgjxAo9869","subCallId":"call_00_Km1jhimv1ZVR4qbgjxAo9869:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Echo CODE_TWO"},"isError":false,"resultSummary":"CODE_TWO\n"}} +{"type":"tool/result","seq":155,"time":1783607326858,"data":{"turn":1,"step":1,"callId":"call_00_Km1jhimv1ZVR4qbgjxAo9869","content":[{"type":"text","text":"CODE_ONE+CODE_TWO"}],"isError":false,"meta":{"logs":[],"dispatches":2}},"sourceEventSeqs":[152],"surfaceOp":"append"} +{"type":"step/end","seq":156,"time":1783607326858,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":157,"time":1783607326859,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":158,"time":1783607327431,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":159,"time":1783607327431,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":160,"time":1783607327701,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":161,"time":1783607327725,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":162,"time":1783607327725,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":163,"time":1783607327725,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":164,"time":1783607327754,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":165,"time":1783607327754,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requested"}}} +{"type":"assistant/chunk","seq":166,"time":1783607327754,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":167,"time":1783607327782,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":168,"time":1783607327783,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":169,"time":1783607327783,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":170,"time":1783607327783,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":171,"time":1783607327783,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"+"}}} +{"type":"assistant/chunk","seq":172,"time":1783607327783,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":173,"time":1783607327811,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":174,"time":1783607327812,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":175,"time":1783607327812,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":176,"time":1783607327812,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":177,"time":1783607327812,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":178,"time":1783607327812,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":179,"time":1783607327839,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":180,"time":1783607327839,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":181,"time":1783607327868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":182,"time":1783607327868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} +{"type":"assistant/chunk","seq":183,"time":1783607327868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":184,"time":1783607327868,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":185,"time":1783607327868,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":186,"time":1783607327868,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_"}}} +{"type":"assistant/chunk","seq":187,"time":1783607327901,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":188,"time":1783607327901,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"+"}}} +{"type":"assistant/chunk","seq":189,"time":1783607327901,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":190,"time":1783607327901,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_T"}}} +{"type":"assistant/chunk","seq":191,"time":1783607327901,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} +{"type":"assistant/chunk","seq":192,"time":1783607327901,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The result is exactly what was requested: \"CODE_ONE+CODE_TWO\". Let me reply with just that string."}}}} +{"type":"assistant/chunk","seq":193,"time":1783607327901,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} +{"type":"assistant/chunk","seq":194,"time":1783607327901,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":140,"outputTokens":33,"cacheReadTokens":2176,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":195,"time":1783607327902,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":196,"time":1783607327902,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The result is exactly what was requested: \"CODE_ONE+CODE_TWO\". Let me reply with just that string."},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"usage":{"inputTokens":140,"outputTokens":33,"cacheReadTokens":2176,"reasoningTokens":25}},"sourceEventSeqs":[158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195],"surfaceOp":"append"} +{"type":"step/end","seq":197,"time":1783607327902,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":198,"time":1783607327902,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl index ef6a5502a5..ff20b92fc5 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl @@ -8,38 +8,39 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"run"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" program"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Calls"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" twice"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Calls"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_T"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Jo"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ins"}}}} @@ -51,7 +52,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" plus"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sign"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"4"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Returns"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} @@ -61,31 +62,35 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" code"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_EvAw7ZWOeySn2jCErZPo6450","title":"Run code","kind":"execute","status":"in_progress","rawInput":"const out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo CODE_ONE\" });\nconst out2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo CODE_TWO\" });\nreturn out1.trim() + \"+\" + out2.trim();","content":[{"type":"content","content":{"type":"text","text":"```ts\nconst out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo CODE_ONE\" });\nconst out2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo CODE_TWO\" });\nreturn out1.trim() + \"+\" + out2.trim();\n```"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_EvAw7ZWOeySn2jCErZPo6450","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```ts\nconst out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo CODE_ONE\" });\nconst out2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo CODE_TWO\" });\nreturn out1.trim() + \"+\" + out2.trim();\n```"}}],"title":"Run code (2 tool calls)"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Km1jhimv1ZVR4qbgjxAo9869","title":"const r1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo CODE_ONE\" });\nconst r2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo CODE_TWO\" });\nreturn r1.trim() + \"+\" + r2.trim();","kind":"execute","status":"in_progress","rawInput":"const r1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo CODE_ONE\" });\nconst r2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo CODE_TWO\" });\nreturn r1.trim() + \"+\" + r2.trim();"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Km1jhimv1ZVR4qbgjxAo9869","status":"completed","content":[{"type":"content","content":{"type":"text","text":"CODE_ONE+CODE_TWO"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" requested"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"+"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_T"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'ll"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" string"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CODE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_"}}}} diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 0175e5811e..a6ef7a271a 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -135,15 +135,6 @@ function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined { return m as unknown as RunCodeMeta } -/** - * Render a program as the markdown block the tool-call cards carry. - * @param code - the program text. - * @returns the ts-fenced markdown block. - */ -function fencedProgram(code: string): string { - return `\`\`\`ts\n${code}\n\`\`\`` -} - /** * Build the `run_code` {@link ToolDefinition}: one required `code` parameter, * executed through the dispatch bridge described in the module doc. The @@ -298,32 +289,29 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => exec.signal?.removeEventListener('abort', onOuterAbort) } }, - // The program IS the call: surface it as a fenced block in the card body - // (rawInput alone lands in detail/expanded views many clients never - // open). Fence collisions are impossible to break rendering — a backtick - // run inside the program at worst ends the block early. + // The program IS the title, the way command tools title their cards with + // the command: an execute-card's title is the one slot an ACP client + // always shows (Zed's execute cards render no body content and no raw + // input without a real terminal attached), so anywhere else the code + // would be invisible. Multi-line titles are the execute-card idiom — + // capable clients render them whole; others truncate to the first line + // and still hold the full program in rawInput. presentCall: args => ({ card: 'generic', - title: 'Run code', + title: args.code, kind: 'execute', rawInput: args.code, - content: [{ type: 'text', text: fencedProgram(args.code) }], }), - // The result re-carries the program BEFORE the captured output: an ACP - // tool_call_update's `content` REPLACES the pending card's (clients - // truncate to the new list), so a result without the program would wipe - // it the moment the run completes. - presentResult: (args, result) => { + // Title omitted on the result: an update replaces only the fields it + // carries, so the pending card's program title persists through + // completion; the captured output rides as body content. + presentResult: (_args, result) => { const meta = asRunCodeMeta(result.meta) if (!meta) return undefined const output = meta.logs.map(entry => entry.text).join('\n') return { card: 'generic', - title: `Run code (${meta.dispatches} tool call${meta.dispatches === 1 ? '' : 's'})`, - content: [ - { type: 'text', text: fencedProgram(args.code) }, - ...output.length > 0 ? [{ type: 'text' as const, text: output }] : [], - ], + ...output.length > 0 ? { content: [{ type: 'text' as const, text: output }] } : {}, } }, }) diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 73e4ffc946..f7f4b058d8 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -444,34 +444,33 @@ describe('the run_code dispatch bridge', () => { expect((result.content[0] as { text: string }).text).toContain('requires a code runtime') }) - it('presents the pending call as a generic execute card carrying the program, and the result with the captured output', async () => { + it('presents the PROGRAM as the execute-card title on both call and result (the one slot execute cards always show)', async () => { const { ctx } = await setup({ mode: 'code' }) const tool = ctx.tools.get(RUN_CODE_NAME)! + // The program IS the title, mirroring how command tools title their cards + // with the command: an ACP client's execute-card header is the only + // always-visible slot (Zed renders no body content and no raw input for + // execute-kind cards without a real terminal). expect(tool.presentCall?.({ code: 'return 1' })).toEqual({ card: 'generic', - title: 'Run code', + title: 'return 1', kind: 'execute', rawInput: 'return 1', - // The program rides the card BODY as a fenced block — visible in ACP - // clients that never open the rawInput detail view. - content: [{ type: 'text', text: '```ts\nreturn 1\n```' }], }) const view = tool.presentResult?.({ code: 'return 1' }, { content: [{ type: 'text', text: 'model-facing' }], isError: false, meta: { logs: [{ source: 'console', level: 'log', text: 'printed' }], dispatches: 1 }, }) - // The result re-carries the fenced program before the output: the ACP - // update's content REPLACES the pending card's, so omitting it would - // wipe the code from the card the moment the run completes. + // The result omits the title — an update replaces only provided fields, + // so the pending card's program title persists through completion. expect(view).toEqual({ card: 'generic', - title: 'Run code (1 tool call)', - content: [{ type: 'text', text: '```ts\nreturn 1\n```' }, { type: 'text', text: 'printed' }], + content: [{ type: 'text', text: 'printed' }], }) - // Plural title, and the program alone when it printed nothing. + // No captured output → no content either; everything pending persists. expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [], dispatches: 2 } })) - .toEqual({ card: 'generic', title: 'Run code (2 tool calls)', content: [{ type: 'text', text: '```ts\nx\n```' }] }) + .toEqual({ card: 'generic' }) // Replay with an unrecognizable meta falls back to the generic rendering. expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { other: true } })).toBeUndefined() expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false })).toBeUndefined() From 64d0703c408016999948fd3bafabf207e652ed01 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:05:43 +0800 Subject: [PATCH 50/57] workflow: host-guarantee the agent-start/agent-end pairing on every stop path agent-end was worker-authored only, so a start already forwarded to observers lost its paired end whenever the worker could no longer speak - the grace force-settle terminating a wedged script, or an unexpected worker death - stranding progress consumers with agents that never finish (ds-review-bot finding on #233). The host now keeps a ledger of forwarded starts and funnels every agent-end through one gate: worker-reported ends pair (and clear) their entry, and both termination paths drain the remainder as synthesized 'cancelled' ends BEFORE the run settles, so ends always precede workflow/end. A real settlement racing the force-settle loses to the synthesized cancellation - the same first-wins override onResult applies to the run's own result. --- docs/cordis-catalog/events.md | 6 +- docs/cordis-catalog/services.md | 2 +- .../workflow/workflow-workerthread/README.md | 2 +- .../workflow-workerthread/src/host.ts | 54 ++++++++- .../tests/workflow-workerthread.spec.ts | 106 ++++++++++++++++++ packages/workflow/workflow/src/index.ts | 5 +- 6 files changed, 164 insertions(+), 11 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 02e12359ac..28dc1f17f5 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -365,13 +365,13 @@ Source: [`packages/core/tools/src/index.ts:77`](../../packages/core/tools/src/in ### `workflow/agent-end` — emit -One `agent()` call settled (clean result, child failure, or run cancellation). Paired with Events['workflow/agent-start']. +One `agent()` call settled (clean result, child failure, or run cancellation). Paired with Events['workflow/agent-start'] by `agent.seq`, exactly once per started call on every stop path — on an engine termination path (a worker killed past its grace) the end is engine-synthesized with outcome `'cancelled'`. ```ts cordis-catalog 'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:93`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:96`](../../packages/workflow/workflow/src/index.ts) ### `workflow/agent-start` — emit @@ -391,7 +391,7 @@ A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves 'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:103`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:106`](../../packages/workflow/workflow/src/index.ts) ### `workflow/log` — emit diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 493381d1c9..0622e56a60 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -276,7 +276,7 @@ Semantics every implementation must honor: abstract start(request: WorkflowStartRequest): WorkflowRun ``` -Source: [`packages/workflow/workflow/src/index.ts:207`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:210`](../../packages/workflow/workflow/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index dc8859d351..2d98273ae0 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -32,7 +32,7 @@ Values LEAVING the script (hook options/schemas, the script's return) are materi Per-run limits: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` posts the cancel to the worker (its hooks start throwing `CANCELLED`; the script dies at its next await) and cancels every host-side child NOW on **both seam channels** — the shared request signal aborts AND each registered child's explicit `cancel()` is called host-side, because the seam leaves a provider free to honor either channel and a worker wedged in a synchronous spin could not relay its own per-child cancel RPCs (those later land as idempotent no-ops). The grace then arms: a run still unsettled `disposeGraceMs` later force-settles `cancelled` and the worker is **terminated**. A cancellation that lands before the body runs (the ready→go handshake) reports `cancelled` without executing anything; a worker `result` racing an in-flight host cancellation reports `cancelled` too (first-wins settlement — the seam-visible result had not settled when cancellation was requested); post-cancel `phase`/`log` narration is suppressed host-side, while cancelled children still deliver their paired `agent-end`. -A worker that dies unexpectedly (an OOM, a script reaching `process.exit` through the documented vm escape) settles the run `stopReason: 'error'` with the exit diagnostics — or `'cancelled'` when a cancel was in flight — and the host-side child registry is what winds every surviving child down. `dispose()` = cancel + immediate host-driven disposal of every registered child (a wedged worker can relay no dispose RPC, so child teardown overlaps the grace instead of starting after it; the worker's own dispose RPCs join the same per-child disposal) + bounded wait (result, then child-registry quiescence, capped by the grace) + unconditional `worker.terminate()`: the thread never outlives its run. Once a run settles, stray children a script fired without awaiting are cancelled too, and `dispose()` waits for their disposal (bounded by the grace) before returning. +A worker that dies unexpectedly (an OOM, a script reaching `process.exit` through the documented vm escape) settles the run `stopReason: 'error'` with the exit diagnostics — or `'cancelled'` when a cancel was in flight — and the host-side child registry is what winds every surviving child down. `dispose()` = cancel + immediate host-driven disposal of every registered child (a wedged worker can relay no dispose RPC, so child teardown overlaps the grace instead of starting after it; the worker's own dispose RPCs join the same per-child disposal) + bounded wait (result, then child-registry quiescence, capped by the grace) + unconditional `worker.terminate()`: the thread never outlives its run. Once a run settles, stray children a script fired without awaiting are cancelled too, and `dispose()` waits for their disposal (bounded by the grace) before returning. `agent-start`/`agent-end` pairing is host-guaranteed the same way: forwarded starts live in a ledger, worker-reported ends pair them on the graceful paths, and the termination paths (grace force-settle, worker death) synthesize the missing ends (outcome `cancelled`) before the run settles — a start still in flight across the force-settle can surface after `workflow/end`, immediately paired the same way. **Engine-specific limitations**: worker startup is paid per run; on a termination path `agentsStarted` reports the HOST-observed count (accepted `child-start`s — calls still queued worker-side for a concurrency slot are unknowable then); and a returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited `return agent('x')` work — with the value-boundary guard applying to the resolution. diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index c512c15dee..28db34f097 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -22,7 +22,11 @@ * survivor when the worker dies or is terminated mid-flight. The three * paths share ONE disposal per child (memoized by callId; the seam's * dispose() is idempotent anyway, the memo keeps the bookkeeping and the - * containment warn single). On a termination path `agentsStarted` reports the + * containment warn single). Lifecycle pairing is host-guaranteed the same + * way: every forwarded `agent-start` lives in a ledger, and a start the + * dead or terminated worker never paired is closed by a synthesized + * `agent-end` (outcome `'cancelled'`) before the run settles. On a + * termination path `agentsStarted` reports the * HOST-observed count (accepted `child-start` messages) — `agent()` calls * still queued worker-side for a concurrency slot are unknowable then; the * worker's own count rides the result message on every graceful path. @@ -37,7 +41,7 @@ import type { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { assertNever } from '@deepseek-ai/dsh-llm' import type { SubagentRun } from '@deepseek-ai/dsh-subagent' -import type { WorkflowMeta, WorkflowResult, WorkflowRun, WorkflowRunId } from '@deepseek-ai/dsh-workflow' +import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowMeta, WorkflowResult, WorkflowRun, WorkflowRunId } from '@deepseek-ai/dsh-workflow' import { renderThrown } from './realm.ts' import type { ExecutionObserver } from './runtime.ts' import { HostToWorkerType, WorkerToHostType } from './protocol.ts' @@ -92,6 +96,8 @@ export class WorkerRun implements WorkflowRun { private readonly children = new Map() /** In-flight child disposals by callId — the memo that gives every path (worker RPC, dispose(), reap) ONE shared disposal per child. */ private readonly childDisposals = new Map>() + /** Started-but-not-ended agents by seq — the pairing ledger the HOST guarantees (see {@link endAgent}). */ + private readonly liveAgents = new Map() private readonly quiescenceWaiters: (() => void)[] = [] /** The per-run abort fanout every child start request carries. */ private readonly controller = new AbortController() @@ -155,6 +161,10 @@ export class WorkerRun implements WorkflowRun { // ChildCancel relay (those later RPCs land as idempotent no-ops). for (const run of this.children.values()) run.cancel(this.cancelReason) this.graceTimer = setTimeout(() => { + // The worker may no longer speak (it is about to be terminated): pair + // every stranded start before the run settles, so ends precede + // workflow/end. + this.endStrandedAgents() this.settleResult(this.cancelledResult(this.hostStarted)) void this.worker.terminate() }, this.disposeGraceMs) @@ -225,13 +235,15 @@ export class WorkerRun implements WorkflowRun { if (this.cancelReason === undefined) this.observer.log(message.message) break case WorkerToHostType.AgentStart: + this.liveAgents.set(message.info.seq, message.info) this.observer.agentStart(message.info) break case WorkerToHostType.AgentEnd: // NOT suppressed on cancel: cancelled children report their paired - // agent-end with outcome 'cancelled' (the one-pair-per-started-child - // contract holds on every stop path). - this.observer.agentEnd(message.info) + // agent-end with outcome 'cancelled'. The gate (with the termination + // paths' synthesis) is what makes the one-pair-per-started-child + // contract hold on every stop path. + this.endAgent(message.info) break case WorkerToHostType.ChildStart: this.onChildStart(message.callId, message.request) @@ -373,6 +385,10 @@ export class WorkerRun implements WorkflowRun { private onWorkerDeath(message: string): void { // Whatever the worker left behind must not leak — abort + dispose it all. if (this.children.size > 0) this.reapChildren('workflow worker gone') + // The thread is gone: no more worker-authored agent-ends can arrive — + // pair every stranded start (a start that crossed between the grace + // force-settle and this exit included) before the run settles. + this.endStrandedAgents() // settleResult no-ops on an already-settled run (the expected exit after // a dispose's terminate lands here too). if (this.cancelReason !== undefined) { @@ -382,6 +398,34 @@ export class WorkerRun implements WorkflowRun { this.settleResult({ value: null, stopReason: 'error', error: message, agentsStarted: this.hostStarted }) } + /** + * The single agent-end emission gate: forwards `end` iff its start is still + * unpaired in the ledger, so every forwarded `workflow/agent-start` gets + * EXACTLY one `workflow/agent-end` — the worker's own report where it can + * speak, a host-synthesized one where it cannot ({@link endStrandedAgents}). + * @param end - the settlement to emit (worker-reported or synthesized). + */ + private endAgent(end: WorkflowAgentEndInfo): void { + /* v8 ignore next -- a real end still in flight across the grace force-settle: not orderable in-process */ + if (!this.liveAgents.delete(end.seq)) return + this.observer.agentEnd(end) + } + + /** + * Synthesize the missing `agent-end` for every started-but-unpaired agent, + * outcome `'cancelled'`: the reap cancels every child, and a real + * settlement racing the force-settle loses to the cancellation — the same + * first-wins override {@link onResult} applies to the run's own result. + * Called where the worker can no longer speak (the grace force-settle, + * worker death), BEFORE settleResult, so the paired ends reach observers + * before `workflow/end`. + */ + private endStrandedAgents(): void { + for (const info of [...this.liveAgents.values()]) { + this.endAgent({ ...info, outcome: 'cancelled' }) + } + } + private cancelledResult(agentsStarted: number): WorkflowResult { // cancel() is the only writer of cancelReason and every caller checks it // first; the fallback guards the type, not a reachable path. diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index c1c26dcf0f..eb50ec1121 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -597,6 +597,73 @@ describe('dsh-workflow-workerthread', () => { // The memo: the host drive and the worker's RPC share one disposal. expect(provider.runs[0]!.disposeCalls).toBe(1) }) + + it('the grace force-settle pairs every stranded start: a host-synthesized cancelled agent-end lands before workflow/end', async () => { + const { ctx, parent, provider } = await setup({ manual: true, config: { provider: 'stub', maxConcurrentAgents: 8, disposeGraceMs: 300 } }) + const ends: { seq: number; outcome: string }[] = [] + const order: string[] = [] + ctx.on('workflow/agent-start', (_info, agent) => { order.push(`start:${agent.seq}`) }) + ctx.on('workflow/agent-end', (_info, agent) => { + ends.push({ seq: agent.seq, outcome: agent.outcome }) + order.push(`end:${agent.seq}`) + }) + ctx.on('workflow/end', () => { order.push('run-end') }) + const handle = ctx.workflows.start({ + // 'slow' starts and its agent-start crosses to observers (the awaited + // 'fast' call keeps the worker loop turning), then the script seizes + // the loop: the wedged worker can never author slow's agent-end — + // only the host's ledger can close the pair. + ...scripted(` + const p = agent('slow') + await agent('fast') + const end = Date.now() + 1500 + while (Date.now() < end) {} + return 'raced' + `), + parent, + }) + await vi.waitFor(() => { expect(order.filter(entry => entry.startsWith('start:')).length).toBe(2) }) + const fast = provider.runs.find(run => (run.request.prompt[0] as { text?: string }).text === 'fast')! + fast.settle(text('fast done')) + handle.cancel('stop now') + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + // fast's end is the worker's own report; slow's is host-synthesized at + // the force-settle — exactly one end per started seq, no third event. + expect(ends).toEqual([ + { seq: 2, outcome: 'completed' }, + { seq: 1, outcome: 'cancelled' }, + ]) + // Both ends reached observers BEFORE workflow/end: a progress consumer + // can finalize its state at run-end without dangling agents. + expect(order.indexOf('run-end')).toBe(order.length - 1) + await handle.dispose() + }, 15_000) + + it('graceful cancellation keeps pairing worker-authored: exactly one agent-end per start, nothing synthesized on top', async () => { + const { ctx, parent, provider } = await setup({ manual: true }) + const ends: { seq: number; outcome: string }[] = [] + const order: string[] = [] + ctx.on('workflow/agent-end', (_info, agent) => { + ends.push({ seq: agent.seq, outcome: agent.outcome }) + order.push(`end:${agent.seq}`) + }) + ctx.on('workflow/end', () => { order.push('run-end') }) + const handle = ctx.workflows.start({ + ...scripted("await parallel([() => agent('a'), () => agent('b')])\nreturn 'unreachable'"), + parent, + }) + await vi.waitFor(() => { expect(provider.runs.length).toBe(2) }) + handle.cancel('user stop') + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + // The live worker reported both pairs itself; the ledger must not add + // a synthesized duplicate on any path that settles inside the grace. + expect(ends.map(end => end.outcome)).toEqual(['cancelled', 'cancelled']) + expect(new Set(ends.map(end => end.seq)).size).toBe(2) + expect(order.indexOf('run-end')).toBe(order.length - 1) + await handle.dispose() + }) }) describe('worker death', () => { @@ -669,6 +736,45 @@ describe('dsh-workflow-workerthread', () => { await handle.dispose() }, 15_000) + it('a worker death pairs every stranded start: the synthesized cancelled agent-end precedes the error workflow/end', async () => { + const { ctx, parent, provider } = await setup({ manual: true }) + const ends: { seq: number; outcome: string }[] = [] + const order: string[] = [] + ctx.on('workflow/agent-start', (_info, agent) => { order.push(`start:${agent.seq}`) }) + ctx.on('workflow/agent-end', (_info, agent) => { + ends.push({ seq: agent.seq, outcome: agent.outcome }) + order.push(`end:${agent.seq}`) + }) + ctx.on('workflow/end', () => { order.push('run-end') }) + const handle = ctx.workflows.start({ + // Same choreography as the force-settle pairing test, but the worker + // DIES (the documented vm escape) instead of being terminated: the + // exit path must close slow's pair from the ledger too. The escaped + // setTimeout lets the already-posted messages flush before the kill. + ...scripted(` + const p = agent('slow') + await agent('fast') + const proc = ${ESCAPE} + const st = globalThis.constructor.constructor('return setTimeout')() + await new Promise(resolve => st(resolve, 150)) + proc.exit(7) + `), + parent, + }) + await vi.waitFor(() => { expect(order.filter(entry => entry.startsWith('start:')).length).toBe(2) }) + const fast = provider.runs.find(run => (run.request.prompt[0] as { text?: string }).text === 'fast')! + fast.settle(text('fast done')) + const result = await handle.result + expect(result.stopReason).toBe('error') + expect(result.error).toContain('exit code 7') + expect(ends).toEqual([ + { seq: 2, outcome: 'completed' }, + { seq: 1, outcome: 'cancelled' }, + ]) + expect(order.indexOf('run-end')).toBe(order.length - 1) + await handle.dispose() + }, 15_000) + it('a dispose ack racing the worker death is dropped, not crashed (post after exit)', async () => { // Slow child disposal: the ack resolves only AFTER the worker died, so // it has nowhere to go and must be dropped silently (the workerGone diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index 6c2b797362..91caa314dc 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -85,7 +85,10 @@ declare module 'cordis' { 'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void /** * One `agent()` call settled (clean result, child failure, or run - * cancellation). Paired with {@link Events['workflow/agent-start']}. + * cancellation). Paired with {@link Events['workflow/agent-start']} by + * `agent.seq`, exactly once per started call on every stop path — on an + * engine termination path (a worker killed past its grace) the end is + * engine-synthesized with outcome `'cancelled'`. * @param info - the run's identity snapshot. * @param agent - the call identity plus its outcome. * @mode emit From 7d3b16c1845ee10386df8577156ed841b94ccd8b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:10:28 +0800 Subject: [PATCH 51/57] docs: update budget / catalog --- docs/event-producer-consumer.md | 4 ++-- scripts/doc-budgets.manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b266089b38..a677e2a24a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -36,9 +36,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:97`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:77`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:93`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:96`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:85`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:103`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:106`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:77`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:62`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 49535f0efb..a5261698a2 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,7 +1,7 @@ { "AGENTS.md": 1802, "docs/AGENTS.md": 1315, - "docs/architecture.md": 1640, + "docs/architecture.md": 1642, "docs/cordis-primer.md": 550, "docs/defensive-patterns.md": 550, "docs/testing.md": 800, From bf1ab14b789e7efeeb1057eba042f1202b084de5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 9 Jul 2026 23:48:29 +0800 Subject: [PATCH 52/57] a human touch get some headroom --- docs/AGENTS.md | 7 ++++--- docs/architecture.md | 47 +++++++++++++++++++++++++------------------ docs/cordis-primer.md | 30 +++++++++++++-------------- 3 files changed, 46 insertions(+), 38 deletions(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 771e30dcc6..118c535874 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — The documentation standard -This file is the contract for every Markdown surface in the repo: each tier's job, the writing rules, and the word budgets the `verify-doc-budgets` gate enforces. The audit/apply workflow is the [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) skill; the decision record is [the doc-tiers-and-budgets RFC](rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md). +This file is the contract for every Markdown files in the repo: each tier's job, the writing rules, and the word budgets that `verify-doc-budgets` enforces. The audit/apply workflow is the [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) skill; the decision record is [the doc-tiers-and-budgets RFC](rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md). ## The tier taxonomy: one home per fact @@ -31,10 +31,11 @@ Placement test: a story about a bug → postmortem. Why we chose X → RFC. How - **Every new event's JSDoc carries an `@mode` tag** (emit | waterfall | parallel | serial); the catalog generator hard-errors without it. Write the JSDoc to stand alone — it becomes the catalog entry ([catalog RFC](rfc/implemented/process/2026-06-20-generated-cordis-catalog.md)). - **The [core-data-structures catalog](core-data-structures/core.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types ([what counts as core](core-data-structures/core.md#what-counts-as-core)). - **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)). +- Your audience is professional programmers. Prefer concise and straight-forward English over metaphor. Do not overuse words like "gate", "vocabulary", "surface", "seams". -## Budgets and the ceiling gate +## Wordcount Budgets -Standing docs accrete: every PR has a lesson it wants to append, and without displacement pressure nothing ever leaves. The gate is that pressure. [scripts/doc-budgets.manifest.json](../scripts/doc-budgets.manifest.json) lists the accretion-prone standing docs with a word ceiling each; `pnpm run verify-doc-budgets` (part of `doc-sync`, so CI and pre-push run it) fails when a doc exceeds its ceiling, and fails when a budgeted file is missing so a rename cannot orphan its budget. +Every PR has a lesson it wants to append, and without pressure nothing ever leaves. [scripts/doc-budgets.manifest.json](../scripts/doc-budgets.manifest.json) is the pressure, which lists the word count of each standing docs; `pnpm run verify-doc-budgets` (part of `doc-sync`, so CI and pre-push run it) fails when a doc exceeds its ceiling, and fails when a budgeted file is missing so a rename cannot orphan its budget. - Ceilings are an enforcement frontier with working headroom: a ceiling sits at least 5% above the doc's current size — routine edits pass, real growth trips the gate — and ratchets down, keeping the margin, as the doc reaches target. Target budgets: root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; each subtree `AGENTS.md` ≤ 600, except this file (which carries the standard) ≤ 1,250; `packages/README.md` ≤ 600. - When the gate goes red, first ask whether the added words belong in this tier and whether the existing wording can be condensed. If the words do not belong, relocate per the taxonomy above; if they belong but can be shorter, condense. If they truly need the space, raise the ceiling and justify the manifest diff in the PR. A ceiling set too low is a budget bug, and correcting it is the fix. diff --git a/docs/architecture.md b/docs/architecture.md index 69e43a423f..c0e7bdac08 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,23 +1,23 @@ # DeepSeek Harness Architecture -The **DeepSeek Harness SDK** is an SDK for building agent harnesses on the Cordis framework. The governing principle is simple: **everything is a plugin**. The shipped agent loop is one plugin in the default bundle, not a privileged kernel. +The project is an SDK for building agent harnesses. The idea is to have **everything as a plugin**. For example, the agent loop is just one plugin shipped by default. -Read this page as the system map before changing `packages/`. It explains how the runtime is shaped, how the default loop moves work, where state lives, and where extensions attach. Type shapes live in [core-data-structures/](core-data-structures/core.md); exact event and service signatures live in the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs; package contracts live in the [package map](../packages/README.md); rationale lives in the [RFCs](rfc/README.md). New to Cordis? Start with the [Cordis primer](cordis-primer.md). +## Overview -## System Shape +The project is based on [Cordis](cordis-primer.md). -A running harness is one Cordis context. Packages contribute service keys, typed events, and disposable registrations to that context. Services are the stable call surfaces (`ctx.llm`, `ctx.tools`, `ctx.sessions`); events are interception and notification points (`agent/request`, `tools/pre-execute`, `session/event`); registrations install prompt sections, tool schemas, providers, adapters, and listeners. +A running harness is one Cordis context. Packages contribute service keys, typed events, and disposable registrations to that context. Services provides stable call signatures (`ctx.llm`, `ctx.tools`, `ctx.sessions`); events are interception and notification points (`agent/request`, `tools/pre-execute`, `session/event`); registrations install prompt sections, tool schemas, providers, adapters, and listeners. -The default distribution is a composition, not a hierarchy. `packages/core/` is a repository grouping for the default agent spine; capability seams around it are equally first-class plugins. +Composition is preferred over inheritance. `packages/core/` is a repository grouping for the default agent flow; capability around it are equally first-class plugins from a Cordis perspective. -### Default Service Spine +### Default Services | ctx key | Package | Role | |---|---|---| | `ctx.sessions` | `dsh-session` | in-memory event-sourced sessions | | `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables | | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | -| `ctx.agents` | `dsh-agent` | live agent registry, public `Agent` handle, `agent/*` vocabulary | +| `ctx.agents` | `dsh-agent` | live agent registry, public `Agent` handle, `agent/*` events | | `ctx.agentLoop` | `dsh-agent-loop` | shipped `ReactLoopAgent` driver | ### Capability Services @@ -29,20 +29,20 @@ The default distribution is a composition, not a hierarchy. `packages/core/` is | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution | | `ctx.fs` | [`fs/`](../packages/fs/README.md) | filesystem provider primitives and policy events | | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | -| `ctx.compact` | [`compact/`](../packages/compact/README.md) | session-surface compaction | +| `ctx.compact` | [`compact/`](../packages/compact/README.md) | session-log compaction | | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs | -## Event Surface +## Event -Events are the harness extension API. Each service owns the vocabulary for the behavior it controls, and the generated [events catalog](cordis-catalog/events.md) is the exhaustive reference. The [producer/consumer map](event-producer-consumer.md) shows which packages emit or listen to each event. +Events are the harness extension API used by Service. The generated [events catalog](cordis-catalog/events.md) is the exhaustive reference. The [producer/consumer map](event-producer-consumer.md) shows which packages emit or listen to each event. ### Event Domains Pick the event domain for new behavior: - **Session events** are durable, replayable facts. Turn and step boundaries, user input, assistant output, tool calls, tool results, steering, compaction records, and tool-owned durable facts append to the session log and flow through `session/event`. -- **Agent events** are live runtime surfaces. They carry the live `Agent` handle for status, diagnostics, prompt admission, call-config shaping, result validation, and continuation policy. +- **Agent events** carry the live `Agent` handle for status, diagnostics, prompt admission, call-config shaping, result validation, and continuation policy. - **Capability events** belong to the seam that owns the action. `tools/*`, `llm/*`, `system-prompt/*`, `fs/*`, and `subagent/*` let policy and adapters attach without importing the loop. ### Interception Semantics @@ -51,9 +51,9 @@ Waterfall events behave like around-middleware: a listener delegates by calling ## Default Loop Lifecycle -The shipped loop drains queued work, assembles a request, streams a model answer, executes tools, decides whether to continue, and checkpoints durable state. The important architecture is where it pauses: each pause is a documented service call or event seam other plugins program against. +The shipped loop drains queued work, assembles a request, streams a model answer, executes tools, decides whether to continue, and checkpoints durable state. The important part is where it pauses: each pause is a documented service call or event that another plugin can use. -A **session** is one agent's append-only event log. A **turn** drains one queued batch and runs until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension seams. +A **session** is one agent's append-only event log. A **turn** drains one queued batch and runs until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. ### Turn Flow @@ -102,9 +102,9 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the ### Agent Handles -`ctx.agents` owns live agents and returns an `AgentHandle { agent, dispose() }`. `Agent` is the surface other plugins drive: `send()` queues work, `steer()` injects mid-turn content, `inject()` appends context and opens a one-shot injection turn when idle, `cancel()` is the public stop primitive, and `whenIdle()` observes quiescence. Lifecycle owners tear down with `await dispose()`. +`ctx.agents` owns live agents and returns an `AgentHandle { agent, dispose() }`. `Agent` is the API other plugins drive: `send()` queues work, `steer()` injects mid-turn content, `inject()` appends context and opens a one-shot injection turn when idle, `cancel()` is the public stop primitive, and `whenIdle()` observes quiescence. Lifecycle owners tear down with `await dispose()`. -## State And Model Surface +## State ### Session Log @@ -116,7 +116,7 @@ Durability is a plugin concern. Persistence backends buffer synchronous `session ### Model Content -Messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`). The union derives from the merge-extensible `ContentBlockMap`; the same pattern types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New block types are coordinated across adapters, UI bridges, compaction pricing, and persistence, so block vocabulary remains a repo-wide contract. +Messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`). The union derives from the merge-extensible `ContentBlockMap`; the same pattern types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New block types are coordinated across adapters, UI bridges, compaction pricing, and persistence, so block types remain a repo-wide contract. Streaming is a raw chunk protocol (`block-start` through `finish`) with `BlockAssembler` as the shared chunk-to-block assembler. The loop logs raw chunks while assembling them for dispatch. `LlmAdapter` is the provider seam: subclass, implement `stream()`, and register with `ctx.llm.registerAdapter(models, adapter)`. StreamChunk conventions live in [llm-streaming.md](core-data-structures/llm-streaming.md). @@ -124,17 +124,17 @@ Streaming is a raw chunk protocol (`block-start` through `finish`) with `BlockAs ### Capability Pattern -A swappable capability usually splits into **interface / implementation / consumer**: the interface owns the `ctx` key and vocabulary; an implementation registers a backend; a consumer exposes model-facing behavior through `ctx.tools` or prompt assembly. The bash trio is the reference shape, and the [capability seam graph](capability-seams.md) shows the package families. +A swappable capability usually splits into **interface / implementation / consumer**: the interface owns the `ctx` key and event names; an implementation registers a backend; a consumer exposes model-facing behavior through `ctx.tools` or prompt assembly. The bash trio is the reference shape, and the [capability graph](capability-seams.md) shows the current package families. -Some seams bend the template deliberately. LLM keeps interface and consumer vocabulary together because adapters are the implementations. Filesystem adds policy as event gates around provider primitives. Web is one service with search and fetch provider registries, so provider swaps do not rename model tools. Subagents use a named provider registry because multiple delegation backends can coexist; `spawn` starts fresh, `fork` seeds from the parent's completed-turn prefix, and ACP can drive an out-of-process child ([subagent.md](core-data-structures/subagent.md)). +Some cases bend the template deliberately. LLM keeps interface and consumer event names together because adapters are the implementations. Filesystem adds policy checks around provider primitives. Web is one service with search and fetch provider registries, so provider swaps do not rename model tools. Subagents use a named provider registry because multiple delegation backends can coexist; `spawn` starts fresh, `fork` seeds from the parent's completed-turn prefix, and ACP can drive an out-of-process child ([subagent.md](core-data-structures/subagent.md)). ### Bundles And Apps -`dsh-agent-core` is the default composition bundle: one plugin loading the providerless spine as code ([README](../packages/core/agent-core/README.md)). App packages compose it with a front door and own the boot `bin`: `dsh-stdio-agent` for the terminal REPL, and `dsh-acp-agent` for ACP over JSON-RPC stdio with no stdout logger ([ui/](../packages/ui/README.md)). A deployment is a thin `cordis.yml` leaf: swappable backends, one app entry, and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-core` is the default bundle: one plugin loading the agent loop ([README](../packages/core/agent-core/README.md)). App packages compose it with a front end and own the entrypoint `bin`: `dsh-stdio-agent` for the terminal REPL, and `dsh-acp-agent` for ACP over JSON-RPC stdio with no stdout logger ([ui/](../packages/ui/README.md)). A deployment is a thin `cordis.yml` leaf: swappable backends, one app entry, and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Where New Behavior Goes -New behavior should attach to a documented seam; changing the shipped loop requires updating this map. +New behavior should attach to a documented extension point; changing the shipped loop requires updating this map. | Goal | Mechanism | |---|---| @@ -149,3 +149,10 @@ New behavior should attach to a documented seam; changing the shipped loop requi | Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` | The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md). + +## Quick Reference +- Type definitions in [core-data-structures/](core-data-structures/core.md) +- Exact event and service signatures in [events](cordis-catalog/events.md) +- [services](cordis-catalog/services.md) catalogs +- package contracts in the [package map](../packages/README.md) +- [RFCs](rfc/README.md) \ No newline at end of file diff --git a/docs/cordis-primer.md b/docs/cordis-primer.md index 15534b0ae6..b59363eea7 100644 --- a/docs/cordis-primer.md +++ b/docs/cordis-primer.md @@ -4,35 +4,35 @@ Cordis is the vendored plugin framework underneath the DeepSeek Harness SDK. Thi ## Cordis In Five Ideas -- **A plugin is a unit of behavior.** It can be a function with optional `inject` and `apply(ctx)` fields, or a `Service` subclass whose lifecycle Cordis mounts into the current context. -- **A context is the service container.** A service claims a stable `ctx.` such as `ctx.tools`, `ctx.llm`, or `ctx.sessions`; other plugins program against that key instead of importing a concrete implementation. -- **`inject` is the dependency gate.** A plugin that names required services waits until those services exist, so load order is expressed through service requirements rather than manual boot sequencing. -- **Events are typed extension seams.** Services declare event names through TypeScript declaration merging, then dispatch them as `emit`, `waterfall`, `parallel`, or `serial` depending on whether listeners observe, wrap, fan out, or run in order. -- **Registrations are disposable effects.** Prompt sections, tool schemas, adapters, providers, and listeners are installed through `ctx.effect()` or `ctx.on()` so reload and teardown unwind them predictably. +- **A plugin is a object that implements Service.** It can be a function with optional `inject` and `apply(ctx)` fields, or a `Service` subclass whose lifecycle Cordis mounts into the current context. +- **A context is a repository of services.** A service claims a stable `ctx.` such as `ctx.tools`, `ctx.llm`, or `ctx.sessions` from a context; other plugins find services via key instead of importing a concrete implementation. +- **Declare service dependency via `inject`.** A plugin that names required services waits until those services exist, so load order is expressed through service requirements rather than manual boot sequencing. +- **Typed Events for communication.** Services declare event names through TypeScript declaration merging, then dispatch them as `emit`, `waterfall`, `parallel`, or `serial` depending on whether listeners observe, wrap, fan out, or run in order. +- **Registrations are reversible effects.** Prompt sections, tool schemas, adapters, providers, and listeners are installed through `ctx.effect()` or `ctx.on()` so reload and teardown unwind them predictably. ## Dispatch Modes -Use the mode to understand what a listener can do: +Every event can have one of the following dispatch mode and can only be dispatched by these methods accordingly. -| Mode | Shape | -|---|---| -| `emit` | synchronous notification; listeners observe but do not shape the result | -| `waterfall` | around-middleware; each listener receives `next()` and may wrap, rewrite, or veto | -| `parallel` | awaited fan-out; all listeners run and the dispatcher waits for them | -| `serial` | awaited in registration order; a non-void bail value stops the chain | +| Mode | Awaited? | Dispatch Order | Has Return Value? | +|---|---|---|---| +| `emit` | No | listeners observe in registration order | No | +| `waterfall` | No | listeners observe in registration order | Yes | +| `parallel` | Yes | all listeners observe the event in parallel | No | +| `serial` | Yes | listeners observe in registration order | Yes | The mode is part of the event's public contract. New harness events document it with an `@mode` tag so the generated catalog can check declarations against dispatch sites. ## Cordis Waterfall Semantics -`ctx.waterfall` is around-middleware, not a reducer. A listener receives `(...args, next)`. Call `next()` to delegate, optionally wrapping the result; return without `next()` to short-circuit. Values propagate through `next()`'s return value. +`ctx.waterfall` is around-middleware. A listener receives `(...args, next)`. Call `next()` to delegate the possibly wrapped result to the next service; return without `next()` to short-circuit. Values propagate through `next()`'s return value. -Cooperative listeners usually mutate a shared request or decision object and then delegate. Returning a replacement is a takeover: downstream listeners see the replacement, and earlier mutations on the original object do not carry forward. Use `prepend: true` only when the listener must run before ordinary registrations. +Cooperative listeners usually mutate a shared request or decision object and then delegate. A listener can also choose to repalce the result entirely and downstream listeners will only see the result after replacement. Use `prepend: true` only when the listener must run before ordinary registrations. For single-decision events, short-circuiting is the design. A policy listener can return without `next()` when it owns the decision, while a listener that only annotates or observes must delegate. ## Practical Rules -Own vocabulary where the behavior lives: a tool pipeline event belongs to `ctx.tools`, model streaming belongs to `ctx.llm`, and live agent coordination belongs to `ctx.agents`. Prefer events for interception and policy; prefer service methods for direct capability calls. +Encapsulate behavior into plugins: a tool pipeline event belongs to `ctx.tools`, model streaming belongs to `ctx.llm`, and live agent coordination belongs to `ctx.agents`. Prefer events for interception and policy; prefer service methods for direct capability calls. Every registration should have a disposer, either by returning one from `ctx.effect()` or using a Cordis helper that does it for you. If teardown order matters, keep the related work in one effect so disposal unwinds in the intended sequence. From d12cb45838b394c1af75f2fbc3e379f4e2fb6c99 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:46:19 +0800 Subject: [PATCH 53/57] workflow: spawn the worker with an empty environment The documented vm escape reaches process, and the worker inherited the harness's env - so a buggy or prompt-injected script could read and exfiltrate ambient credentials (DEEPSEEK_API_KEY et al.) without touching a single file (ds-review-bot finding on #233). Spawn with env: {} and a hermetic execArgv on both runtime shapes, the same stance as dsh-code-runtime-worker and stronger than the scrubbed env the defensive-patterns rule requires for spawned commands (a shell needs PATH; this worker needs nothing). Ambient-channel hardening only: an escapee keeps the process-wide privileges the trust premise already admits - the genuine sandbox remains an engine swap. --- .../workflow/workflow-workerthread/README.md | 1 + .../workflow/workflow-workerthread/src/host.ts | 16 +++++++++++++--- .../tests/workflow-workerthread.spec.ts | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index 2d98273ae0..2c556fc35d 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -8,6 +8,7 @@ Workflow scripts are **model-written** — the same trust level as the model's e - **The host never blocks**: `start()` returns without running any script code on the host; a synchronous spin anywhere in the script occupies the worker's loop, not the harness's. - **Termination is real**: a script that outlives its post-cancel grace is `worker.terminate()`d — nothing of it survives `dispose()`, where an in-process engine could only abandon the spin on its own loop. +- **No ambient credentials**: the worker spawns with an EMPTY environment (`env: {}` plus hermetic `execArgv`, the same stance as `dsh-code-runtime-worker`), so an escapee reading `process.env` finds no harness secrets — ambient-channel hardening only; the process-wide privileges above (fs and the rest) remain, so a genuine sandbox is still the engine swap. - **Serialization by construction**: everything crossing the thread is structured-clone data, and plain JSON before that — the `materializeFromRealm` walk rejects loud what JSON cannot carry, which is also what makes every postMessage hop total. What the seam guarantees regardless, because benign scripts hit these constantly: `result` never rejects, a dropped hook promise never becomes an unhandled rejection, values JSON cannot carry are rejected **loud** instead of silently mangled, and hook misuse is fatal instead of dissolving into a per-item `null`. Genuine sandboxing (containing what an escaped script may touch) remains an isolated-vm/separate-process engine swap behind the seam, still deferred. diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 28db34f097..9f39cd80e4 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -56,20 +56,30 @@ import type { ChildStartRequest, WorkerInit } from './types.ts' * vitest (vite transforms in-process, not via a node loader), and passing * execArgv explicitly also shields the worker from any loader flags the * parent was started with. Built (`lib/index.js`), the entry is the sibling - * bundle the package tsdown config emits and no loader is needed. + * bundle the package tsdown config emits and no loader is needed (execArgv + * pinned empty — hermetic, like the environment). + * + * Both shapes spawn with an EMPTY environment (`env: {}`): the documented vm + * escape reaches `process`, and the harness's ambient credentials + * (`DEEPSEEK_API_KEY` et al.) must not ride along — the same stance as + * `dsh-code-runtime-worker`, stronger than the scrubbed env the + * defensive-patterns rule requires for spawned commands (a shell needs PATH; + * this worker needs nothing). This closes the AMBIENT channel only — an + * escapee still holds process-wide privileges like fs access (the README's + * trust premise stands). * @param init - the run payload, passed as `workerData`. * @returns the entry URL and the Worker options to spawn it with. */ function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOptions } { /* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/); the built-worker e2e exercises this shape for real */ if (!import.meta.url.endsWith('.ts')) { - return { entry: new URL('./worker.js', import.meta.url), options: { workerData: init } } + return { entry: new URL('./worker.js', import.meta.url), options: { workerData: init, env: {}, execArgv: [] } } } // Lazy tsx resolution: only the unbuilt shape needs it, so the built // bundle never requires tsx to be installed. return { entry: new URL('./worker.ts', import.meta.url), - options: { workerData: init, execArgv: ['--import', fileURLToPath(import.meta.resolve('tsx'))] }, + options: { workerData: init, env: {}, execArgv: ['--import', fileURLToPath(import.meta.resolve('tsx'))] }, } } diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index eb50ec1121..32f63a6c97 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -254,6 +254,24 @@ describe('dsh-workflow-workerthread', () => { expect(result.stopReason).toBe('completed') expect(result.value).toBe('fine') }) + + it('the worker spawns with an EMPTY environment: an escaped script finds no ambient credentials', async () => { + const { ctx, parent } = await setup() + // A canary in the HARNESS process's env: with an inherited environment + // the escape below would read it back (exactly how DEEPSEEK_API_KEY + // would leak); env: {} in the spawn options is what keeps it out. + process.env.WORKFLOW_ENV_CANARY = 'leak me' + try { + const result = await run(ctx, parent, scripted(` + const proc = ${ESCAPE} + return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).length } + `)) + expect(result.stopReason).toBe('completed') + expect(result.value).toEqual({ canary: null, keys: 0 }) + } finally { + delete process.env.WORKFLOW_ENV_CANARY + } + }) }) describe('lifecycle: parse errors, cancellation, termination, disposal', () => { From f77174f13a52475d381eb9959ba2cc6ab8c499f0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 10 Jul 2026 00:13:06 +0800 Subject: [PATCH 54/57] make it clearer --- docs/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 118c535874..a6e3c7236c 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -35,7 +35,7 @@ Placement test: a story about a bug → postmortem. Why we chose X → RFC. How ## Wordcount Budgets -Every PR has a lesson it wants to append, and without pressure nothing ever leaves. [scripts/doc-budgets.manifest.json](../scripts/doc-budgets.manifest.json) is the pressure, which lists the word count of each standing docs; `pnpm run verify-doc-budgets` (part of `doc-sync`, so CI and pre-push run it) fails when a doc exceeds its ceiling, and fails when a budgeted file is missing so a rename cannot orphan its budget. +Every PR has a lesson it wants to append, and without pressure nothing ever leaves. [scripts/doc-budgets.manifest.json](../scripts/doc-budgets.manifest.json) is the pressure: it stores the allowed word-count ceiling for each budgeted standing doc; `pnpm run verify-doc-budgets` (part of `doc-sync`, so CI and pre-push run it) fails when a doc exceeds its ceiling, and fails when a budgeted file is missing so a rename cannot orphan its budget. - Ceilings are an enforcement frontier with working headroom: a ceiling sits at least 5% above the doc's current size — routine edits pass, real growth trips the gate — and ratchets down, keeping the margin, as the doc reaches target. Target budgets: root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; each subtree `AGENTS.md` ≤ 600, except this file (which carries the standard) ≤ 1,250; `packages/README.md` ≤ 600. - When the gate goes red, first ask whether the added words belong in this tier and whether the existing wording can be condensed. If the words do not belong, relocate per the taxonomy above; if they belong but can be shorter, condense. If they truly need the space, raise the ceiling and justify the manifest diff in the PR. A ceiling set too low is a budget bug, and correcting it is the fix. From aff657cc2823ae7a8e85446e668aa9d0afd88190 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:16:29 +0800 Subject: [PATCH 55/57] fix: ci run without build lib for snapshot --- .../workflow/workflow-workerthread/README.md | 2 +- .../workflow-workerthread/src/host.ts | 21 ++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index 2c556fc35d..1ee959cc0c 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -8,7 +8,7 @@ Workflow scripts are **model-written** — the same trust level as the model's e - **The host never blocks**: `start()` returns without running any script code on the host; a synchronous spin anywhere in the script occupies the worker's loop, not the harness's. - **Termination is real**: a script that outlives its post-cancel grace is `worker.terminate()`d — nothing of it survives `dispose()`, where an in-process engine could only abandon the spin on its own loop. -- **No ambient credentials**: the worker spawns with an EMPTY environment (`env: {}` plus hermetic `execArgv`, the same stance as `dsh-code-runtime-worker`), so an escapee reading `process.env` finds no harness secrets — ambient-channel hardening only; the process-wide privileges above (fs and the rest) remain, so a genuine sandbox is still the engine swap. +- **No ambient credentials**: the worker spawns with an EMPTY environment (`env: {}` plus hermetic `execArgv`, the same stance as `dsh-code-runtime-worker`; the unbuilt dev shape forwards exactly one loader variable, `TSX_TSCONFIG_PATH` — path plumbing, not a secret), so an escapee reading `process.env` finds no harness secrets — ambient-channel hardening only; the process-wide privileges above (fs and the rest) remain, so a genuine sandbox is still the engine swap. - **Serialization by construction**: everything crossing the thread is structured-clone data, and plain JSON before that — the `materializeFromRealm` walk rejects loud what JSON cannot carry, which is also what makes every postMessage hop total. What the seam guarantees regardless, because benign scripts hit these constantly: `result` never rejects, a dropped hook promise never becomes an unhandled rejection, values JSON cannot carry are rejected **loud** instead of silently mangled, and hook misuse is fatal instead of dissolving into a per-item `null`. Genuine sandboxing (containing what an escaped script may touch) remains an isolated-vm/separate-process engine swap behind the seam, still deferred. diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 9f39cd80e4..6b6391a02e 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -64,9 +64,11 @@ import type { ChildStartRequest, WorkerInit } from './types.ts' * (`DEEPSEEK_API_KEY` et al.) must not ride along — the same stance as * `dsh-code-runtime-worker`, stronger than the scrubbed env the * defensive-patterns rule requires for spawned commands (a shell needs PATH; - * this worker needs nothing). This closes the AMBIENT channel only — an - * escapee still holds process-wide privileges like fs access (the README's - * trust premise stands). + * this worker needs nothing). Sole exception: the unbuilt shape forwards + * `TSX_TSCONFIG_PATH` when the parent carries it (loader plumbing the paths + * map depends on outside the repo cwd, not a secret). This closes the + * AMBIENT channel only — an escapee still holds process-wide privileges + * like fs access (the README's trust premise stands). * @param init - the run payload, passed as `workerData`. * @returns the entry URL and the Worker options to spawn it with. */ @@ -76,10 +78,19 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOpti return { entry: new URL('./worker.js', import.meta.url), options: { workerData: init, env: {}, execArgv: [] } } } // Lazy tsx resolution: only the unbuilt shape needs it, so the built - // bundle never requires tsx to be installed. + // bundle never requires tsx to be installed. TSX_TSCONFIG_PATH is the one + // variable forwarded through the scrub: tsx finds a tsconfig by searching + // UP from the worker's cwd, and a parent running with its cwd outside the + // repo (the ACP snapshot harness pins the tsconfig through this exact + // variable) would otherwise lose the dsh-* paths map and resolve workspace + // imports to unbuilt lib/ bundles. Loader plumbing, not a secret. return { entry: new URL('./worker.ts', import.meta.url), - options: { workerData: init, env: {}, execArgv: ['--import', fileURLToPath(import.meta.resolve('tsx'))] }, + options: { + workerData: init, + env: process.env.TSX_TSCONFIG_PATH === undefined ? {} : { TSX_TSCONFIG_PATH: process.env.TSX_TSCONFIG_PATH }, + execArgv: ['--import', fileURLToPath(import.meta.resolve('tsx'))], + }, } } From c60eb878601f54ee5a9d32496c7e2237a49643b0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 10 Jul 2026 00:20:35 +0800 Subject: [PATCH 56/57] make wordcount budget guidance clearer and dedup docs --- .agents/skills/dsh-doc-standards/SKILL.md | 4 +--- docs/AGENTS.md | 12 ++++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md index 7c396ed93c..de7eda4142 100644 --- a/.agents/skills/dsh-doc-standards/SKILL.md +++ b/.agents/skills/dsh-doc-standards/SKILL.md @@ -38,9 +38,7 @@ Compression discipline: every load-bearing rule survives — as one to three lin ## When verify-doc-budgets goes red -1. Relocate: does the new content belong in a linked home (RFC, postmortem, cookbook, README) with a one-line pointer left behind? -2. Condense: can existing prose in the doc pay for the addition — a story compressed to its rule, a duplicate converted to a link? -3. Only then raise the ceiling: edit `scripts/doc-budgets.manifest.json` and justify the raise explicitly in the PR description. After any rewrite that shrinks a budgeted doc, ratchet its ceiling down to the new size plus working headroom (at least 5%) in the same PR. +Apply the ordered relocate-condense-raise policy in [docs/AGENTS.md](../../../docs/AGENTS.md); this skill only supplies the workflow probes above. ## Validation and PR hygiene diff --git a/docs/AGENTS.md b/docs/AGENTS.md index a6e3c7236c..6824c71cc6 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -35,11 +35,15 @@ Placement test: a story about a bug → postmortem. Why we chose X → RFC. How ## Wordcount Budgets -Every PR has a lesson it wants to append, and without pressure nothing ever leaves. [scripts/doc-budgets.manifest.json](../scripts/doc-budgets.manifest.json) is the pressure: it stores the allowed word-count ceiling for each budgeted standing doc; `pnpm run verify-doc-budgets` (part of `doc-sync`, so CI and pre-push run it) fails when a doc exceeds its ceiling, and fails when a budgeted file is missing so a rename cannot orphan its budget. +Every PR has a lesson it wants to append, and without pressure nothing leaves. [scripts/doc-budgets.manifest.json](../scripts/doc-budgets.manifest.json) stores the allowed word-count ceiling for each budgeted standing doc; `pnpm run verify-doc-budgets` fails when a doc exceeds its ceiling or a budgeted file is missing. -- Ceilings are an enforcement frontier with working headroom: a ceiling sits at least 5% above the doc's current size — routine edits pass, real growth trips the gate — and ratchets down, keeping the margin, as the doc reaches target. Target budgets: root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; each subtree `AGENTS.md` ≤ 600, except this file (which carries the standard) ≤ 1,250; `packages/README.md` ≤ 600. -- When the gate goes red, first ask whether the added words belong in this tier and whether the existing wording can be condensed. If the words do not belong, relocate per the taxonomy above; if they belong but can be shorter, condense. If they truly need the space, raise the ceiling and justify the manifest diff in the PR. A ceiling set too low is a budget bug, and correcting it is the fix. -- Unbudgeted tiers (package READMEs, RFCs, reference matrices) have no ceiling — length is legitimate there when every row is a fact. Review and the slop checklist govern them instead. +When the gate goes red: + +1. **Relocate** content that belongs in another tier; leave a one-line link if needed. +2. **Condense** content that belongs here but can be shorter. +3. **Raise** the ceiling only when the words truly need the space; justify the manifest diff in the PR. A too-low ceiling is a budget bug. + +Ceilings keep working headroom: at least 5% above the current size, ratcheted down after trims. Target budgets: root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; each subtree `AGENTS.md` ≤ 600, except this file ≤ 1,250; `packages/README.md` ≤ 600. Unbudgeted tiers (package READMEs, RFCs, reference matrices) have no ceiling; review and the slop checklist govern them. ## The slop checklist From a53be53d64732cf3ded39bb4fd008b78e5c076aa Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:23:11 +0800 Subject: [PATCH 57/57] workflow: forward TSX_TSCONFIG_PATH through the worker env scrub The empty-env hardening wiped the one variable the UNBUILT worker's loader depends on: tsx finds a tsconfig by searching up from the worker's cwd, and a parent running outside the repo (the ACP snapshot harness pins the repo tsconfig through TSX_TSCONFIG_PATH exactly because its child cwd is a temp dir) lost the dsh-* paths map - the worker then resolved workspace imports to unbuilt lib/ bundles and died on CI with ERR_MODULE_NOT_FOUND (green locally only because stale built lib/ masked the wrong resolution). Forward exactly that variable when the parent carries it - loader plumbing, not a secret; the built shape stays fully empty - and pin the whole contract with an escape-based test: the worker env is exactly {TSX_TSCONFIG_PATH}, the credential canary still never crosses. --- .../tests/workflow-workerthread.spec.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 32f63a6c97..ecaeed61f6 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { fileURLToPath } from 'node:url' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { AgentId } from '@deepseek-ai/dsh-agent' @@ -272,6 +273,28 @@ describe('dsh-workflow-workerthread', () => { delete process.env.WORKFLOW_ENV_CANARY } }) + + it('the unbuilt worker forwards exactly TSX_TSCONFIG_PATH through the scrub: the paths-map pin survives, secrets do not', async () => { + const { ctx, parent } = await setup() + // The ACP snapshot harness runs the parent with its cwd OUTSIDE the + // repo and pins the repo tsconfig through this variable; the worker + // must inherit the pin (or its dsh-* imports silently resolve to + // unbuilt lib/ bundles) while every other variable stays scrubbed. + const tsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + process.env.TSX_TSCONFIG_PATH = tsconfig + process.env.WORKFLOW_ENV_CANARY = 'leak me' + try { + const result = await run(ctx, parent, scripted(` + const proc = ${ESCAPE} + return { keys: Object.keys(proc.env), tsconfig: proc.env.TSX_TSCONFIG_PATH } + `)) + expect(result.stopReason).toBe('completed') + expect(result.value).toEqual({ keys: ['TSX_TSCONFIG_PATH'], tsconfig }) + } finally { + delete process.env.TSX_TSCONFIG_PATH + delete process.env.WORKFLOW_ENV_CANARY + } + }) }) describe('lifecycle: parse errors, cancellation, termination, disposal', () => {