diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb36f02..964919ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.117.0 + +- Run every supervisor, including the root, from one complete `AgentProfile`, preserve exact profile/task/candidate identity through recursive delegation, and reject execution paths that would silently drop profile fields. +- Expose node-scoped product tools, product authorization for exact spawns and continuations, awaited replay-safe coordination observation, structured worker traces, trace-derived failure guidance, and caller cancellation across the complete recursive run. +- Make durable run and assignment identity stable across restart while retaining exact materialization, accounting, delivery, and settlement evidence for each node. +- Add live root-manager steering, trusted post-authorization manager/leaf classification, per-assignment completion checks, a cold recursive forest reader, and public exact-profile candidate conversion helpers. + ## 0.116.0 ### A supervisor tree spans machines @@ -139,6 +146,7 @@ The supervisor's public contract closes six gaps found by running a real recursi - Candidate profile freeze/thaw preserves config values instead of unwrapping them to schema-invalid strings; certified intelligence bindings wrap through `defineAgentProfilePublicConfig`. - Implement eval 0.138's `TraceAnalysisStore` contract on the iterations store: real `hasTrace`/`hasSpans`, byte-ceiling span continuation (`omitted_span_ids`/`has_more`), and `total_matches` removed from search results. - Sandbox 0.15.2 remains typed against interface 0.36; profiles cross that boundary as data through one commented adapter pair (`profileAsSandboxProfile`), to be removed when sandbox releases against 0.40. +>>>>>>> origin/main ## 0.109.2 diff --git a/README.md b/README.md index a2b995bf..eb31294d 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,13 @@ One supervisor spawns and steers workers toward a goal. Where the workers run (a import { supervise } from '@tangle-network/agent-runtime/kernel' const result = await supervise( - { name: 'supervisor', harness: null, systemPrompt: 'Delegate to workers; do not solve the task yourself.' }, + { + name: 'supervisor', + harness: 'cli-base', + prompt: { + systemPrompt: 'Delegate to workers; do not solve the task yourself.', + }, + }, 'Implement the feature and make the tests pass.', { budget, router, backend }, // backend = where workers run: router-tools | sandbox+harness | bridge ) diff --git a/docs/api/agent.md b/docs/api/agent.md index 65e783cc..d2490252 100644 --- a/docs/api/agent.md +++ b/docs/api/agent.md @@ -1108,6 +1108,57 @@ AgentProfile axis name, with `custom:` reserved for caller-owned extension ## Variables +### fullProfileMaterialization + +> `const` **fullProfileMaterialization**: [`ProfileMaterializationContract`](#profilematerializationcontract) + +Materialization contract for a run path that executes every canonical AgentProfile leaf. + +*** + +### promptModelProfileMaterialization + +> `const` **promptModelProfileMaterialization**: [`ProfileMaterializationContract`](#profilematerializationcontract) + +Materialization contract for an intentionally limited prompt-and-model execution path. +Identity, harness, and metadata are control fields consumed for naming, placement, +authorization, and durable attribution; they are carried without adding worker behavior. +Every behavioral axis other than prompt and model remains unsupported. + +*** + +### worktreeCliProfileMaterialization + +> `const` **worktreeCliProfileMaterialization**: [`ProfileMaterializationContract`](#profilematerializationcontract) + +Materialization contract for a local coding CLI in an isolated git worktree. +The shared workspace materializer carries native tools, permissions, MCP, hooks, subagents, +modes, and file-backed resources when the selected CLI supports their exact values. +`resourceFailOnError` is carried: it is the fail-closed policy the pre-worktree resource +RESOLUTION step (`resolveAgentProfileResources`) applies to remote profile resources. Runtime +placement concerns (hub connections and confidential execution), provider-native extensions, +and unused model hints are deliberately absent so they fail before a worktree or executor is +created rather than being mistaken for an effective candidate change. + +*** + +### controlProfileMaterialization + +> `const` **controlProfileMaterialization**: [`ProfileMaterializationContract`](#profilematerializationcontract) + +Materialization contract for a raw process path that carries only control/identity fields. + +*** + +### promptControlProfileMaterialization + +> `const` **promptControlProfileMaterialization**: [`ProfileMaterializationContract`](#profilematerializationcontract) + +Materialization contract for an injected inference function whose surrounding driver still +applies the profile prompt, name, placement, and metadata, but not model selection. + +*** + ### sandboxActProfileMaterialization > `const` **sandboxActProfileMaterialization**: [`ProfileMaterializationContract`](#profilematerializationcontract) diff --git a/docs/api/candidate-execution.md b/docs/api/candidate-execution.md index b1d14ab1..69203b88 100644 --- a/docs/api/candidate-execution.md +++ b/docs/api/candidate-execution.md @@ -254,6 +254,12 @@ Re-exports [prepareAgentCandidateExecution](index.md#prepareagentcandidateexecut *** +### agentCandidateProfileAsAgentProfile + +Re-exports [agentCandidateProfileAsAgentProfile](index.md#agentcandidateprofileasagentprofile) + +*** + ### applyExactAgentProfileDiff Re-exports [applyExactAgentProfileDiff](index.md#applyexactagentprofilediff) @@ -266,6 +272,18 @@ Re-exports [assertCandidateProfileBinding](index.md#assertcandidateprofilebindin *** +### freezeGenericAgentCandidateProfile + +Re-exports [freezeGenericAgentCandidateProfile](index.md#freezegenericagentcandidateprofile) + +*** + +### omitUndefinedObjectFields + +Re-exports [omitUndefinedObjectFields](index.md#omitundefinedobjectfields) + +*** + ### parseExactAgentProfile Re-exports [parseExactAgentProfile](index.md#parseexactagentprofile) @@ -278,6 +296,12 @@ Re-exports [parseExactAgentProfileDiff](index.md#parseexactagentprofilediff) *** +### parseExactCandidateProfile + +Re-exports [parseExactCandidateProfile](index.md#parseexactcandidateprofile) + +*** + ### AgentCandidateModelGrantActivateInput Re-exports [AgentCandidateModelGrantActivateInput](index.md#agentcandidatemodelgrantactivateinput) diff --git a/docs/api/index.md b/docs/api/index.md index 47467f4f..2e8a7b1c 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -6656,7 +6656,7 @@ Max research rounds (correct-on-veto remediation). Default 1. ###### trace -`unknown` +`TraceAnalysisStore` ###### Returns @@ -7952,7 +7952,7 @@ What a finalizer gets to decide with. `delivered` is the ONLY output material; ` ##### budget -> `readonly` **budget**: `Readonly`\<\{ `tokensLeft`: `number`; `usdLeft`: `number`; `usdCapped`: `boolean`; `deadlineMs`: `number`; `reservedTokens`: `number`; `tokensKnown?`: `boolean`; \}\> +> `readonly` **budget**: `Readonly`\<\{ `tokensLeft`: `number`; `tokensKnown`: `boolean`; `usdLeft`: `number`; `usdCapped`: `boolean`; `usdKnown`: `boolean`; `iterationsLeft`: `number`; `deadlineMs`: `number`; `reservedTokens`: `number`; \}\> *** @@ -7968,7 +7968,7 @@ meters every runtime identically. Built-in implementations (in `runtime.ts`, NOT variants here): router/inline (a direct Router/HTTP inference call, no box), sandbox (COMPOSES `runAgentRounds` as a leaf, forwarding PR #150's optional `lineage` passthrough — does NOT reinvent checkpoint/fork), cli -(Halo/RLM subprocess; `budgetExempt`, excluded from equal-k by construction). A user's +(Halo/RLM subprocess; `budgetExempt`, refused by budgeted supervision). A user's own agent (mastra/agno/raw HTTP/anything) is first-class by implementing this interface. #### Type Parameters @@ -7981,7 +7981,7 @@ own agent (mastra/agno/raw HTTP/anything) is first-class by implementing this in ##### runtime -> `readonly` **runtime**: [`Runtime`](runtime.md#runtime-2) +> `readonly` **runtime**: [`Runtime`](runtime.md#runtime-4) Stable runtime tag for traces + the equal-k exemption check. @@ -7989,9 +7989,10 @@ Stable runtime tag for traces + the equal-k exemption check. > `readonly` `optional` **budgetExempt?**: `boolean` -When true, this executor's spend is NOT metered against the conserved pool and its -iterations are excluded from the equal-k assertion (a `cli` subprocess without -token accounting). Fail-loud everywhere else: a metered executor MUST report usage. +When true, this executor cannot report the usage a conserved pool would need (for example, a +subscription CLI with no token receipt). `Executor` can still be used directly, but `Scope` +refuses it before `execute` so unknown compute can never appear as measured zero in a +supervised or equal-resource run. A metered executor MUST report usage. #### Methods @@ -8019,13 +8020,13 @@ the terminal artifact is read from `resultArtifact()` after the stream drains. ##### deliver()? -> `optional` **deliver**(`msg`): `void` +> `optional` **deliver**(`msg`): `boolean` \| `void` Optional inbox: receive an out-of-band message from the driver mid-run (the `send`/`steer_agent` verb). A streaming executor drains pending messages between turns and folds them into the next step (a steer / interrupt / resume). A one-shot executor that can't be steered mid-flight omits -this; `Scope.send` then returns `false` for it. Never throws — a malformed message is the -executor's to ignore. +this; `Scope.send` then returns `false` for it. Never throws — an inbox that rejects a malformed +message returns `false`, and that refusal propagates to the caller. ###### Parameters @@ -8035,7 +8036,7 @@ executor's to ignore. ###### Returns -`void` +`boolean` \| `void` ##### progress()? @@ -8114,6 +8115,21 @@ driver branched on, its verdict, and the conserved spend. Read once, after settl > **spent**: [`Spend`](#spend) +##### accounting()? + +> `optional` **accounting**(): [`ExecutorAccounting`](runtime.md#executoraccounting) \| `undefined` + +Optional accounting split for recursive executors. +`reported` is the child-work spend written on this node's settlement; `reservation` is the +whole amount reconciled against this node's parent reservation. +They differ when a driver owns a nested allocation: its child work and own inference consume +that allocation together, while the journal keeps those two categories separate. +Valid after `execute` resolves or throws; ordinary leaf executors omit it. + +###### Returns + +[`ExecutorAccounting`](runtime.md#executoraccounting) \| `undefined` + ##### metered()? > `optional` **metered**(): [`Spend`](#spend) \| `undefined` @@ -8133,11 +8149,12 @@ executors omit it (returns `undefined`). ### AgentSpec -`AgentProfile` does NOT carry a `harness`/backend field — `harness` lives on the -sandbox SDK's `BackendConfig`, not the portable profile. So an agent is mapped to its -executor through this MINIMAL wrapper, never by fabricating a field onto `AgentProfile`. +`AgentProfile.harness` is a portable preference; this wrapper records the executor decision for +one concrete run. A caller may honor the preference, override it for a comparison cell, or supply +an executor directly, without changing the profile's behavioral identity. Resolution (in `runtime.ts`): + - `executorFactory` present → BYO: build it after admission with the live context. - `executor` present → BYO: use it verbatim (a user's own `Executor`). - `harness === null` → router/inline: a direct Router call, no box. - `harness` is a `BackendType` → sandbox: compose `runAgentRounds` against `profile` on that backend. @@ -8155,6 +8172,20 @@ Fail loud on an unresolvable spec (no executor and an unknown harness). `null` selects router/inline; a `BackendType` selects the sandboxed harness. +##### execution? + +> `readonly` `optional` **execution?**: [`AgentExecutionRef`](runtime.md#agentexecutionref) + +Trusted candidate/campaign attribution supplied by the caller. Profile/task digests are + computed by Scope from the exact values it executes and cannot be supplied here. + +##### executorFactory? + +> `readonly` `optional` **executorFactory?**: [`ExecutorFactory`](runtime.md#executorfactory)\<`unknown`\> + +Per-spawn factory carrying caller configuration. Constructed only after admission, with the + real child signal and nested-scope context. + ##### executor? > `readonly` `optional` **executor?**: [`Executor`](#executor-2)\<`unknown`\> @@ -8188,7 +8219,7 @@ Register a factory for a named runtime. Throws on a duplicate name (fail loud). ###### runtime -[`Runtime`](runtime.md#runtime-2) +[`Runtime`](runtime.md#runtime-4) ###### factory @@ -8202,8 +8233,8 @@ Register a factory for a named runtime. Throws on a duplicate name (fail loud). > **resolve**\<`Out`\>(`spec`): \{ `succeeded`: `true`; `value`: [`ExecutorFactory`](runtime.md#executorfactory)\<`Out`\>; \} \| \{ `succeeded`: `false`; `error`: `string`; \} -Resolve a spec to a factory. Precedence: a BYO `spec.executor` → a trivial factory -returning it; else `harness === null` → the `'router'` factory; else a registered +Resolve a spec to a factory. Precedence: a BYO `spec.executorFactory` → `spec.executor` → +`harness === null` → the `'router'` factory; else a registered factory for the harness-derived runtime. Returns a typed outcome — the caller inspects `succeeded` before `value` (no silent fallback). @@ -8335,21 +8366,32 @@ The live tree — reads the in-memory nursery, not the journal. ##### budget -> `readonly` **budget**: `Readonly`\<\{ `tokensLeft`: `number`; `usdLeft`: `number`; `usdCapped`: `boolean`; `deadlineMs`: `number`; `reservedTokens`: `number`; `tokensKnown?`: `boolean`; \}\> +> `readonly` **budget**: `Readonly`\<\{ `tokensLeft`: `number`; `tokensKnown`: `boolean`; `usdLeft`: `number`; `usdCapped`: `boolean`; `usdKnown`: `boolean`; `iterationsLeft`: `number`; `deadlineMs`: `number`; `reservedTokens`: `number`; \}\> Conserved-pool readouts (post-reservation). +##### workerCapacity + +> `readonly` **workerCapacity**: `Readonly`\<\{ `live`: `number`; `freeSlots`: `number` \| `null`; \}\> + +One tree-wide view of simultaneous spawned work. Every nested scope reads the same counter; + the root agent itself is not a spawned worker. `freeSlots` is `null` when no limit is set. + #### Methods ##### spawn() > **spawn**\<`C`\>(`agent`, `task`, `opts`): \{ `ok`: `true`; `handle`: [`Handle`](runtime.md#handle-2)\<`C`\>; `prior?`: [`SpawnPrior`](runtime.md#spawnprior)\<`C`\>; \} \| \{ `ok`: `false`; `reason`: [`SpawnRejection`](runtime.md#spawnrejection); \} -Spawn a child. Reserves `opts.budget` from the conserved pool atomically; refunds the -unspent remainder on settle. Returns a typed outcome — fail-closed on an exhausted -pool, an exceeded depth ceiling, or a still-live duplicate `key` (the caller inspects -`ok` before `handle`). A KEYED spawn whose key already settled `done` spends nothing: -it returns the committed result on `prior` instead of re-running (see `SpawnOpts.key`). +Spawn a child. For a fresh key or an unkeyed spawn, tree-wide worker admission happens before a +lazy factory is called, so a full worker allocation creates no worker, executor, or reservation. +Reserves `opts.budget` from the conserved pool atomically; refunds the unspent remainder on +settle. Returns a typed outcome — fail-closed on an exhausted pool, an exceeded depth ceiling, a +full worker allocation, or a still-live duplicate `key` (the caller inspects `ok` before +`handle`). A KEYED spawn whose key already settled `done` invokes the factory only far enough to +prepare and authorize the exact profile/task identity, then compares that identity with the +journal. On a match it spends nothing, constructs no executor, reserves no budget, and runs no +work: it returns the committed result on `prior` (see `SpawnOpts.key`). ###### Type Parameters @@ -8361,7 +8403,7 @@ it returns the committed result on `prior` instead of re-running (see `SpawnOpts ###### agent -[`Agent`](runtime.md#agent-1)\<`unknown`, `C`\> +[`Agent`](runtime.md#agent-1)\<`unknown`, `C`\> \| (() => [`Agent`](runtime.md#agent-1)\<`unknown`, `C`\>) ###### task @@ -8588,7 +8630,7 @@ live `RootHandle` (the Q2 substrate the chat/pi-viz client later consumes). ###### h -[`RootHandle`](runtime.md#roothandle)\<`Out`\> +[`RootHandle`](runtime.md#roothandle-1)\<`Out`\> ###### Returns @@ -11115,11 +11157,12 @@ Mode → configured runner. Partial: only register the modes a ### CoordinationEvent -> **CoordinationEvent** = \{ `type`: `"question"`; `question`: [`QuestionRecord`](mcp.md#questionrecord); \} \| \{ `type`: `"settled"`; `worker`: [`SettledWorker`](mcp.md#settledworker); \} \| \{ `type`: `"finding"`; `finding`: [`AnalystFindingEvent`](runtime.md#analystfindingevent); \} \| \{ `type`: `"steer"`; `down`: [`DownMessageEvent`](runtime.md#downmessageevent); \} \| \{ `type`: `"answer"`; `down`: [`DownMessageEvent`](runtime.md#downmessageevent); `questionId`: `string`; \} +> **CoordinationEvent** = \{ `type`: `"question"`; `question`: [`QuestionRecord`](mcp.md#questionrecord); \} \| \{ `type`: `"settled"`; `worker`: [`SettledWorker`](mcp.md#settledworker); \} \| \{ `type`: `"finding"`; `finding`: [`AnalystFindingEvent`](runtime.md#analystfindingevent); \} \| \{ `type`: `"steer"`; `down`: [`DownMessageEvent`](runtime.md#downmessageevent); \} \| \{ `type`: `"answer"`; `down`: [`DownMessageEvent`](runtime.md#downmessageevent); `questionId`: `string`; \} \| \{ `type`: `"instruction"`; `instruction`: [`ContinuationInstruction`](runtime.md#continuationinstruction); \} \| \{ `type`: `"delivery-attempt"`; `attempt`: [`DownMessageDeliveryAttempt`](runtime.md#downmessagedeliveryattempt); \} Every message on the one typed pipe. UP (child→parent): question / settled / finding — queued for - the driver to `pull`. DOWN (parent→child): steer / answer — record-only (history + subscribers), - routed to the child inbox. New kinds are additive. + the driver to `pull`. An `instruction` is the pre-delivery authorization receipt and is retained + as evidence. DOWN (parent→child): steer / answer — record-only (history + subscribers), routed + to the child inbox. Receipts are never auto-delivered on restart. New kinds are additive. *** @@ -11219,9 +11262,51 @@ The finalization seam: ledger in, output (or `undefined` = nothing deliverable) *** +### WorkerTraceUnavailableReason + +> **WorkerTraceUnavailableReason** = `"execution-did-not-start"` \| `"executor-did-not-expose-trace-source"` \| `"trace-source-unavailable"` \| `"no-tool-spans-captured"` \| `"invalid-tool-spans"` \| `"trace-collection-failed"` \| `"trace-persistence-failed"` \| `"legacy-settlement-without-trace-evidence"` \| `"not-an-executor"` + +Why Runtime cannot provide structured tool-call evidence for one settled execution. + +*** + +### WorkerTraceEvidence + +> **WorkerTraceEvidence** = \{ `status`: `"available"`; `traceRef`: `string`; `spanCount`: `number`; \} \| \{ `status`: `"unavailable"`; `reason`: [`WorkerTraceUnavailableReason`](#workertraceunavailablereason); \} + +Durable proof of a worker's structured tool trace, or the exact reason it is unavailable. + +#### Union Members + +##### Type Literal + +\{ `status`: `"available"`; `traceRef`: `string`; `spanCount`: `number`; \} + +###### status + +> `readonly` **status**: `"available"` + +###### traceRef + +> `readonly` **traceRef**: `string` + +Content-addressed pointer to a persisted `WorkerToolTraceArtifact`. + +###### spanCount + +> `readonly` **spanCount**: `number` + +*** + +##### Type Literal + +\{ `status`: `"unavailable"`; `reason`: [`WorkerTraceUnavailableReason`](#workertraceunavailablereason); \} + +*** + ### Settled -> **Settled**\<`Out`\> = \{ `kind`: `"done"`; `handle`: [`Handle`](runtime.md#handle-2)\<`Out`\>; `out`: `Out`; `outRef`: `string`; `verdict?`: `DefaultVerdict`; `spent`: [`Spend`](#spend); `seq`: `number`; \} \| \{ `kind`: `"down"`; `handle`: [`Handle`](runtime.md#handle-2)\<`Out`\>; `reason`: `string`; `infra`: `boolean`; `restartCount`: `number`; `seq`: `number`; \} +> **Settled**\<`Out`\> = \{ `kind`: `"done"`; `handle`: [`Handle`](runtime.md#handle-2)\<`Out`\>; `out`: `Out`; `outRef`: `string`; `verdict?`: `DefaultVerdict`; `spent`: [`Spend`](#spend); `trace`: [`WorkerTraceEvidence`](#workertraceevidence); `settledAt?`: `number`; `seq`: `number`; \} \| \{ `kind`: `"down"`; `handle`: [`Handle`](runtime.md#handle-2)\<`Out`\>; `reason`: `string`; `infra`: `boolean`; `restartCount`: `number`; `trace`: [`WorkerTraceEvidence`](#workertraceevidence); `settledAt?`: `number`; `seq`: `number`; \} A settled child, delivered by `scope.next()`. `seq` is the monotonic cursor order `next()` yielded this settlement (B2) — NOT wall-clock — and replay delivers strictly @@ -11237,13 +11322,53 @@ in `seq` order. `outRef` rehydrates `out` from the `ResultBlobStore` on replay. ##### Type Literal -\{ `kind`: `"done"`; `handle`: [`Handle`](runtime.md#handle-2)\<`Out`\>; `out`: `Out`; `outRef`: `string`; `verdict?`: `DefaultVerdict`; `spent`: [`Spend`](#spend); `seq`: `number`; \} +\{ `kind`: `"done"`; `handle`: [`Handle`](runtime.md#handle-2)\<`Out`\>; `out`: `Out`; `outRef`: `string`; `verdict?`: `DefaultVerdict`; `spent`: [`Spend`](#spend); `trace`: [`WorkerTraceEvidence`](#workertraceevidence); `settledAt?`: `number`; `seq`: `number`; \} + +###### kind + +> **kind**: `"done"` + +###### handle + +> **handle**: [`Handle`](runtime.md#handle-2)\<`Out`\> + +###### out + +> **out**: `Out` + +###### outRef + +> **outRef**: `string` + +###### verdict? + +> `optional` **verdict?**: `DefaultVerdict` + +###### spent + +> **spent**: [`Spend`](#spend) + +###### trace + +> **trace**: [`WorkerTraceEvidence`](#workertraceevidence) + +Structured tool evidence captured before this settlement was journaled. + +###### settledAt? + +> `optional` **settledAt?**: `number` + +Epoch ms parsed from the durable settlement record when available. + +###### seq + +> **seq**: `number` *** ##### Type Literal -\{ `kind`: `"down"`; `handle`: [`Handle`](runtime.md#handle-2)\<`Out`\>; `reason`: `string`; `infra`: `boolean`; `restartCount`: `number`; `seq`: `number`; \} +\{ `kind`: `"down"`; `handle`: [`Handle`](runtime.md#handle-2)\<`Out`\>; `reason`: `string`; `infra`: `boolean`; `restartCount`: `number`; `trace`: [`WorkerTraceEvidence`](#workertraceevidence); `settledAt?`: `number`; `seq`: `number`; \} ###### kind @@ -11267,6 +11392,18 @@ True = infrastructure failure (excluded from merge `n` / equal-k), not a bad res > **restartCount**: `number` +###### trace + +> **trace**: [`WorkerTraceEvidence`](#workertraceevidence) + +Partial structured tool evidence captured before this failure was journaled. + +###### settledAt? + +> `optional` **settledAt?**: `number` + +Epoch ms parsed from the durable settlement/cancellation record when available. + ###### seq > **seq**: `number` @@ -12413,6 +12550,24 @@ Materializes a verified candidate into one immutable evaluator-owned execution p *** +### freezeGenericAgentCandidateProfile() + +> **freezeGenericAgentCandidateProfile**(`input`): `AgentCandidateProfile` + +Convert only behavior-preserving generic profile fields into the closed candidate contract. + +#### Parameters + +##### input + +`AgentProfile` + +#### Returns + +`AgentCandidateProfile` + +*** + ### assertCandidateProfileBinding() > **assertCandidateProfileBinding**(`measuredInput`, `bundled`): `void` @@ -12505,6 +12660,64 @@ Apply one exact diff and reject any value that cannot be preserved canonically. *** +### parseExactCandidateProfile() + +> **parseExactCandidateProfile**(`input`): `AgentCandidateProfile` + +Parse a candidate profile without silently discarding unsupported or non-canonical fields. + +#### Parameters + +##### input + +`unknown` + +#### Returns + +`AgentCandidateProfile` + +*** + +### agentCandidateProfileAsAgentProfile() + +> **agentCandidateProfileAsAgentProfile**(`candidate`): `AgentProfile` + +Convert the candidate profile contract into the portable interface profile it represents. + +#### Parameters + +##### candidate + +`AgentCandidateProfile` + +#### Returns + +`AgentProfile` + +*** + +### omitUndefinedObjectFields() + +> **omitUndefinedObjectFields**(`value`, `path`): `unknown` + +Recursively remove undefined object fields while refusing undefined array entries. + +#### Parameters + +##### value + +`unknown` + +##### path + +`string` + +#### Returns + +`unknown` + +*** + ### createProtectedAgentCandidateModelPort() > **createProtectedAgentCandidateModelPort**(`options`): [`AgentCandidateModelPort`](#agentcandidatemodelport) diff --git a/docs/api/mcp.md b/docs/api/mcp.md index f758699d..f4d04072 100644 --- a/docs/api/mcp.md +++ b/docs/api/mcp.md @@ -2134,7 +2134,7 @@ Which harness handled this delegation. ###### Inherited from -[`LoopSandboxPlacement`](runtime.md#loopsandboxplacement).[`kind`](runtime.md#kind-6) +[`LoopSandboxPlacement`](runtime.md#loopsandboxplacement).[`kind`](runtime.md#kind-10) ##### sandboxId? @@ -3826,6 +3826,36 @@ A worker the driver has drained via `await_event`. > `readonly` **status**: `"done"` \| `"down"` +##### assignmentId? + +> `readonly` `optional` **assignmentId?**: `string` + +Stable manager-scoped assignment, including deterministic unkeyed siblings. + +##### identity? + +> `readonly` `optional` **identity?**: [`NodeExecutionIdentity`](runtime.md#nodeexecutionidentity) + +Exact profile/task/candidate identity authorized for this node. + +##### materialization? + +> `readonly` `optional` **materialization?**: [`ProfileMaterializationReceipt`](runtime.md#profilematerializationreceipt) + +Stable effective execution plan, or an explicit unknown receipt. + +##### executionBindings? + +> `readonly` `optional` **executionBindings?**: readonly [`ExecutionBindingReceipt`](runtime.md#executionbindingreceipt)[] + +Backend bindings for each attempt, in durable oldest-first order. + +##### spent? + +> `readonly` `optional` **spent?**: [`Spend`](index.md#spend) + +Conserved spend. Missing means unavailable; unknown accounting remains explicitly unknown. + ##### score? > `readonly` `optional` **score?**: `number` @@ -3842,13 +3872,24 @@ A worker the driver has drained via `await_event`. > `readonly` `optional` **reason?**: `string` +##### trace + +> `readonly` **trace**: [`WorkerTraceEvidence`](index.md#workertraceevidence) + +Structured tool-call evidence, never the worker's final prose. + +##### resumed? + +> `readonly` `optional` **resumed?**: `boolean` + +True when projected from a prior process of the same durable run. + ##### settledAt? > `readonly` `optional` **settledAt?**: `number` -Epoch ms the ledger recorded this settlement — the resolution a progress-based stop rule - needs to answer "how long since anything landed?" without inventing a timestamp at read - time. Stamped when the cursor yields the settlement, not when a reader first looks. +Epoch ms from the durable terminal record — the resolution a progress-based stop rule needs + to answer "how long since anything landed?" without inventing a timestamp at read time. *** @@ -3970,7 +4011,7 @@ Epoch ms the ledger recorded this settlement — the resolution a progress-based ##### status -> `readonly` **status**: `"open"` \| `"answered"` \| `"deferred"` \| `"escalated"` +> `readonly` **status**: `"deferred"` \| `"open"` \| `"answered"` \| `"escalated"` ##### decision? @@ -4016,7 +4057,9 @@ first passing submission is retained; a false or throwing check fails closed. ##### onEvent? -> `readonly` `optional` **onEvent?**: (`event`) => `void` \| `Promise`\<`void`\> +> `readonly` `optional` **onEvent?**: (`event`, `record`) => `void` \| `Promise`\<`void`\> + +Event-first for source compatibility; the second argument is its exact bus ordering stamp. ###### Parameters @@ -4024,10 +4067,28 @@ first passing submission is retained; a false or throwing check fails closed. [`CoordinationEvent`](index.md#coordinationevent) +###### record + +[`BusRecord`](runtime.md#busrecord)\<[`CoordinationEvent`](index.md#coordinationevent)\> + ###### Returns `void` \| `Promise`\<`void`\> +##### replaySettlements? + +> `readonly` `optional` **replaySettlements?**: `boolean` + +Re-publish resumed settlements through the awaited observer before the driver starts. This is + the crash-window recovery path for product transactions; off preserves low-level legacy reads. + +##### authorizeDownMessage? + +> `readonly` `optional` **authorizeDownMessage?**: [`AuthorizeDownMessage`](runtime.md#authorizedownmessage) + +Authorize each continuation against the exact worker identity. The returned instruction is +detached, recorded durably through `onEvent`, and only then delivered. + ##### questionPolicy? > `readonly` `optional` **questionPolicy?**: [`QuestionPolicy`](#questionpolicy) @@ -4049,7 +4110,8 @@ Hard cap on how many workers may be LIVE (spawned but not yet settled) at once. counts the scope's non-terminal nodes and fails closed (`error: 'max-live-workers'`) BEFORE reserving from the pool when the cap is already met — a concurrency fence on top of the conserved-budget fence (the pool bounds total work; this bounds simultaneous work, e.g. live - sandboxes/boxes). Omit or `<= 0` = no cap (the prior behavior; the pool stays the only fence). + sandboxes/boxes). A tree-wide limit owned by `Scope` takes precedence when present; this field + is the local form for a caller-owned scope. Omit or `<= 0` = no local cap. ##### awaitTimeoutMs? @@ -4136,6 +4198,16 @@ choice, steerable counterpart to the one-shot own-sandbox delegation MCP. #### Methods +##### ready() + +> **ready**(): `Promise`\<`void`\> + +Commit any resume-time event replay before a supervisor can reason or an MCP can listen. + +###### Returns + +`Promise`\<`void`\> + ##### isStopped() > **isStopped**(): `boolean` @@ -4182,9 +4254,9 @@ readonly [`QuestionRecord`](#questionrecord)[] > **history**(): readonly [`BusRecord`](runtime.md#busrecord)\<[`CoordinationEvent`](index.md#coordinationevent)\>[] -The full ordered log of every bus event — UP (settled / question / finding) and DOWN - (steer / answer) — the observability audit + replay trail. Each record carries seq, - timestamp, and priority. +The full ordered log of every bus event — UP (settled / question / finding), authorized + instruction receipts, and DOWN delivery outcomes (steer / answer). Each record carries seq, + timestamp, and priority. A receipt is evidence and is never auto-delivered on restart. ###### Returns @@ -7601,6 +7673,24 @@ Re-exports [AnalystRegistry](index.md#analystregistry) *** +### AuthorizeDownMessage + +Re-exports [AuthorizeDownMessage](runtime.md#authorizedownmessage) + +*** + +### AuthorizedDownMessage + +Re-exports [AuthorizedDownMessage](runtime.md#authorizeddownmessage) + +*** + +### ContinuationInstruction + +Re-exports [ContinuationInstruction](runtime.md#continuationinstruction) + +*** + ### CoordinationEvent Re-exports [CoordinationEvent](index.md#coordinationevent) @@ -7613,6 +7703,24 @@ Re-exports [DEFAULT_AWAIT_EVENT_TIMEOUT_MS](runtime.md#default_await_event_timeo *** +### DownMessageAuthorizationInput + +Re-exports [DownMessageAuthorizationInput](runtime.md#downmessageauthorizationinput) + +*** + +### DownMessageDeliveryAttempt + +Re-exports [DownMessageDeliveryAttempt](runtime.md#downmessagedeliveryattempt) + +*** + +### DownMessageDeliveryOutcome + +Re-exports [DownMessageDeliveryOutcome](runtime.md#downmessagedeliveryoutcome) + +*** + ### DownMessageEvent Re-exports [DownMessageEvent](runtime.md#downmessageevent) @@ -7622,3 +7730,9 @@ Re-exports [DownMessageEvent](runtime.md#downmessageevent) ### MakeWorkerAgent Re-exports [MakeWorkerAgent](runtime.md#makeworkeragent) + +*** + +### WorkerSpawnContext + +Re-exports [WorkerSpawnContext](runtime.md#workerspawncontext) diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index b54c646a..6f03ca76 100644 --- a/docs/api/primitive-catalog.md +++ b/docs/api/primitive-catalog.md @@ -7,7 +7,7 @@ # Primitive catalog — the never-stale anti-reinvention inventory -> **GENERATED** from `@tangle-network/agent-runtime@0.116.0` and `@tangle-network/agent-eval@0.139.2` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. +> **GENERATED** from `@tangle-network/agent-runtime@0.117.0` and `@tangle-network/agent-eval@0.139.2` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. ## 1. agent-runtime — own public surface @@ -15,10 +15,11 @@ Every subpath this package declares in `package.json` `exports`. Reach for these ### Root — task lifecycle, conversation, RSI verbs, observability -Import from `@tangle-network/agent-runtime` — 403 exports. +Import from `@tangle-network/agent-runtime` — 409 exports. | Symbol | Kind | Summary | |---|---|---| +| `agentCandidateProfileAsAgentProfile` | function | Convert the candidate profile contract into the portable interface profile it represents. | | `agenticGenerator` | function | Full-agentic `CandidateGenerator` (the `shots=N, sandbox=on` setting): run a real coding harness inside the candidate worktree so the agent makes the change in place. | | `applyExactAgentProfileDiff` | function | Apply one exact diff and reject any value that cannot be preserved canonically. | | `applyRolloutPolicyToProfile` | function | Persist a detached policy under the profile extension without mutating the input. | @@ -63,6 +64,7 @@ Import from `@tangle-network/agent-runtime` — 403 exports. | `exportEvalRuns` | function | Ship self-improvement eval-run events to Tangle Intelligence. Unlike the | | `findingLines` | function | Render findings as the ranked-evidence block every build prompt ends with. | | `formatSupervisedKnowledgeTask` | function | Format the supervisor task with the KB root, readiness requirements, current findings, and metadata. | +| `freezeGenericAgentCandidateProfile` | function | Convert only behavior-preserving generic profile fields into the closed candidate contract. | | `generateSpanId` | function | Mint a fresh 16-hex-character OTLP span id. Exported so a producer that must know a span's id | | `getModels` | function | Fetch the model catalog from the router's `/v1/models`. Throws on a non-2xx | | `improve` | function | Optimize one exact profile surface with a complete method. | @@ -80,8 +82,10 @@ Import from `@tangle-network/agent-runtime` — 403 exports. | `notifyRuntimeHookEvent` | function | Fire `hooks.onEvent`, swallowing sync throws and surfacing async failures to `onError`. | | `officialGepa` | function | Build a complete method backed by GEPA's official Optimize Anything API. | | `officialSkillOpt` | function | Build a complete method backed by Microsoft's official SkillOpt trainer. | +| `omitUndefinedObjectFields` | function | Recursively remove undefined object fields while refusing undefined array entries. | | `parseExactAgentProfile` | function | Parse a complete profile without silently discarding unsupported fields. | | `parseExactAgentProfileDiff` | function | Parse a profile diff without silently discarding unsupported fields. | +| `parseExactCandidateProfile` | function | Parse a candidate profile without silently discarding unsupported or non-canonical fields. | | `parseLoopRunnerArgv` | function | Parse `--mode X --config Y` from an argv tail (`process.argv.slice(2)`). | | `parseRolloutPolicy` | function | Parse a serialized policy surface. Returns `undefined` for non-strings, | | `persistCandidateOutputArtifact` | function | Persist evaluator evidence, read it back, and bind the returned locator to the exact bytes. | @@ -178,7 +182,7 @@ Import from `@tangle-network/agent-runtime` — 403 exports. | `AgentCandidateRepositoryPort` | interface | Resolves a declared GitHub repository to an already-present local Git object store. | | `AgentCandidateTaskExecution` | interface | Runtime placement for one exact cell from a signed candidate experiment. | | `AgentCandidateWorkspacePort` | interface | Materializes an already-verified workspace archive. | -| `AgentSpec` | interface | `AgentProfile` does NOT carry a `harness`/backend field — `harness` lives on the | +| `AgentSpec` | interface | `AgentProfile.harness` is a portable preference; this wrapper records the executor decision for | | `BackendErrorDetail` | interface | Typed transport / backend failure detail. Carried on `backend_error` and | | `BackendRetryPolicy` | interface | Retry policy for transient transport errors (rate limits, upstream | | `Budget` | interface | A budget envelope on a spawn or the root. All ceilings; the pool reserves against them. | @@ -250,13 +254,15 @@ Import from `@tangle-network/agent-runtime` — 403 exports. | `SupervisorFinalizer` | type | The finalization seam: ledger in, output (or `undefined` = nothing deliverable) out. | | `VerifiedAgentCandidateTaskOutcome` | type | Branded task outcome that has survived independent evaluator verification. | | `Verifier` | type | Verifies the edited worktree. Sync or async; throws only on a setup fault | +| `WorkerTraceEvidence` | type | Durable proof of a worker's structured tool trace, or the exact reason it is unavailable. | +| `WorkerTraceUnavailableReason` | type | Why Runtime cannot provide structured tool-call evidence for one settled execution. | | `WorktreeCheckRunner` | type | The single shell-command-in-worktree runner seam (replaces the per-executor copies). | **Undocumented supporting types** (add a TSDoc line at the declaration to earn a table row): `AgentAdapter`, `AgentBackendContext`, `AgentBackendInput`, `AgentCandidateContainerPort`, `AgentCandidateExecutionAttemptRef`, `AgentCandidateExecutionPorts`, `AgentCandidateExecutorWorkspaceFile`, `AgentCandidateExecutorWorkspaceInput`, `AgentCandidateMemoryPort`, `AgentCandidateMemoryResetResult`, `AgentCandidateModelPort`, `AgentCandidatePreparationEvidence`, `AgentCandidateProtectedModelActivation`, `AgentCandidateProtectedModelReservation`, `AgentCandidateProtectedModelSettlement`, `AgentCandidateProtectedRunCapture`, `AgentCandidateVerificationPorts`, `AgentCandidateWorkspaceArchiveLimits`, `AgentExecutionBackend`, `AgenticGeneratorOptions`, `AgenticGeneratorShotReceipt`, `AgentKnowledgeProvider`, `AgentKnowledgeReadinessCheckOptions`, `AgentTaskContext`, `AgentTaskRunResult`, `AgentTaskSpec`, `AnalystRegistry`, `BackendCallPolicy`, `CanonicalCandidateDocument`, `CaptureAgentCandidateWorkspaceOptions`, `CapturedAgentCandidateWorkspace`, `ChatModelCandidate`, `ControlBudget`, `ControlEvalResult`, `ControlRunResult`, `ControlStep`, `Conversation`, `ConversationDriveState`, `ConversationJournal`, `ConversationJournalEntry`, `ConversationParticipant`, `ConversationPolicy`, `ConversationResult`, `ConversationTurn`, `CreateAgentCandidateWorkspacePortOptions`, `CreateKnowledgeImprovementActivationExecutorOptions`, `CreateProtectedAgentCandidateModelPortOptions`, `D1StmtLike`, `DataAcquisitionPlan`, `DelegatedLoopResult`, `DisposePreparedAgentCandidateOptions`, `Driver`, `DriverLoopGeneratorOptions`, `EvalRunEvent`, `EvalRunGeneration`, `EvalRunsExportConfig`, `EvalRunsExportResult`, `ExactProcessCandidateExecutorOptions`, `ExecutePreparedAgentCandidateOptions`, `FileAgentCandidateExecutionClaimStoreOptions`, `HaltContext`, `HaltSignal`, `ImproveCodeOptions`, `ImproveCodeResult`, `ImprovementCodeCandidate`, `ImprovementProfileCandidate`, `ImproveMethodContext`, `ImproveMethodResult`, `ImproveSkillsOptions`, `InMemoryAgentCandidateExecutionClaimStoreOptions`, `KnowledgeImprovementActivationExecutor`, `KnowledgeImprovementCandidatePair`, `KnowledgeImprovementExperimentBundles`, `KnowledgeImprovementJobMeasurement`, `KnowledgeImprovementJobResult`, `KnowledgeReadinessCheckInput`, `KnowledgeReadinessDecision`, `KnowledgeReadinessReport`, `KnowledgeRequirement`, `LoopResult`, `LoopRunnerCliArgs`, `LoopRunnerCliResult`, `McpServeSpec`, `OfficialSensitiveCandidateInput`, `OtelAttribute`, `OtelExportConfig`, `OtelExporter`, `OtelSpan`, `PersonaConversationResult`, `PrepareAgentCandidateExecutionOptions`, `PreparedAgentCandidateExecution`, `PreparedAgentCandidateInstruction`, `PreparedAgentCandidateLaunch`, `PreparedAgentCandidateTrace`, `RawTraceDistillerOptions`, `RecoverExpiredAgentCandidateOptions`, `ReflectiveGeneratorOptions`, `ResearchLoopResult`, `ResearchLoopRunnerOptions`, `ResolveAgentBackendOptions`, `ResolvedAgentCandidateContainer`, `ResolvedChatModel`, `RunAgentTaskOptions`, `RunAgentTaskStreamOptions`, `RunConversationOptions`, `RunDelegatedLoopOptions`, `RunKnowledgeImprovementJobOptions`, `RunPersonaConfig`, `RunPersonaConversationOptions`, `RuntimeDecisionEvidenceRef`, `RuntimeDecisionPoint`, `RuntimeEventCollector`, `RuntimeEventOtelOptions`, `RuntimeHookContext`, `RuntimeHookErrorContext`, `RuntimeHookEvent`, `RuntimeRunCompleteInput`, `RuntimeRunCost`, `RuntimeRunHandle`, `RuntimeRunOptions`, `RuntimeRunPersistenceAdapter`, `RuntimeRunRow`, `RuntimeSession`, `RuntimeSessionStore`, `RuntimeStreamEventCollector`, `RuntimeStreamEventSummary`, `RuntimeTelemetryOptions`, `SanitizedKnowledgeReadinessReport`, `SanitizedKnowledgeRequirement`, `ServerSentEventOptions`, `SupervisedKnowledgeUpdateInput`, `SupervisedKnowledgeUpdateOptions`, `SupervisedKnowledgeUpdateResult`, `VerifiedAgentCandidate`, `VetoedFact`, `WorktreeLoopRunnerOptions`, `AgentCandidateModelGrantActivateInput`, `AgentCandidateModelGrantReserveInput`, `AgentCandidateModelGrantSettleInput`, `AgentCandidateOutputPurpose`, `AgentCandidateRetryRejection`, `AgentCandidateRunFinalization`, `AgentRuntimeEvent`, `AgentRuntimeEventSink`, `AgentTaskStatus`, `AuthSource`, `ChatModelValidation`, `ControlDecision`, `ConversationStreamEvent`, `DeepReadonly`, `DelegatedLoopMode`, `DelegatedLoopRegistry`, `DelegatedLoopRunner`, `ForwardHeaderName`, `HaltPredicate`, `HaltReason`, `ImproveCandidateValidator`, `ImprovementCandidate`, `ImproveMethodSource`, `ImproveOptimizationRunOptions`, `ImproveProfileSurface`, `ImproveResult`, `KnowledgeReadinessCheck`, `KnowledgeReadinessCheckResult`, `RuntimeDecisionKind`, `RuntimeHookTarget`, `RuntimeRunStatus`, `RuntimeStreamEvent`, `RuntimeStreamEventSink`, `SupervisedKnowledgeUpdater`, `TurnOrder`. ### Vertical agent — manifest + surface proposal source -Import from `@tangle-network/agent-runtime/agent` — 41 exports. +Import from `@tangle-network/agent-runtime/agent` — 48 exports. | Symbol | Kind | Summary | |---|---|---| @@ -266,6 +272,7 @@ Import from `@tangle-network/agent-runtime/agent` — 41 exports. | `createSurfaceImprovementProposer` | function | Resolve each finding to a real surface and draft a detached patch candidate. | | `defineAgent` | function | Construct a validated agent manifest. Throws `AgentManifestError` | | `defineProfileMaterializationContract` | function | Define the profile axes a concrete run path actually carries into execution. | +| `profileMaterializationAxes` | function | Return every canonical profile leaf that contains a meaningful request. | | `renderProfileMaterializationIssues` | function | Format profile-axis drop issues into a concise operator-facing error. | | `renderSurfaceIssues` | function | Format a list of surface validation issues into a human-readable error string. | | `resolveSubjectPath` | function | Resolve a parsed `FindingSubject` to the file path the substrate | @@ -273,9 +280,14 @@ Import from `@tangle-network/agent-runtime/agent` — 41 exports. | `validateProfileMaterialization` | function | Return every changed profile axis that the selected run path would drop. | | `validateSurfaces` | function | Validate an `AgentSurfaces` map on disk — missing paths fail loud at `defineAgent` time instead of silently skipping self-improvement edits. | | `AGENT_PROFILE_MATERIALIZATION_AXES` | const | The 29 canonical AgentProfile leaves that can affect one execution. | +| `controlProfileMaterialization` | const | Materialization contract for a raw process path that carries only control/identity fields. | +| `fullProfileMaterialization` | const | Materialization contract for a run path that executes every canonical AgentProfile leaf. | +| `promptControlProfileMaterialization` | const | Materialization contract for an injected inference function whose surrounding driver still | +| `promptModelProfileMaterialization` | const | Materialization contract for an intentionally limited prompt-and-model execution path. | | `promptOnlyProfileMaterialization` | const | Materialization contract for a run path that only injects prompt text. | | `promptResourceProfileMaterialization` | const | Materialization contract for a run path that injects prompt text plus inline resources. | | `sandboxActProfileMaterialization` | const | Materialization contract for `createSandboxAct`. | +| `worktreeCliProfileMaterialization` | const | Materialization contract for a local coding CLI in an isolated git worktree. | | `AgentManifestError` | class | Thrown when `defineAgent` finds a required surface missing on disk. | | `AgentManifest` | interface | The full agent manifest. Each agent ships ONE of these. | | `AgentSurfaces` | interface | Surface declarations. Every path is repo-relative (or absolute) at | @@ -287,6 +299,7 @@ Import from `@tangle-network/agent-runtime/agent` — 41 exports. | `SurfaceValidationIssue` | interface | Validate that every declared surface exists on disk under `repoRoot`. | | `ValidateProfileMaterializationOptions` | interface | Input for checking a candidate diff against a run path. | | `AgentProfileMaterializationAxis` | type | AgentProfile axis name, with `custom:` reserved for caller-owned extensions. | +| `CanonicalAgentProfileMaterializationAxis` | type | Compatibility name used by runtimes that distinguish canonical axes. | **Undocumented supporting types** (add a TSDoc line at the declaration to earn a table row): `AgentRubric`, `AgentRunContext`, `AgentRunInvocation`, `AgentRuntime`, `AnalystConfig`, `CreateSandboxActOptions`, `CreateSurfaceImprovementProposerOptions`, `DraftPatchInput`, `DraftPatchOutput`, `JudgeConfig`, `ResolvedSurface`, `RubricDimension`, `SurfaceImprovementEdit`, `KnownAgentProfileMaterializationAxis`. @@ -494,7 +507,7 @@ Import from `@tangle-network/agent-runtime/intelligence` — 166 exports. ### Execution kernel — recursive atom, supervision, executors, round-synchronous loop -Import from `@tangle-network/agent-runtime/kernel` — 647 exports. +Import from `@tangle-network/agent-runtime/kernel` — 698 exports. | Symbol | Kind | Summary | |---|---|---| @@ -508,10 +521,11 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `asAuthoredProfile` | function | Narrow an untyped `spawn_agent` profile argument to an `AuthoredProfile`, or null if the | | `assertCoordinationBinding` | function | Fail closed on a non-loopback coordination bind. `serveCoordinationMcp` mounts spawn_agent / | | `assertModelAllowed` | function | Throw a `ConfigError` when `allowed` is set, `model` is defined, and `model` is not a | +| `assertProfileModelsAllowed` | function | Check every canonical model-bearing field in a complete profile, including the models a | | `assertStrategyContract` | function | Static CONTRACT lint over an authored strategy module — the module-boundary | | `assessAuthoredProfile` | function | OBSERVE one authored `AgentProfile` and score its richness (no judge verdict is read). The task | | `auditIntent` | function | The route-rigor analyst: compare declared vs revealed vs user intent over a trajectory and return aligned / drifting / diverged with evidence and one recommended intervention. | -| `authoredWorker` | function | Build a worker AGENT from a profile the supervisor authored: the authored `systemPrompt` + | +| `authoredWorker` | function | Build a router-only worker from an authored profile. This helper executes the prompt/model axes; | | `authorStrategy` | function | Author + load a strategy from losses. Throws when the author emits no loadable module; | | `bestSoFar` | function | The best-so-far fold — the ONE definition of "how good was the run after k results", shared by | | `breadthStrategy` | function | BREADTH: K independent rollouts (each own artifact), verifier picks the best. | @@ -519,6 +533,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `buildSteerContext` | function | Build the `SteerContext` a combinator reads to steer (its `loopUntil.until`, `widen` gate, any | | `canDisplace` | function | The repair keep-best guard: a challenger displaces the incumbent only when it is | | `canonicalizeAuthoredProfile` | function | Lift a profile the supervisor AUTHORED into the canonical shape every executor reads. | +| `captureWorkerTraceEvidence` | function | Collect and persist one executor's structured tool trace without changing its task outcome. | | `closingWorkerNote` | function | The worker's closing commentary off a local harness run: the TAIL of its | | `collectAgentTurn` | function | Drain a `streamAgentTurn` stream (or any `RuntimeStreamEvent` stream that | | `compareCheckOutcomes` | function | The selection order: crash < ran; then official pass-fraction; authored guesses only | @@ -527,7 +542,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `composeWorkerEvidence` | function | Compose the settle evidence block. Section order is priority order under the | | `computeFindingId` | function | Compute the stable finding_id from the identity-defining fields. | | `connectStdioMcp` | function | Spawn a trusted host command, complete the stdio MCP handshake, and return | -| `contentAddress` | function | Mint the content-addressed `outRef` for a result artifact: `sha256:` over a | +| `contentAddress` | function | Stable content address shared by result and trace artifacts. | | `copyUntrackedIntoClone` | function | Copy every untracked file of `sourceDir`'s working tree — including git-ignored | | `createActivityLog` | function | Create a bounded activity ring. `limit` caps memory for a worker that runs thousands of tools. | | `createAgentEnvironmentProviderRegistry` | function | Create a registry that resolves provider names to concrete provider instances. | @@ -541,6 +556,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `createMcpEnvironment` | function | Wrap any MCP server as an `Environment`: `tools/list` becomes `AgenticTool[]` with provider-safe schemas; the domain supplies only the artifact lifecycle hooks. | | `createProgressTracker` | function | Build the settled-work ledger a `StopRule` decides from: record each settlement (idempotent by | | `createPushTraceSource` | function | A push source for OWNED tool loops (router-tools / cli-bridge tool dispatch): the loop calls | +| `createRootHandle` | function | Mint a `RootHandle` plus its supervisor-private control. The handle is the substrate a | | `createSandboxLineage` | function | Build a lineage bound to one client + its probed capabilities. The | | `createSandboxToolPartState` | function | Fresh per-turn {@link SandboxToolPartState} for {@link mapSandboxToolEvent} — an | | `createScope` | function | Create the reactive `Scope` a driver's `Agent.act` runs inside: spawn children on an atomically reserved conserved budget, settle via the `next()` cursor, journal for replay. | @@ -586,6 +602,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `jjWorkspace` | function | A jj-backed `Workspace` (Jujutsu, colocated with git for the durable remote). | | `leaderboard` | function | Aggregate a fleet of records into the ranked, multi-axis report. Pure — no IO, deterministic. | | `legacySupervisorRunDir` | function | Where a pre-rename writer put the same run (`/.loops/supervisor/`). Readers that must | +| `loadSpawnForest` | function | Load every journal tree owned by one recursive supervision run and flatten its nodes/events. | | `localSandboxClient` | function | A same-host `SandboxClient` adapter with no process isolation. Local MCP is | | `localShell` | function | Host-process `Shell`: run a command via `execFile`, resolving `{ stdout, stderr, code }` (never throws on non-zero exit). | | `loopCampaignDispatch` | function | Adapter for plain `runCampaign` scenarios. This is the Runtime-side pair for | @@ -604,6 +621,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `openSandboxRun` | function | Open a sandbox run. Harness-agnostic: the harness lives in | | `pairwiseSignificance` | function | Compare EVERY profile pair on the scenarios they both ran — paired-bootstrap effect + CI, a real | | `panel` | function | `panel(spec)` — spawn the M judge children over the SAME artifact, drain their settlements, | +| `parseWorkerToolTraceArtifact` | function | Validate a stored trace artifact before an analyst or replay trusts it. | | `patchDelivered` | function | Build the `DeliverableSpec`: `check(artifact)` runs the shared mechanical | | `pendingWaits` | function | The waits a journaled tree shows as ARMED but never woken — what a resumed run re-arms with the | | `pickBestDelivered` | function | The single argmax both the default finalizer and `finalizeBestDelivered` share: highest | @@ -691,6 +709,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `workerFromBackend` | function | Build the worker seam from a backend (WHERE workers run) + an optional completion oracle (the | | `workerInboxFile` | function | The durable inbox file for one worker of one run. | | `workerInboxFileFromEventDir` | function | Same, addressed from an already-known run directory (the reader's usual entry point). | +| `workerTraceAnalysisStore` | function | Rehydrate exact persisted spans through agent-eval's one bounded trace-analysis adapter. | | `workerTraceEnv` | function | The `TRACE_ID` / `PARENT_SPAN_ID` pair to merge into a worker's environment — EMPTY when the run | | `worktreeFanout` | function | Build the worktree fanout combinator. Run it with `runPersonified({ persona, shape, task, budget })` | | `writeWorkerSteer` | function | Durably append one steer request to a worker's inbox and log the delivery attempt. | @@ -700,6 +719,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `builtinShapes` | const | The default registry `runPersonified` resolves a shape name against. Empty by construction — | | `cliWorktreeExecutor` | const | The leaf `createWorktreeCliExecutor` as a backend-as-data factory: a supervisor-authored | | `collectDelivered` | const | Every verified distinct output, highest score first — the shape for competing hypotheses, a | +| `DEFAULT_AUTHORED_PROFILE_SECURITY_POLICY` | const | Manager-authored profiles are untrusted until product policy says otherwise. Remote MCP and | | `DEFAULT_AWAIT_EVENT_TIMEOUT_MS` | const | Default ceiling for a single `await_event` block (ms). Chosen well under any reasonable remote | | `DEFAULT_SANDBOX_STEERING_MAX_TURNS` | const | Ceiling on continuation turns. Turn 0 is the task; every later turn is a folded steer, so | | `DEFAULT_STALL_AFTER_MS` | const | How long a worker may produce no metered activity before a `progress()` read calls it stalled. | @@ -723,6 +743,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `sampleThenRefine` | const | The explore-then-exploit MIX: spend ⌈budget/2⌉ on independent samples (kept open), | | `strategyAuthorContract` | const | The compressed consumable a skill carries: everything an author needs to emit a loop. | | `VERIFY_TAIL_CHARS` | const | Tail of the verify output — the failing assertion lives at the END of a test log. | +| `WORKER_TOOL_TRACE_SCHEMA_VERSION` | const | Schema version for content-addressed worker tool-trace artifacts. | | `workerTraceSeamKey` | const | Seam key the `Scope` seeds a {@link TraceContext} under on each child's `ExecutorContext.seams`. | | `FileCoordinationLog` | class | FS-backed `CoordinationLog`: append-only JSONL, fsynced per record. | | `FileCorpus` | class | JSONL on disk — one validated `CorpusRecord` per line, append-only. `query` replays the whole | @@ -738,18 +759,22 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `ActivityNote` | interface | The most recent activity the executor can name — one tool call, one turn, or a free-form note. | | `Agent` | interface | One self-similar atom. A leaf is an `Agent` that never calls `scope.spawn`; a driver | | `AgentEnvironmentProviderRegistry` | interface | In-memory registry for named `AgentEnvironmentProvider` instances. | +| `AgentExecutionRef` | interface | Caller-owned identity beyond the exact profile/task bytes Scope can compute itself. | | `AgenticSurface` | interface | A stateful, checkable environment an agent operates over with tools. Open behind one interface. | | `AgentProfile` | interface | Public provider-neutral agent profile contract. | | `AgentRunSpec` | interface | Sandbox-SDK-shaped agent specification. | -| `AgentSpec` | interface | `AgentProfile` does NOT carry a `harness`/backend field — `harness` lives on the | +| `AgentSpec` | interface | `AgentProfile.harness` is a portable preference; this wrapper records the executor decision for | | `AgentTurnUsage` | interface | Metered usage of one turn, summed over every cost-bearing event the backend | | `AnalystFinding` | interface | Unified envelope every analyst emits. Schema-versioned so renderers | | `AnalystFindingEvent` | interface | A trace-analyst result re-entered as a message on the bus (the `finding` event kind). | -| `AuthoredProfile` | interface | What the supervisor AUTHORS per sub-task — a worker recipe (a partial `AgentProfile`). | +| `AuthorizedDownMessage` | interface | Product-authorized continuation bytes. Returning a narrowed instruction replaces the proposed | +| `AuthorizedSpawn` | interface | The product-authorized result for one complete spawn request. Attribution is never accepted | +| `AuthorizedSpawnContext` | interface | Exact trusted context after a manager-authored spawn has passed product authorization. | | `BenchmarkCell` | interface | One strategy's outcome on one task — the per-task cell an optimizer consumes. | | `BenchmarkReport` | interface | Benchmark output: per-strategy means plus the full per-task × per-strategy losses table an optimizer mines. | | `BridgeSeam` | interface | cli-bridge seam. A local OpenAI-compatible bridge that fronts harness CLIs | | `Budget` | interface | A budget envelope on a spawn or the root. All ceilings; the pool reserves against them. | +| `BudgetPoolRestore` | interface | State recovered from a prior process before new work is admitted. `committed` is measured spend | | `BusEvent` | interface | Every bus event is a discriminated union member keyed by `type`. | | `BusRecord` | interface | A published event stamped for ordering and observability. `seq` is the monotonic publish index; | | `CheckExecChannel` | interface | Minimal exec channel the default runner needs. `SandboxInstance` (and therefore | @@ -766,6 +791,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `CompletionPolicy` | interface | When a verdict authorizes the driver to END. Deterministic → trust (ground truth); | | `CompletionVerdict` | interface | The "is it done?" verdict an analyst returns to the parent. | | `ConcurrencyCaps` | interface | The caps a host can set on simultaneous work. See the ledger in this module's header for what | +| `ContinuationInstruction` | interface | Durable authorization receipt written before a continuation reaches a worker. | | `CoordinationBinding` | interface | Where the coordination MCP binds. Omit = an ephemeral port on `127.0.0.1` (the local-harness | | `CoordinationLog` | interface | The durable coordination side-log seam. `append` records one bus event (kinds it does not | | `Corpus` | interface | The durable cross-run corpus — the learning-flywheel store. DISTINCT from `SpawnJournal` | @@ -780,14 +806,21 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `DeliverableSpec` | interface | The deployable completion oracle passed to {@link gateOnDeliverable}: a `check` that | | `DeliveredOutput` | interface | One DELIVERED child, materialized: settled `done`, oracle-passed, output rehydrated. `out` is | | `DispatchUnit` | interface | One unit of queued work: the agent to run, its task, and the spawn options (budget + label). | -| `DownMessageEvent` | interface | A parent→child message (the down-leg): recorded for observability, delivered via the child inbox, | +| `DownMessageAuthorizationInput` | interface | Detached continuation bytes and exact worker identity presented to product authorization before | +| `DownMessageDeliveryAttempt` | interface | A durable marker written after authorization and immediately before Runtime calls `Scope.send`. | +| `DownMessageEvent` | interface | A parent→child delivery result (the down-leg): recorded for observability, never pulled back by | +| `DriveHarness` | interface | How to run an external harness as the DRIVER, with the coordination verbs mounted — the substrate | | `DumbDriverOptions` | interface | Options for {@link dumbDriver}. | | `EqualKArm` | interface | One arm of an equal-k comparison — a labeled trajectory (a `TrajectoryReport` is one arm's whole | | `EqualKOnCostOptions` | interface | `equalKOnCost(arms, { tolerance? })` — assert arms are comparable at EQUAL conserved COST | | `EqualKVerdict` | interface | The equal-k-on-cost verdict: whether every arm spent within `tolerance` of the others on the | | `ExecCtx` | interface | Execution context for `runAgentRounds`: the sandbox client the kernel creates boxes through, plus optional runtime hooks. | | `Executor` | interface | The leaf runtime — ONE open interface, not a closed union. `execute` returns a | +| `ExecutorAccounting` | interface | Split used by a recursive executor when journaled child work differs from the full amount | | `ExecutorContext` | interface | Construction context handed to a `ExecutorFactory` — the seams a built-in needs | +| `ExecutorExecutionBinding` | interface | Volatile execution routing that is true for one attempt but is not profile identity. The full | +| `ExecutorMaterialization` | interface | Data-only declaration from trusted executor code about the exact sealed plan `execute` uses. | +| `ExecutorNodeContext` | interface | Kernel-owned context for the concrete supervised node a factory is constructing. | | `ExecutorProgress` | interface | What an executor OPTIONALLY adds to the scope-derived progress (`Executor.progress()`). Every | | `ExecutorRegistry` | interface | The OPEN resolver: maps an `AgentSpec` to a `ExecutorFactory`. The default | | `ExecutorResult` | interface | Terminal artifact of a one-shot `Executor.execute`. | @@ -822,9 +855,11 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `LoopTokenUsage` | interface | LLM token usage. Structurally maps into agent-eval's paid-call receipt so a | | `LoopUntilSpec` | interface | `loopUntil({ until, step })` — iterative deepening inside the conserved pool: spawn one `step` | | `LoopUntilState` | interface | The accumulated state `loopUntil` threads across rounds — the running candidate + the round | +| `MaterializedExecutionIdentity` | interface | External execution identity that operators can use to join this node to its backend. | | `McpEndpoint` | interface | Where a handle's MCP server lives; headers carry per-artifact scoping. | | `MountManifestEntry` | interface | One mounted resource recorded during box preparation — a pure provenance | | `NaiveDriverOptions` | interface | Options for {@link naiveDriver}. | +| `NodeExecutionIdentity` | interface | Durable identity of one realized node. Missing digests mean the input was not canonical JSON. | | `NoWinnerError` | interface | A driver's `act()` rejection, normalized to a serializable triple so it survives the typed | | `OpenSandboxRunBeforeStartContext` | interface | Context available after the box/session exists and before the first prompt is | | `OutputAdapter` | interface | Stream of `SandboxEvent`s → typed `Output`. | @@ -843,7 +878,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `PiMcpReceipt` | interface | What pi was actually given, as opposed to what the profile declared. This is the observable that | | `PipelineStage` | interface | `pipeline(stages)` — sequential composition: each stage's `Outcome.deliverable` feeds the next | | `PiSeam` | interface | How to launch pi in its out-of-process RPC mode, and how long to wait on it. | -| `PriorCoordination` | interface | What a prior process's coordination log replays into a resumed driver. | +| `PriorCoordination` | interface | Coordination evidence loaded from prior processes of one durable supervised run. | | `ProfileRichness` | interface | Per-field verdict on one authored profile — the raw material the bench renders + scores. | | `ProfileRichnessThresholds` | interface | Thresholds below which a system prompt is treated as a thin stub. Tunable per call. | | `ProgressSample` | interface | One settled unit of work, reduced to what a stop rule reads. `objective` is the run's own | @@ -860,7 +895,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `ResultBlobStore` | interface | Content-addressed result blobs (the `outRef` → artifact map) backing the replay | | `ResumedKeyState` | interface | What the journal proves about one keyed assignment at resume time. | | `ResumedWork` | interface | The committed work a resumed run inherits from its journal. `settled` is the replayed | -| `RootHandle` | interface | Live root handle — the substrate a chat/pi-viz client attaches to (Q2). `signal` | +| `RootHandle` | interface | Live root handle — a chat/pi-viz client uses it to inspect and control one root run. | | `RouterSeam` | interface | Router/inline connection seam. A direct OpenAI-compatible Router endpoint — | | `RouterToolCall` | interface | A tool-call the model emitted (provider-neutral; mirrors the runtime's ToolCallRequest). | | `RouterToolsSeam` | interface | Router seam WITH tool use — the tool-using router backend. Same direct | @@ -889,8 +924,15 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `ShapeContext` | interface | The construction context a `LoopShape` factory receives. Carries the persona's resolved | | `ShapeRegistry` | interface | The open shape registry — the extension point that makes a new loop-shape ONE file + one | | `ShotPersona` | interface | A role for one shot — multi-agent loops (researcher + engineer, a panel of k | +| `SpawnForest` | interface | Complete cold-readable view of one recursive supervision run. | +| `SpawnForestEvent` | interface | One event with the journal tree that establishes its cursor namespace. | +| `SpawnForestInDoubtNode` | interface | A spawned worker with no terminal record in a cold snapshot. Resume treats the same state as | +| `SpawnForestMissingTree` | interface | A driver spawn whose owned journal tree was never begun before the process stopped. | +| `SpawnForestNode` | interface | One flattened node with the journal tree that owns its records. | +| `SpawnForestTree` | interface | One journal tree in a recursively loaded supervision forest. | | `SpawnJournal` | interface | The spawn-tree event source (mirrors `ConversationJournal`'s begin/append/load shape). | | `Spend` | interface | Conserved spend, reconciled from the normalized `UsageEvent` stream. Tokens and usd | +| `SteerableRootHandle` | interface | A Runtime-minted root handle that can deliver raw steering or answers to a live manager inbox. | | `SteerableSandboxSession` | interface | What the steerable session exposes to its executor: the usage stream plus the live reads. | | `SteerContext` | interface | How a combinator's `act` consumes findings to steer — the SINGLE firewalled steer surface a | | `StrategyArtifacts` | interface | Artifact lifecycle a strategy may manage itself — open/close ONLY. Raw `call`/`score` | @@ -902,8 +944,11 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `SuperviseRegistryTable` | interface | A name→value table, in this package's resolver-port shape (the same one `WaitProbeRegistry` | | `SuperviseSurfaceResult` | interface | The deployable outcome of a supervised surface run. | | `Supervisor` | interface | Owns the conserved pool, the spawn log, the abort cascade, the OTP intensity breaker, | +| `SupervisorNodeContext` | interface | Trusted run/node identity Runtime binds to one manager. Model-authored tool arguments cannot | | `SupervisorProfile` | interface | The supervisor's profile — the subset of an `AgentProfile` that selects + shapes its brain. | | `SupervisorSpanOutcome` | interface | How the supervised run ended, as `finish()` records it on the root span. | +| `SupervisorToolDescriptor` | interface | One product-owned tool. It reuses the canonical MCP descriptor fields while Runtime supplies | +| `SupervisorToolInvocationContext` | interface | Trusted context for one product-tool invocation. The node identity remains the same detached, | | `SurfaceWorkerConfig` | interface | How a worker runs the surface task (its router substrate + per-attempt bounds). | | `SurfaceWorkerOut` | interface | What a surface worker settles with — the surface verdict the driver + deliverable read. `resolved` is | | `ToolLoopCompaction` | interface | Self-compaction — bound the loop's OWN context window the way a fresh-respawn (dumb-Ralph) loop | @@ -921,7 +966,9 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `WidenLineage` | interface | A lineage the gate may widen toward — the settled child that looked promising + the findings | | `WidenSpec` | interface | `widen({ gate })` (G5) — the STREAMING spawn-on-completion driver. Unlike the static-fanout | | `WorkerProgress` | interface | The full live view of one worker, as `observe_agent` returns it mid-flight. | +| `WorkerSpawnContext` | interface | Immutable task, allocation, identity attribution, and semantic key supplied while a manager's | | `WorkerSteerRequest` | interface | One durable down-leg request appended to a worker's inbox file. | +| `WorkerToolTraceArtifact` | interface | Bytes stored under `WorkerTraceEvidence.traceRef`. | | `WorkerTraceSeamCarrier` | interface | What the two readers below need off an `ExecutorContext` — its seam bag, and nothing else. | | `WorktreeCommandResult` | interface | Outcome of one verification command run in the worktree (test or typecheck). | | `WorktreeHarnessResult` | interface | The canonical result of one worktree-harness run, projected by each port to its own shape. | @@ -931,16 +978,23 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `AgentTurnBackend` | type | The execution substrate one turn runs on — a closed discriminated union over | | `ApplyContinuation` | type | Fold a steering string into the caller's Task shape, producing the Task for | | `AssertTraceDerivedFindings` | type | The firewall assertion contract, re-stated for the reactive seam (PORT of | +| `AuthoredProfile` | type | What the supervisor AUTHORS per sub-task: one complete canonical profile whose name and | +| `AuthorizeDownMessage` | type | Product decision over an exact continuation before it is durably recorded or delivered. | | `AxisScoresOf` | type | Decompose ONE record into per-axis scores (e.g. judge dimensions). When set, it REPLACES the | | `BudgetReadout` | type | Post-reservation pool readout — the shape `Scope.budget` exposes. `tokensLeft`, | | `CombinatorShape` | type | A combinator is just a `LoopShape`: a factory `(ShapeContext) => Agent` whose `Agent.act` | +| `CoordinationDeliveryEvidence` | type | Durable delivery evidence retained in commit order. An attempt without a later event carrying | | `CoordinationEvent` | type | Every message on the one typed pipe. UP (child→parent): question / settled / finding — queued for | +| `CoordinationOwnerId` | type | Stable identity of the supervisor that owns one coordination stream. High-level supervision | | `DefinePersona` | type | Builds a frozen `Persona`, failing loud on the executors-supplied invariant (neither a | | `Deliverable` | type | How a typed deliverable `Out` is materialized from a finished turn. | +| `DeliverableResolutionInput` | type | Exact trusted context for selecting one backend-derived leaf's completion check. | | `DispatchStopReason` | type | Why the dispatcher stopped admitting work. `drained` = the queue ran dry (the ordinary end); | -| `DriveHarness` | type | How to run a sandboxed harness as the DRIVER, with the coordination verbs mounted — the substrate | +| `DownMessageDeliveryOutcome` | type | The exact result of one parent→child delivery attempt. | +| `DriveHarnessOwnerContext` | type | Trusted manager identity available before its external harness starts. A product uses this to | | `Environment` | type | A checkable task domain — implement these 5 hooks and the suite does the rest. The | | `EqualKOnCost` | type | `equalKOnCost(arms, opts)` — the cross-arm equal-compute check on conserved cost. | +| `ExecutionBindingReceipt` | type | One attempt's immutable link from a stable materialization plan to its actual transport. | | `ExecutorConfig` | type | Config for {@link createExecutor}: the backend is DATA — the cost dial a profile, | | `ExecutorFactory` | type | Builds a fresh `Executor` for one spawn from the resolved spec. Per-spawn (not | | `Fanout` | type | `fanout(items, opts)` — build the fanout combinator over a static item list. | @@ -951,17 +1005,23 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `LoopOptionsForDispatch` | type | runAgentRounds options minus the `ctx` (loopDispatch builds the ctx). | | `LoopShape` | type | A reusable act-body factory. Given the persona's content + seams (`ShapeContext`), it | | `LoopUntil` | type | `loopUntil(spec)` — build the iterative-deepening combinator. `seed` is the initial state. | +| `MaterializedModelIdentity` | type | A named model carried into an execution, or an explicit reason the exact model is unknowable. | | `MountRecorder` | type | Records a mounted resource into the run's provenance manifest. Passed to | | `NodeId` | type | Deterministic node id — `${parent}:s${seq}` from the cursor order, never wall-clock. | | `NodeStatus` | type | `'acquiring'` is first-class (M1): a node spends real time + reaps an orphan box | +| `ObserveSupervisorNodeEvent` | type | Context-aware observer used internally to bind product transactions to the actual live node. | | `OpenSandboxRunPromptOptions` | type | Prompt options forwarded to every sandbox prompt turn in this run. The | | `Outcome` | type | The terminal contract Drew wants: a loop returns a FINISHED deliverable, or the concrete | | `Panel` | type | `panel(spec)` — build the M-judge write-only-merge combinator. | | `Pipeline` | type | `pipeline(stages)` — build the sequential combinator from an ordered stage list. The first | | `ProfileKeyOf` | type | The profile (matrix row) a record belongs to — default `harness·model` from the record's profile cell, | +| `ProfileMaterializationReceipt` | type | What the kernel can prove about one node's actual execution plan. | | `RenderCorpusToInstructions` | type | `renderCorpusToInstructions(opts)` — the flywheel read-back projection. Async (queries the | | `ReservationRejection` | type | Why a reservation was refused. `budget-exhausted` means the pool ran out of a channel it | +| `ResolveDriveHarness` | type | Resolve an external harness for one exact Runtime-owned manager identity. | +| `ResolveSupervisorTools` | type | Product policy for the tools one exact supervisor node may call. Resolved once per node. | | `Restart` | type | OTP child-spec restart class. | +| `RootMaterialization` | type | Trusted root composition evidence. Generic `Agent.act` roots omit this and remain unknown. | | `RootSignal` | type | Out-of-band message to a running root. Open by intent — a client extends it. | | `RunContext` | type | The stores a supervised run needs, in-memory or file-backed. `InMemoryRunContext` is the | | `RunLoopOptions` | type | Pre-rename name for {@link RunAgentRoundsOptions}. | @@ -980,11 +1040,13 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `StructuralRolloutMessage` | type | Provider-neutral conversation records read by structural candidate extraction. | | `SupervisedResult` | type | Typed terminal result (M2) — a no-winner is NEVER coerced to a best-effort output. | | `SupervisorFinalizer` | type | The finalization seam: ledger in, output (or `undefined` = nothing deliverable) out. | +| `SupervisorNodeContextSeed` | type | Context known before `Agent.act`; Runtime adds the concrete node, profile, and task. | | `SupervisorSpanAttributes` | type | OTLP span attribute values. Exported because `SupervisorSpanOptions.attributes` is public and | | `ToolLoopChat` | type | One inference turn over the running conversation + the tool specs → the model's text, any | | `ToolLoopCompactionOptions` | type | Public supervisor-facing compaction config: same knobs as the primitive, but `distill` is optional | | `ToolLoopMessageRecord` | type | Provider-neutral conversation record accepted by a tool-loop brain. | | `TrajectoryReportFn` | type | `trajectoryReport(...)` — the tree+cost reconstructor. Async (reads journal + optionally blobs). | +| `UnknownMaterializationReason` | type | Why exact materialization evidence is unavailable for a node. | | `UsageEvent` | type | Normalized usage event — the single channel every executor reports through, so the | | `Verify` | type | `verify(spec)` — build the 2-node implement→verifier-gate combinator. | | `WaitProbe` | type | A named predicate a `poll` node re-checks. Returns true when the condition it watches has | @@ -993,7 +1055,9 @@ Import from `@tangle-network/agent-runtime/kernel` — 647 exports. | `Widen` | type | `widen(spec)` — build the streaming progressive-widening combinator. | | `WidenDecision` | type | A widening decision: extend one lineage by one child, or stop widening. `flatWidenGate` | | `WinnerStrategy` | type | Built-in valid-only winner strategies for `selectValidWinner` (selector≠judge): best gated-valid | +| `WorkerTraceEvidence` | type | Durable proof of a worker's structured tool trace, or the exact reason it is unavailable. | | `WorkerTraceResolver` | type | Resolve the trace context a worker spawned BY `spawningNodeId` should inherit. `undefined` means | +| `WorkerTraceUnavailableReason` | type | Why Runtime cannot provide structured tool-call evidence for one settled execution. | | `WorktreeCheckRunner` | type | The single shell-command-in-worktree runner seam (replaces the per-executor copies). | | `WorktreePatchArtifact` | type | Terminal artifact of one worktree-CLI run — the canonical worktree-harness result (the captured | @@ -1138,10 +1202,11 @@ Import from `@tangle-network/agent-runtime/primeintellect` — 30 exports. ### Candidate execution — immutable prepare, run, grade, and receipt -Import from `@tangle-network/agent-runtime/candidate-execution` — 104 exports. +Import from `@tangle-network/agent-runtime/candidate-execution` — 108 exports. | Symbol | Kind | Summary | |---|---|---| +| `agentCandidateProfileAsAgentProfile` | function | Convert the candidate profile contract into the portable interface profile it represents. | | `applyExactAgentProfileDiff` | function | Apply one exact diff and reject any value that cannot be preserved canonically. | | `assertCandidateProfileBinding` | function | Prove the measured generic profile and sealed candidate profile describe the same behavior. | | `buildAgentCandidateBundle` | function | Compile one measured profile/code candidate into the immutable execution | @@ -1154,8 +1219,11 @@ Import from `@tangle-network/agent-runtime/candidate-execution` — 104 exports. | `disposePreparedAgentCandidateExecution` | function | Revoke reservations held by a prepared candidate that will not be executed. | | `exactProcessProviderAsCandidateExecutor` | function | Adapt one neutral exact-process provider to Runtime's trusted candidate boundary. | | `executePreparedAgentCandidate` | function | Executes and finalizes one durably claimed candidate without exposing an unproven result. | +| `freezeGenericAgentCandidateProfile` | function | Convert only behavior-preserving generic profile fields into the closed candidate contract. | +| `omitUndefinedObjectFields` | function | Recursively remove undefined object fields while refusing undefined array entries. | | `parseExactAgentProfile` | function | Parse a complete profile without silently discarding unsupported fields. | | `parseExactAgentProfileDiff` | function | Parse a profile diff without silently discarding unsupported fields. | +| `parseExactCandidateProfile` | function | Parse a candidate profile without silently discarding unsupported or non-canonical fields. | | `persistCandidateOutputArtifact` | function | Persist evaluator evidence, read it back, and bind the returned locator to the exact bytes. | | `prepareAgentCandidateExecution` | function | Materializes a verified candidate into one immutable evaluator-owned execution plan. | | `recoverExpiredAgentCandidateExecution` | function | Close an expired crashed attempt from persisted non-secret handles, then record failure. | @@ -1223,7 +1291,7 @@ Import from `@tangle-network/agent-runtime/testing` — 4 exports. ### MCP servers — delegate / coordination / detached-session -Import from `@tangle-network/agent-runtime/mcp` — 200 exports. +Import from `@tangle-network/agent-runtime/mcp` — 207 exports. | Symbol | Kind | Summary | |---|---|---| @@ -1305,10 +1373,12 @@ Import from `@tangle-network/agent-runtime/mcp` — 200 exports. | `InMemoryFeedbackStore` | class | In-memory `FeedbackStore` — suitable for single-process use and tests. | | `AgentMemorySpec` | interface | The `memory` artifact payload — HOW a profile's memory is stored and served: | | `AnalystFindingEvent` | interface | A trace-analyst result re-entered as a message on the bus (the `finding` event kind). | +| `AuthorizedDownMessage` | interface | Product-authorized continuation bytes. Returning a narrowed instruction replaces the proposed | | `CodexExecutionEvidence` | interface | Zero-model-call evidence for the exact Codex process about to run. | | `CodexExecutionFailureDiagnostic` | interface | Bounded, credential-redacted process context attached when reproducible Codex output fails | | `CodexExecutionPolicy` | interface | Isolation settings asserted before a reproducible Codex run is allowed to start. | | `CodexTokenUsage` | interface | Exact aggregate usage emitted by Codex's terminal `turn.completed` JSONL event. | +| `ContinuationInstruction` | interface | Durable authorization receipt written before a continuation reaches a worker. | | `CoordinationTools` | interface | The supervisor-side toolbox returned by {@link createCoordinationTools}: the MCP tool | | `DelegateArgs` | interface | Parsed `delegate` tool arguments. | | `DelegateCodeConfig` | interface | Minimal `CoderTask` overrides exposed over the MCP wire. The full | @@ -1319,7 +1389,9 @@ Import from `@tangle-network/agent-runtime/mcp` — 200 exports. | `DelegationTraceCollector` | interface | Per-delegation trace collector. Buffers `LoopTraceEvent`s per runId | | `DelegationTraceSpan` | interface | One span of a delegation's compact trace. Flat (parent linkage by id), all | | `DetachedSessionRefParts` | interface | Decoded `DelegationRecord.detachedSessionRef`. `sandboxId` is absent between | -| `DownMessageEvent` | interface | A parent→child message (the down-leg): recorded for observability, delivered via the child inbox, | +| `DownMessageAuthorizationInput` | interface | Detached continuation bytes and exact worker identity presented to product authorization before | +| `DownMessageDeliveryAttempt` | interface | A durable marker written after authorization and immediately before Runtime calls `Scope.send`. | +| `DownMessageEvent` | interface | A parent→child delivery result (the down-leg): recorded for observability, never pulled back by | | `DriveTurnCapableBox` | interface | The box surface detached turns need. `SandboxInstance` | | `FleetHandle` | interface | Minimal `SandboxFleet` surface the fleet executor calls. Declared | | `JsonRpcMessage` | interface | One JSON-RPC 2.0 request or notification. | @@ -1331,13 +1403,16 @@ Import from `@tangle-network/agent-runtime/mcp` — 200 exports. | `ResolvedMemoryEnv` | interface | What the memory bin resolved from its environment. | | `SettledWorker` | interface | A worker the driver has drained via `await_event`. | | `UiAuditorDelegationOutput` | interface | Wire-shape of a completed UI-audit delegation. The `findings` array | +| `WorkerSpawnContext` | interface | Immutable task, allocation, identity attribution, and semantic key supplied while a manager's | | `WorkerWatchOptions` | interface | Online-detector wiring for spawned workers (`CoordinationToolsOptions.watchWorkers`). | +| `AuthorizeDownMessage` | type | Product decision over an exact continuation before it is durably recorded or delivered. | | `CoderReviewer` | type | Optional adversarial reviewer over a coder candidate that already passed | | `CoordinationEvent` | type | Every message on the one typed pipe. UP (child→parent): question / settled / finding — queued for | | `DelegateResult` | type | The synchronous result the `delegate` tool returns to the calling agent: the delivered output (or | | `DelegationArgs` | type | Arguments accepted by the durable delegation queue. | | `DelegationResultPayload` | type | Polymorphic `result` field: `CoderOutput` when the underlying profile | | `DelegationResumeTick` | type | One observation of a detached run, mapped 1:1 from a single-tick driver | +| `DownMessageDeliveryOutcome` | type | The exact result of one parent→child delivery attempt. | | `DriveTurnTick` | type | Structural mirror of the sandbox SDK's `TurnDriveResult` (>= 0.6). | | `GitRunner` | type | Pluggable git runner (sync) — replaceable in tests. | | `LocalHarness` | type | Local coding harness available inside the sandbox. | diff --git a/docs/api/runtime.md b/docs/api/runtime.md index b8e43af9..1cf193ac 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -619,7 +619,7 @@ FS-backed `CoordinationLog`: append-only JSONL, fsynced per record. ##### append() -> **append**(`runId`, `event`, `at`): `Promise`\<`void`\> +> **append**(`runId`, `record`, `ownerId?`): `Promise`\<`void`\> ###### Parameters @@ -627,11 +627,11 @@ FS-backed `CoordinationLog`: append-only JSONL, fsynced per record. `string` -###### event +###### record -[`CoordinationEvent`](index.md#coordinationevent) +[`BusRecord`](#busrecord)\<[`CoordinationEvent`](index.md#coordinationevent)\> -###### at +###### ownerId? `string` @@ -645,7 +645,7 @@ FS-backed `CoordinationLog`: append-only JSONL, fsynced per record. ##### load() -> **load**(`runId`): `Promise`\<[`PriorCoordination`](#priorcoordination-1)\> +> **load**(`runId`, `ownerId?`): `Promise`\<[`PriorCoordination`](#priorcoordination-1)\> ###### Parameters @@ -653,6 +653,10 @@ FS-backed `CoordinationLog`: append-only JSONL, fsynced per record. `string` +###### ownerId? + +`string` + ###### Returns `Promise`\<[`PriorCoordination`](#priorcoordination-1)\> @@ -663,3466 +667,3389 @@ FS-backed `CoordinationLog`: append-only JSONL, fsynced per record. ## Interfaces -### AnalystFindingEvent +### SpawnForestTree -A trace-analyst result re-entered as a message on the bus (the `finding` event kind). +One journal tree in a recursively loaded supervision forest. #### Properties -##### fromWorker +##### root -> `readonly` **fromWorker**: `string` +> `readonly` **root**: `string` -##### analyst +##### ownerNodeId? -> `readonly` **analyst**: `string` +> `readonly` `optional` **ownerNodeId?**: `string` -##### findings +Driver node that owns this tree; absent for the requested root tree. -> `readonly` **findings**: `unknown` +##### parentTreeRoot? + +> `readonly` `optional` **parentTreeRoot?**: `string` + +Journal tree containing `ownerNodeId`; absent for the requested root tree. + +##### events + +> `readonly` **events**: readonly [`SpawnEvent`](#spawnevent)[] + +##### view + +> `readonly` **view**: [`TreeView`](#treeview) *** -### DownMessageEvent +### SpawnForestEvent -A parent→child message (the down-leg): recorded for observability, delivered via the child inbox, - never pulled back by the parent. `delivered` mirrors whether the live child accepted it. +One event with the journal tree that establishes its cursor namespace. #### Properties -##### toWorker +##### treeRoot -> `readonly` **toWorker**: `string` +> `readonly` **treeRoot**: `string` -##### instruction +##### event -> `readonly` **instruction**: `string` +> `readonly` **event**: [`SpawnEvent`](#spawnevent) -##### delivered +*** -> `readonly` **delivered**: `boolean` +### SpawnForestNode -*** +One flattened node with the journal tree that owns its records. -### WorktreeCommandResult +#### Extends -Outcome of one verification command run in the worktree (test or typecheck). +- [`NodeSnapshot`](#nodesnapshot) #### Properties -##### command - -> **command**: `string` +##### treeRoot -The shell command line that was run. +> `readonly` **treeRoot**: `string` -##### passed +##### id -> **passed**: `boolean` +> `readonly` **id**: `string` -Did the command exit 0? The PASS signal a deliverable gate / coder output reads. +###### Inherited from -##### exitCode +[`NodeSnapshot`](#nodesnapshot).[`id`](#id-18) -> **exitCode**: `number` \| `null` +##### parent? -OS exit code, or `null` when killed before exit. +> `readonly` `optional` **parent?**: `string` -##### output +###### Inherited from -> **output**: `string` +[`NodeSnapshot`](#nodesnapshot).[`parent`](#parent-4) -Combined stdout+stderr (capped) — surfaced in traces for diagnosis. +##### label -*** +> `readonly` **label**: `string` -### WorktreeProfileMaterializationReceipt +###### Inherited from -Proof of the profile inputs delivered before the worker process started. +[`NodeSnapshot`](#nodesnapshot).[`label`](#label-17) -#### Properties +##### status -##### workspacePlanDigest +> `readonly` **status**: [`NodeStatus`](#nodestatus) -> **workspacePlanDigest**: `string` +###### Inherited from -Digest of the exact materializer plan: files, modes, environment, flags, and unsupported rows. +[`NodeSnapshot`](#nodesnapshot).[`status`](#status-9) -##### writtenPaths +##### runtime -> **writtenPaths**: `string`[] +> `readonly` **runtime**: [`Runtime`](#runtime-4) -Repository-relative profile input files written into the worker worktree. +###### Inherited from -##### unsupported +[`NodeSnapshot`](#nodesnapshot).[`runtime`](#runtime-5) -> **unsupported**: `Unsupported`[] +##### budget -Must be empty on a successful run because this path fails closed. +> `readonly` **budget**: [`Budget`](index.md#budget-4) -##### environmentNames +###### Inherited from -> **environmentNames**: `string`[] +[`NodeSnapshot`](#nodesnapshot).[`budget`](#budget-17) -Environment variable names added to the worker process. Values remain out of telemetry. +##### ownedTreeRoot? -##### flags +> `readonly` `optional` **ownedTreeRoot?**: `string` -> **flags**: `string`[] +Exact nested journal tree owned by this node, when Runtime attested recursive ownership. -Exact additional CLI arguments emitted by the materializer. +###### Inherited from -##### resourceInstructions +[`NodeSnapshot`](#nodesnapshot).[`ownedTreeRoot`](#ownedtreeroot-1) -> **resourceInstructions**: `object` +##### assignmentId? -`resources.instructions` bypasses native project files so reproducible Codex cannot drop it. +> `readonly` `optional` **assignmentId?**: `string` -###### delivery +Manager-scoped assignment identity, including deterministic ids for unkeyed siblings. -> **delivery**: `"none"` \| `"invocation-prompt"` +###### Inherited from -###### sha256 +[`NodeSnapshot`](#nodesnapshot).[`assignmentId`](#assignmentid-7) -> **sha256**: `string` \| `null` +##### identity? -###### byteLength +> `readonly` `optional` **identity?**: [`NodeExecutionIdentity`](#nodeexecutionidentity) -> **byteLength**: `number` +###### Inherited from -*** +[`NodeSnapshot`](#nodesnapshot).[`identity`](#identity-6) -### WorktreeHarnessResult +##### materialization? -The canonical result of one worktree-harness run, projected by each port to its own shape. +> `readonly` `optional` **materialization?**: [`ProfileMaterializationReceipt`](#profilematerializationreceipt) -#### Properties +Kernel-owned execution evidence. `unknown` is distinct from a known zero/empty plan. -##### branch +###### Inherited from -> **branch**: `string` +[`NodeSnapshot`](#nodesnapshot).[`materialization`](#materialization-2) -The branch the worktree was cut on (`delegate/`). +##### executionBindings? -##### patch +> `readonly` `optional` **executionBindings?**: readonly [`ExecutionBindingReceipt`](#executionbindingreceipt)[] -> **patch**: `string` +Immutable attempt bindings, oldest first. A retried/resumed node may have more than one. -`git diff` of the worktree against its base — the unified patch the harness produced. +###### Inherited from -##### stats +[`NodeSnapshot`](#nodesnapshot).[`executionBindings`](#executionbindings-2) -> **stats**: `object` +##### settledAt? -Shortstat-derived change counts. +> `readonly` `optional` **settledAt?**: `number` -###### filesChanged +Epoch ms of the terminal journal record; absent while live or when legacy evidence lacks it. -> **filesChanged**: `number` +###### Inherited from -###### insertions +[`NodeSnapshot`](#nodesnapshot).[`settledAt`](#settledat-1) -> **insertions**: `number` +##### spent -###### deletions +> `readonly` **spent**: [`Spend`](index.md#spend) -> **deletions**: `number` +Conserved spend so far for this node. -##### profileMaterialization? +###### Inherited from -> `optional` **profileMaterialization?**: [`WorktreeProfileMaterializationReceipt`](#worktreeprofilematerializationreceipt) +[`NodeSnapshot`](#nodesnapshot).[`spent`](#spent-2) -Exact profile materialization applied before the harness launched. -Absent on transports that cannot return a materializer receipt; never fabricated. +##### outRef? -##### harness +> `readonly` `optional` **outRef?**: `string` -> **harness**: `object` +`outRef` once the node is `done` (the replay/result pointer). -The harness subprocess outcome. +###### Inherited from -###### name +[`NodeSnapshot`](#nodesnapshot).[`outRef`](#outref-5) -> **name**: [`LocalHarness`](mcp.md#localharness) \| `"bridge"` +##### trace? -###### exitCode +> `readonly` `optional` **trace?**: [`WorkerTraceEvidence`](index.md#workertraceevidence) -> **exitCode**: `number` \| `null` +Present on terminal executor nodes; legacy records carry an explicit unavailable reason. -###### timedOut +###### Inherited from -> **timedOut**: `boolean` +[`NodeSnapshot`](#nodesnapshot).[`trace`](#trace-3) -###### killedBySignal +*** -> **killedBySignal**: `Signals` \| `null` +### SpawnForestInDoubtNode -###### durationMs +A spawned worker with no terminal record in a cold snapshot. Resume treats the same state as +in-doubt and conservatively retains its reservation. Root nodes and armed waits are excluded. -> **durationMs**: `number` +#### Properties -###### stdout +##### treeRoot -> **stdout**: `string` +> `readonly` **treeRoot**: `string` -###### stderr +##### nodeId -> **stderr**: `string` +> `readonly` **nodeId**: `string` -###### usage? +##### label -> `optional` **usage?**: [`CodexTokenUsage`](mcp.md#codextokenusage) +> `readonly` **label**: `string` -Exact Codex JSONL usage when reproducible mode is enabled. +##### runtime -###### cliVersion? +> `readonly` **runtime**: [`Runtime`](#runtime-4) -> `optional` **cliVersion?**: `string` +*** -Installed CLI version captured immediately before execution. +### SpawnForestMissingTree -###### executableSha256? +A driver spawn whose owned journal tree was never begun before the process stopped. -> `optional` **executableSha256?**: `string` +#### Properties -SHA-256 of the native Codex executable staged read-only in the candidate worktree. +##### parentTreeRoot -###### requestedPromptSha256? +> `readonly` **parentTreeRoot**: `string` -> `optional` **requestedPromptSha256?**: `string` +##### ownerNodeId -SHA-256 of the exact composed prompt argument proved present in Codex's rendered prompt. +> `readonly` **ownerNodeId**: `string` -###### effectivePromptSha256? +##### root -> `optional` **effectivePromptSha256?**: `string` +> `readonly` **root**: `string` -SHA-256 of `codex debug prompt-input` output for the exact isolated prompt. +*** -###### nonPromptArgsSha256? +### SpawnForest -> `optional` **nonPromptArgsSha256?**: `string` +Complete cold-readable view of one recursive supervision run. -SHA-256 of the exact executable + argv with prompt content replaced by ``. +#### Properties -###### controlledConfigSha256? +##### root -> `optional` **controlledConfigSha256?**: `string` +> `readonly` **root**: `string` -SHA-256 of the isolated config that fixes permissions and shell environment. +##### trees -###### readDeniedPathsSha256? +> `readonly` **trees**: readonly [`SpawnForestTree`](#spawnforesttree)[] -> `optional` **readDeniedPathsSha256?**: `string` +##### nodes -SHA-256 of the normalized caller-supplied host read-denial paths. +> `readonly` **nodes**: readonly [`SpawnForestNode`](#spawnforestnode)[] -###### readDeniedPaths? +##### events -> `optional` **readDeniedPaths?**: `string`[] +> `readonly` **events**: readonly [`SpawnForestEvent`](#spawnforestevent)[] -Sorted normalized caller-supplied host read-denial paths. +##### inDoubt -###### readDeniedPathCount? +> `readonly` **inDoubt**: readonly [`SpawnForestInDoubtNode`](#spawnforestindoubtnode)[] -> `optional` **readDeniedPathCount?**: `number` +##### missingTrees -Number of normalized caller-supplied host read-denial paths. +> `readonly` **missingTrees**: readonly [`SpawnForestMissingTree`](#spawnforestmissingtree)[] -###### executionPolicy? +*** -> `optional` **executionPolicy?**: [`CodexExecutionPolicy`](mcp.md#codexexecutionpolicy) +### AnalystFindingEvent -Explicit isolation claims checked before model execution. +A trace-analyst result re-entered as a message on the bus (the `finding` event kind). -##### checks? +#### Properties -> `optional` **checks?**: `object` +##### fromWorker -Verification signals derived in the live worktree (present only when commands were given). +> `readonly` **fromWorker**: `string` -###### tests? +##### analyst -> `optional` **tests?**: [`WorktreeCommandResult`](#worktreecommandresult) +> `readonly` **analyst**: `string` -###### typecheck? +##### findings -> `optional` **typecheck?**: [`WorktreeCommandResult`](#worktreecommandresult) +> `readonly` **findings**: `unknown` *** -### AnytimeTaskCurve +### DownMessageDeliveryAttempt + +A durable marker written after authorization and immediately before Runtime calls `Scope.send`. +If a process dies with this marker but no matching outcome, delivery is unknown and is never +replayed automatically. #### Properties -##### taskId +##### receiptId -> **taskId**: `string` +> `readonly` **receiptId**: `string` -##### strategy +##### kind -> **strategy**: `string` +> `readonly` **kind**: `"steer"` \| `"answer"` -##### points +##### toWorker -> **points**: `object`[] +> `readonly` **toWorker**: `string` -Best-so-far after each settled shot: elapsed ms from the task's first spawn, - cumulative usd, and the running max score. +##### instructionDigest -###### elapsedMs +> `readonly` **instructionDigest**: `string` -> **elapsedMs**: `number` +##### interrupt -###### cumUsd +> `readonly` **interrupt**: `boolean` -> **cumUsd**: `number` +##### questionId? -###### best +> `readonly` `optional` **questionId?**: `string` -> **best**: `number` +*** -##### hits +### DownMessageEvent -> **hits**: `Record`\<`string`, \{ `ms`: `number`; `shots`: `number`; `usd`: `number`; \} \| `null`\> +A parent→child delivery result (the down-leg): recorded for observability, never pulled back by +the parent. `receiptId` and `instructionDigest` link it to the pre-delivery authorization receipt +and attempt marker. -Per satisficing target (keyed by the target value as a string): the first point - where best ≥ target, or null when never reached within budget. +#### Properties -*** +##### receiptId -### AnytimeStrategySummary +> `readonly` **receiptId**: `string` -#### Properties +##### toWorker -##### strategy +> `readonly` **toWorker**: `string` -> **strategy**: `string` +##### instruction -##### target +> `readonly` **instruction**: `string` -> **target**: `number` +##### instructionDigest -The satisficing target this row summarizes. +> `readonly` **instructionDigest**: `string` -##### tasks +##### delivered -> **tasks**: `number` +> `readonly` **delivered**: `boolean` -##### reachedTarget +##### outcome -> **reachedTarget**: `number` +> `readonly` **outcome**: [`DownMessageDeliveryOutcome`](#downmessagedeliveryoutcome) -##### medianTttMs +##### error? -> **medianTttMs**: `number` \| `null` +> `readonly` `optional` **error?**: `string` -Median time-to-target over the tasks that reached it (null when none did). +*** -##### medianShotsToTarget +### ContinuationInstruction -> **medianShotsToTarget**: `number` \| `null` +Durable authorization receipt written before a continuation reaches a worker. -##### ertMs +#### Properties -> **ertMs**: `number` \| `null` +##### receiptId -COCO ERT: Σ all task wall-time (incl. failures) / #successes. Null when 0 succeed. +> `readonly` **receiptId**: `string` -##### erUsd +##### kind -> **erUsd**: `number` \| `null` +> `readonly` **kind**: `"steer"` \| `"answer"` -Same construction over dollars: Σ all spend / #successes. +##### toWorker -##### curveByShot +> `readonly` **toWorker**: `string` -> **curveByShot**: `number`[] +##### instruction -Mean best-so-far score by shot index (the anytime curve, averaged over tasks). +> `readonly` **instruction**: `string` -##### auc +##### instructionDigest -> **auc**: `number` +> `readonly` **instructionDigest**: `string` -Area under the per-shot anytime curve, normalized to [0,1]. +##### workerIdentity? -*** +> `readonly` `optional` **workerIdentity?**: [`NodeExecutionIdentity`](#nodeexecutionidentity) -### AnytimeReport +##### interrupt -#### Properties +> `readonly` **interrupt**: `boolean` -##### targets +##### questionId? -> **targets**: `number`[] +> `readonly` `optional` **questionId?**: `string` -##### perTask +*** -> **perTask**: [`AnytimeTaskCurve`](#anytimetaskcurve)[] +### DownMessageAuthorizationInput -##### perStrategy +Detached continuation bytes and exact worker identity presented to product authorization before +Runtime records or delivers a steer/answer. -> **perStrategy**: [`AnytimeStrategySummary`](#anytimestrategysummary)[] +#### Properties -One summary per (strategy, target) pair — the COCO-style multi-target view. +##### kind -*** +> `readonly` **kind**: `"steer"` \| `"answer"` -### AuditIntentInput +##### workerId -#### Properties +> `readonly` **workerId**: `string` -##### declaredIntent +##### workerIdentity -> **declaredIntent**: `string` +> `readonly` **workerIdentity**: [`NodeExecutionIdentity`](#nodeexecutionidentity) -The declared intent: the task text / acceptance criteria the agent was given. +##### instruction -##### trace +> `readonly` **instruction**: `string` -> **trace**: readonly `unknown`[] +##### interrupt -The trajectory so far — tool calls + results + assistant turns (any event shapes). +> `readonly` **interrupt**: `boolean` -##### userIntent? +##### questionId? -> `optional` **userIntent?**: `string` +> `readonly` `optional` **questionId?**: `string` -The principal's actual intent when it differs from the literal task (the contract). +*** -##### metaIntent? +### AuthorizedDownMessage -> `optional` **metaIntent?**: `string` +Product-authorized continuation bytes. Returning a narrowed instruction replaces the proposed +bytes; throwing refuses delivery. -The loop-level purpose (meta-intent): what the WHOLE run is for — lets the auditor - flag locally-sensible work that serves the wrong larger objective. +#### Properties -##### runId? +##### instruction -> `optional` **runId?**: `string` +> `readonly` **instruction**: `string` *** -### AuditIntentOptions - -#### Properties - -##### chat - -> **chat**: `ChatClient` +### WorkerSpawnContext -##### model? +Immutable task, allocation, identity attribution, and semantic key supplied while a manager's +complete worker profile is prepared for one spawn. -> `optional` **model?**: `string` +#### Properties -##### auditorInstruction? +##### assignmentId -> `optional` **auditorInstruction?**: `string` +> `readonly` **assignmentId**: `string` -Override the auditor instruction (optimizable like any analyst prompt). +Stable assignment identity within this manager. A semantic key wins; otherwise Runtime mints +the manager's deterministic pre-factory spawn ordinal so identical unkeyed siblings stay +isolated and can recover by issuing the same assignments in the same order. -##### maxTraceLines? +##### parentNodeId -> `optional` **maxTraceLines?**: `number` +> `readonly` **parentNodeId**: `string` -Cap trace lines fed to the auditor. Default 80. +Trusted concrete manager node authorizing this spawn. Never accepted from model arguments. -##### signal? +##### budget -> `optional` **signal?**: `AbortSignal` +> `readonly` **budget**: [`Budget`](index.md#budget-4) -*** +The exact allocation this node receives after the tool's optional override is merged. -### IntentAudit +##### task -#### Properties +> `readonly` **task**: `unknown` -##### revealedIntent +Detached, deeply immutable task bytes from this spawn request. -> **revealedIntent**: `string` +##### label -What the agent's actions reveal it is actually optimizing — one sentence. +> `readonly` **label**: `string` -##### verdict +Exact trace label selected for this spawn. -> **verdict**: `"aligned"` \| `"drifting"` \| `"diverged"` +##### key? -##### evidence +> `readonly` `optional` **key?**: `string` -> **evidence**: `string` +Semantic restart key, when the manager supplied one. -Trajectory-grounded evidence for the verdict (specific calls/patterns). +##### execution? -##### recommendation +> `readonly` `optional` **execution?**: [`AgentExecutionRef`](#agentexecutionref) -> **recommendation**: `"abort"` \| `"steer"` \| `"continue"` +Trusted candidate/campaign attribution attached by product authorization. -The single recommended intervention. +*** -##### steer? +### WorktreeCommandResult -> `optional` **steer?**: `string` +Outcome of one verification command run in the worktree (test or typecheck). -When recommendation is 'steer': the corrective instruction to inject. +#### Properties -##### confidence +##### command -> **confidence**: `number` +> **command**: `string` -*** +The shell command line that was run. -### LeaderboardOptions +##### passed -#### Properties +> **passed**: `boolean` -##### title? +Did the command exit 0? The PASS signal a deliverable gate / coder output reads. -> `readonly` `optional` **title?**: `string` +##### exitCode -##### scoreOf? +> **exitCode**: `number` \| `null` -> `readonly` `optional` **scoreOf?**: [`ScoreOf`](#scoreof) +OS exit code, or `null` when killed before exit. -##### profileKeyOf? +##### output -> `readonly` `optional` **profileKeyOf?**: [`ProfileKeyOf`](#profilekeyof) +> **output**: `string` -##### groupOf? +Combined stdout+stderr (capped) — surfaced in traces for diagnosis. -> `readonly` `optional` **groupOf?**: [`GroupOf`](#groupof) +*** -##### axisScoresOf? +### WorktreeProfileMaterializationReceipt -> `readonly` `optional` **axisScoresOf?**: [`AxisScoresOf`](#axisscoresof) +Proof of the profile inputs delivered before the worker process started. -##### labelOf? +#### Properties -> `readonly` `optional` **labelOf?**: (`profileKey`) => `string` +##### workspacePlanDigest -Display label for a profile key (default: the key itself). +> **workspacePlanDigest**: `string` -###### Parameters +Digest of the exact materializer plan: files, modes, environment, flags, and unsupported rows. -###### profileKey +##### writtenPaths -`string` +> **writtenPaths**: `string`[] -###### Returns +Repository-relative profile input files written into the worker worktree. -`string` +##### unsupported -##### meta? +> **unsupported**: `Unsupported`[] -> `readonly` `optional` **meta?**: `Record`\<`string`, `string`\> +Must be empty on a successful run because this path fails closed. -Commit SHA / dataset / dates surfaced in the provenance block. +##### environmentNames -##### stats? +> **environmentNames**: `string`[] -> `readonly` `optional` **stats?**: `boolean` +Environment variable names added to the worker process. Values remain out of telemetry. -Compute per-row confidence intervals (bootstrap on score, Wilson on pass rate). Needs a - `scenarioId` on every record (reps are collapsed per scenario for the honest n). Default off. +##### flags -##### passThreshold? +> **flags**: `string`[] -> `readonly` `optional` **passThreshold?**: `number` +Exact additional CLI arguments emitted by the materializer. -A score ≥ this counts as a "pass" for the pass-rate proportion + its Wilson CI. Default 0.999 - (fully solved). Lower it (e.g. 0.6) for a partial-credit domain. +##### resourceInstructions -*** +> **resourceInstructions**: `object` -### Interval +`resources.instructions` bypasses native project files so reproducible Codex cannot drop it. -A 95%-by-default confidence interval. +###### delivery -#### Properties +> **delivery**: `"none"` \| `"invocation-prompt"` -##### lower +###### sha256 -> `readonly` **lower**: `number` +> **sha256**: `string` \| `null` -##### upper +###### byteLength -> `readonly` **upper**: `number` +> **byteLength**: `number` *** -### LeaderboardRow +### WorktreeHarnessResult -One leaderboard row — a harness×model profile, every measured column. +The canonical result of one worktree-harness run, projected by each port to its own shape. #### Properties -##### profileKey +##### branch -> `readonly` **profileKey**: `string` +> **branch**: `string` -##### label +The branch the worktree was cut on (`delegate/`). -> `readonly` **label**: `string` +##### patch -##### model +> **patch**: `string` -> `readonly` **model**: `string` +`git diff` of the worktree against its base — the unified patch the harness produced. -##### n +##### stats -> `readonly` **n**: `number` +> **stats**: `object` -##### meanScore +Shortstat-derived change counts. -> `readonly` **meanScore**: `number` +###### filesChanged -##### solveRate +> **filesChanged**: `number` -> `readonly` **solveRate**: `number` +###### insertions -Fraction of records scoring ≥ `passThreshold` (default 0.999) — the binary pass rate. +> **insertions**: `number` -##### perAxis +###### deletions -> `readonly` **perAxis**: `Record`\<`string`, `number`\> +> **deletions**: `number` -axis → mean score for this profile (blank in render when the profile never ran that axis). +##### profileMaterialization? -##### costUsd +> `optional` **profileMaterialization?**: [`WorktreeProfileMaterializationReceipt`](#worktreeprofilematerializationreceipt) -> `readonly` **costUsd**: `number` \| `null` +Exact profile materialization applied before the harness launched. +Absent on transports that cannot return a materializer receipt; never fabricated. -Exact total when every run captured cost; otherwise `null`. - -##### capturedCostUsd - -> `readonly` **capturedCostUsd**: `number` - -Sum of captured cost only. This is a lower bound when `uncapturedCostRuns > 0`. +##### harness -##### uncapturedCostRuns +> **harness**: `object` -> `readonly` **uncapturedCostRuns**: `number` +The harness subprocess outcome. -Runs whose cost was unavailable, never treated as free. +###### name -##### tokensIn +> **name**: [`LocalHarness`](mcp.md#localharness) \| `"bridge"` -> `readonly` **tokensIn**: `number` +###### exitCode -##### tokensOut +> **exitCode**: `number` \| `null` -> `readonly` **tokensOut**: `number` +###### timedOut -##### latencyP50Ms +> **timedOut**: `boolean` -> `readonly` **latencyP50Ms**: `number` +###### killedBySignal -##### latencyP90Ms +> **killedBySignal**: `Signals` \| `null` -> `readonly` **latencyP90Ms**: `number` +###### durationMs -##### scoreCi? +> **durationMs**: `number` -> `readonly` `optional` **scoreCi?**: [`Interval`](#interval) +###### stdout -Bootstrap CI on the mean score — present only when `opts.stats` is set. Computed over - per-scenario means (reps collapsed first), so identical reps can't fake a narrow interval. +> **stdout**: `string` -##### passCi? +###### stderr -> `readonly` `optional` **passCi?**: [`Interval`](#interval) +> **stderr**: `string` -Wilson CI on the pass rate — present only when `opts.stats` is set. +###### usage? -*** +> `optional` **usage?**: [`CodexTokenUsage`](mcp.md#codextokenusage) -### Leaderboard +Exact Codex JSONL usage when reproducible mode is enabled. -#### Properties +###### cliVersion? -##### title +> `optional` **cliVersion?**: `string` -> `readonly` **title**: `string` +Installed CLI version captured immediately before execution. -##### axes +###### executableSha256? -> `readonly` **axes**: readonly `string`[] +> `optional` **executableSha256?**: `string` -Column order — scenario groups (default) or dimension keys (`axisScoresOf`). +SHA-256 of the native Codex executable staged read-only in the candidate worktree. -##### profiles +###### requestedPromptSha256? -> `readonly` **profiles**: readonly [`LeaderboardRow`](#leaderboardrow)[] +> `optional` **requestedPromptSha256?**: `string` -Rows ranked by `meanScore` desc (ties → lower cost, then label). +SHA-256 of the exact composed prompt argument proved present in Codex's rendered prompt. -##### meta +###### effectivePromptSha256? -> `readonly` **meta**: `Record`\<`string`, `string`\> +> `optional` **effectivePromptSha256?**: `string` -##### provenance +SHA-256 of `codex debug prompt-input` output for the exact isolated prompt. -> `readonly` **provenance**: `object` +###### nonPromptArgsSha256? -Provenance counts — the denominators every honest report leads with. +> `optional` **nonPromptArgsSha256?**: `string` -###### records +SHA-256 of the exact executable + argv with prompt content replaced by ``. -> `readonly` **records**: `number` +###### controlledConfigSha256? -###### profiles +> `optional` **controlledConfigSha256?**: `string` -> `readonly` **profiles**: `number` +SHA-256 of the isolated config that fixes permissions and shell environment. -###### axes +###### readDeniedPathsSha256? -> `readonly` **axes**: `number` +> `optional` **readDeniedPathsSha256?**: `string` -###### models +SHA-256 of the normalized caller-supplied host read-denial paths. -> `readonly` **models**: readonly `string`[] +###### readDeniedPaths? -###### totalCostUsd +> `optional` **readDeniedPaths?**: `string`[] -> `readonly` **totalCostUsd**: `number` \| `null` +Sorted normalized caller-supplied host read-denial paths. -Exact total when every record captured cost; otherwise `null`. +###### readDeniedPathCount? -###### capturedCostUsd +> `optional` **readDeniedPathCount?**: `number` -> `readonly` **capturedCostUsd**: `number` +Number of normalized caller-supplied host read-denial paths. -Sum of captured cost only. +###### executionPolicy? -###### uncapturedCostRecords +> `optional` **executionPolicy?**: [`CodexExecutionPolicy`](mcp.md#codexexecutionpolicy) -> `readonly` **uncapturedCostRecords**: `number` +Explicit isolation claims checked before model execution. -Records whose cost was unavailable. +##### checks? -*** +> `optional` **checks?**: `object` -### PairwiseVerdict +Verification signals derived in the live worktree (present only when commands were given). -One profile pair compared on the scenarios they BOTH ran — the "who actually beat whom" verdict. +###### tests? -#### Properties +> `optional` **tests?**: [`WorktreeCommandResult`](#worktreecommandresult) -##### a +###### typecheck? -> `readonly` **a**: `string` +> `optional` **typecheck?**: [`WorktreeCommandResult`](#worktreecommandresult) -##### b +*** -> `readonly` **b**: `string` +### AnytimeTaskCurve -##### pairs +#### Properties -> `readonly` **pairs**: `number` +##### taskId -Paired unit count (shared scenarios). The significance is suppressed below `minPairs`. +> **taskId**: `string` -##### delta +##### strategy -> `readonly` **delta**: `number` +> **strategy**: `string` -Median paired delta (b − a) and its bootstrap CI. +##### points -##### ciLow +> **points**: `object`[] -> `readonly` **ciLow**: `number` +Best-so-far after each settled shot: elapsed ms from the task's first spawn, + cumulative usd, and the running max score. -##### ciHigh +###### elapsedMs -> `readonly` **ciHigh**: `number` +> **elapsedMs**: `number` -##### nonZeroPairs +###### cumUsd -> `readonly` **nonZeroPairs**: `number` +> **cumUsd**: `number` -Non-zero paired differences used by the signed-rank test. +###### best -##### testMethod +> **best**: `number` -> `readonly` **testMethod**: `RankTestMethod` +##### hits -How the signed-rank p-value was computed. +> **hits**: `Record`\<`string`, \{ `ms`: `number`; `shots`: `number`; `usd`: `number`; \} \| `null`\> -##### pFloor +Per satisficing target (keyed by the target value as a string): the first point + where best ≥ target, or null when never reached within budget. -> `readonly` **pFloor**: `number` +*** -Smallest p-value attainable by this paired design. +### AnytimeStrategySummary -##### p +#### Properties -> `readonly` **p**: `number` +##### strategy -Raw two-sided signed-rank p-value. +> **strategy**: `string` -##### q +##### target -> `readonly` **q**: `number` +> **target**: `number` -Benjamini-Hochberg adjusted q-value across every profile pair. +The satisficing target this row summarizes. -##### significant +##### tasks -> `readonly` **significant**: `boolean` +> **tasks**: `number` -BH-significant and above the `minPairs` observation floor. +##### reachedTarget -*** +> **reachedTarget**: `number` -### PairwiseOptions +##### medianTttMs -#### Properties +> **medianTttMs**: `number` \| `null` -##### scoreOf? +Median time-to-target over the tasks that reached it (null when none did). -> `readonly` `optional` **scoreOf?**: [`ScoreOf`](#scoreof) +##### medianShotsToTarget -##### profileKeyOf? +> **medianShotsToTarget**: `number` \| `null` -> `readonly` `optional` **profileKeyOf?**: [`ProfileKeyOf`](#profilekeyof) +##### ertMs -##### labelOf? +> **ertMs**: `number` \| `null` -> `readonly` `optional` **labelOf?**: (`profileKey`) => `string` +COCO ERT: Σ all task wall-time (incl. failures) / #successes. Null when 0 succeed. -###### Parameters +##### erUsd -###### profileKey +> **erUsd**: `number` \| `null` -`string` +Same construction over dollars: Σ all spend / #successes. -###### Returns +##### curveByShot -`string` +> **curveByShot**: `number`[] -##### fdr? +Mean best-so-far score by shot index (the anytime curve, averaged over tasks). -> `readonly` `optional` **fdr?**: `number` +##### auc -False-discovery rate for the Benjamini–Hochberg correction. Default 0.05. +> **auc**: `number` -##### minPairs? +Area under the per-shot anytime curve, normalized to [0,1]. -> `readonly` `optional` **minPairs?**: `number` +*** -Below this many shared scenarios a paired test can't defensibly separate two profiles, so the - `significant` tag is suppressed regardless of p (small-n mirage protection). Default 12. +### AnytimeReport -*** +#### Properties -### CompletionEvidence +##### targets -Trace-derived evidence for a completion claim — an artifact (output) or a verifier metric, - never the judge's own verdict. Mirrors the steer-firewall's provenance discipline. +> **targets**: `number`[] -#### Properties +##### perTask -##### kind +> **perTask**: [`AnytimeTaskCurve`](#anytimetaskcurve)[] -> **kind**: `"artifact"` \| `"metric"` +##### perStrategy -##### uri +> **perStrategy**: [`AnytimeStrategySummary`](#anytimestrategysummary)[] -> **uri**: `string` +One summary per (strategy, target) pair — the COCO-style multi-target view. *** -### CompletionVerdict - -The "is it done?" verdict an analyst returns to the parent. +### AuditIntentInput #### Properties -##### done +##### declaredIntent -> **done**: `boolean` +> **declaredIntent**: `string` -##### determinism +The declared intent: the task text / acceptance criteria the agent was given. -> **determinism**: `"deterministic"` \| `"probabilistic"` +##### trace -How verifiable the claim is — sets whether the driver trusts it or validates it. +> **trace**: readonly `unknown`[] -##### reasons? +The trajectory so far — tool calls + results + assistant turns (any event shapes). -> `optional` **reasons?**: `string` - -Why the analyst believes it is (or isn't) done — what the driver validates. - -##### confidence? - -> `optional` **confidence?**: `number` +##### userIntent? -0..1, for probabilistic verdicts; the driver's validation threshold reads this. +> `optional` **userIntent?**: `string` -##### evidence? +The principal's actual intent when it differs from the literal task (the contract). -> `optional` **evidence?**: readonly [`CompletionEvidence`](#completionevidence)[] +##### metaIntent? -*** +> `optional` **metaIntent?**: `string` -### CompletionAnalyst +The loop-level purpose (meta-intent): what the WHOLE run is for — lets the auditor + flag locally-sensible work that serves the wrong larger objective. -Reads a node's trace → a completion verdict. Same input shape as the `analyze` hook, so - ONE analyst node can back both channels (findings for steer, a verdict for stop). +##### runId? -#### Type Parameters +> `optional` **runId?**: `string` -##### Task +*** -`Task` +### AuditIntentOptions -##### Output +#### Properties -`Output` +##### chat -#### Methods +> **chat**: `ChatClient` -##### assess() +##### model? -> **assess**(`input`): [`CompletionVerdict`](#completionverdict) \| `Promise`\<[`CompletionVerdict`](#completionverdict)\> +> `optional` **model?**: `string` -###### Parameters +##### auditorInstruction? -###### input +> `optional` **auditorInstruction?**: `string` -###### task +Override the auditor instruction (optimizable like any analyst prompt). -`Task` +##### maxTraceLines? -###### history +> `optional` **maxTraceLines?**: `number` -readonly [`Iteration`](#iteration-1)\<`Task`, `Output`\>[] +Cap trace lines fed to the auditor. Default 80. -###### Returns +##### signal? -[`CompletionVerdict`](#completionverdict) \| `Promise`\<[`CompletionVerdict`](#completionverdict)\> +> `optional` **signal?**: `AbortSignal` *** -### CompletionPolicy - -When a verdict authorizes the driver to END. Deterministic → trust (ground truth); - probabilistic → validate by confidence threshold (the driver's check). +### IntentAudit #### Properties -##### minConfidence? - -> `optional` **minConfidence?**: `number` - -Minimum confidence a PROBABILISTIC verdict must clear to end. Default 0.8. - -*** +##### revealedIntent -### LeaderboardScore +> **revealedIntent**: `string` -Structured per-case verdict a `score` function may return (a bare number is - shorthand for `{ composite }`). `composite` is the [0,1] leaderboard score; - `dimensions` are recorded as extra judge dimensions. +What the agent's actions reveal it is actually optimizing — one sentence. -#### Properties +##### verdict -##### composite +> **verdict**: `"aligned"` \| `"drifting"` \| `"diverged"` -> **composite**: `number` +##### evidence -##### dimensions? +> **evidence**: `string` -> `optional` **dimensions?**: `Record`\<`string`, `number`\> +Trajectory-grounded evidence for the verdict (specific calls/patterns). -##### notes? +##### recommendation -> `optional` **notes?**: `string` +> **recommendation**: `"abort"` \| `"steer"` \| `"continue"` -*** +The single recommended intervention. -### LeaderboardScenario +##### steer? -The campaign scenario a case is wrapped into: the case rides along so - judges and hooks can reach the full domain payload, not just its id. +> `optional` **steer?**: `string` -#### Extends +When recommendation is 'steer': the corrective instruction to inject. -- `Scenario` +##### confidence -#### Type Parameters +> **confidence**: `number` -##### TCase +*** -`TCase` +### LeaderboardOptions #### Properties -##### case - -> **case**: `TCase` - -*** +##### title? -### LeaderboardFlagSpec +> `readonly` `optional` **title?**: `string` -One extra CLI flag a spec declares. Parsed by `run()` as `-- ` - and surfaced to every hook via `ctx.args`. +##### scoreOf? -#### Properties +> `readonly` `optional` **scoreOf?**: [`ScoreOf`](#scoreof) -##### default? +##### profileKeyOf? -> `optional` **default?**: `string` +> `readonly` `optional` **profileKeyOf?**: [`ProfileKeyOf`](#profilekeyof) -##### description +##### groupOf? -> **description**: `string` +> `readonly` `optional` **groupOf?**: [`GroupOf`](#groupof) -*** +##### axisScoresOf? -### LeaderboardRunContext +> `readonly` `optional` **axisScoresOf?**: [`AxisScoresOf`](#axisscoresof) -Resolved run configuration handed to `setup` / `teardown` / `export`. +##### labelOf? -#### Properties +> `readonly` `optional` **labelOf?**: (`profileKey`) => `string` -##### name +Display label for a profile key (default: the key itself). -> **name**: `string` +###### Parameters -##### backend +###### profileKey -> **backend**: `string` +`string` -Execution backend name (`--backend`), a key of `backends`. +###### Returns -##### runDir +`string` -> **runDir**: `string` +##### meta? -##### exportDir +> `readonly` `optional` **meta?**: `Record`\<`string`, `string`\> -> **exportDir**: `string` +Commit SHA / dataset / dates surfaced in the provenance block. -##### args +##### stats? -> **args**: `Record`\<`string`, `string` \| `undefined`\> +> `readonly` `optional` **stats?**: `boolean` -Every parsed flag (standard + `spec.flags`), by name without `--`. +Compute per-row confidence intervals (bootstrap on score, Wilson on pass rate). Needs a + `scenarioId` on every record (reps are collapsed per scenario for the honest n). Default off. -##### harnesses +##### passThreshold? -> **harnesses**: readonly `HarnessType`[] +> `readonly` `optional` **passThreshold?**: `number` -##### models +A score ≥ this counts as a "pass" for the pass-rate proportion + its Wilson CI. Default 0.999 + (fully solved). Lower it (e.g. 0.6) for a partial-credit domain. -> **models**: readonly `string`[] +*** -Snapshot-stamped model ids (`name@snapshot`) — the eval identity models. +### Interval -##### caseIds +A 95%-by-default confidence interval. -> **caseIds**: readonly `string`[] +#### Properties -##### shots +##### lower -> **shots**: `number` +> `readonly` **lower**: `number` -##### reps +##### upper -> **reps**: `number` +> `readonly` **upper**: `number` *** -### LeaderboardBenchTask +### LeaderboardRow -Structurally `BenchTask` (bench registry shape) — declared locally so this - module adds no dependency on a benchmark package. +One leaderboard row — a harness×model profile, every measured column. #### Properties -##### id +##### profileKey -> **id**: `string` +> `readonly` **profileKey**: `string` -##### prompt +##### label -> **prompt**: `string` +> `readonly` **label**: `string` -##### split? +##### model -> `optional` **split?**: `string` +> `readonly` **model**: `string` -##### metadata? +##### n -> `optional` **metadata?**: `Record`\<`string`, `unknown`\> +> `readonly` **n**: `number` -*** +##### meanScore -### LeaderboardBenchScore +> `readonly` **meanScore**: `number` -Structurally `BenchScore` (bench registry shape). +##### solveRate -#### Properties +> `readonly` **solveRate**: `number` -##### resolved +Fraction of records scoring ≥ `passThreshold` (default 0.999) — the binary pass rate. -> **resolved**: `boolean` +##### perAxis -##### score +> `readonly` **perAxis**: `Record`\<`string`, `number`\> -> **score**: `number` +axis → mean score for this profile (blank in render when the profile never ran that axis). -##### detail? +##### costUsd -> `optional` **detail?**: `string` +> `readonly` **costUsd**: `number` \| `null` -*** +Exact total when every run captured cost; otherwise `null`. -### LeaderboardBenchmarkAdapter +##### capturedCostUsd -Structurally `BenchmarkAdapter` (bench registry shape): `name`, - `preflight()`, `loadTasks()`, deterministic `judge()`, `goldArtifact()`. - Generic over the artifact channel; the `string` default IS the registry - shape, so a default-artifact adapter registers unchanged. +> `readonly` **capturedCostUsd**: `number` -#### Type Parameters +Sum of captured cost only. This is a lower bound when `uncapturedCostRuns > 0`. -##### TArtifact +##### uncapturedCostRuns -`TArtifact` = `string` +> `readonly` **uncapturedCostRuns**: `number` -#### Properties +Runs whose cost was unavailable, never treated as free. -##### name +##### tokensIn -> `readonly` **name**: `string` +> `readonly` **tokensIn**: `number` -#### Methods +##### tokensOut -##### preflight() +> `readonly` **tokensOut**: `number` -> **preflight**(): `Promise`\<`void`\> +##### latencyP50Ms -###### Returns +> `readonly` **latencyP50Ms**: `number` -`Promise`\<`void`\> +##### latencyP90Ms -##### loadTasks() +> `readonly` **latencyP90Ms**: `number` -> **loadTasks**(`opts?`): `Promise`\<[`LeaderboardBenchTask`](#leaderboardbenchtask)[]\> +##### scoreCi? -###### Parameters +> `readonly` `optional` **scoreCi?**: [`Interval`](#interval) -###### opts? +Bootstrap CI on the mean score — present only when `opts.stats` is set. Computed over + per-scenario means (reps collapsed first), so identical reps can't fake a narrow interval. -###### limit? +##### passCi? -`number` +> `readonly` `optional` **passCi?**: [`Interval`](#interval) -###### split? +Wilson CI on the pass rate — present only when `opts.stats` is set. -`string` +*** -###### ids? +### Leaderboard -`string`[] +#### Properties -###### Returns +##### title -`Promise`\<[`LeaderboardBenchTask`](#leaderboardbenchtask)[]\> +> `readonly` **title**: `string` -##### judge() +##### axes -> **judge**(`task`, `artifact`): `Promise`\<[`LeaderboardBenchScore`](#leaderboardbenchscore)\> +> `readonly` **axes**: readonly `string`[] -###### Parameters +Column order — scenario groups (default) or dimension keys (`axisScoresOf`). -###### task +##### profiles -[`LeaderboardBenchTask`](#leaderboardbenchtask) +> `readonly` **profiles**: readonly [`LeaderboardRow`](#leaderboardrow)[] -###### artifact +Rows ranked by `meanScore` desc (ties → lower cost, then label). -`TArtifact` +##### meta -###### Returns +> `readonly` **meta**: `Record`\<`string`, `string`\> -`Promise`\<[`LeaderboardBenchScore`](#leaderboardbenchscore)\> +##### provenance -##### goldArtifact() +> `readonly` **provenance**: `object` -> **goldArtifact**(`task`): `Promise`\<`string` \| `undefined`\> +Provenance counts — the denominators every honest report leads with. -###### Parameters +###### records -###### task +> `readonly` **records**: `number` -[`LeaderboardBenchTask`](#leaderboardbenchtask) +###### profiles -###### Returns +> `readonly` **profiles**: `number` -`Promise`\<`string` \| `undefined`\> +###### axes -*** +> `readonly` **axes**: `number` -### LeaderboardIterationInfo +###### models -Per-shot outcome context passed as `onCellEvents`'s third argument — how a - thrown shot (which never reaches `parseOutput`) stays visible through the - facade instead of surfacing only as an empty zero-token cell. +> `readonly` **models**: readonly `string`[] -#### Properties +###### totalCostUsd -##### index +> `readonly` **totalCostUsd**: `number` \| `null` -> **index**: `number` +Exact total when every record captured cost; otherwise `null`. -0-based shot index within the cell. +###### capturedCostUsd -##### error? +> `readonly` **capturedCostUsd**: `number` -> `optional` **error?**: `string` +Sum of captured cost only. -The shot's thrown error message, when the shot failed before scoring. +###### uncapturedCostRecords -##### verdict? +> `readonly` **uncapturedCostRecords**: `number` -> `optional` **verdict?**: `object` +Records whose cost was unavailable. -The shot's validator verdict, when the shot reached scoring. +*** -###### score? +### PairwiseVerdict -> `optional` **score?**: `number` +One profile pair compared on the scenarios they BOTH ran — the "who actually beat whom" verdict. -*** +#### Properties -### LeaderboardSpec +##### a -The declarative leaderboard spec. `TArtifact` is the artifact channel the -dispatch produces and the judges score — `string` (the default) is the plain -agent-response-text path; a structured artifact type flows natively once the -spec supplies `parseOutput` (or a LEVEL-2 `dispatch`) producing it. +> `readonly` **a**: `string` -#### Type Parameters +##### b -##### TCase +> `readonly` **b**: `string` -`TCase` +##### pairs -##### TArtifact +> `readonly` **pairs**: `number` -`TArtifact` = `string` +Paired unit count (shared scenarios). The significance is suppressed below `minPairs`. -#### Properties +##### delta -##### name +> `readonly` **delta**: `number` -> **name**: `string` +Median paired delta (b − a) and its bootstrap CI. -Leaderboard name — the scenario `kind`, default profile name, and report title. +##### ciLow -##### cases +> `readonly` **ciLow**: `number` -> **cases**: `TCase`[] +##### ciHigh -The case corpus. Every case needs a stable string id (see `caseId`). +> `readonly` **ciHigh**: `number` -##### caseId? +##### nonZeroPairs -> `optional` **caseId?**: (`c`) => `string` +> `readonly` **nonZeroPairs**: `number` -Stable id extractor. Default: the case's own `id` property (fail-loud - when absent or not a string). +Non-zero paired differences used by the signed-rank test. -###### Parameters +##### testMethod -###### c +> `readonly` **testMethod**: `RankTestMethod` -`TCase` +How the signed-rank p-value was computed. -###### Returns +##### pFloor -`string` +> `readonly` **pFloor**: `number` -##### prompt +Smallest p-value attainable by this paired design. -> **prompt**: (`c`) => `string` \| `Promise`\<`string`\> +##### p -The per-case task prompt. May be async (e.g. built by shelling out to a - reference implementation); resolved ONCE per case before dispatch. +> `readonly` **p**: `number` -###### Parameters +Raw two-sided signed-rank p-value. -###### c +##### q -`TCase` +> `readonly` **q**: `number` -###### Returns +Benjamini-Hochberg adjusted q-value across every profile pair. -`string` \| `Promise`\<`string`\> +##### significant -##### score +> `readonly` **significant**: `boolean` -> **score**: (`output`, `c`) => `number` \| [`LeaderboardScore`](#leaderboardscore) +BH-significant and above the `minPairs` observation floor. -The domain grader: agent output artifact → score. Used BOTH as the - per-shot validator (a shot with `composite > 0` stops the naive retry - loop) and, wrapped as a campaign judge, as the recorded leaderboard score. +*** -###### Parameters +### PairwiseOptions -###### output +#### Properties -`TArtifact` +##### scoreOf? -###### c +> `readonly` `optional` **scoreOf?**: [`ScoreOf`](#scoreof) -`TCase` +##### profileKeyOf? -###### Returns +> `readonly` `optional` **profileKeyOf?**: [`ProfileKeyOf`](#profilekeyof) -`number` \| [`LeaderboardScore`](#leaderboardscore) +##### labelOf? -##### axis? +> `readonly` `optional` **labelOf?**: (`profileKey`) => `string` -> `optional` **axis?**: `object` +###### Parameters -Harness × model axes for `expandProfileAxes`. Defaults: the canonical - `CODING_HARNESSES` × the base profile's `model.default`. `--harnesses` / - `--models` override per run. +###### profileKey -###### harnesses? +`string` -> `optional` **harnesses?**: readonly `HarnessType`[] +###### Returns -###### models? +`string` -> `optional` **models?**: readonly `string`[] +##### fdr? -##### baseProfile? +> `readonly` `optional` **fdr?**: `number` -> `optional` **baseProfile?**: `AgentProfile` +False-discovery rate for the Benjamini–Hochberg correction. Default 0.05. -Base profile the axes expand over (prompt/tools/skills held fixed). - Default: a minimal `{ name, model: { default: } }`. +##### minPairs? -##### backends? +> `readonly` `optional` **minPairs?**: `number` -> `optional` **backends?**: `Record`\<`string`, (() => [`SandboxClient`](#sandboxclient-5)) \| `undefined`\> +Below this many shared scenarios a paired test can't defensibly separate two profiles, so the + `significant` tag is suppressed regardless of p (small-n mirage protection). Default 12. -Execution-backend registry: `--backend ` picks the factory that -yields the `SandboxClient` every cell runs on. Merged over the defaults: - - `sandbox` — throws with guidance (a product must supply its real - Sandbox-backed client; the facade has no credentials). - - `cli-bridge` — `resolveSandboxClient({ backend: 'bridge' })` reading - `CLI_BRIDGE_URL` + `BRIDGE_BEARER`/`CLI_BRIDGE_BEARER`; the per-cell - harness/model ride in via `sandboxOverrides.backend`. +*** -##### flags? +### CompletionEvidence -> `optional` **flags?**: `Record`\<`string`, [`LeaderboardFlagSpec`](#leaderboardflagspec)\> +Trace-derived evidence for a completion claim — an artifact (output) or a verifier metric, + never the judge's own verdict. Mirrors the steer-firewall's provenance discipline. -Extra `--flag value` CLI args `run()` parses and surfaces via `ctx.args`. +#### Properties -##### modelBackend? +##### kind -> `optional` **modelBackend?**: `Record`\<`string`, `unknown`\> +> **kind**: `"artifact"` \| `"metric"` -Extra fields merged into each cell's `backend.model` create override — - e.g. `{ provider: 'openai-compat', apiKey, baseUrl }` for a router-backed - sandbox. The cell's bare model id is set by the facade from the axis. +##### uri -##### setup? +> **uri**: `string` -> `optional` **setup?**: (`ctx`) => `void` \| `Promise`\<`void`\> +*** -Runs once before the matrix (fetch fixtures, warm caches). +### CompletionVerdict -###### Parameters +The "is it done?" verdict an analyst returns to the parent. -###### ctx +#### Properties -[`LeaderboardRunContext`](#leaderboardruncontext) +##### done -###### Returns +> **done**: `boolean` -`void` \| `Promise`\<`void`\> +##### determinism -##### teardown? +> **determinism**: `"deterministic"` \| `"probabilistic"` -> `optional` **teardown?**: (`ctx`) => `void` \| `Promise`\<`void`\> +How verifiable the claim is — sets whether the driver trusts it or validates it. -Runs once after the matrix, even on failure (reap boxes, close handles). +##### reasons? -###### Parameters +> `optional` **reasons?**: `string` -###### ctx +Why the analyst believes it is (or isn't) done — what the driver validates. -[`LeaderboardRunContext`](#leaderboardruncontext) +##### confidence? -###### Returns +> `optional` **confidence?**: `number` -`void` \| `Promise`\<`void`\> - -##### onCellEvents? - -> `optional` **onCellEvents?**: (`events`, `c`, `iteration?`) => `void` +0..1, for probabilistic verdicts; the driver's validation threshold reads this. -Per-cell event tap: the raw sandbox events of EVERY shot, with the case — - the seam for domain metric capture (search counts, citations) without a - substrate change. Fires once per shot after the cell's loop settles, in - shot order, including thrown shots (whose events may be partial or empty); - the third argument carries the shot's index + error/verdict outcome. +##### evidence? -###### Parameters +> `optional` **evidence?**: readonly [`CompletionEvidence`](#completionevidence)[] -###### events +*** -readonly `SandboxEvent`[] +### CompletionAnalyst -###### c +Reads a node's trace → a completion verdict. Same input shape as the `analyze` hook, so + ONE analyst node can back both channels (findings for steer, a verdict for stop). -`TCase` +#### Type Parameters -###### iteration? +##### Task -[`LeaderboardIterationInfo`](#leaderboarditerationinfo) +`Task` -###### Returns +##### Output -`void` +`Output` -##### parseOutput? +#### Methods -> `optional` **parseOutput?**: (`events`, `c`) => `TArtifact` +##### assess() -Output decode override: raw events → the scored artifact. Default: the - sandbox SDK's `collectAgentResponseText` (final answer text; empty string - when the stream carried none — which then scores 0). The default only - produces `string`, so a spec with a structured `TArtifact` MUST supply - this (or a LEVEL-2 `dispatch`). +> **assess**(`input`): [`CompletionVerdict`](#completionverdict) \| `Promise`\<[`CompletionVerdict`](#completionverdict)\> ###### Parameters -###### events +###### input -readonly `SandboxEvent`[] +###### task -###### c +`Task` -`TCase` +###### history + +readonly [`Iteration`](#iteration-1)\<`Task`, `Output`\>[] ###### Returns -`TArtifact` +[`CompletionVerdict`](#completionverdict) \| `Promise`\<[`CompletionVerdict`](#completionverdict)\> -##### resolveModel? +*** -> `optional` **resolveModel?**: (`events`) => `string` \| `undefined` +### CompletionPolicy -Resolve the model the backend ACTUALLY served off a shot's raw events. -Required for HARNESS_NATIVE_MODEL-snapped cells (a vendor-locked harness × -an out-of-family model expands to the `default` sentinel): the RunRecord -must pin a real snapshot-bearing model id, which only the dispatch — -reading the backend's usage/terminal events — can know. When this returns -a value the default dispatch records it on the paid-call receipt; -in-family cells (concrete declared model) never need it. +When a verdict authorizes the driver to END. Deterministic → trust (ground truth); + probabilistic → validate by confidence threshold (the driver's check). -###### Parameters +#### Properties -###### events +##### minConfidence? -readonly `SandboxEvent`[] +> `optional` **minConfidence?**: `number` -###### Returns +Minimum confidence a PROBABILISTIC verdict must clear to end. Default 0.8. -`string` \| `undefined` +*** -##### export? +### LeaderboardScore -> `optional` **export?**: (`result`, `ctx`) => `void` \| `Promise`\<`void`\> +Structured per-case verdict a `score` function may return (a bare number is + shorthand for `{ composite }`). `composite` is the [0,1] leaderboard score; + `dimensions` are recorded as extra judge dimensions. -Result export. Default: write `matrix-result.json` under the run dir and - print (+ write) the ranked leaderboard markdown under the export dir. +#### Properties -###### Parameters +##### composite -###### result +> **composite**: `number` -`RunProfileMatrixResult`\<`TArtifact`, [`LeaderboardScenario`](#leaderboardscenario)\<`TCase`\>\> +##### dimensions? -###### ctx +> `optional` **dimensions?**: `Record`\<`string`, `number`\> -[`LeaderboardRunContext`](#leaderboardruncontext) +##### notes? -###### Returns +> `optional` **notes?**: `string` -`void` \| `Promise`\<`void`\> +*** -##### dispatch? +### LeaderboardScenario -> `optional` **dispatch?**: `ProfileDispatchFn`\<[`LeaderboardScenario`](#leaderboardscenario)\<`TCase`\>, `TArtifact`\> +The campaign scenario a case is wrapped into: the case rides along so + judges and hooks can reach the full domain payload, not just its id. -LEVEL 2 — full dispatch replacement (in-process products bring their own). - The default is `loopDispatch` + `naiveDriver` over the resolved backend. +#### Extends -##### judges? +- `Scenario` -> `optional` **judges?**: `JudgeConfig`\<`TArtifact`, [`LeaderboardScenario`](#leaderboardscenario)\<`TCase`\>\>[] +#### Type Parameters -LEVEL 2 — full judge replacement. Default: `score` wrapped as one judge. +##### TCase -##### shots? +`TCase` -> `optional` **shots?**: `number` +#### Properties -Naive-retry shot cap per cell (`--shots`). Default 1. +##### case -##### reps? +> **case**: `TCase` -> `optional` **reps?**: `number` +*** -Replicates per cell (`--reps`). Default 1. +### LeaderboardFlagSpec -##### maximumCharge? +One extra CLI flag a spec declares. Parsed by `run()` as `-- ` + and surfaced to every hook via `ctx.args`. -> `optional` **maximumCharge?**: `MaximumCharge` \| ((`profile`, `scenario`) => MaximumCharge \| undefined) +#### Properties -Provider- or executor-enforced maximum for one cell dispatch. Required -before execution when `matrix.costCeiling` is configured. +##### default? -##### matrix? +> `optional` **default?**: `string` -> `optional` **matrix?**: `Partial`\<`RunProfileMatrixOptions`\<[`LeaderboardScenario`](#leaderboardscenario)\<`TCase`\>, `TArtifact`\>\> +##### description -Passthrough overrides spread onto the final `runProfileMatrix` call - (e.g. `maxConcurrency`, `costCeiling`, `integrity`, `storage`) — spread - LAST, so anything the facade wired can be overridden. +> **description**: `string` *** -### DefinedLeaderboard - -#### Type Parameters - -##### TCase +### LeaderboardRunContext -`TCase` +Resolved run configuration handed to `setup` / `teardown` / `export`. -##### TArtifact +#### Properties -`TArtifact` = `string` +##### name -#### Methods +> **name**: `string` -##### run() +##### backend -> **run**(`argv?`): `Promise`\<`RunProfileMatrixResult`\<`TArtifact`, [`LeaderboardScenario`](#leaderboardscenario)\<`TCase`\>\>\> +> **backend**: `string` -Parse flags, run the matrix, export, and return the raw result. +Execution backend name (`--backend`), a key of `backends`. -Standard flags: `--backend ` (default `sandbox`), `--harnesses a,b`, -`--models m1,m2`, `--cases id1,id2`, `--shots N`, `--reps N`, -`--model-snapshot `, `--run-dir `, `--export-dir `, -plus every `spec.flags` entry. `argv` defaults to `process.argv.slice(2)`. +##### runDir -The default run dir is FRESH per invocation (timestamp+pid under the OS -tmpdir). `runProfileMatrix` caches cells by run dir, and a stable default -would silently reuse a prior FAILED zero-token cell and skip dispatch — -only an explicit `--run-dir` opts into that resume behavior. +> **runDir**: `string` -###### Parameters +##### exportDir -###### argv? +> **exportDir**: `string` -`string`[] +##### args -###### Returns +> **args**: `Record`\<`string`, `string` \| `undefined`\> -`Promise`\<`RunProfileMatrixResult`\<`TArtifact`, [`LeaderboardScenario`](#leaderboardscenario)\<`TCase`\>\>\> +Every parsed flag (standard + `spec.flags`), by name without `--`. -##### toBenchmarkAdapter() +##### harnesses -> **toBenchmarkAdapter**(): [`LeaderboardBenchmarkAdapter`](#leaderboardbenchmarkadapter)\<`TArtifact`\> +> **harnesses**: readonly `HarnessType`[] -The same domain surface in the structural `BenchmarkAdapter` shape. +##### models -###### Returns +> **models**: readonly `string`[] -[`LeaderboardBenchmarkAdapter`](#leaderboardbenchmarkadapter)\<`TArtifact`\> +Snapshot-stamped model ids (`name@snapshot`) — the eval identity models. -*** +##### caseIds -### HarvestCorpusOptions +> **caseIds**: readonly `string`[] -#### Properties +##### shots -##### runs +> **shots**: `number` -> **runs**: `AsyncIterable`\<[`ObserveInput`](#observeinput), `any`, `any`\> \| `Iterable`\<[`ObserveInput`](#observeinput), `any`, `any`\> +##### reps -The completed runs to analyze — map your store's rows to `ObserveInput`. +> **reps**: `number` -##### chat +*** -> **chat**: `ChatClient` +### LeaderboardBenchTask -The model-call seam (agent-eval `createChatClient`). +Structurally `BenchTask` (bench registry shape) — declared locally so this + module adds no dependency on a benchmark package. -##### model? +#### Properties -> `optional` **model?**: `string` +##### id -##### corpus +> **id**: `string` -> **corpus**: [`Corpus`](#corpus-2) +##### prompt -The durable corpus the facts accrete into. +> **prompt**: `string` -##### tags? +##### split? -> `optional` **tags?**: readonly `string`[] +> `optional` **split?**: `string` -Tags written onto learned facts (the product/domain key the read side queries by). +##### metadata? -##### analystInstruction? +> `optional` **metadata?**: `Record`\<`string`, `unknown`\> -> `optional` **analystInstruction?**: `string` +*** -Override the analyst instruction (the GEPA-tunable knob). +### LeaderboardBenchScore -##### concurrency? +Structurally `BenchScore` (bench registry shape). -> `optional` **concurrency?**: `number` +#### Properties -Runs analyzed in parallel. Default 4. +##### resolved -##### maxRuns? +> **resolved**: `boolean` -> `optional` **maxRuns?**: `number` +##### score -Hard cap on runs consumed from the stream (a cost guard for unbounded stores). +> **score**: `number` -##### signal? +##### detail? -> `optional` **signal?**: `AbortSignal` +> `optional` **detail?**: `string` *** -### HarvestFailure +### LeaderboardBenchmarkAdapter -#### Properties +Structurally `BenchmarkAdapter` (bench registry shape): `name`, + `preflight()`, `loadTasks()`, deterministic `judge()`, `goldArtifact()`. + Generic over the artifact channel; the `string` default IS the registry + shape, so a default-artifact adapter registers unchanged. -##### runId - -> **runId**: `string` - -##### error - -> **error**: `string` +#### Type Parameters -*** +##### TArtifact -### HarvestReport +`TArtifact` = `string` #### Properties -##### runsObserved +##### name -> **runsObserved**: `number` +> `readonly` **name**: `string` -##### findings +#### Methods -> **findings**: `number` +##### preflight() -Total findings the analyst produced (including ones already known). +> **preflight**(): `Promise`\<`void`\> -##### learned +###### Returns -> **learned**: `number` +`Promise`\<`void`\> -NEW facts actually appended (idempotent dedup excludes re-learned ones). +##### loadTasks() -##### failures +> **loadTasks**(`opts?`): `Promise`\<[`LeaderboardBenchTask`](#leaderboardbenchtask)[]\> -> **failures**: [`HarvestFailure`](#harvestfailure)[] +###### Parameters -Per-run analysis failures — reported, never silently dropped. +###### opts? -*** +###### limit? -### InProcessPromptCtx +`number` -Context handed to each `onPrompt` call. +###### split? -#### Properties +`string` -##### round +###### ids? -> **round**: `number` +`string`[] -0-based round index — increments per `streamPrompt` on the same box. - Fresh boxes start at 0. +###### Returns -##### workdir? +`Promise`\<[`LeaderboardBenchTask`](#leaderboardbenchtask)[]\> -> `optional` **workdir?**: `string` +##### judge() -Absolute path of this box's workspace, when a `workdir` was configured. - Write the deliverable / fixtures here; `fs.read`/`fs.write`/`exec` operate - over it. `undefined` for pure event-only boxes. +> **judge**(`task`, `artifact`): `Promise`\<[`LeaderboardBenchScore`](#leaderboardbenchscore)\> -##### signal +###### Parameters -> **signal**: `AbortSignal` +###### task -Cooperative cancellation channel for this turn. +[`LeaderboardBenchTask`](#leaderboardbenchtask) -##### options? +###### artifact -> `optional` **options?**: `Record`\<`string`, `unknown`\> +`TArtifact` -The verbatim per-call options the caller passed to the box verb (minus - `signal`, surfaced above) — lets an offline test assert an options - passthrough (`model`, `sessionId`, …) actually arrived. +###### Returns -*** +`Promise`\<[`LeaderboardBenchScore`](#leaderboardbenchscore)\> -### InProcessSandboxClientOptions +##### goldArtifact() -**`Experimental`** +> **goldArtifact**(`task`): `Promise`\<`string` \| `undefined`\> -#### Properties +###### Parameters -##### onPrompt +###### task -> **onPrompt**: [`InProcessOnPrompt`](#inprocessonprompt) +[`LeaderboardBenchTask`](#leaderboardbenchtask) -**`Experimental`** +###### Returns -The per-turn behavior — see [InProcessOnPrompt](#inprocessonprompt). +`Promise`\<`string` \| `undefined`\> -##### workdir? +*** -> `optional` **workdir?**: `string` +### LeaderboardIterationInfo -**`Experimental`** +Per-shot outcome context passed as `onCellEvents`'s third argument — how a + thrown shot (which never reaches `parseOutput`) stays visible through the + facade instead of surfacing only as an empty zero-token cell. -Opt in to a REAL filesystem-backed box. When set, each `create()` mints a -fresh temp directory (prefixed `-`) and the box exposes -`fs.read`/`fs.write` and `exec` over it; `delete()` removes the dir. Omit -for a pure event-only box (no `fs`/`exec` members), which is all a driver -or fanout loop needs. +#### Properties -##### id? +##### index -> `optional` **id?**: `string` \| ((`seq`) => `string`) +> **index**: `number` -**`Experimental`** +0-based shot index within the cell. -Override the box `id`. A string is used verbatim; a function receives the -0-based create-sequence and returns the id (e.g. machine-keyed placement -demos). Default `in-process-`. The id is the value `describePlacement` -tags, so set it when a demo's output reads on a meaningful sandbox id. +##### error? -*** +> `optional` **error?**: `string` -### KeyProvider +The shot's thrown error message, when the shot failed before scoring. -Resolve named secrets. The ONE seam every secret store adapts to. +##### verdict? -#### Methods +> `optional` **verdict?**: `object` -##### get() +The shot's validator verdict, when the shot reached scoring. -> **get**(`name`): `Promise`\<`string` \| `undefined`\> +###### score? -The value for `name`, or `undefined` when this provider does not hold it. +> `optional` **score?**: `number` -###### Parameters +*** -###### name +### LeaderboardSpec -`string` +The declarative leaderboard spec. `TArtifact` is the artifact channel the +dispatch produces and the judges score — `string` (the default) is the plain +agent-response-text path; a structured artifact type flows natively once the +spec supplies `parseOutput` (or a LEVEL-2 `dispatch`) producing it. -###### Returns +#### Type Parameters -`Promise`\<`string` \| `undefined`\> +##### TCase -*** +`TCase` -### ResolvedMcpServerLaunch +##### TArtifact -The spawn-ready strings for one stdio MCP server: profile config values - resolved, secrets separated so the client can redact them. +`TArtifact` = `string` #### Properties -##### args? +##### name -> `optional` **args?**: `string`[] +> **name**: `string` -##### env? +Leaderboard name — the scenario `kind`, default profile name, and report title. -> `optional` **env?**: `Record`\<`string`, `string`\> +##### cases -Public env, safe to appear in diagnostics. +> **cases**: `TCase`[] -##### protectedEnv? +The case corpus. Every case needs a stable string id (see `caseId`). -> `optional` **protectedEnv?**: `Record`\<`string`, `string`\> +##### caseId? -Resolved secret env. Reaches only the child process; redacted everywhere else. +> `optional` **caseId?**: (`c`) => `string` -*** +Stable id extractor. Default: the case's own `id` property (fail-loud + when absent or not a string). -### LocalSandboxClientOptions +###### Parameters -#### Properties +###### c -##### router +`TCase` -> **router**: `object` +###### Returns -The worker brain: router chat-completions with tool-calling. All three required. +`string` -###### baseUrl +##### prompt -> **baseUrl**: `string` +> **prompt**: (`c`) => `string` \| `Promise`\<`string`\> -###### key +The per-case task prompt. May be async (e.g. built by shelling out to a + reference implementation); resolved ONCE per case before dispatch. -> **key**: `string` +###### Parameters -###### model +###### c -> **model**: `string` +`TCase` -##### maxTurns? +###### Returns -> `optional` **maxTurns?**: `number` +`string` \| `Promise`\<`string`\> -Tool-loop turns per prompt. Default 8. +##### score -##### temperature? +> **score**: (`output`, `c`) => `number` \| [`LeaderboardScore`](#leaderboardscore) -> `optional` **temperature?**: `number` +The domain grader: agent output artifact → score. Used BOTH as the + per-shot validator (a shot with `composite > 0` stops the naive retry + loop) and, wrapped as a campaign judge, as the recorded leaderboard score. -Brain sampling temperature. Default: `routerBrain`'s (0.4). +###### Parameters -##### profile? +###### output -> `optional` **profile?**: `AgentProfile` +`TArtifact` -Fallback profile when `create(options)` carries none on `backend.profile`. +###### c -##### keys? +`TCase` -> `optional` **keys?**: [`KeyProvider`](#keyprovider) +###### Returns -Resolves profile-declared MCP secret names at child-process spawn time. +`number` \| [`LeaderboardScore`](#leaderboardscore) -##### profileSecurityPolicy? +##### axis? -> `optional` **profileSecurityPolicy?**: `AgentProfileSecurityPolicy` +> `optional` **axis?**: `object` -Explicit trust decision for the exact `profile` bytes supplied here. -Omit to refuse local processes. A permissive policy never transfers to a -different per-create profile and provides no host isolation. +Harness × model axes for `expandProfileAxes`. Defaults: the canonical + `CODING_HARNESSES` × the base profile's `model.default`. `--harnesses` / + `--models` override per run. -*** +###### harnesses? -### LoopDispatchOptions +> `optional` **harnesses?**: readonly `HarnessType`[] -#### Type Parameters +###### models? -##### Task +> `optional` **models?**: readonly `string`[] -`Task` +##### baseProfile? -##### Output +> `optional` **baseProfile?**: `AgentProfile` -`Output` +Base profile the axes expand over (prompt/tools/skills held fixed). + Default: a minimal `{ name, model: { default: } }`. -##### Decision +##### backends? -`Decision` +> `optional` **backends?**: `Record`\<`string`, (() => [`SandboxClient`](#sandboxclient-5)) \| `undefined`\> -##### TScenario +Execution-backend registry: `--backend ` picks the factory that +yields the `SandboxClient` every cell runs on. Merged over the defaults: + - `sandbox` — throws with guidance (a product must supply its real + Sandbox-backed client; the facade has no credentials). + - `cli-bridge` — `resolveSandboxClient({ backend: 'bridge' })` reading + `CLI_BRIDGE_URL` + `BRIDGE_BEARER`/`CLI_BRIDGE_BEARER`; the per-cell + harness/model ride in via `sandboxOverrides.backend`. -`TScenario` *extends* `Scenario` +##### flags? -##### TArtifact +> `optional` **flags?**: `Record`\<`string`, [`LeaderboardFlagSpec`](#leaderboardflagspec)\> -`TArtifact` +Extra `--flag value` CLI args `run()` parses and surfaces via `ctx.args`. -#### Properties - -##### sandboxClient +##### modelBackend? -> **sandboxClient**: [`SandboxClient`](#sandboxclient-5) +> `optional` **modelBackend?**: `Record`\<`string`, `unknown`\> -Sandbox client used for every cell's `runAgentRounds`. Supplied once. +Extra fields merged into each cell's `backend.model` create override — + e.g. `{ provider: 'openai-compat', apiKey, baseUrl }` for a router-backed + sandbox. The cell's bare model id is set by the facade from the axis. -##### toLoopOptions +##### setup? -> **toLoopOptions**: (`scenario`, `profile`) => [`LoopOptionsForDispatch`](#loopoptionsfordispatch)\<`Task`, `Output`, `Decision`\> +> `optional` **setup?**: (`ctx`) => `void` \| `Promise`\<`void`\> -Build the per-cell runAgentRounds options from the scenario (+ profile, when - used with `runProfileMatrix`). +Runs once before the matrix (fetch fixtures, warm caches). ###### Parameters -###### scenario - -`TScenario` - -###### profile +###### ctx -`AgentProfile` +[`LeaderboardRunContext`](#leaderboardruncontext) ###### Returns -[`LoopOptionsForDispatch`](#loopoptionsfordispatch)\<`Task`, `Output`, `Decision`\> +`void` \| `Promise`\<`void`\> -##### toArtifact? +##### teardown? -> `optional` **toArtifact?**: (`result`) => `TArtifact` +> `optional` **teardown?**: (`ctx`) => `void` \| `Promise`\<`void`\> -Map the finished loop to the artifact the judges score. Default: - `result.winner?.output`. A loop with no winner yields `undefined` (judges - skip the cell) — but the loop's token usage is STILL reported, so the - integrity guard sees real activity. +Runs once after the matrix, even on failure (reap boxes, close handles). ###### Parameters -###### result +###### ctx -[`LoopResult`](index.md#loopresult)\<`Task`, `Output`, `Decision`\> +[`LeaderboardRunContext`](#leaderboardruncontext) ###### Returns -`TArtifact` - -##### forwardTrace? +`void` \| `Promise`\<`void`\> -> `optional` **forwardTrace?**: `boolean` +##### onCellEvents? -Forward `loop.*` trace events into the campaign's scoped trace so loop - spans correlate with the cell. Default true. +> `optional` **onCellEvents?**: (`events`, `c`, `iteration?`) => `void` -##### costSource? +Per-cell event tap: the raw sandbox events of EVERY shot, with the case — + the seam for domain metric capture (search counts, citations) without a + substrate change. Fires once per shot after the cell's loop settles, in + shot order, including thrown shots (whose events may be partial or empty); + the third argument carries the shot's index + error/verdict outcome. -> `optional` **costSource?**: `string` +###### Parameters -Cost-meter source label for the loop's spend. Default `'loop'`. +###### events -##### maximumCharge? +readonly `SandboxEvent`[] -> `optional` **maximumCharge?**: `MaximumCharge` \| ((`scenario`, `profile`) => MaximumCharge \| undefined) +###### c -Provider- or executor-enforced maximum for this whole cell dispatch. -Required by agent-eval before execution when the campaign is cost-capped. +`TCase` -##### resolveCostModel? +###### iteration? -> `optional` **resolveCostModel?**: (`result`, `scenario`, `profile`) => `string` \| `undefined` +[`LeaderboardIterationInfo`](#leaderboarditerationinfo) -Resolve the model actually served from the completed loop. +###### Returns -###### Parameters +`void` -###### result +##### parseOutput? -[`LoopResult`](index.md#loopresult)\<`Task`, `Output`, `Decision`\> +> `optional` **parseOutput?**: (`events`, `c`) => `TArtifact` -###### scenario +Output decode override: raw events → the scored artifact. Default: the + sandbox SDK's `collectAgentResponseText` (final answer text; empty string + when the stream carried none — which then scores 0). The default only + produces `string`, so a spec with a structured `TArtifact` MUST supply + this (or a LEVEL-2 `dispatch`). -`TScenario` +###### Parameters -###### profile +###### events -`AgentProfile` +readonly `SandboxEvent`[] -###### Returns +###### c -`string` \| `undefined` +`TCase` -*** +###### Returns -### LoopCampaignDispatchOptions +`TArtifact` -Options for adapting plain agent-eval campaign scenarios into Runtime cells. +##### resolveModel? -#### Type Parameters +> `optional` **resolveModel?**: (`events`) => `string` \| `undefined` -##### Task +Resolve the model the backend ACTUALLY served off a shot's raw events. +Required for HARNESS_NATIVE_MODEL-snapped cells (a vendor-locked harness × +an out-of-family model expands to the `default` sentinel): the RunRecord +must pin a real snapshot-bearing model id, which only the dispatch — +reading the backend's usage/terminal events — can know. When this returns +a value the default dispatch records it on the paid-call receipt; +in-family cells (concrete declared model) never need it. -`Task` +###### Parameters -##### Output +###### events -`Output` +readonly `SandboxEvent`[] -##### Decision +###### Returns -`Decision` +`string` \| `undefined` -##### TScenario +##### export? -`TScenario` *extends* `Scenario` +> `optional` **export?**: (`result`, `ctx`) => `void` \| `Promise`\<`void`\> -##### TArtifact +Result export. Default: write `matrix-result.json` under the run dir and + print (+ write) the ranked leaderboard markdown under the export dir. -`TArtifact` +###### Parameters -#### Properties +###### result -##### sandboxClient +`RunProfileMatrixResult`\<`TArtifact`, [`LeaderboardScenario`](#leaderboardscenario)\<`TCase`\>\> -> **sandboxClient**: [`SandboxClient`](#sandboxclient-5) +###### ctx -Sandbox client used for every campaign cell's `runAgentRounds`. +[`LeaderboardRunContext`](#leaderboardruncontext) -##### toLoopOptions +###### Returns -> **toLoopOptions**: (`scenario`) => [`LoopOptionsForDispatch`](#loopoptionsfordispatch)\<`Task`, `Output`, `Decision`\> +`void` \| `Promise`\<`void`\> -Build the per-cell runAgentRounds options from the campaign scenario. +##### dispatch? -###### Parameters +> `optional` **dispatch?**: `ProfileDispatchFn`\<[`LeaderboardScenario`](#leaderboardscenario)\<`TCase`\>, `TArtifact`\> -###### scenario +LEVEL 2 — full dispatch replacement (in-process products bring their own). + The default is `loopDispatch` + `naiveDriver` over the resolved backend. -`TScenario` +##### judges? -###### Returns +> `optional` **judges?**: `JudgeConfig`\<`TArtifact`, [`LeaderboardScenario`](#leaderboardscenario)\<`TCase`\>\>[] -[`LoopOptionsForDispatch`](#loopoptionsfordispatch)\<`Task`, `Output`, `Decision`\> +LEVEL 2 — full judge replacement. Default: `score` wrapped as one judge. -##### toArtifact? +##### shots? -> `optional` **toArtifact?**: (`result`) => `TArtifact` +> `optional` **shots?**: `number` -Map the finished loop to the artifact the campaign judges score. +Naive-retry shot cap per cell (`--shots`). Default 1. -###### Parameters +##### reps? -###### result +> `optional` **reps?**: `number` -[`LoopResult`](index.md#loopresult)\<`Task`, `Output`, `Decision`\> +Replicates per cell (`--reps`). Default 1. -###### Returns +##### maximumCharge? -`TArtifact` +> `optional` **maximumCharge?**: `MaximumCharge` \| ((`profile`, `scenario`) => MaximumCharge \| undefined) -##### forwardTrace? +Provider- or executor-enforced maximum for one cell dispatch. Required +before execution when `matrix.costCeiling` is configured. -> `optional` **forwardTrace?**: `boolean` +##### matrix? -Forward `loop.*` trace events into the campaign's scoped trace. Default true. +> `optional` **matrix?**: `Partial`\<`RunProfileMatrixOptions`\<[`LeaderboardScenario`](#leaderboardscenario)\<`TCase`\>, `TArtifact`\>\> -##### costSource? +Passthrough overrides spread onto the final `runProfileMatrix` call + (e.g. `maxConcurrency`, `costCeiling`, `integrity`, `storage`) — spread + LAST, so anything the facade wired can be overridden. -> `optional` **costSource?**: `string` +*** -Cost-meter source label for the loop's spend. Default `'loop'`. +### DefinedLeaderboard -##### maximumCharge? +#### Type Parameters -> `optional` **maximumCharge?**: `MaximumCharge` \| ((`scenario`) => MaximumCharge \| undefined) +##### TCase -Provider- or executor-enforced maximum for this whole cell dispatch. +`TCase` -##### resolveCostModel? +##### TArtifact -> `optional` **resolveCostModel?**: (`result`, `scenario`) => `string` \| `undefined` +`TArtifact` = `string` -Resolve the model actually served from the completed loop. +#### Methods -###### Parameters +##### run() -###### result +> **run**(`argv?`): `Promise`\<`RunProfileMatrixResult`\<`TArtifact`, [`LeaderboardScenario`](#leaderboardscenario)\<`TCase`\>\>\> -[`LoopResult`](index.md#loopresult)\<`Task`, `Output`, `Decision`\> +Parse flags, run the matrix, export, and return the raw result. -###### scenario +Standard flags: `--backend ` (default `sandbox`), `--harnesses a,b`, +`--models m1,m2`, `--cases id1,id2`, `--shots N`, `--reps N`, +`--model-snapshot `, `--run-dir `, `--export-dir `, +plus every `spec.flags` entry. `argv` defaults to `process.argv.slice(2)`. -`TScenario` +The default run dir is FRESH per invocation (timestamp+pid under the OS +tmpdir). `runProfileMatrix` caches cells by run dir, and a stable default +would silently reuse a prior FAILED zero-token cell and skip dispatch — +only an explicit `--run-dir` opts into that resume behavior. -###### Returns +###### Parameters -`string` \| `undefined` +###### argv? -*** +`string`[] -### McpEndpoint +###### Returns -Where a handle's MCP server lives; headers carry per-artifact scoping. +`Promise`\<`RunProfileMatrixResult`\<`TArtifact`, [`LeaderboardScenario`](#leaderboardscenario)\<`TCase`\>\>\> -#### Properties +##### toBenchmarkAdapter() -##### url +> **toBenchmarkAdapter**(): [`LeaderboardBenchmarkAdapter`](#leaderboardbenchmarkadapter)\<`TArtifact`\> -> **url**: `string` +The same domain surface in the structural `BenchmarkAdapter` shape. -##### headers? +###### Returns -> `optional` **headers?**: `Record`\<`string`, `string`\> +[`LeaderboardBenchmarkAdapter`](#leaderboardbenchmarkadapter)\<`TArtifact`\> *** -### McpEnvironmentOptions +### HarvestCorpusOptions #### Properties -##### name +##### runs -> **name**: `string` +> **runs**: `AsyncIterable`\<[`ObserveInput`](#observeinput), `any`, `any`\> \| `Iterable`\<[`ObserveInput`](#observeinput), `any`, `any`\> -##### maxResultChars? +The completed runs to analyze — map your store's rows to `ObserveInput`. -> `optional` **maxResultChars?**: `number` +##### chat -Cap on a tool result's text fed back to the worker. Default 1500 chars. +> **chat**: `ChatClient` -#### Methods +The model-call seam (agent-eval `createChatClient`). -##### open() +##### model? -> **open**(`task`): `Promise`\<\{ `handle`: [`ArtifactHandle`](#artifacthandle); `endpoint`: [`McpEndpoint`](#mcpendpoint); \}\> +> `optional` **model?**: `string` -Create/seed the per-task artifact; return its handle + the MCP endpoint scoped to it. +##### corpus -###### Parameters +> **corpus**: [`Corpus`](#corpus-2) -###### task +The durable corpus the facts accrete into. -[`AgenticTask`](#agentictask) +##### tags? -###### Returns +> `optional` **tags?**: readonly `string`[] -`Promise`\<\{ `handle`: [`ArtifactHandle`](#artifacthandle); `endpoint`: [`McpEndpoint`](#mcpendpoint); \}\> +Tags written onto learned facts (the product/domain key the read side queries by). -##### score() +##### analystInstruction? -> **score**(`task`, `handle`): `Promise`\<[`SurfaceScore`](#surfacescore)\> +> `optional` **analystInstruction?**: `string` -The deployable check over the artifact's current state. +Override the analyst instruction (the GEPA-tunable knob). -###### Parameters +##### concurrency? -###### task +> `optional` **concurrency?**: `number` -[`AgenticTask`](#agentictask) +Runs analyzed in parallel. Default 4. -###### handle +##### maxRuns? -[`ArtifactHandle`](#artifacthandle) +> `optional` **maxRuns?**: `number` -###### Returns +Hard cap on runs consumed from the stream (a cost guard for unbounded stores). -`Promise`\<[`SurfaceScore`](#surfacescore)\> +##### signal? -##### close()? +> `optional` **signal?**: `AbortSignal` -> `optional` **close**(`handle`): `Promise`\<`void`\> +*** -Teardown (delete the seeded artifact). Optional — omit for stateless servers. +### HarvestFailure -###### Parameters +#### Properties -###### handle +##### runId -[`ArtifactHandle`](#artifacthandle) +> **runId**: `string` -###### Returns +##### error -`Promise`\<`void`\> +> **error**: `string` -##### selectTools()? +*** -> `optional` **selectTools**(`task`, `all`): [`AgenticTool`](#agentictool)[] +### HarvestReport -Restrict/order the server's tools per task (e.g. the task's selected_tools). Default: all. +#### Properties -###### Parameters +##### runsObserved -###### task +> **runsObserved**: `number` -[`AgenticTask`](#agentictask) +##### findings -###### all +> **findings**: `number` -[`AgenticTool`](#agentictool)[] +Total findings the analyst produced (including ones already known). -###### Returns +##### learned -[`AgenticTool`](#agentictool)[] +> **learned**: `number` -*** +NEW facts actually appended (idempotent dedup excludes re-learned ones). -### ObserveInput +##### failures -#### Properties +> **failures**: [`HarvestFailure`](#harvestfailure)[] -##### task +Per-run analysis failures — reported, never silently dropped. -> **task**: `string` +*** -What the worker was asked to do. +### InProcessPromptCtx -##### output +Context handed to each `onPrompt` call. -> **output**: `string` +#### Properties -What it produced (its final answer / artifact summary). +##### round -##### trace +> **round**: `number` -> **trace**: readonly `unknown`[] +0-based round index — increments per `streamPrompt` on the same box. + Fresh boxes start at 0. -The worker's trace — any event array (sandbox events, tool-call records). +##### workdir? -##### outcome? +> `optional` **workdir?**: `string` -> `optional` **outcome?**: `"failed"` \| `"unknown"` \| `"passed"` +Absolute path of this box's workspace, when a `workdir` was configured. + Write the deliverable / fixtures here; `fs.read`/`fs.write`/`exec` operate + over it. `undefined` for pure event-only boxes. -Terminal status only (passed/failed/unknown) — NOT a judge score; the - observer never reads the verdict, it reads behavior. +##### signal -##### runId? +> **signal**: `AbortSignal` -> `optional` **runId?**: `string` +Cooperative cancellation channel for this turn. -Provenance back to the run. +##### options? + +> `optional` **options?**: `Record`\<`string`, `unknown`\> + +The verbatim per-call options the caller passed to the box verb (minus + `signal`, surfaced above) — lets an offline test assert an options + passthrough (`model`, `sessionId`, …) actually arrived. *** -### ObserveOptions +### InProcessSandboxClientOptions + +**`Experimental`** #### Properties -##### chat +##### onPrompt -> **chat**: `ChatClient` +> **onPrompt**: [`InProcessOnPrompt`](#inprocessonprompt) -The model-call seam (agent-eval `createChatClient`: router / cli-bridge / …). +**`Experimental`** -##### model? +The per-turn behavior — see [InProcessOnPrompt](#inprocessonprompt). -> `optional` **model?**: `string` +##### workdir? -##### corpus? +> `optional` **workdir?**: `string` -> `optional` **corpus?**: [`Corpus`](#corpus-2) +**`Experimental`** -When set, learned facts are appended (idempotent) for the next run to read. +Opt in to a REAL filesystem-backed box. When set, each `create()` mints a +fresh temp directory (prefixed `-`) and the box exposes +`fs.read`/`fs.write` and `exec` over it; `delete()` removes the dir. Omit +for a pure event-only box (no `fs`/`exec` members), which is all a driver +or fanout loop needs. -##### tags? +##### id? -> `optional` **tags?**: readonly `string`[] +> `optional` **id?**: `string` \| ((`seq`) => `string`) -Tags written onto learned facts + used by the next run's corpus query. +**`Experimental`** -##### signal? +Override the box `id`. A string is used verbatim; a function receives the +0-based create-sequence and returns the id (e.g. machine-keyed placement +demos). Default `in-process-`. The id is the value `describePlacement` +tags, so set it when a demo's output reads on a meaningful sandbox id. -> `optional` **signal?**: `AbortSignal` +*** -##### maxTraceLines? +### KeyProvider -> `optional` **maxTraceLines?**: `number` +Resolve named secrets. The ONE seam every secret store adapts to. -Cap the trace lines fed to the observer (keeps the call cheap). Default 80. +#### Methods -##### analystInstruction? +##### get() -> `optional` **analystInstruction?**: `string` +> **get**(`name`): `Promise`\<`string` \| `undefined`\> -Override the analyst's system instruction — the prompt that turns a trace into - findings + recommended_actions. The analyst IS the steerer, so this is the knob a - prompt optimizer (GEPA) tunes. Omitted ⇒ the default observer instruction. The - firewall (trace-only, never the verdict) is structural (input has no score), so a - custom instruction cannot break it. +The value for `name`, or `undefined` when this provider does not hold it. + +###### Parameters + +###### name + +`string` + +###### Returns + +`Promise`\<`string` \| `undefined`\> *** -### Observation +### ResolvedMcpServerLaunch + +The spawn-ready strings for one stdio MCP server: profile config values + resolved, secrets separated so the client can redact them. #### Properties -##### findings +##### args? -> **findings**: `ProposalFinding`[] +> `optional` **args?**: `string`[] -##### learned +##### env? -> **learned**: [`CorpusRecord`](#corpusrecord)[] +> `optional` **env?**: `Record`\<`string`, `string`\> -Facts persisted to the corpus (empty when no corpus was supplied). +Public env, safe to appear in diagnostics. -##### report +##### protectedEnv? -> **report**: `string` +> `optional` **protectedEnv?**: `Record`\<`string`, `string`\> -Operator-facing markdown: what the observer noticed + what to change. +Resolved secret env. Reaches only the child process; redacted everywhere else. *** -### CreateScopeAnalystOptions +### LocalSandboxClientOptions -The analyst run an `Agent` performs over the children settled so far. -The combinator supplies the analyst's task projection (how to frame the drained settlements as -the analyst's input) — the analyst's `act` reads the trace and returns its raw findings; the -firewall is enforced afterwards by `createScopeAnalyst`, not by the analyst itself. +#### Properties -#### Type Parameters +##### router -##### D +> **router**: `object` -`D` +The worker brain: router chat-completions with tool-calling. All three required. -#### Properties +###### baseUrl -##### analyst +> **baseUrl**: `string` -> `readonly` **analyst**: [`Agent`](#agent-1)\<`unknown`, readonly `AnalystFinding`[]\> +###### key -The analyst agent the combinator spawns over the trace. `harness` is the persona's choice - (`null` for an inline router analyst, a `BackendType` for a sandboxed one). Its `act` returns - the RAW findings; this module asserts the firewall on them before returning. +> **key**: `string` -##### budget +###### model -> `readonly` **budget**: [`Budget`](index.md#budget-4) +> **model**: `string` -The conserved budget reserved for one analyst spawn. The pool reserves against it and fails - closed; an analyst that cannot be admitted is a fail-loud abort, never silent empty findings. +##### maxTurns? -##### label? +> `optional` **maxTurns?**: `number` -> `readonly` `optional` **label?**: `string` +Tool-loop turns per prompt. Default 8. -Trace/journal label for the spawned analyst child. Default `'analyst'`. +##### temperature? -#### Methods +> `optional` **temperature?**: `number` -##### buildTask() +Brain sampling temperature. Default: `routerBrain`'s (0.4). -> **buildTask**(`input`): `unknown` +##### profile? -Build the analyst agent's task from the analyze input (the root-task framing + the children - drained so far). Pure projection — the analyst interprets it, this never reads it. +> `optional` **profile?**: `AgentProfile` -###### Parameters +Fallback profile when `create(options)` carries none on `backend.profile`. -###### input +##### keys? -[`ScopeAnalyzeInput`](#scopeanalyzeinput)\<`D`\> +> `optional` **keys?**: [`KeyProvider`](#keyprovider) -###### Returns +Resolves profile-declared MCP secret names at child-process spawn time. -`unknown` +##### profileSecurityPolicy? + +> `optional` **profileSecurityPolicy?**: `AgentProfileSecurityPolicy` + +Explicit trust decision for the exact `profile` bytes supplied here. +Omit to refuse local processes. A permissive policy never transfers to a +different per-create profile and provides no host isolation. *** -### RegistryAnalyzeProjection +### LoopDispatchOptions -Project a `ScopeAnalyzeInput` into the `AnalystRegistry.run` arguments. The registry runs over a -`runId` + `AnalystRunInputs` (a trace store / run record / artifact dir), NOT in-memory scope -settlements — so the CALLER owns the projection from the combinator's drained children to the -registry's inputs (e.g. the trace store the run already wrote). This adapter never invents that -bridge; it only runs the projected inputs and firewalls the merged findings. +#### Type Parameters -#### Properties +##### Task -##### runId +`Task` -> `readonly` **runId**: `string` +##### Output -##### inputs +`Output` -> `readonly` **inputs**: `AnalystRunInputs` +##### Decision -##### opts? +`Decision` -> `readonly` `optional` **opts?**: `object` +##### TScenario -Optional `run` opts (e.g. `priorFindings`, `chainFindings`) forwarded verbatim to the registry. +`TScenario` *extends* `Scenario` -###### Index Signature +##### TArtifact -\[`k`: `string`\]: `unknown` +`TArtifact` -###### priorFindings? +#### Properties -> `optional` **priorFindings?**: readonly `AnalystFinding`[] \| `Record`\<`string`, readonly `AnalystFinding`[]\> +##### sandboxClient -###### chainFindings? +> **sandboxClient**: [`SandboxClient`](#sandboxclient-5) -> `optional` **chainFindings?**: `boolean` +Sandbox client used for every cell's `runAgentRounds`. Supplied once. -*** +##### toLoopOptions -### Persona +> **toLoopOptions**: (`scenario`, `profile`) => [`LoopOptionsForDispatch`](#loopoptionsfordispatch)\<`Task`, `Output`, `Decision`\> -The "act like X" record. A thin composition over the keystone's `AgentSpec`: it pairs the -root spec (the executor mapping for the root agent the shape builds) with the CONTENT a -shape consumes — the goal framing (`directive`) and who the loop is acting as (`context`). +Build the per-cell runAgentRounds options from the scenario (+ profile, when + used with `runProfileMatrix`). -The framework never reads `directive`/`context` semantically; it threads them to the shape -verbatim through `ShapeContext`. This is the rule the mandate names: the FRAMEWORK is -structure, the PERSONA carries model/prompt/tools/directive. No model name, prompt, or -persona string is ever hardcoded in a shape or the engine. +###### Parameters -`D` is the deliverable type this persona's loops produce; it flows into `Outcome`. +###### scenario -#### Type Parameters +`TScenario` -##### D +###### profile -`D` = `unknown` +`AgentProfile` -#### Properties +###### Returns -##### name +[`LoopOptionsForDispatch`](#loopoptionsfordispatch)\<`Task`, `Output`, `Decision`\> -> `readonly` **name**: `string` +##### toArtifact? -Stable persona name — used as the trace/journal label root, never as content. +> `optional` **toArtifact?**: (`result`) => `TArtifact` -##### root +Map the finished loop to the artifact the judges score. Default: + `result.winner?.output`. A loop with no winner yields `undefined` (judges + skip the cell) — but the loop's token usage is STILL reported, so the + integrity guard sees real activity. -> `readonly` **root**: [`AgentSpec`](index.md#agentspec) +###### Parameters -The root agent's executor mapping (profile + harness + optional BYO executor). The -shape's root `Agent` carries THIS as its `executorSpec`; child specs the shape spawns -are derived from / resolved against the same persona registry (see `ShapeContext`). +###### result -##### directive +[`LoopResult`](index.md#loopresult)\<`Task`, `Output`, `Decision`\> -> `readonly` **directive**: `string` +###### Returns -The goal framing handed to the shape — the "what to achieve", not "how". +`TArtifact` -##### context +##### forwardTrace? -> `readonly` **context**: [`PersonaContext`](#personacontext-1) +> `optional` **forwardTrace?**: `boolean` -Who the loop is acting as — the opaque persona context blob the shape may inject into - child tasks. Opaque to the framework; only the persona's profiles/prompts interpret it. +Forward `loop.*` trace events into the campaign's scoped trace so loop + spans correlate with the cell. Default true. -##### executors +##### costSource? -> `readonly` **executors**: [`PersonaExecutors`](#personaexecutors-1) +> `optional` **costSource?**: `string` -The executor seams (router endpoint+key, sandbox client, cli bin) the built-in runtimes -read off `ExecutorContext.seams`, OR a fully pre-configured registry. The supervisor -threads an EMPTY seam bag to the root scope, so a persona that uses built-in metered -runtimes MUST supply a registry whose factories close over their seams (or BYO executors -on each `AgentSpec`). Carried here so `runPersonified` can build `SupervisorOpts.executors`. +Cost-meter source label for the loop's spend. Default `'loop'`. -##### extensions? +##### maximumCharge? -> `readonly` `optional` **extensions?**: `Readonly`\<`Record`\<`string`, `unknown`\>\> +> `optional` **maximumCharge?**: `MaximumCharge` \| ((`scenario`, `profile`) => MaximumCharge \| undefined) -Forward-compatible extension bag — a later world-model / memory / tool-budget field is an -additive key here, never a breaking change to the `Persona` shape. Opaque to the engine. +Provider- or executor-enforced maximum for this whole cell dispatch. +Required by agent-eval before execution when the campaign is cost-capped. -##### \_\_deliverable? +##### resolveCostModel? -> `readonly` `optional` **\_\_deliverable?**: `D` +> `optional` **resolveCostModel?**: (`result`, `scenario`, `profile`) => `string` \| `undefined` -Phantom: binds the persona to its deliverable type so `runPersonified` infers `D` from - the persona and the chosen shape must agree. Type-only — never present at runtime. +Resolve the model actually served from the completed loop. -*** +###### Parameters -### PersonaContext +###### result -The persona context blob — who the loop is acting as. Open by intent: a persona names its - own role/audience/constraints; the framework treats it as opaque content. +[`LoopResult`](index.md#loopresult)\<`Task`, `Output`, `Decision`\> -#### Indexable +###### scenario -> \[`key`: `string`\]: `unknown` +`TScenario` -Open content bag — persona-specific fields a shape's child tasks may carry. +###### profile -#### Properties +`AgentProfile` -##### role +###### Returns -> `readonly` **role**: `string` +`string` \| `undefined` -The role the loop embodies ("senior staff engineer", "equity research analyst", …). +*** -##### notes? +### LoopCampaignDispatchOptions -> `readonly` `optional` **notes?**: `string` +Options for adapting plain agent-eval campaign scenarios into Runtime cells. -Optional freeform framing the persona's prompts/profiles consume. +#### Type Parameters -*** +##### Task -### PersonaExecutors +`Task` -How a persona supplies executor resolution. Either a pre-built registry (factories already -closed over their seams) OR the raw seam bag the engine uses to construct a registry + -thread the seams onto each spawn. Exactly one is required — fail loud if neither is set. +##### Output -#### Properties +`Output` -##### registry? +##### Decision -> `readonly` `optional` **registry?**: [`ExecutorRegistry`](index.md#executorregistry) +`Decision` -A registry whose factories already capture their seams. Highest precedence. +##### TScenario -##### seams? +`TScenario` *extends* `Scenario` -> `readonly` `optional` **seams?**: `Readonly`\<`Record`\<`string`, `unknown`\>\> +##### TArtifact -Raw seams to thread onto built-in runtimes (`router`/`sandbox`/`cli` keys). +`TArtifact` -*** +#### Properties -### DefinePersonaInput +##### sandboxClient -The minimal input to build a `Persona`. Mirrors `Persona` but lets the builder default - the executors-supplied invariant check and freeze the record. +> **sandboxClient**: [`SandboxClient`](#sandboxclient-5) -#### Type Parameters +Sandbox client used for every campaign cell's `runAgentRounds`. -##### D +##### toLoopOptions -`D` = `unknown` +> **toLoopOptions**: (`scenario`) => [`LoopOptionsForDispatch`](#loopoptionsfordispatch)\<`Task`, `Output`, `Decision`\> -#### Properties +Build the per-cell runAgentRounds options from the campaign scenario. -##### name +###### Parameters -> `readonly` **name**: `string` +###### scenario -##### root +`TScenario` -> `readonly` **root**: [`AgentSpec`](index.md#agentspec) +###### Returns -##### directive +[`LoopOptionsForDispatch`](#loopoptionsfordispatch)\<`Task`, `Output`, `Decision`\> -> `readonly` **directive**: `string` +##### toArtifact? -##### context +> `optional` **toArtifact?**: (`result`) => `TArtifact` -> `readonly` **context**: [`PersonaContext`](#personacontext-1) +Map the finished loop to the artifact the campaign judges score. -##### executors +###### Parameters -> `readonly` **executors**: [`PersonaExecutors`](#personaexecutors-1) +###### result -##### extensions? +[`LoopResult`](index.md#loopresult)\<`Task`, `Output`, `Decision`\> -> `readonly` `optional` **extensions?**: `Readonly`\<`Record`\<`string`, `unknown`\>\> +###### Returns -##### \_\_deliverable? +`TArtifact` -> `readonly` `optional` **\_\_deliverable?**: `D` +##### forwardTrace? -Phantom: pins the input's deliverable type so `definePersona` returns a `Persona` - the caller's shape must agree with. Type-only — never supplied at a call site. +> `optional` **forwardTrace?**: `boolean` -*** +Forward `loop.*` trace events into the campaign's scoped trace. Default true. -### ShapeBudget +##### costSource? -Budget knobs a shape reads to size its fanout/children WITHOUT owning the conserved pool. -The root budget lives on `SupervisorOpts.budget`; the shape only needs the per-child -sizing hints + the fanout width it is allowed to open. All ceilings — the pool reserves -against them and fails closed, so an over-eager shape can never overspend. +> `optional` **costSource?**: `string` -#### Properties +Cost-meter source label for the loop's spend. Default `'loop'`. -##### perChild +##### maximumCharge? -> `readonly` **perChild**: [`Budget`](index.md#budget-4) +> `optional` **maximumCharge?**: `MaximumCharge` \| ((`scenario`) => MaximumCharge \| undefined) -Per-child spawn budget the shape reserves for each leaf/sub-loop it opens. +Provider- or executor-enforced maximum for this whole cell dispatch. -##### fanout +##### resolveCostModel? -> `readonly` **fanout**: `number` +> `optional` **resolveCostModel?**: (`result`, `scenario`) => `string` \| `undefined` -Max children a fanout step may open in one round (the shape's structural width). +Resolve the model actually served from the completed loop. -*** +###### Parameters -### ShapeContext +###### result -The construction context a `LoopShape` factory receives. Carries the persona's resolved -executor seams + the budget knobs, plus the ONE helper a shape needs to spawn a child -through the keystone: `spawnChild` resolves an `AgentSpec` (or a persona-derived child -profile) into an `Agent` the shape hands to `scope.spawn`. The shape never touches the -registry directly — it asks the context, keeping resolution single-sourced. +[`LoopResult`](index.md#loopresult)\<`Task`, `Output`, `Decision`\> -#### Type Parameters +###### scenario -##### D +`TScenario` -`D` = `unknown` +###### Returns -#### Properties +`string` \| `undefined` -##### persona +*** -> `readonly` **persona**: [`Persona`](#persona)\<`D`\> +### McpEndpoint -##### budget +Where a handle's MCP server lives; headers carry per-artifact scoping. -> `readonly` **budget**: [`ShapeBudget`](#shapebudget) +#### Properties -##### analyst? +##### url -> `readonly` `optional` **analyst?**: [`ScopeAnalyst`](#scopeanalyst)\<`D`\> +> **url**: `string` -The scope analyst (selector≠judge firewall) the combinator steers from. Absent ⇒ the - dormant default (empty findings → gates read deliverables/state only). +##### headers? -#### Methods +> `optional` **headers?**: `Record`\<`string`, `string`\> -##### spawnChild() +*** -> **spawnChild**(`name`, `spec`): [`Agent`](#agent-1)\<`unknown`, [`Outcome`](#outcome-1)\<`D`\>\> +### McpEnvironmentOptions -Wrap an `AgentSpec` into a leaf `Agent` carrying it as `executorSpec`, so the shape can -`scope.spawn(spawnChild(spec), task, opts)`. `name` labels the child for traces. The -returned agent's `act` is never invoked by the keystone (it is spawned, not run) — the -spec drives the resolved `Executor`; `act` exists only to satisfy the `Agent` shape. +#### Properties -###### Parameters +##### name -###### name +> **name**: `string` -`string` +##### maxResultChars? -###### spec +> `optional` **maxResultChars?**: `number` -[`AgentSpec`](index.md#agentspec) +Cap on a tool result's text fed back to the worker. Default 1500 chars. + +#### Methods + +##### open() + +> **open**(`task`): `Promise`\<\{ `handle`: [`ArtifactHandle`](#artifacthandle); `endpoint`: [`McpEndpoint`](#mcpendpoint); \}\> + +Create/seed the per-task artifact; return its handle + the MCP endpoint scoped to it. + +###### Parameters + +###### task + +[`AgenticTask`](#agentictask) ###### Returns -[`Agent`](#agent-1)\<`unknown`, [`Outcome`](#outcome-1)\<`D`\>\> +`Promise`\<\{ `handle`: [`ArtifactHandle`](#artifacthandle); `endpoint`: [`McpEndpoint`](#mcpendpoint); \}\> -##### childSpec() +##### score() -> **childSpec**(`profile`, `harness?`): [`AgentSpec`](index.md#agentspec) +> **score**(`task`, `handle`): `Promise`\<[`SurfaceScore`](#surfacescore)\> -Derive a child `AgentSpec` from the persona's root spec with an overridden profile — - the seam a shape uses to give a worker a narrower role/prompt than the root persona. +The deployable check over the artifact's current state. ###### Parameters -###### profile +###### task -`AgentProfile` +[`AgenticTask`](#agentictask) -###### harness? +###### handle -`BackendType` \| `null` +[`ArtifactHandle`](#artifacthandle) ###### Returns -[`AgentSpec`](index.md#agentspec) +`Promise`\<[`SurfaceScore`](#surfacescore)\> -*** +##### close()? -### ShapeRegistry +> `optional` **close**(`handle`): `Promise`\<`void`\> -The open shape registry — the extension point that makes a new loop-shape ONE file + one -`registerShape` call with zero edits elsewhere. `resolve` returns a typed outcome (inspect -`succeeded` before `value`); `register` fails loud on a duplicate name. +Teardown (delete the seeded artifact). Optional — omit for stateless servers. -#### Methods +###### Parameters -##### register() +###### handle -> **register**\<`Task`, `D`\>(`name`, `factory`): `void` +[`ArtifactHandle`](#artifacthandle) -###### Type Parameters +###### Returns -###### Task +`Promise`\<`void`\> -`Task` +##### selectTools()? -###### D +> `optional` **selectTools**(`task`, `all`): [`AgenticTool`](#agentictool)[] -`D` +Restrict/order the server's tools per task (e.g. the task's selected_tools). Default: all. ###### Parameters -###### name +###### task -`string` +[`AgenticTask`](#agentictask) -###### factory +###### all -[`LoopShape`](#loopshape)\<`Task`, `D`\> +[`AgenticTool`](#agentictool)[] ###### Returns -`void` +[`AgenticTool`](#agentictool)[] -##### resolve() +*** -> **resolve**\<`Task`, `D`\>(`name`): \{ `succeeded`: `true`; `value`: [`LoopShape`](#loopshape)\<`Task`, `D`\>; \} \| \{ `succeeded`: `false`; `error`: `string`; \} +### ObserveInput -###### Type Parameters +#### Properties -###### Task +##### task -`Task` +> **task**: `string` -###### D +What the worker was asked to do. -`D` +##### output -###### Parameters +> **output**: `string` -###### name +What it produced (its final answer / artifact summary). -`string` +##### trace -###### Returns +> **trace**: readonly `unknown`[] -\{ `succeeded`: `true`; `value`: [`LoopShape`](#loopshape)\<`Task`, `D`\>; \} \| \{ `succeeded`: `false`; `error`: `string`; \} +The worker's trace — any event array (sandbox events, tool-call records). -##### names() +##### outcome? -> **names**(): `string`[] +> `optional` **outcome?**: `"failed"` \| `"unknown"` \| `"passed"` -The registered shape names — for diagnostics + a fail-loud "unknown shape" message. +Terminal status only (passed/failed/unknown) — NOT a judge score; the + observer never reads the verdict, it reads behavior. -###### Returns +##### runId? -`string`[] +> `optional` **runId?**: `string` + +Provenance back to the run. *** -### RunPersonifiedOptions +### ObserveOptions -The end-to-end entrypoint. Builds the persona's root `Agent` from the chosen shape, then -runs it through a fresh `createSupervisor` over the persona's executors + the supplied -budget/journal/blobs. Returns the keystone's typed `SupervisedResult>` — a -`winner` carries the synthesized `Outcome`; a `no-winner` is never coerced into one. +#### Properties -`shape` is either a resolved `LoopShape` or a registered shape NAME (resolved through the -default registry). The journal/blobs default to in-memory impls in the engine when omitted -(durable FS impls are passed explicitly for a persisted run). +##### chat -#### Type Parameters +> **chat**: `ChatClient` -##### Task +The model-call seam (agent-eval `createChatClient`: router / cli-bridge / …). -`Task` +##### model? -##### D +> `optional` **model?**: `string` -`D` +##### corpus? -#### Properties +> `optional` **corpus?**: [`Corpus`](#corpus-2) -##### persona +When set, learned facts are appended (idempotent) for the next run to read. -> `readonly` **persona**: [`Persona`](#persona)\<`D`\> +##### tags? -##### shape +> `optional` **tags?**: readonly `string`[] -> `readonly` **shape**: `string` \| [`LoopShape`](#loopshape)\<`Task`, `D`\> +Tags written onto learned facts + used by the next run's corpus query. -A resolved shape factory OR a registered shape name. +##### signal? -##### task +> `optional` **signal?**: `AbortSignal` -> `readonly` **task**: `Task` +##### maxTraceLines? -##### budget +> `optional` **maxTraceLines?**: `number` -> `readonly` **budget**: [`Budget`](index.md#budget-4) +Cap the trace lines fed to the observer (keeps the call cheap). Default 80. -##### shapeBudget? +##### analystInstruction? -> `readonly` `optional` **shapeBudget?**: `Partial`\<[`ShapeBudget`](#shapebudget)\> +> `optional` **analystInstruction?**: `string` -Per-child sizing + fanout width handed to the shape. Defaults derive from `budget`. +Override the analyst's system instruction — the prompt that turns a trace into + findings + recommended_actions. The analyst IS the steerer, so this is the knob a + prompt optimizer (GEPA) tunes. Omitted ⇒ the default observer instruction. The + firewall (trace-only, never the verdict) is structural (input has no score), so a + custom instruction cannot break it. -##### runId? +*** -> `readonly` `optional` **runId?**: `string` +### Observation -Trace/journal root key. Defaults to the persona name + a run discriminator in the engine. +#### Properties -##### journal? +##### findings -> `readonly` `optional` **journal?**: [`SpawnJournal`](#spawnjournal) +> **findings**: `ProposalFinding`[] -##### blobs? +##### learned -> `readonly` `optional` **blobs?**: [`ResultBlobStore`](#resultblobstore) +> **learned**: [`CorpusRecord`](#corpusrecord)[] -##### maxDepth? +Facts persisted to the corpus (empty when no corpus was supplied). -> `readonly` `optional` **maxDepth?**: `number` +##### report -Runtime recursion-depth ceiling, paired with the conserved pool. +> **report**: `string` -##### maxRestarts? +Operator-facing markdown: what the observer noticed + what to change. -> `readonly` `optional` **maxRestarts?**: `number` +*** -OTP intensity breaker bounds, forwarded to the supervisor verbatim. +### CreateScopeAnalystOptions -##### withinMs? +The analyst run an `Agent` performs over the children settled so far. +The combinator supplies the analyst's task projection (how to frame the drained settlements as +the analyst's input) — the analyst's `act` reads the trace and returns its raw findings; the +firewall is enforced afterwards by `createScopeAnalyst`, not by the analyst itself. -> `readonly` `optional` **withinMs?**: `number` +#### Type Parameters -##### handle? +##### D -> `readonly` `optional` **handle?**: [`RootHandle`](#roothandle)\<[`Outcome`](#outcome-1)\<`D`\>\> +`D` -A live root handle to attach (view/signal/abort) before the run starts. +#### Properties -##### now? +##### analyst -> `readonly` `optional` **now?**: () => `number` +> `readonly` **analyst**: [`Agent`](#agent-1)\<`unknown`, readonly `AnalystFinding`[]\> -###### Returns +The analyst agent the combinator spawns over the trace. `harness` is the persona's choice + (`null` for an inline router analyst, a `BackendType` for a sandboxed one). Its `act` returns + the RAW findings; this module asserts the firewall on them before returning. -`number` +##### budget -##### signal? +> `readonly` **budget**: [`Budget`](index.md#budget-4) -> `readonly` `optional` **signal?**: `AbortSignal` +The conserved budget reserved for one analyst spawn. The pool reserves against it and fails + closed; an analyst that cannot be admitted is a fail-loud abort, never silent empty findings. -##### analyst? +##### label? -> `readonly` `optional` **analyst?**: [`ScopeAnalyst`](#scopeanalyst)\<`D`\> +> `readonly` `optional` **label?**: `string` -Optional scope analyst threaded into the shape's ShapeContext so loopUntil/widen steer - on trace-derived findings instead of the dormant empty default. +Trace/journal label for the spawned analyst child. Default `'analyst'`. -##### hooks? +#### Methods -> `readonly` `optional` **hooks?**: [`RuntimeHooks`](index.md#runtimehooks) +##### buildTask() -Lifecycle stream sink, forwarded to `SupervisorOpts.hooks` so the root `Scope`'s -`agent.spawn`/`agent.child` events flow to an observer (e.g. the Intelligence SDK's -trace export). Absent ⇒ no stream (the run is silent, as today). +> **buildTask**(`input`): `unknown` -*** +Build the analyst agent's task from the analyze input (the root-task framing + the children + drained so far). Pure projection — the analyst interprets it, this never reads it. -### PipelineStage +###### Parameters -`pipeline(stages)` — sequential composition: each stage's `Outcome.deliverable` feeds the next -stage's task (via `feed`). The first `blocked` stage short-circuits the whole pipeline (its -blockers ARE the pipeline's blockers — never coerced past a failed stage). The terminal -stage's `done` deliverable is the pipeline's deliverable. Spawns one child per stage in order; -a stage that the conserved pool cannot admit is a concrete blocker. - -No domain: "code build test" is `pipeline([plan, implement, integrate])` under a coder persona, -not a named shape. A stage names only its label + how to derive its task from the prior output. - -#### Type Parameters +###### input -##### Task +[`ScopeAnalyzeInput`](#scopeanalyzeinput)\<`D`\> -`Task` +###### Returns -##### StepIn +`unknown` -`StepIn` +*** -##### StepOut +### RegistryAnalyzeProjection -`StepOut` +Project a `ScopeAnalyzeInput` into the `AnalystRegistry.run` arguments. The registry runs over a +`runId` + `AnalystRunInputs` (a trace store / run record / artifact dir), NOT in-memory scope +settlements — so the CALLER owns the projection from the combinator's drained children to the +registry's inputs (e.g. the trace store the run already wrote). This adapter never invents that +bridge; it only runs the projected inputs and firewalls the merged findings. #### Properties -##### label - -> `readonly` **label**: `string` - -Trace/journal label for this stage's spawned child. - -#### Methods - -##### feed() - -> **feed**(`prior`, `ctx`, `rootTask`): `unknown` - -Derive this stage's task from the prior stage's deliverable (or the root task for stage 0). - Pure projection — the framework never interprets the result; the resolved leaf does. - -###### Parameters - -###### prior - -`StepIn` - -###### ctx - -[`ShapeContext`](#shapecontext)\<`unknown`\> +##### runId -###### rootTask +> `readonly` **runId**: `string` -`Task` +##### inputs -###### Returns +> `readonly` **inputs**: `AnalystRunInputs` -`unknown` +##### opts? -##### collect() +> `readonly` `optional` **opts?**: `object` -> **collect**(`settled`): [`Outcome`](#outcome-1)\<`StepOut`\> +Optional `run` opts (e.g. `priorFindings`, `chainFindings`) forwarded verbatim to the registry. -Read this stage's settled child output into the typed `StepOut` the next stage feeds on. - Fail loud (return a `blocked`) when the child produced nothing usable for the next stage. +###### Index Signature -###### Parameters +\[`k`: `string`\]: `unknown` -###### settled +###### priorFindings? -[`Settled`](index.md#settled)\<[`Outcome`](#outcome-1)\<`StepOut`\>\> +> `optional` **priorFindings?**: readonly `AnalystFinding`[] \| `Record`\<`string`, readonly `AnalystFinding`[]\> -###### Returns +###### chainFindings? -[`Outcome`](#outcome-1)\<`StepOut`\> +> `optional` **chainFindings?**: `boolean` *** -### FanoutOptions - -`fanout(items, { synthesize? })` — N children spawned in one round (one per item, bounded by -the conserved pool's fail-closed admission), drained via `scope.next()`, then optionally a -single SYNTHESIS child over the gathered results. Without `synthesize`, the combinator returns -the best-valid child via the single-sourced selector (selector≠judge). A round that admitted -zero children, or whose synthesis child could not be admitted, is a concrete blocker. +### Persona -No domain: a "research sweep over angles" is `fanout(angles, { synthesize: cite })` under a -research persona; a "fanout-vote" is `fanout(copies)` with the default selector. The item list -+ the synthesis posture are the SHAPE's args; the prompt that turns an item into work is the -persona's. +The "act like X" record. A thin composition over the keystone's `AgentSpec`: it pairs the +root spec (the executor mapping for the root agent the shape builds) with the CONTENT a +shape consumes — the goal framing (`directive`) and who the loop is acting as (`context`). -#### Type Parameters +The framework never reads `directive`/`context` semantically; it threads them to the shape +verbatim through `ShapeContext`. This is the rule the mandate names: the FRAMEWORK is +structure, the PERSONA carries model/prompt/tools/directive. No model name, prompt, or +persona string is ever hardcoded in a shape or the engine. -##### Item +`D` is the deliverable type this persona's loops produce; it flows into `Outcome`. -`Item` +#### Type Parameters ##### D -`D` +`D` = `unknown` #### Properties -##### synthesize? - -> `optional` **synthesize?**: [`FanoutSynthesis`](#fanoutsynthesis)\<`D`\> +##### name -Optional synthesis over the gathered child results: when present, the combinator spawns ONE -synthesis child whose task is built from the drained settlements, and its `done` output is -the deliverable. When absent, the deliverable is the best-valid child via `defaultSelectWinner`. -The synthesis child is a SEPARATE keystone agent (not a re-rank behind the driver). +> `readonly` **name**: `string` -##### selectWinner? +Stable persona name — used as the trace/journal label root, never as content. -> `optional` **selectWinner?**: [`FanoutWinnerSelector`](#fanoutwinnerselector)\<`D`\> +##### root -Winner-selection strategy among the gathered `done` children when there is no `synthesize`. -Receives the SAME `Iteration[]` the default selector reads (each child's output is its -`Outcome`), so a strategy is a thin re-sort (smallest-diff, highest-readiness, first-valid -…) over the candidates — NEVER a re-rank behind a judge. Default = `defaultSelectWinner` -semantics (best-valid-score, ties→earliest). Mutually exclusive with `synthesize` (a -synthesis child IS the selection); supplying both is a config error. +> `readonly` **root**: [`AgentSpec`](index.md#agentspec) -##### width? +The root agent's executor mapping (profile + harness + optional BYO executor). The +shape's root `Agent` carries THIS as its `executorSpec`; child specs the shape spawns +are derived from / resolved against the same persona registry (see `ShapeContext`). -> `optional` **width?**: `number` +##### directive -Cap on how many item children run AT ONCE. When set, the fanout dispatches through -`rollingDispatch`: it fills `width` slots and admits the next item the moment one settles, -instead of opening every item in a single round. Same items, same selection, same conserved -pool — only the simultaneity changes. +> `readonly` **directive**: `string` -Unset (the default) keeps the single-round batch behavior every existing caller has. Set it -when the items outnumber the live capacity a host can actually afford, so the pool is not -spent opening children that then queue behind a real fence. +The goal framing handed to the shape — the "what to achieve", not "how". -#### Methods +##### context -##### itemTask() +> `readonly` **context**: [`PersonaContext`](#personacontext-1) -> **itemTask**(`item`, `index`, `ctx`): `unknown` +Who the loop is acting as — the opaque persona context blob the shape may inject into + child tasks. Opaque to the framework; only the persona's profiles/prompts interpret it. -One child task per item: `item` + the index discriminator. The persona's directive/context - is threaded in by the combinator; this only supplies the per-item discriminator. +##### executors -###### Parameters +> `readonly` **executors**: [`PersonaExecutors`](#personaexecutors-1) -###### item +The executor seams (router endpoint+key, sandbox client, cli bin) the built-in runtimes +read off `ExecutorContext.seams`, OR a fully pre-configured registry. The supervisor +threads an EMPTY seam bag to the root scope, so a persona that uses built-in metered +runtimes MUST supply a registry whose factories close over their seams (or BYO executors +on each `AgentSpec`). Carried here so `runPersonified` can build `SupervisorOpts.executors`. -`Item` +##### extensions? -###### index +> `readonly` `optional` **extensions?**: `Readonly`\<`Record`\<`string`, `unknown`\>\> -`number` +Forward-compatible extension bag — a later world-model / memory / tool-budget field is an +additive key here, never a breaking change to the `Persona` shape. Opaque to the engine. -###### ctx +##### \_\_deliverable? -[`ShapeContext`](#shapecontext)\<`D`\> +> `readonly` `optional` **\_\_deliverable?**: `D` -###### Returns +Phantom: binds the persona to its deliverable type so `runPersonified` infers `D` from + the persona and the chosen shape must agree. Type-only — never present at runtime. -`unknown` +*** -##### label()? +### PersonaContext -> `optional` **label**(`item`, `index`): `string` +The persona context blob — who the loop is acting as. Open by intent: a persona names its + own role/audience/constraints; the framework treats it as opaque content. -Per-item child label (defaults to `item:` in the impl). +#### Indexable -###### Parameters +> \[`key`: `string`\]: `unknown` -###### item +Open content bag — persona-specific fields a shape's child tasks may carry. -`Item` +#### Properties -###### index +##### role -`number` +> `readonly` **role**: `string` -###### Returns +The role the loop embodies ("senior staff engineer", "equity research analyst", …). -`string` +##### notes? -##### itemSpec()? +> `readonly` `optional` **notes?**: `string` -> `optional` **itemSpec**(`item`, `index`, `ctx`): [`AgentSpec`](index.md#agentspec) +Optional freeform framing the persona's prompts/profiles consume. -Optional per-item `AgentSpec` override. When set, each item's child is spawned against the -returned spec instead of `persona.root` — the seam a heterogeneous fanout uses to give each -item a DISTINCT executor (e.g. N authored harness profiles, each on its own worktree-CLI -leaf). Absent ⇒ every item runs against the persona's root spec (the homogeneous default). +*** -###### Parameters +### PersonaExecutors -###### item +How a persona supplies executor resolution. Either a pre-built registry (factories already +closed over their seams) OR the raw seam bag the engine uses to construct a registry + +thread the seams onto each spawn. Exactly one is required — fail loud if neither is set. -`Item` +#### Properties -###### index +##### registry? -`number` +> `readonly` `optional` **registry?**: [`ExecutorRegistry`](index.md#executorregistry) -###### ctx +A registry whose factories already capture their seams. Highest precedence. -[`ShapeContext`](#shapecontext)\<`D`\> +##### seams? -###### Returns +> `readonly` `optional` **seams?**: `Readonly`\<`Record`\<`string`, `unknown`\>\> -[`AgentSpec`](index.md#agentspec) +Raw seams to thread onto built-in runtimes (`router`/`sandbox`/`cli` keys). *** -### FanoutSynthesis +### DefinePersonaInput -How a fanout's synthesis child is built + read. `synthesisTask` projects the drained child - settlements into the synthesis child's task; `collect` reads its settled output into the - deliverable `Outcome`. +The minimal input to build a `Persona`. Mirrors `Persona` but lets the builder default + the executors-supplied invariant check and freeze the record. #### Type Parameters ##### D -`D` +`D` = `unknown` -#### Methods +#### Properties -##### synthesisTask() +##### name -> **synthesisTask**(`gathered`, `ctx`): `unknown` +> `readonly` **name**: `string` -###### Parameters +##### root -###### gathered +> `readonly` **root**: [`AgentSpec`](index.md#agentspec) -readonly [`Settled`](index.md#settled)\<[`Outcome`](#outcome-1)\<`D`\>\>[] +##### directive -###### ctx +> `readonly` **directive**: `string` -[`ShapeContext`](#shapecontext)\<`D`\> +##### context -###### Returns +> `readonly` **context**: [`PersonaContext`](#personacontext-1) -`unknown` +##### executors -##### collect() +> `readonly` **executors**: [`PersonaExecutors`](#personaexecutors-1) -> **collect**(`settled`): [`Outcome`](#outcome-1)\<`D`\> +##### extensions? -###### Parameters +> `readonly` `optional` **extensions?**: `Readonly`\<`Record`\<`string`, `unknown`\>\> -###### settled +##### \_\_deliverable? -[`Settled`](index.md#settled)\<[`Outcome`](#outcome-1)\<`D`\>\> +> `readonly` `optional` **\_\_deliverable?**: `D` -###### Returns - -[`Outcome`](#outcome-1)\<`D`\> +Phantom: pins the input's deliverable type so `definePersona` returns a `Persona` + the caller's shape must agree with. Type-only — never supplied at a call site. *** -### LoopUntilSpec +### ShapeBudget -`loopUntil({ until, step })` — iterative deepening inside the conserved pool: spawn one `step` -child per round, ask `until` whether the accumulated state satisfies the goal, and stop when it -does OR when the pool can no longer admit a step (budget IS the loop bound — no unbounded -while). The deployable, non-oracle stop: `until` is the satisfiability gate, read from trace -findings + accumulated deliverables, never a fresh raw verdict the loop minted to stop itself. +Budget knobs a shape reads to size its fanout/children WITHOUT owning the conserved pool. +The root budget lives on `SupervisorOpts.budget`; the shape only needs the per-child +sizing hints + the fanout width it is allowed to open. All ceilings — the pool reserves +against them and fails closed, so an over-eager shape can never overspend. -No domain: "refine until tests pass" is `loopUntil` with a coder persona + a `step` that edits -and an `until` that reads the test-finding; the combinator owns only the round/stop wiring. +#### Properties -#### Type Parameters +##### perChild -##### Task +> `readonly` **perChild**: [`Budget`](index.md#budget-4) -`Task` +Per-child spawn budget the shape reserves for each leaf/sub-loop it opens. -##### State +##### fanout -`State` +> `readonly` **fanout**: `number` -##### D +Max children a fanout step may open in one round (the shape's structural width). -`D` +*** -#### Methods +### ShapeContext -##### step() +The construction context a `LoopShape` factory receives. Carries the persona's resolved +executor seams + the budget knobs, plus the ONE helper a shape needs to spawn a child +through the keystone: `spawnChild` resolves an `AgentSpec` (or a persona-derived child +profile) into an `Agent` the shape hands to `scope.spawn`. The shape never touches the +registry directly — it asks the context, keeping resolution single-sourced. -> **step**(`rootTask`, `state`, `ctx`): `unknown` +#### Type Parameters -Build the next step child's task from the root task + the state accumulated so far. +##### D -###### Parameters +`D` = `unknown` -###### rootTask +#### Properties -`Task` +##### persona -###### state +> `readonly` **persona**: [`Persona`](#persona)\<`D`\> -[`LoopUntilState`](#loopuntilstate-2)\<`State`\> +##### budget -###### ctx +> `readonly` **budget**: [`ShapeBudget`](#shapebudget) -[`ShapeContext`](#shapecontext)\<`D`\> +##### analyst? -###### Returns +> `readonly` `optional` **analyst?**: [`ScopeAnalyst`](#scopeanalyst)\<`D`\> -`unknown` +The scope analyst (selector≠judge firewall) the combinator steers from. Absent ⇒ the + dormant default (empty findings → gates read deliverables/state only). -##### fold() +#### Methods -> **fold**(`prior`, `settled`): [`LoopUntilState`](#loopuntilstate-2)\<`State`\> +##### spawnChild() -Fold one settled step into the accumulated state (the loop's running deliverable candidate). +> **spawnChild**(`name`, `spec`): [`Agent`](#agent-1)\<`unknown`, [`Outcome`](#outcome-2)\<`D`\>\> + +Wrap an `AgentSpec` into a leaf `Agent` carrying it as `executorSpec`, so the shape can +`scope.spawn(spawnChild(spec), task, opts)`. `name` labels the child for traces. The +returned agent's `act` is never invoked by the keystone (it is spawned, not run) — the +spec drives the resolved `Executor`; `act` exists only to satisfy the `Agent` shape. ###### Parameters -###### prior +###### name -[`LoopUntilState`](#loopuntilstate-2)\<`State`\> +`string` -###### settled +###### spec -[`Settled`](index.md#settled)\<[`Outcome`](#outcome-1)\<`D`\>\> +[`AgentSpec`](index.md#agentspec) ###### Returns -[`LoopUntilState`](#loopuntilstate-2)\<`State`\> +[`Agent`](#agent-1)\<`unknown`, [`Outcome`](#outcome-2)\<`D`\>\> -##### until() +##### childSpec() -> **until**(`state`, `findings`): [`Outcome`](#outcome-1)\<`D`\> \| `null` +> **childSpec**(`profile`, `harness?`): [`AgentSpec`](index.md#agentspec) -The satisfiability gate: given the accumulated state + the round's trace findings, has the -goal been reached? Returns the terminal deliverable when satisfied, or `null` to keep going. -Reads `findings` (trace-derived), NOT a raw verdict score — the deployable-stop discipline. +Derive a child `AgentSpec` from the persona's root spec with an overridden profile — + the seam a shape uses to give a worker a narrower role/prompt than the root persona. ###### Parameters -###### state +###### profile -[`LoopUntilState`](#loopuntilstate-2)\<`State`\> +`AgentProfile` -###### findings +###### harness? -readonly `AnalystFinding`[] +`BackendType` \| `null` ###### Returns -[`Outcome`](#outcome-1)\<`D`\> \| `null` +[`AgentSpec`](index.md#agentspec) -##### label()? +*** -> `optional` **label**(`round`): `string` +### ShapeRegistry -Per-round step label (defaults to `step:` in the impl). +The open shape registry — the extension point that makes a new loop-shape ONE file + one +`registerShape` call with zero edits elsewhere. `resolve` returns a typed outcome (inspect +`succeeded` before `value`); `register` fails loud on a duplicate name. -###### Parameters +#### Methods -###### round +##### register() -`number` +> **register**\<`Task`, `D`\>(`name`, `factory`): `void` -###### Returns +###### Type Parameters -`string` +###### Task -*** +`Task` -### LoopUntilState +###### D -The accumulated state `loopUntil` threads across rounds — the running candidate + the round - index, so `step`/`fold`/`until` are pure functions of it (replay-safe, no wall-clock). +`D` -#### Type Parameters +###### Parameters -##### State +###### name -`State` +`string` -#### Properties +###### factory -##### round +[`LoopShape`](#loopshape)\<`Task`, `D`\> -> `readonly` **round**: `number` +###### Returns -##### value +`void` -> `readonly` **value**: `State` +##### resolve() -*** +> **resolve**\<`Task`, `D`\>(`name`): \{ `succeeded`: `true`; `value`: [`LoopShape`](#loopshape)\<`Task`, `D`\>; \} \| \{ `succeeded`: `false`; `error`: `string`; \} -### PanelSpec +###### Type Parameters -`panel(judges)` — M judges over ONE artifact, merged WRITE-ONLY (selector≠judge taken to its -limit). The combinator spawns the M judge children over the same input artifact, drains their -settlements, and MERGES their findings into a panel verdict via `merge` — a pure WRITE-ONLY -fold (a judge's output is never fed back to steer another judge, and the merge never re-ranks -the children behind the driver). The merged verdict gates the deliverable. +###### Task -No domain: a "code review panel" and an "essay rubric panel" are the same `panel` shape under -different personas; the rubric lives in each judge persona's profile, not the combinator. +`Task` -#### Type Parameters +###### D -##### Artifact +`D` -`Artifact` +###### Parameters -##### D +###### name -`D` +`string` -#### Properties +###### Returns -##### judges +\{ `succeeded`: `true`; `value`: [`LoopShape`](#loopshape)\<`Task`, `D`\>; \} \| \{ `succeeded`: `false`; `error`: `string`; \} -> `readonly` **judges**: readonly [`PanelJudge`](#paneljudge)[] +##### names() -The M judge child specs: each is a persona-derived child (a narrower judge profile). The - combinator spawns one child per entry over the SAME `artifact` and never lets one judge's - output reach another's task (write-only). +> **names**(): `string`[] -#### Methods +The registered shape names — for diagnostics + a fail-loud "unknown shape" message. -##### judgeTask() +###### Returns -> **judgeTask**(`artifact`, `judge`, `ctx`): `unknown` +`string`[] -Build one judge child's task from the shared artifact under review + the judge descriptor. +*** -###### Parameters +### RunPersonifiedOptions -###### artifact +The end-to-end entrypoint. Builds the persona's root `Agent` from the chosen shape, then +runs it through a fresh `createSupervisor` over the persona's executors + the supplied +budget/journal/blobs. Returns the keystone's typed `SupervisedResult>` — a +`winner` carries the synthesized `Outcome`; a `no-winner` is never coerced into one. -`Artifact` +`shape` is either a resolved `LoopShape` or a registered shape NAME (resolved through the +default registry). The journal/blobs default to in-memory impls in the engine when omitted +(durable FS impls are passed explicitly for a persisted run). -###### judge +#### Type Parameters -[`PanelJudge`](#paneljudge) +##### Task -###### ctx +`Task` -[`ShapeContext`](#shapecontext)\<`D`\> +##### D -###### Returns +`D` -`unknown` +#### Properties -##### merge() +##### persona -> **merge**(`verdicts`, `artifact`): [`Outcome`](#outcome-1)\<`D`\> +> `readonly` **persona**: [`Persona`](#persona)\<`D`\> -Write-only merge: fold the M settled judge verdicts into the panel's terminal `Outcome`. -Pure over the drained settlements — it MUST NOT spawn, re-judge, or feed one verdict into -another. A panel that reached no quorum is a concrete blocker (fail loud, never a vacuous done). +##### shape -###### Parameters +> `readonly` **shape**: `string` \| [`LoopShape`](#loopshape)\<`Task`, `D`\> -###### verdicts +A resolved shape factory OR a registered shape name. -readonly [`PanelVerdict`](#panelverdict)[] +##### task -###### artifact +> `readonly` **task**: `Task` -`Artifact` +##### budget -###### Returns +> `readonly` **budget**: [`Budget`](index.md#budget-4) -[`Outcome`](#outcome-1)\<`D`\> +##### shapeBudget? -*** +> `readonly` `optional` **shapeBudget?**: `Partial`\<[`ShapeBudget`](#shapebudget)\> -### PanelJudge +Per-child sizing + fanout width handed to the shape. Defaults derive from `budget`. -One judge in a panel — a labeled persona-derived judge child. Content (the rubric) lives in - the judge's profile; this carries only the label + the optional weight the merge may read. +##### runId? -#### Properties +> `readonly` `optional` **runId?**: `string` -##### label +Trace/journal root key. Defaults to the persona name + a run discriminator in the engine. -> `readonly` **label**: `string` +##### journal? -##### weight? +> `readonly` `optional` **journal?**: [`SpawnJournal`](#spawnjournal) -> `readonly` `optional` **weight?**: `number` +##### blobs? -Optional merge weight (a write-only hint the `merge` fold may use; default-equal in the impl). +> `readonly` `optional` **blobs?**: [`ResultBlobStore`](#resultblobstore) -*** +##### maxDepth? -### PanelVerdict +> `readonly` `optional` **maxDepth?**: `number` -One judge child's settled verdict, surfaced to the write-only `merge`. `down` judges carry no - verdict (excluded from the merge `n`, like an infra-errored cell). +Runtime recursion-depth ceiling, paired with the conserved pool. -#### Properties +##### maxRestarts? -##### judge +> `readonly` `optional` **maxRestarts?**: `number` -> `readonly` **judge**: [`PanelJudge`](#paneljudge) +OTP intensity breaker bounds, forwarded to the supervisor verbatim. -##### verdict? +##### withinMs? -> `readonly` `optional` **verdict?**: `DefaultVerdict` - -##### output? - -> `readonly` `optional` **output?**: `unknown` - -The judge child's raw output — what it was asked to assess, for a merge that quotes it. +> `readonly` `optional` **withinMs?**: `number` -##### down +##### handle? -> `readonly` **down**: `boolean` +> `readonly` `optional` **handle?**: [`RootHandle`](#roothandle-1)\<[`Outcome`](#outcome-2)\<`D`\>\> -True when the judge child went `down` (no usable verdict — kept out of the merge denominator). +A live root handle to attach (view/signal/abort) before the run starts. -*** +##### now? -### VerifySpec +> `readonly` `optional` **now?**: () => `number` -`verify({ implement, verifier })` — the 2-node sequential gate: an IMPLEMENT child produces a -candidate, then a SEPARATE VERIFIER child's verdict GATES shippability. A `valid` verifier -verdict ships the implement deliverable; any other outcome (implement down, verifier down, -invalid verdict) becomes a concrete blocker carrying the failure verbatim — never a coerced -"done". The verifier is a distinct keystone agent (selector≠judge: the implement child does -not grade itself). +###### Returns -No domain: "write code then run the test gate" and "draft then fact-check" are the same `verify` -shape under different personas; the gate rubric is the verifier persona's, not the combinator's. +`number` -#### Type Parameters +##### signal? -##### Task +> `readonly` `optional` **signal?**: `AbortSignal` -`Task` +##### analyst? -##### Candidate +> `readonly` `optional` **analyst?**: [`ScopeAnalyst`](#scopeanalyst)\<`D`\> -`Candidate` +Optional scope analyst threaded into the shape's ShapeContext so loopUntil/widen steer + on trace-derived findings instead of the dormant empty default. -##### D +##### hooks? -`D` +> `readonly` `optional` **hooks?**: [`RuntimeHooks`](index.md#runtimehooks) -#### Properties +Lifecycle stream sink, forwarded to `SupervisorOpts.hooks` so the root `Scope`'s +`agent.spawn`/`agent.child` events flow to an observer (e.g. the Intelligence SDK's +trace export). Absent ⇒ no stream (the run is silent, as today). -##### implementLabel? +*** -> `readonly` `optional` **implementLabel?**: `string` +### PipelineStage -Implement / verifier child labels (default `implement` / `verify` in the impl). +`pipeline(stages)` — sequential composition: each stage's `Outcome.deliverable` feeds the next +stage's task (via `feed`). The first `blocked` stage short-circuits the whole pipeline (its +blockers ARE the pipeline's blockers — never coerced past a failed stage). The terminal +stage's `done` deliverable is the pipeline's deliverable. Spawns one child per stage in order; +a stage that the conserved pool cannot admit is a concrete blocker. -##### verifierLabel? +No domain: "code build test" is `pipeline([plan, implement, integrate])` under a coder persona, +not a named shape. A stage names only its label + how to derive its task from the prior output. -> `readonly` `optional` **verifierLabel?**: `string` +#### Type Parameters -#### Methods +##### Task -##### implement() +`Task` -> **implement**(`rootTask`, `ctx`): `unknown` +##### StepIn -Build the implement child's task from the root task. +`StepIn` -###### Parameters +##### StepOut -###### rootTask +`StepOut` -`Task` +#### Properties -###### ctx +##### label -[`ShapeContext`](#shapecontext)\<`D`\> +> `readonly` **label**: `string` -###### Returns +Trace/journal label for this stage's spawned child. -`unknown` +#### Methods -##### verifier() +##### feed() -> **verifier**(`candidate`, `ctx`): `unknown` +> **feed**(`prior`, `ctx`, `rootTask`): `unknown` -Build the verifier child's task from the implement child's settled candidate. +Derive this stage's task from the prior stage's deliverable (or the root task for stage 0). + Pure projection — the framework never interprets the result; the resolved leaf does. ###### Parameters -###### candidate +###### prior -[`Settled`](index.md#settled)\<[`Outcome`](#outcome-1)\<`Candidate`\>\> +`StepIn` ###### ctx -[`ShapeContext`](#shapecontext)\<`D`\> +[`ShapeContext`](#shapecontext)\<`unknown`\> + +###### rootTask + +`Task` ###### Returns @@ -4130,45 +4057,41 @@ Build the verifier child's task from the implement child's settled candidate. ##### collect() -> **collect**(`candidate`, `verdict`): [`Outcome`](#outcome-1)\<`D`\> +> **collect**(`settled`): [`Outcome`](#outcome-2)\<`StepOut`\> -Project the gated (verifier-`valid`) candidate into the terminal deliverable. +Read this stage's settled child output into the typed `StepOut` the next stage feeds on. + Fail loud (return a `blocked`) when the child produced nothing usable for the next stage. ###### Parameters -###### candidate - -[`Settled`](index.md#settled)\<[`Outcome`](#outcome-1)\<`Candidate`\>\> - -###### verdict +###### settled -`DefaultVerdict` +[`Settled`](index.md#settled)\<[`Outcome`](#outcome-2)\<`StepOut`\>\> ###### Returns -[`Outcome`](#outcome-1)\<`D`\> +[`Outcome`](#outcome-2)\<`StepOut`\> *** -### WidenSpec +### FanoutOptions -`widen({ gate })` (G5) — the STREAMING spawn-on-completion driver. Unlike the static-fanout -combinators above, the widener REACTS to each `scope.next()`: as each child settles it consults -the `WidenGate` and, when a lineage is `promising`, widens by AT MOST ONE child toward it under -the remaining conserved pool. Defaults to FLAT (the gate never widens) so a gate run stays -non-widening and the R2 selector≠judge collision is dormant. `promising` is derived from the -round's analyst FINDINGS (via `ScopeAnalyst`, §2), NOT a child's raw `verdict` — the firewall. +`fanout(items, { synthesize? })` — N children spawned in one round (one per item, bounded by +the conserved pool's fail-closed admission), drained via `scope.next()`, then optionally a +single SYNTHESIS child over the gathered results. Without `synthesize`, the combinator returns +the best-valid child via the single-sourced selector (selector≠judge). A round that admitted +zero children, or whose synthesis child could not be admitted, is a concrete blocker. -This is the progressive-widening (MCTS-PW) combinator: the one shape whose breadth is decided -at runtime from the diagnosis, not fixed at spawn. It is the mechanism the diverse-strategy-vs- -blind GATE is run with — kept FLAT by default until that gate returns positive (don't build -mechanism ahead of the gate). +No domain: a "research sweep over angles" is `fanout(angles, { synthesize: cite })` under a +research persona; a "fanout-vote" is `fanout(copies)` with the default selector. The item list ++ the synthesis posture are the SHAPE's args; the prompt that turns an item into work is the +persona's. #### Type Parameters -##### Seed +##### Item -`Seed` +`Item` ##### D @@ -4176,32 +4099,53 @@ mechanism ahead of the gate). #### Properties -##### seeds +##### synthesize? -> `readonly` **seeds**: readonly `Seed`[] +> `optional` **synthesize?**: [`FanoutSynthesis`](#fanoutsynthesis)\<`D`\> -The initial children to spawn before any widening — the seed lineages the gate widens from. - One child task per seed; bounded by the conserved pool's fail-closed admission. +Optional synthesis over the gathered child results: when present, the combinator spawns ONE +synthesis child whose task is built from the drained settlements, and its `done` output is +the deliverable. When absent, the deliverable is the best-valid child via `defaultSelectWinner`. +The synthesis child is a SEPARATE keystone agent (not a re-rank behind the driver). -##### gate +##### selectWinner? -> `readonly` **gate**: [`ScopeWidenGate`](#scopewidengate)\<`D`\> +> `optional` **selectWinner?**: [`FanoutWinnerSelector`](#fanoutwinnerselector)\<`D`\> -The progressive-widening gate. Consulted on EVERY settled child with the round's -trace-derived `findings`; returns a widen decision (spawn one more toward a lineage) or a -stop. DEFAULTS to flat via `flatWidenGate` — never widens, so the firewall stays dormant. +Winner-selection strategy among the gathered `done` children when there is no `synthesize`. +Receives the SAME `Iteration[]` the default selector reads (each child's output is its +`Outcome`), so a strategy is a thin re-sort (smallest-diff, highest-readiness, first-valid +…) over the candidates — NEVER a re-rank behind a judge. Default = `defaultSelectWinner` +semantics (best-valid-score, ties→earliest). Mutually exclusive with `synthesize` (a +synthesis child IS the selection); supplying both is a config error. + +##### width? + +> `optional` **width?**: `number` + +Cap on how many item children run AT ONCE. When set, the fanout dispatches through +`rollingDispatch`: it fills `width` slots and admits the next item the moment one settles, +instead of opening every item in a single round. Same items, same selection, same conserved +pool — only the simultaneity changes. + +Unset (the default) keeps the single-round batch behavior every existing caller has. Set it +when the items outnumber the live capacity a host can actually afford, so the pool is not +spent opening children that then queue behind a real fence. #### Methods -##### seedTask() +##### itemTask() -> **seedTask**(`seed`, `index`, `ctx`): `unknown` +> **itemTask**(`item`, `index`, `ctx`): `unknown` + +One child task per item: `item` + the index discriminator. The persona's directive/context + is threaded in by the combinator; this only supplies the per-item discriminator. ###### Parameters -###### seed +###### item -`Seed` +`Item` ###### index @@ -4215,38 +4159,44 @@ stop. DEFAULTS to flat via `flatWidenGate` — never widens, so the firewall sta `unknown` -##### widenTask() +##### label()? -> **widenTask**(`toward`, `ctx`): `unknown` +> `optional` **label**(`item`, `index`): `string` -Build the widened child's task from the lineage the gate chose to extend. +Per-item child label (defaults to `item:` in the impl). ###### Parameters -###### toward +###### item -[`WidenLineage`](#widenlineage)\<`D`\> +`Item` -###### ctx +###### index -[`ShapeContext`](#shapecontext)\<`D`\> +`number` ###### Returns -`unknown` +`string` -##### synthesize() +##### itemSpec()? -> **synthesize**(`gathered`, `ctx`): [`Outcome`](#outcome-1)\<`D`\> +> `optional` **itemSpec**(`item`, `index`, `ctx`): [`AgentSpec`](index.md#agentspec) -Synthesize the terminal deliverable from every settled lineage (selector≠judge: the - single-sourced selector over the gathered children, never a re-judge). +Optional per-item `AgentSpec` override. When set, each item's child is spawned against the +returned spec instead of `persona.root` — the seam a heterogeneous fanout uses to give each +item a DISTINCT executor (e.g. N authored harness profiles, each on its own worktree-CLI +leaf). Absent ⇒ every item runs against the persona's root spec (the homogeneous default). ###### Parameters -###### gathered +###### item + +`Item` + +###### index -readonly [`Settled`](index.md#settled)\<[`Outcome`](#outcome-1)\<`D`\>\>[] +`number` ###### ctx @@ -4254,16 +4204,15 @@ readonly [`Settled`](index.md#settled)\<[`Outcome`](#outcome-1)\<`D`\>\>[] ###### Returns -[`Outcome`](#outcome-1)\<`D`\> +[`AgentSpec`](index.md#agentspec) *** -### ScopeWidenGate +### FanoutSynthesis -The runtime widening gate (the reactive analogue of the keystone's `WidenGate`, lifted to read -trace FINDINGS instead of a raw verdict). `decide` is consulted per settled child; it MUST -derive `promising` from `findings`, never from `settled.verdict`, unless `judgeExempt` is -explicitly argued (the documented off-by-default escape hatch). Flat default never widens. +How a fanout's synthesis child is built + read. `synthesisTask` projects the drained child + settlements into the synthesis child's task; `collect` reads its settled output into the + deliverable `Outcome`. #### Type Parameters @@ -4271,1607 +4220,2614 @@ explicitly argued (the documented off-by-default escape hatch). Flat default nev `D` -#### Properties +#### Methods -##### judgeExempt? +##### synthesisTask() -> `readonly` `optional` **judgeExempt?**: `boolean` +> **synthesisTask**(`gathered`, `ctx`): `unknown` -When true, `decide` may read `settled.verdict` directly — collides with the steer firewall, - so it must be argued per cell, never defaulted on (mirrors the keystone `WidenGate`). +###### Parameters -#### Methods +###### gathered -##### decide() +readonly [`Settled`](index.md#settled)\<[`Outcome`](#outcome-2)\<`D`\>\>[] -> **decide**(`settled`, `findings`, `budget`): [`WidenDecision`](#widendecision)\<`D`\> +###### ctx -###### Parameters +[`ShapeContext`](#shapecontext)\<`D`\> -###### settled +###### Returns -[`Settled`](index.md#settled)\<[`Outcome`](#outcome-1)\<`D`\>\> +`unknown` -###### findings +##### collect() -readonly `AnalystFinding`[] +> **collect**(`settled`): [`Outcome`](#outcome-2)\<`D`\> -###### budget +###### Parameters + +###### settled -`Readonly`\<\{ `tokensLeft`: `number`; `usdLeft`: `number`; `usdCapped`: `boolean`; `deadlineMs`: `number`; `reservedTokens`: `number`; `tokensKnown?`: `boolean`; \}\> +[`Settled`](index.md#settled)\<[`Outcome`](#outcome-2)\<`D`\>\> ###### Returns -[`WidenDecision`](#widendecision)\<`D`\> +[`Outcome`](#outcome-2)\<`D`\> *** -### WidenLineage - -A lineage the gate may widen toward — the settled child that looked promising + the findings - that justified it (the trace-derived provenance the firewall requires). - -#### Type Parameters +### LoopUntilSpec -##### D - -`D` - -#### Properties - -##### settled +`loopUntil({ until, step })` — iterative deepening inside the conserved pool: spawn one `step` +child per round, ask `until` whether the accumulated state satisfies the goal, and stop when it +does OR when the pool can no longer admit a step (budget IS the loop bound — no unbounded +while). The deployable, non-oracle stop: `until` is the satisfiability gate, read from trace +findings + accumulated deliverables, never a fresh raw verdict the loop minted to stop itself. -> `readonly` **settled**: `object` +No domain: "refine until tests pass" is `loopUntil` with a coder persona + a `step` that edits +and an `until` that reads the test-finding; the combinator owns only the round/stop wiring. -###### kind +#### Type Parameters -> **kind**: `"done"` +##### Task -###### handle +`Task` -> **handle**: [`Handle`](#handle-2)\<[`Outcome`](#outcome-1)\<`D`\>\> +##### State -###### out +`State` -> **out**: [`Outcome`](#outcome-1) +##### D -###### outRef +`D` -> **outRef**: `string` +#### Methods -###### verdict? +##### step() -> `optional` **verdict?**: `DefaultVerdict` +> **step**(`rootTask`, `state`, `ctx`): `unknown` -###### spent +Build the next step child's task from the root task + the state accumulated so far. -> **spent**: [`Spend`](index.md#spend) +###### Parameters -###### seq +###### rootTask -> **seq**: `number` +`Task` -##### findings +###### state -> `readonly` **findings**: readonly `AnalystFinding`[] +[`LoopUntilState`](#loopuntilstate-2)\<`State`\> -*** +###### ctx -### ScopeAnalyst +[`ShapeContext`](#shapecontext)\<`D`\> -The reactive analyst seam — the PORT of the round-synchronous driver's `analyze` hook -(dynamic.ts) onto the reactive `Scope`. The old driver wired the analyst at round -boundaries (`plan` ran the analyst over `history` BEFORE the planner); the reactive `Scope` has -no rounds, so this carries the wire across: a combinator's `act` asks the `ScopeAnalyst` to turn -the settled children SO FAR into `AnalystFinding[]`, and steers from THOSE findings. +###### Returns -The firewall is preserved (selector≠judge): `analyze` runs the trace-derived analyst and the -impl asserts `assertTraceDerivedFindings` semantics — a finding citing judge/verdict/score -`metric` evidence aborts the round. The steer decision reads `findings`, NEVER the children's -raw `verdict`. Fail loud — a throwing or non-array analyst aborts (no silent empty findings). +`unknown` -#### Type Parameters +##### fold() -##### D +> **fold**(`prior`, `settled`): [`LoopUntilState`](#loopuntilstate-2)\<`State`\> -`D` +Fold one settled step into the accumulated state (the loop's running deliverable candidate). -#### Methods +###### Parameters -##### analyze() +###### prior -> **analyze**(`input`): `Promise`\ +[`LoopUntilState`](#loopuntilstate-2)\<`State`\> -Turn the children settled so far into trace-derived findings. `settledSoFar` is the cursor- -ordered settlement list a combinator has drained (the reactive analogue of the old driver's -`history`). The impl runs the analyst, then enforces the trace-derived firewall before -returning — a judge-derived finding is rejected, not filtered. +###### settled -###### Parameters +[`Settled`](index.md#settled)\<[`Outcome`](#outcome-2)\<`D`\>\> -###### input +###### Returns -[`ScopeAnalyzeInput`](#scopeanalyzeinput)\<`D`\> +[`LoopUntilState`](#loopuntilstate-2)\<`State`\> -###### Returns +##### until() -`Promise`\ +> **until**(`state`, `findings`): [`Outcome`](#outcome-2)\<`D`\> \| `null` -*** +The satisfiability gate: given the accumulated state + the round's trace findings, has the +goal been reached? Returns the terminal deliverable when satisfied, or `null` to keep going. +Reads `findings` (trace-derived), NOT a raw verdict score — the deployable-stop discipline. -### ScopeAnalyzeInput +###### Parameters -Input to a `ScopeAnalyst.analyze` — the root task framing + the children settled so far. +###### state -#### Type Parameters +[`LoopUntilState`](#loopuntilstate-2)\<`State`\> -##### D +###### findings -`D` +readonly `AnalystFinding`[] -#### Properties +###### Returns -##### task +[`Outcome`](#outcome-2)\<`D`\> \| `null` -> `readonly` **task**: `unknown` +##### label()? -Opaque root-task framing (whatever the combinator was invoked with). +> `optional` **label**(`round`): `string` -##### settledSoFar +Per-round step label (defaults to `step:` in the impl). -> `readonly` **settledSoFar**: readonly [`Settled`](index.md#settled)\<[`Outcome`](#outcome-1)\<`D`\>\>[] +###### Parameters -The children this combinator has drained off `scope.next()`, in cursor order. +###### round -##### nodeId +`number` -> `readonly` **nodeId**: `string` +###### Returns -This combinator's scope id (the trace-correlation root for the analyst). +`string` *** -### SteerContext +### LoopUntilState -How a combinator's `act` consumes findings to steer — the SINGLE firewalled steer surface a -reactive combinator reads. `loopUntil.until`, `widen` gate, and any future steer all funnel -through a `SteerContext` so the firewall is enforced in one place: `findings` is trace-derived -(the analyst already asserted it), and a combinator MUST NOT reach back to `settled.verdict` -for the steer decision. `lastValidScore` is provided for OBSERVABILITY only (rendering/traces), -explicitly NOT for steering — reading it to steer is the coupling the architecture forbids. +The accumulated state `loopUntil` threads across rounds — the running candidate + the round + index, so `step`/`fold`/`until` are pure functions of it (replay-safe, no wall-clock). #### Type Parameters -##### D +##### State -`D` +`State` #### Properties -##### findings +##### round -> `readonly` **findings**: readonly `AnalystFinding`[] +> `readonly` **round**: `number` -##### settledSoFar +##### value -> `readonly` **settledSoFar**: readonly [`Settled`](index.md#settled)\<[`Outcome`](#outcome-1)\<`D`\>\>[] +> `readonly` **value**: `State` -##### lastValidScore? +*** -> `readonly` `optional` **lastValidScore?**: `number` +### PanelSpec -Observability-only: the best valid score seen so far. Rendering/trace use ONLY — steering - off this re-introduces selector=judge. Marked so a reviewer catches a misuse. +`panel(judges)` — M judges over ONE artifact, merged WRITE-ONLY (selector≠judge taken to its +limit). The combinator spawns the M judge children over the same input artifact, drains their +settlements, and MERGES their findings into a panel verdict via `merge` — a pure WRITE-ONLY +fold (a judge's output is never fed back to steer another judge, and the merge never re-ranks +the children behind the driver). The merged verdict gates the deliverable. -*** +No domain: a "code review panel" and an "essay rubric panel" are the same `panel` shape under +different personas; the rubric lives in each judge persona's profile, not the combinator. -### CorpusRecord +#### Type Parameters -One accreted fact in the cross-run corpus — the learning-flywheel's durable unit. DISTINCT from -a `SpawnEvent` (a per-run decision record): a `CorpusRecord` is a fact a run LEARNED that a -FUTURE run should read back (the world-model for story 5). It is content the next persona reads, -not a replay input. Tagged + scored so `query`/`renderCorpusToInstructions` can project the -relevant, high-confidence subset. +##### Artifact -#### Properties +`Artifact` -##### schemaVersion +##### D -> `readonly` **schemaVersion**: `"1.0.0"` +`D` -##### id +#### Properties -> `readonly` **id**: `string` +##### judges -Stable id over identity-defining fields (claim + tags) so a re-learned fact dedups. +> `readonly` **judges**: readonly [`PanelJudge`](#paneljudge)[] -##### runId +The M judge child specs: each is a persona-derived child (a narrower judge profile). The + combinator spawns one child per entry over the SAME `artifact` and never lets one judge's + output reach another's task (write-only). -> `readonly` **runId**: `string` +#### Methods -The run that produced this fact (the journal `runId`/`root`) — provenance back to the trace. +##### judgeTask() -##### producedAt +> **judgeTask**(`artifact`, `judge`, `ctx`): `unknown` -> `readonly` **producedAt**: `string` +Build one judge child's task from the shared artifact under review + the judge descriptor. -##### area +###### Parameters -> `readonly` **area**: `string` +###### artifact -Coarse classification the query/render filters on (free-form, mirrors `AnalystFinding.area`). +`Artifact` -##### claim +###### judge -> `readonly` **claim**: `string` +[`PanelJudge`](#paneljudge) -The accreted fact — the instruction-shaped statement the next run reads back. +###### ctx -##### rationale? +[`ShapeContext`](#shapecontext)\<`D`\> -> `readonly` `optional` **rationale?**: `string` +###### Returns -Optional supporting detail the renderer may include under the claim. +`unknown` -##### tags +##### merge() -> `readonly` **tags**: readonly `string`[] +> **merge**(`verdicts`, `artifact`): [`Outcome`](#outcome-2)\<`D`\> -Free-form tags for `query` filtering (domain, persona, surface). +Write-only merge: fold the M settled judge verdicts into the panel's terminal `Outcome`. +Pure over the drained settlements — it MUST NOT spawn, re-judge, or feed one verdict into +another. A panel that reached no quorum is a concrete blocker (fail loud, never a vacuous done). -##### confidence +###### Parameters -> `readonly` **confidence**: `number` +###### verdicts -0..1 — the producing run's confidence in this fact (the render threshold reads it). +readonly [`PanelVerdict`](#panelverdict)[] -##### evidence? +###### artifact -> `readonly` `optional` **evidence?**: readonly `object`[] +`Artifact` -Optional provenance back into the run that learned it (a finding id / outRef / span). +###### Returns + +[`Outcome`](#outcome-2)\<`D`\> *** -### CorpusFilter +### PanelJudge -A corpus query filter — every field is an AND-narrowing; an omitted field does not constrain. +One judge in a panel — a labeled persona-derived judge child. Content (the rubric) lives in + the judge's profile; this carries only the label + the optional weight the merge may read. #### Properties -##### area? +##### label -> `readonly` `optional` **area?**: `string` +> `readonly` **label**: `string` -##### tags? +##### weight? -> `readonly` `optional` **tags?**: readonly `string`[] +> `readonly` `optional` **weight?**: `number` -Match records carrying ALL of these tags. +Optional merge weight (a write-only hint the `merge` fold may use; default-equal in the impl). -##### minConfidence? +*** -> `readonly` `optional` **minConfidence?**: `number` +### PanelVerdict -Minimum confidence a record must clear to be returned (the render gate). +One judge child's settled verdict, surfaced to the write-only `merge`. `down` judges carry no + verdict (excluded from the merge `n`, like an infra-errored cell). -##### runId? +#### Properties -> `readonly` `optional` **runId?**: `string` +##### judge -Only records from this run (rare — usually a cross-run read). +> `readonly` **judge**: [`PanelJudge`](#paneljudge) -##### limit? +##### verdict? -> `readonly` `optional` **limit?**: `number` +> `readonly` `optional` **verdict?**: `DefaultVerdict` -Cap the result count (most-confident first in the impl). +##### output? -*** +> `readonly` `optional` **output?**: `unknown` -### Corpus +The judge child's raw output — what it was asked to assess, for a merge that quotes it. -The durable cross-run corpus — the learning-flywheel store. DISTINCT from `SpawnJournal` -(per-run decisions, replay) and `ResultBlobStore` (per-run payloads): `Corpus` holds accreted -FACTS across runs that the next run reads back. `InMemoryCorpus` + `FileCorpus` (JSONL) impls -live in `corpus.ts` and MAY share a storage spine with the JSONL journal, but the INTERFACE is -separate so a consumer never confuses a replay record with a learned fact. +##### down -Fail-loud, typed-outcome boundary: `append` is idempotent on an identical record (same `id` + -`claim`); a conflicting re-append under the same `id` is a typed error, never a silent overwrite. +> `readonly` **down**: `boolean` -#### Methods +True when the judge child went `down` (no usable verdict — kept out of the merge denominator). -##### append() +*** -> **append**(`record`): `Promise`\<\{ `succeeded`: `true`; \} \| \{ `succeeded`: `false`; `error`: `string`; \}\> +### VerifySpec -Append one accreted fact. Idempotent on an identical record; returns a typed outcome — - inspect `succeeded` before treating it as durable (no silent write-through on conflict). +`verify({ implement, verifier })` — the 2-node sequential gate: an IMPLEMENT child produces a +candidate, then a SEPARATE VERIFIER child's verdict GATES shippability. A `valid` verifier +verdict ships the implement deliverable; any other outcome (implement down, verifier down, +invalid verdict) becomes a concrete blocker carrying the failure verbatim — never a coerced +"done". The verifier is a distinct keystone agent (selector≠judge: the implement child does +not grade itself). -###### Parameters +No domain: "write code then run the test gate" and "draft then fact-check" are the same `verify` +shape under different personas; the gate rubric is the verifier persona's, not the combinator's. -###### record +#### Type Parameters -[`CorpusRecord`](#corpusrecord) +##### Task -###### Returns +`Task` -`Promise`\<\{ `succeeded`: `true`; \} \| \{ `succeeded`: `false`; `error`: `string`; \}\> +##### Candidate -##### query() +`Candidate` -> **query**(`filter`): `Promise`\ +##### D -Query accreted facts by filter — most-confident first. Returns the matching records (an - empty array when none match is a valid result, NOT an error). +`D` -###### Parameters +#### Properties -###### filter +##### implementLabel? -[`CorpusFilter`](#corpusfilter) +> `readonly` `optional` **implementLabel?**: `string` -###### Returns +Implement / verifier child labels (default `implement` / `verify` in the impl). -`Promise`\ +##### verifierLabel? -*** +> `readonly` `optional` **verifierLabel?**: `string` -### RenderCorpusToInstructionsOptions +#### Methods -Project accreted corpus facts into an `AgentProfile`'s instruction seams — the learning-flywheel -READ side. Reads the corpus through `filter`, renders the matching facts into instruction lines, -and returns a NEW profile with them merged into `prompt.instructions` (the append-line seam) so -the next run's persona reads the accreted world-model. Pure projection over the queried records; -never mutates the input profile (returns a fresh one). The impl lives in `corpus.ts`. +##### implement() -`resources.instructions` is `string | AgentProfileResourceRef`; `prompt.instructions` is -`string[]`. The render targets `prompt.instructions` (additive lines) by default; a caller that -wants the single-blob `resources.instructions` form passes `target: 'resources'`. +> **implement**(`rootTask`, `ctx`): `unknown` -#### Properties +Build the implement child's task from the root task. -##### corpus +###### Parameters -> `readonly` **corpus**: [`Corpus`](#corpus-2) +###### rootTask -##### filter +`Task` -> `readonly` **filter**: [`CorpusFilter`](#corpusfilter) +###### ctx -##### profile +[`ShapeContext`](#shapecontext)\<`D`\> -> `readonly` **profile**: `AgentProfile` +###### Returns -The profile to project the facts into. The result is a fresh profile — the input is unchanged. +`unknown` -##### target? +##### verifier() -> `readonly` `optional` **target?**: `"resources"` \| `"prompt"` +> **verifier**(`candidate`, `ctx`): `unknown` -Where the rendered facts land: appended to `prompt.instructions[]` (default) or folded into - the single-blob `resources.instructions` string. +Build the verifier child's task from the implement child's settled candidate. -##### maxLines? +###### Parameters -> `readonly` `optional` **maxLines?**: `number` +###### candidate -Optional cap on rendered lines (most-confident first), independent of the query `limit`. +[`Settled`](index.md#settled)\<[`Outcome`](#outcome-2)\<`Candidate`\>\> -*** +###### ctx -### TrajectoryNode +[`ShapeContext`](#shapecontext)\<`D`\> -One node in the reconstructed trajectory tree — a driver OR a leaf, with its OWN spend and the -spend ROLLED UP over its subtree. Reconstructed from the `SpawnJournal` (structure + per-node -`Spend`) + the `ResultBlobStore` (the `out` artifact, rehydrated by `outRef`). The realized tree -shape: `parent`/`children` are the actual spawn edges the run took, not a planned topology. +###### Returns -#### Properties +`unknown` -##### id +##### collect() -> `readonly` **id**: `string` +> **collect**(`candidate`, `verdict`): [`Outcome`](#outcome-2)\<`D`\> -##### parent? +Project the gated (verifier-`valid`) candidate into the terminal deliverable. -> `readonly` `optional` **parent?**: `string` +###### Parameters -##### children +###### candidate -> `readonly` **children**: readonly `string`[] +[`Settled`](index.md#settled)\<[`Outcome`](#outcome-2)\<`Candidate`\>\> -##### label +###### verdict -> `readonly` **label**: `string` +`DefaultVerdict` -##### runtime +###### Returns -> `readonly` **runtime**: `string` +[`Outcome`](#outcome-2)\<`D`\> -##### status +*** -> `readonly` **status**: `"done"` \| `"failed"` \| `"cancelled"` \| `"pending"` \| `"waiting"` +### WidenSpec -Terminal status the journal recorded for this node. `'waiting'` is a wait-state node that was - armed and never woken — the journal's record of a run that died mid-wait. +`widen({ gate })` (G5) — the STREAMING spawn-on-completion driver. Unlike the static-fanout +combinators above, the widener REACTS to each `scope.next()`: as each child settles it consults +the `WidenGate` and, when a lineage is `promising`, widens by AT MOST ONE child toward it under +the remaining conserved pool. Defaults to FLAT (the gate never widens) so a gate run stays +non-widening and the R2 selector≠judge collision is dormant. `promising` is derived from the +round's analyst FINDINGS (via `ScopeAnalyst`, §2), NOT a child's raw `verdict` — the firewall. -##### ownSpend +This is the progressive-widening (MCTS-PW) combinator: the one shape whose breadth is decided +at runtime from the diagnosis, not fixed at spawn. It is the mechanism the diverse-strategy-vs- +blind GATE is run with — kept FLAT by default until that gate returns positive (don't build +mechanism ahead of the gate). -> `readonly` **ownSpend**: [`Spend`](index.md#spend) +#### Type Parameters -This node's OWN conserved spend (from its `settled` event). +##### Seed -##### rolledUpSpend +`Seed` -> `readonly` **rolledUpSpend**: [`Spend`](index.md#spend) +##### D -This node's spend PLUS every descendant's — the rolled-up subtree cost. The cost a parent - "really" consumed inclusive of its children's fanout (the equal-k-on-cost basis). +`D` -##### verdict? +#### Properties -> `readonly` `optional` **verdict?**: `DefaultVerdict` +##### seeds -The node's verdict, when its settlement carried one (observability — NOT a steer input). +> `readonly` **seeds**: readonly `Seed`[] -##### output? +The initial children to spawn before any widening — the seed lineages the gate widens from. + One child task per seed; bounded by the conserved pool's fail-closed admission. -> `readonly` `optional` **output?**: `unknown` +##### gate -The rehydrated output artifact, when `withOutputs` was requested + the blob resolved. +> `readonly` **gate**: [`ScopeWidenGate`](#scopewidengate)\<`D`\> -##### outRef? +The progressive-widening gate. Consulted on EVERY settled child with the round's +trace-derived `findings`; returns a widen decision (spawn one more toward a lineage) or a +stop. DEFAULTS to flat via `flatWidenGate` — never widens, so the firewall stays dormant. -> `readonly` `optional` **outRef?**: `string` +#### Methods -*** +##### seedTask() -### TrajectoryReport +> **seedTask**(`seed`, `index`, `ctx`): `unknown` -The whole reconstructed trajectory — the realized tree + its root-rolled-up total. The - per-node + rolled-up `Spend` is the evidence both the trace viewer and `equalKOnCost` read. +###### Parameters -#### Properties +###### seed -##### root +`Seed` -> `readonly` **root**: `string` +###### index -##### nodes +`number` -> `readonly` **nodes**: readonly [`TrajectoryNode`](#trajectorynode)[] +###### ctx -Every node, in cursor/spawn order — the realized tree (`parent`/`children` are the real edges). +[`ShapeContext`](#shapecontext)\<`D`\> -##### total +###### Returns -> `readonly` **total**: [`Spend`](index.md#spend) +`unknown` -The root's rolled-up spend — the whole run's conserved total (tokens + usd + iterations + ms). +##### widenTask() -##### statusCounts +> **widenTask**(`toward`, `ctx`): `unknown` -> `readonly` **statusCounts**: `Readonly`\<`Record`\<[`TrajectoryNode`](#trajectorynode)\[`"status"`\], `number`\>\> +Build the widened child's task from the lineage the gate chose to extend. -Count of nodes by terminal status — a quick "how did the tree end" readout. +###### Parameters -*** +###### toward -### TrajectoryReportOptions +[`WidenLineage`](#widenlineage)\<`D`\> -`trajectoryReport(journal, blobs, root, { withOutputs? })` — reconstruct the whole tree with -per-node + rolled-up `Spend`. Reads the journal for structure + spend and (when `withOutputs`) -the blob store for each `done` node's artifact. Fail loud on a tree that was never journaled or -a `done` node whose blob the store cannot rehydrate (a silent gap would mis-cost the tree). The -impl lives in `trajectory.ts`. +###### ctx -#### Properties +[`ShapeContext`](#shapecontext)\<`D`\> -##### withOutputs? +###### Returns -> `readonly` `optional` **withOutputs?**: `boolean` +`unknown` -Rehydrate each `done` node's `output` from the blob store. Off by default (cost-only report). +##### synthesize() -*** +> **synthesize**(`gathered`, `ctx`): [`Outcome`](#outcome-2)\<`D`\> -### EqualKArm +Synthesize the terminal deliverable from every settled lineage (selector≠judge: the + single-sourced selector over the gathered children, never a re-judge). -One arm of an equal-k comparison — a labeled trajectory (a `TrajectoryReport` is one arm's whole -run). The arm's conserved COST is `report.total` (tokens + usd), which the sandbox executor -already reports INCLUSIVE of a leaf's internal sub-agent fanout — so comparing arms on this cost -(not raw `iterations`) closes the leaf-fanout confound: a treatment arm whose leaf fanned out -internally is charged for that fanout in `total.tokens`/`total.usd`, not hidden behind one -iteration count. +###### Parameters -#### Properties +###### gathered -##### label +readonly [`Settled`](index.md#settled)\<[`Outcome`](#outcome-2)\<`D`\>\>[] -> `readonly` **label**: `string` +###### ctx -##### report +[`ShapeContext`](#shapecontext)\<`D`\> -> `readonly` **report**: [`TrajectoryReport`](#trajectoryreport-3) +###### Returns + +[`Outcome`](#outcome-2)\<`D`\> *** -### EqualKVerdict +### ScopeWidenGate -The equal-k-on-cost verdict: whether every arm spent within `tolerance` of the others on the -CONSERVED cost channels (tokens + usd), so a downstream metric comparison is "at equal k". Per- -arm cost is surfaced so a caller can see HOW close. `withinTolerance: false` means the arms are -NOT comparable at equal compute — a confound to report, not a result to publish. +The runtime widening gate (the reactive analogue of the keystone's `WidenGate`, lifted to read +trace FINDINGS instead of a raw verdict). `decide` is consulted per settled child; it MUST +derive `promising` from `findings`, never from `settled.verdict`, unless `judgeExempt` is +explicitly argued (the documented off-by-default escape hatch). Flat default never widens. -#### Properties +#### Type Parameters -##### withinTolerance +##### D + +`D` + +#### Properties + +##### judgeExempt? + +> `readonly` `optional` **judgeExempt?**: `boolean` + +When true, `decide` may read `settled.verdict` directly — collides with the steer firewall, + so it must be argued per cell, never defaulted on (mirrors the keystone `WidenGate`). + +#### Methods + +##### decide() + +> **decide**(`settled`, `findings`, `budget`): [`WidenDecision`](#widendecision)\<`D`\> + +###### Parameters + +###### settled + +[`Settled`](index.md#settled)\<[`Outcome`](#outcome-2)\<`D`\>\> + +###### findings + +readonly `AnalystFinding`[] + +###### budget + +`Readonly`\<\{ `tokensLeft`: `number`; `tokensKnown`: `boolean`; `usdLeft`: `number`; `usdCapped`: `boolean`; `usdKnown`: `boolean`; `iterationsLeft`: `number`; `deadlineMs`: `number`; `reservedTokens`: `number`; \}\> + +###### Returns + +[`WidenDecision`](#widendecision)\<`D`\> + +*** + +### WidenLineage + +A lineage the gate may widen toward — the settled child that looked promising + the findings + that justified it (the trace-derived provenance the firewall requires). + +#### Type Parameters + +##### D + +`D` + +#### Properties + +##### settled + +> `readonly` **settled**: `object` + +###### kind + +> **kind**: `"done"` + +###### handle + +> **handle**: [`Handle`](#handle-2)\<[`Outcome`](#outcome-2)\<`D`\>\> + +###### out + +> **out**: [`Outcome`](#outcome-2) + +###### outRef + +> **outRef**: `string` + +###### verdict? + +> `optional` **verdict?**: `DefaultVerdict` + +###### spent + +> **spent**: [`Spend`](index.md#spend) + +###### trace + +> **trace**: [`WorkerTraceEvidence`](index.md#workertraceevidence) + +Structured tool evidence captured before this settlement was journaled. + +###### settledAt? + +> `optional` **settledAt?**: `number` + +Epoch ms parsed from the durable settlement record when available. + +###### seq + +> **seq**: `number` + +##### findings + +> `readonly` **findings**: readonly `AnalystFinding`[] + +*** + +### ScopeAnalyst + +The reactive analyst seam — the PORT of the round-synchronous driver's `analyze` hook +(dynamic.ts) onto the reactive `Scope`. The old driver wired the analyst at round +boundaries (`plan` ran the analyst over `history` BEFORE the planner); the reactive `Scope` has +no rounds, so this carries the wire across: a combinator's `act` asks the `ScopeAnalyst` to turn +the settled children SO FAR into `AnalystFinding[]`, and steers from THOSE findings. + +The firewall is preserved (selector≠judge): `analyze` runs the trace-derived analyst and the +impl asserts `assertTraceDerivedFindings` semantics — a finding citing judge/verdict/score +`metric` evidence aborts the round. The steer decision reads `findings`, NEVER the children's +raw `verdict`. Fail loud — a throwing or non-array analyst aborts (no silent empty findings). + +#### Type Parameters + +##### D + +`D` + +#### Methods + +##### analyze() + +> **analyze**(`input`): `Promise`\ + +Turn the children settled so far into trace-derived findings. `settledSoFar` is the cursor- +ordered settlement list a combinator has drained (the reactive analogue of the old driver's +`history`). The impl runs the analyst, then enforces the trace-derived firewall before +returning — a judge-derived finding is rejected, not filtered. + +###### Parameters + +###### input + +[`ScopeAnalyzeInput`](#scopeanalyzeinput)\<`D`\> + +###### Returns + +`Promise`\ + +*** + +### ScopeAnalyzeInput + +Input to a `ScopeAnalyst.analyze` — the root task framing + the children settled so far. + +#### Type Parameters + +##### D + +`D` + +#### Properties + +##### task + +> `readonly` **task**: `unknown` + +Opaque root-task framing (whatever the combinator was invoked with). + +##### settledSoFar + +> `readonly` **settledSoFar**: readonly [`Settled`](index.md#settled)\<[`Outcome`](#outcome-2)\<`D`\>\>[] + +The children this combinator has drained off `scope.next()`, in cursor order. + +##### nodeId + +> `readonly` **nodeId**: `string` + +This combinator's scope id (the trace-correlation root for the analyst). + +*** + +### SteerContext + +How a combinator's `act` consumes findings to steer — the SINGLE firewalled steer surface a +reactive combinator reads. `loopUntil.until`, `widen` gate, and any future steer all funnel +through a `SteerContext` so the firewall is enforced in one place: `findings` is trace-derived +(the analyst already asserted it), and a combinator MUST NOT reach back to `settled.verdict` +for the steer decision. `lastValidScore` is provided for OBSERVABILITY only (rendering/traces), +explicitly NOT for steering — reading it to steer is the coupling the architecture forbids. + +#### Type Parameters + +##### D + +`D` + +#### Properties + +##### findings + +> `readonly` **findings**: readonly `AnalystFinding`[] + +##### settledSoFar + +> `readonly` **settledSoFar**: readonly [`Settled`](index.md#settled)\<[`Outcome`](#outcome-2)\<`D`\>\>[] + +##### lastValidScore? + +> `readonly` `optional` **lastValidScore?**: `number` + +Observability-only: the best valid score seen so far. Rendering/trace use ONLY — steering + off this re-introduces selector=judge. Marked so a reviewer catches a misuse. + +*** + +### CorpusRecord + +One accreted fact in the cross-run corpus — the learning-flywheel's durable unit. DISTINCT from +a `SpawnEvent` (a per-run decision record): a `CorpusRecord` is a fact a run LEARNED that a +FUTURE run should read back (the world-model for story 5). It is content the next persona reads, +not a replay input. Tagged + scored so `query`/`renderCorpusToInstructions` can project the +relevant, high-confidence subset. + +#### Properties + +##### schemaVersion + +> `readonly` **schemaVersion**: `"1.0.0"` + +##### id + +> `readonly` **id**: `string` + +Stable id over identity-defining fields (claim + tags) so a re-learned fact dedups. + +##### runId + +> `readonly` **runId**: `string` + +The run that produced this fact (the journal `runId`/`root`) — provenance back to the trace. + +##### producedAt + +> `readonly` **producedAt**: `string` + +##### area + +> `readonly` **area**: `string` + +Coarse classification the query/render filters on (free-form, mirrors `AnalystFinding.area`). + +##### claim + +> `readonly` **claim**: `string` + +The accreted fact — the instruction-shaped statement the next run reads back. + +##### rationale? + +> `readonly` `optional` **rationale?**: `string` + +Optional supporting detail the renderer may include under the claim. + +##### tags + +> `readonly` **tags**: readonly `string`[] + +Free-form tags for `query` filtering (domain, persona, surface). + +##### confidence + +> `readonly` **confidence**: `number` + +0..1 — the producing run's confidence in this fact (the render threshold reads it). + +##### evidence? + +> `readonly` `optional` **evidence?**: readonly `object`[] + +Optional provenance back into the run that learned it (a finding id / outRef / span). + +*** + +### CorpusFilter + +A corpus query filter — every field is an AND-narrowing; an omitted field does not constrain. + +#### Properties + +##### area? + +> `readonly` `optional` **area?**: `string` + +##### tags? + +> `readonly` `optional` **tags?**: readonly `string`[] + +Match records carrying ALL of these tags. + +##### minConfidence? + +> `readonly` `optional` **minConfidence?**: `number` + +Minimum confidence a record must clear to be returned (the render gate). + +##### runId? + +> `readonly` `optional` **runId?**: `string` + +Only records from this run (rare — usually a cross-run read). + +##### limit? + +> `readonly` `optional` **limit?**: `number` + +Cap the result count (most-confident first in the impl). + +*** + +### Corpus + +The durable cross-run corpus — the learning-flywheel store. DISTINCT from `SpawnJournal` +(per-run decisions, replay) and `ResultBlobStore` (per-run payloads): `Corpus` holds accreted +FACTS across runs that the next run reads back. `InMemoryCorpus` + `FileCorpus` (JSONL) impls +live in `corpus.ts` and MAY share a storage spine with the JSONL journal, but the INTERFACE is +separate so a consumer never confuses a replay record with a learned fact. + +Fail-loud, typed-outcome boundary: `append` is idempotent on an identical record (same `id` + +`claim`); a conflicting re-append under the same `id` is a typed error, never a silent overwrite. + +#### Methods + +##### append() + +> **append**(`record`): `Promise`\<\{ `succeeded`: `true`; \} \| \{ `succeeded`: `false`; `error`: `string`; \}\> + +Append one accreted fact. Idempotent on an identical record; returns a typed outcome — + inspect `succeeded` before treating it as durable (no silent write-through on conflict). + +###### Parameters + +###### record + +[`CorpusRecord`](#corpusrecord) + +###### Returns + +`Promise`\<\{ `succeeded`: `true`; \} \| \{ `succeeded`: `false`; `error`: `string`; \}\> + +##### query() + +> **query**(`filter`): `Promise`\ + +Query accreted facts by filter — most-confident first. Returns the matching records (an + empty array when none match is a valid result, NOT an error). + +###### Parameters + +###### filter + +[`CorpusFilter`](#corpusfilter) + +###### Returns + +`Promise`\ + +*** + +### RenderCorpusToInstructionsOptions + +Project accreted corpus facts into an `AgentProfile`'s instruction seams — the learning-flywheel +READ side. Reads the corpus through `filter`, renders the matching facts into instruction lines, +and returns a NEW profile with them merged into `prompt.instructions` (the append-line seam) so +the next run's persona reads the accreted world-model. Pure projection over the queried records; +never mutates the input profile (returns a fresh one). The impl lives in `corpus.ts`. + +`resources.instructions` is `string | AgentProfileResourceRef`; `prompt.instructions` is +`string[]`. The render targets `prompt.instructions` (additive lines) by default; a caller that +wants the single-blob `resources.instructions` form passes `target: 'resources'`. + +#### Properties + +##### corpus + +> `readonly` **corpus**: [`Corpus`](#corpus-2) + +##### filter + +> `readonly` **filter**: [`CorpusFilter`](#corpusfilter) + +##### profile + +> `readonly` **profile**: `AgentProfile` + +The profile to project the facts into. The result is a fresh profile — the input is unchanged. + +##### target? + +> `readonly` `optional` **target?**: `"resources"` \| `"prompt"` + +Where the rendered facts land: appended to `prompt.instructions[]` (default) or folded into + the single-blob `resources.instructions` string. + +##### maxLines? + +> `readonly` `optional` **maxLines?**: `number` + +Optional cap on rendered lines (most-confident first), independent of the query `limit`. + +*** + +### TrajectoryNode + +One node in the reconstructed trajectory tree — a driver OR a leaf, with its OWN spend and the +spend ROLLED UP over its subtree. Reconstructed from the `SpawnJournal` (structure + per-node +`Spend`) + the `ResultBlobStore` (the `out` artifact, rehydrated by `outRef`). The realized tree +shape: `parent`/`children` are the actual spawn edges the run took, not a planned topology. + +#### Properties + +##### id + +> `readonly` **id**: `string` + +##### parent? + +> `readonly` `optional` **parent?**: `string` + +##### children + +> `readonly` **children**: readonly `string`[] + +##### label + +> `readonly` **label**: `string` + +##### runtime + +> `readonly` **runtime**: `string` + +##### status + +> `readonly` **status**: `"done"` \| `"failed"` \| `"cancelled"` \| `"pending"` \| `"waiting"` + +Terminal status the journal recorded for this node. `'waiting'` is a wait-state node that was + armed and never woken — the journal's record of a run that died mid-wait. + +##### ownSpend + +> `readonly` **ownSpend**: [`Spend`](index.md#spend) + +This node's OWN conserved spend (from its `settled` event). + +##### rolledUpSpend + +> `readonly` **rolledUpSpend**: [`Spend`](index.md#spend) + +This node's spend PLUS every descendant's — the rolled-up subtree cost. The cost a parent + "really" consumed inclusive of its children's fanout (the equal-k-on-cost basis). + +##### verdict? + +> `readonly` `optional` **verdict?**: `DefaultVerdict` + +The node's verdict, when its settlement carried one (observability — NOT a steer input). + +##### output? + +> `readonly` `optional` **output?**: `unknown` + +The rehydrated output artifact, when `withOutputs` was requested + the blob resolved. + +##### outRef? + +> `readonly` `optional` **outRef?**: `string` + +*** + +### TrajectoryReport + +The whole reconstructed trajectory — the realized tree + its root-rolled-up total. The + per-node + rolled-up `Spend` is the evidence both the trace viewer and `equalKOnCost` read. + +#### Properties + +##### root + +> `readonly` **root**: `string` + +##### nodes + +> `readonly` **nodes**: readonly [`TrajectoryNode`](#trajectorynode)[] + +Every node, in cursor/spawn order — the realized tree (`parent`/`children` are the real edges). + +##### total + +> `readonly` **total**: [`Spend`](index.md#spend) + +The root's rolled-up spend — the whole run's conserved total (tokens + usd + iterations + ms). + +##### statusCounts + +> `readonly` **statusCounts**: `Readonly`\<`Record`\<[`TrajectoryNode`](#trajectorynode)\[`"status"`\], `number`\>\> + +Count of nodes by terminal status — a quick "how did the tree end" readout. + +*** + +### TrajectoryReportOptions + +`trajectoryReport(journal, blobs, root, { withOutputs? })` — reconstruct the whole tree with +per-node + rolled-up `Spend`. Reads the journal for structure + spend and (when `withOutputs`) +the blob store for each `done` node's artifact. Fail loud on a tree that was never journaled or +a `done` node whose blob the store cannot rehydrate (a silent gap would mis-cost the tree). The +impl lives in `trajectory.ts`. + +#### Properties + +##### withOutputs? + +> `readonly` `optional` **withOutputs?**: `boolean` + +Rehydrate each `done` node's `output` from the blob store. Off by default (cost-only report). + +*** + +### EqualKArm + +One arm of an equal-k comparison — a labeled trajectory (a `TrajectoryReport` is one arm's whole +run). The arm's conserved COST is `report.total` (tokens + usd), which the sandbox executor +already reports INCLUSIVE of a leaf's internal sub-agent fanout — so comparing arms on this cost +(not raw `iterations`) closes the leaf-fanout confound: a treatment arm whose leaf fanned out +internally is charged for that fanout in `total.tokens`/`total.usd`, not hidden behind one +iteration count. + +#### Properties + +##### label + +> `readonly` **label**: `string` + +##### report + +> `readonly` **report**: [`TrajectoryReport`](#trajectoryreport-3) + +*** + +### EqualKVerdict + +The equal-k-on-cost verdict: whether every arm spent within `tolerance` of the others on the +CONSERVED cost channels (tokens + usd), so a downstream metric comparison is "at equal k". Per- +arm cost is surfaced so a caller can see HOW close. `withinTolerance: false` means the arms are +NOT comparable at equal compute — a confound to report, not a result to publish. + +#### Properties + +##### withinTolerance > `readonly` **withinTolerance**: `boolean` -##### arms +##### arms + +> `readonly` **arms**: readonly `object`[] + +Per-arm conserved cost (the basis: tokens total + usd). + +##### spread + +> `readonly` **spread**: `object` + +The realized spread on each channel (max − min across arms), for the report. + +###### tokens + +> `readonly` **tokens**: `number` + +###### usd + +> `readonly` **usd**: `number` + +##### tolerance + +> `readonly` **tolerance**: `number` + +The fractional tolerance the check used (spread / median ≤ tolerance per channel). + +*** + +### EqualKOnCostOptions + +`equalKOnCost(arms, { tolerance? })` — assert arms are comparable at EQUAL conserved COST +(tokens + usd), NOT raw iteration count. The conserved-pool guarantees `Σk` equal by +construction WITHIN one supervised run; this checks it ACROSS arms (separate runs) where the +pool cannot, so a cross-arm gate comparison can prove equal compute before claiming a win. The +impl lives in `trajectory.ts`. Pure over the reports — no I/O. + +#### Properties + +##### tolerance? + +> `readonly` `optional` **tolerance?**: `number` + +Max fractional spread (spread/median) per channel for arms to count as equal-k. Default in + the impl (e.g. 0.05). A tighter tolerance = a stricter equal-compute claim. + +*** + +### PromotionGateOptions + +#### Properties + +##### report + +> **report**: [`BenchmarkReport`](#benchmarkreport) + +The HOLDOUT report — must carry per-task cells for both strategy names. + +##### incumbent + +> **incumbent**: `string` + +The incumbent champion's strategy name. + +##### candidate + +> **candidate**: `string` + +The challenger's strategy name. + +##### mode? + +> `optional` **mode?**: `"superiority"` \| `"non-inferiority"` + +'superiority' (default): the candidate must score significantly BETTER. + 'non-inferiority': the candidate must prove its score is not worse than the + incumbent by more than `scoreTolerance` AND its cost savings are significant — + the gate for "same quality, cheaper" claims. + +##### scoreTolerance? + +> `optional` **scoreTolerance?**: `number` + +non-inferiority: the score CI lower bound must clear −scoreTolerance. Default 0.05. + +##### deltaThreshold? + +> `optional` **deltaThreshold?**: `number` + +The CI lower bound on the paired lift must EXCEED this (score scale). Default 0. + +##### minPairedTasks? + +> `optional` **minPairedTasks?**: `number` + +Minimum paired tasks before significance can be claimed. Default 6 — below that + the bootstrap CI is too wide to separate a real lift from the per-task noise. + +##### statistic? + +> `optional` **statistic?**: `"mean"` \| `"median"` + +Bootstrap statistic over the paired deltas. Default 'mean'. + +##### seed? + +> `optional` **seed?**: `number` + +Fixed by the substrate by default — the same report always yields the same verdict. + +##### resamples? + +> `optional` **resamples?**: `number` + +*** + +### PromotionVerdict + +#### Properties + +##### promoted + +> **promoted**: `boolean` + +##### reason + +> **reason**: `"identical-champion"` \| `"few-tasks"` \| `"no-margin"` \| `"significant"` \| `"non-inferior-and-cheaper"` \| `"non-inferiority-unproven"` \| `"not-cheaper"` + +##### mode + +> **mode**: `"superiority"` \| `"non-inferiority"` + +##### n + +> **n**: `number` + +Paired tasks that carried both strategies' cells. + +##### lift + +> **lift**: `object` + +Paired (candidate − incumbent) lift across the holdout tasks. `low` and `high` + are the bounds that carried the decision; `mean` and `median` are diagnostics. + +###### mean + +> **mean**: `number` + +###### median + +> **median**: `number` + +###### low + +> **low**: `number` + +###### high + +> **high**: `number` + +##### costSavings? + +> `optional` **costSavings?**: `object` + +non-inferiority mode: paired (incumbent − candidate) cost savings per task (usd). + Positive means the candidate is cheaper; `low` and `high` carried the decision. + +###### mean + +> **mean**: `number` + +###### median + +> **median**: `number` + +###### low + +> **low**: `number` + +###### high + +> **high**: `number` + +##### latency? + +> `optional` **latency?**: `object` + +Paired (candidate − incumbent) wall-clock per task (ms) — negative = the candidate + is FASTER. Informational in every mode (never gates); the latency answer to "what + does this win actually cost the user?". + +###### mean + +> **mean**: `number` + +###### median + +> **median**: `number` + +###### low + +> **low**: `number` + +###### high + +> **high**: `number` + +*** + +### ResolveSandboxClientOptions + +#### Properties + +##### backend + +> **backend**: `"router"` \| `"sandbox"` \| `"bridge"` \| `"local"` + +The execution transport for the driven loop. + +##### sandboxClient? + +> `optional` **sandboxClient?**: [`SandboxClient`](#sandboxclient-5) + +`sandbox` backend: the caller's real Sandbox-backed client. Required for that backend. + +##### bridge? + +> `optional` **bridge?**: `object` + +`bridge` backend: local cli-bridge transport. `bearer` + `model` required. + +###### url? + +> `optional` **url?**: `string` + +cli-bridge base URL. Defaults to `http://127.0.0.1:3355`. + +###### bearer + +> **bearer**: `string` + +###### model + +> **model**: `string` + +Bridge model id, doubling as the harness selector (e.g. `claude-code/sonnet`). -> `readonly` **arms**: readonly `object`[] +###### timeoutMs? -Per-arm conserved cost (the basis: tokens total + usd). +> `optional` **timeoutMs?**: `number` -##### spread +Per-turn deadline (ms). -> `readonly` **spread**: `object` +##### router? -The realized spread on each channel (max − min across arms), for the report. +> `optional` **router?**: `object` -###### tokens +`router` backend: router chat-completion transport. All three fields required. -> `readonly` **tokens**: `number` +###### baseUrl -###### usd +> **baseUrl**: `string` -> `readonly` **usd**: `number` +###### key -##### tolerance +> **key**: `string` -> `readonly` **tolerance**: `number` +###### model -The fractional tolerance the check used (spread / median ≤ tolerance per channel). +> **model**: `string` + +##### local? + +> `optional` **local?**: [`LocalSandboxClientOptions`](#localsandboxclientoptions) + +`local` backend: same-host pseudo-box — the router brain drives a tool loop + with the profile's stdio MCP servers spawned as local children. *** -### EqualKOnCostOptions +### RouterConfig -`equalKOnCost(arms, { tolerance? })` — assert arms are comparable at EQUAL conserved COST -(tokens + usd), NOT raw iteration count. The conserved-pool guarantees `Σk` equal by -construction WITHIN one supervised run; this checks it ACROSS arms (separate runs) where the -pool cannot, so a cross-arm gate comparison can prove equal compute before claiming a win. The -impl lives in `trajectory.ts`. Pure over the reports — no I/O. +#### Properties + +##### routerBaseUrl + +> **routerBaseUrl**: `string` + +##### routerKey + +> **routerKey**: `string` + +##### model + +> **model**: `string` + +##### complete? + +> `optional` **complete?**: (`body`) => `Promise`\<`unknown`\> + +Optional completion transport. When set, `routerChatWithUsage` / `routerChatWithTools` call it +with the OpenAI-shape request body and use the parsed `/chat/completions` JSON it returns, +INSTEAD of `fetch(routerBaseUrl + '/chat/completions')`. When absent the fetch path runs +unchanged — the live router stays the default. The injection seam an offline benchmark uses to +drive the worker with no network: a deterministic in-process responder satisfies it, no server. + +###### Parameters + +###### body + +`Record`\<`string`, `unknown`\> + +###### Returns + +`Promise`\<`unknown`\> + +##### maxTokens? + +> `optional` **maxTokens?**: `number` + +Ceiling for one completion, forwarded as `max_tokens`. Defaults to 8192. + +A REASONING model spends this budget on hidden thinking BEFORE it emits a visible token, so +the default can truncate one mid-thought and return no content at all — observed live with a +model that spent 8,188 of the 8,192 on reasoning and answered with nothing. Raise it for a +thinking model; the ceiling belongs to the router and model a caller chose, which is why it +lives here rather than on one call site. + +##### stream? + +> `optional` **stream?**: `boolean` + +Take the tool-calling completion over SSE instead of one buffered POST. Off by default — +`routerChatWithTools` never streams, and every existing caller keeps the buffered transport +byte for byte. + +Why it exists: a buffered POST holds one connection idle for the WHOLE completion, and a +supervisor turn is the longest completion in the system. An intermediary gateway with an +idle-read timeout kills that connection mid-completion (the 524/503 family). A streamed +response puts bytes on the wire from the first generated token on, so the connection is only +idle through prefill. It does NOT shorten prefill, so a gateway whose deadline is +time-to-FIRST-byte is unaffected; only an idle-timeout gateway is. + +Mutually exclusive with `complete`: the injected transport returns one parsed JSON body and has +no stream to read, so setting both throws rather than silently taking the buffered path. + +WHICH PATHS CAN OPT IN. This flag is read in exactly one place (the private `chatWithTools` transport switch), so +every entry point that takes a caller-supplied `RouterConfig` honors it: `routerBrain`, +`routerToolLoop`, and `supervisorAgent` (which spreads `deps.router` into the brain's config — +the supervisor turn this exists for). Two production call sites build a `RouterConfig` literal +from their own options and therefore CANNOT express it today: the bench strategy's +`routerToolLoop` config in `strategy.ts` and the local sandbox client's `routerBrain` config in +`local-sandbox-client.ts`. Neither drives a supervisor-length turn; setting `stream` on a +config handed to either has no path to reach them, and they stay buffered. + +*** + +### RouterChatResult #### Properties -##### tolerance? +##### content + +> **content**: `string` + +The final answer, with any inline `...` block stripped into `reasoning`. + +##### reasoning? + +> `optional` **reasoning?**: `string` + +Thinking-model reasoning, when the provider surfaced it — either as a separate +`reasoning`/`reasoning_content` message field (OpenRouter style) or inlined into +`content` as a `` block (Groq style). Undefined for non-thinking models. +Downstream parsers that match single-token answers must read `content`, which is +clean either way; before this split, Groq-style inlining made the same model look +broken on one provider and fine on another. + +##### usage? + +> `optional` **usage?**: `object` + +REAL usage, or undefined when the provider reported none. + +###### input + +> **input**: `number` + +###### output + +> **output**: `number` + +##### costUsd? + +> `optional` **costUsd?**: `number` + +Derived from usage via `estimateCost` when the model is priced; else undefined. + +*** + +### RouterToolCall + +A tool-call the model emitted (provider-neutral; mirrors the runtime's ToolCallRequest). + +#### Properties + +##### id + +> **id**: `string` + +##### name + +> **name**: `string` + +##### arguments + +> **arguments**: `string` + +Raw JSON arguments string as emitted by the model. + +*** + +### RouterChatToolsResult + +#### Properties + +##### content + +> **content**: `string` \| `null` + +##### toolCalls + +> **toolCalls**: [`RouterToolCall`](#routertoolcall)[] + +##### usage? + +> `optional` **usage?**: `object` + +###### input + +> **input**: `number` + +###### output + +> **output**: `number` + +##### costUsd? + +> `optional` **costUsd?**: `number` + +##### reasoning? + +> `optional` **reasoning?**: `string` + +Thinking-model reasoning, normalized the way `RouterChatResult.reasoning` is (a separate +`reasoning_content`/`reasoning` field, or an inline `` block split out of `content`). +Populated by the STREAMED path only — `routerChatWithTools` discards reasoning today and its +behavior is preserved unchanged, so a buffered turn still leaves this undefined. + +##### finishReason? + +> `optional` **finishReason?**: `string` + +The provider's `finish_reason` for the turn (`'stop'`, `'tool_calls'`, `'length'`, …). +Populated by the STREAMED path only. `'length'` is the truncation signal the buffered path +cannot surface: it says the turn hit `max_tokens`, not that the model chose to stop. + +##### usageUnknown? + +> `optional` **usageUnknown?**: `true` + +The turn happened and its token usage is UNKNOWN — not zero, not free. Set by the STREAMED +transport when the stream ran to completion without a single usage-bearing chunk, which means +the `stream_options.include_usage` contract was not honored upstream. + +It exists so a bare `usage: undefined` cannot read as a free turn: a metering caller branches +on this marker and records an UNKNOWN turn (see the coordination driver's `meteredBrain`), +rather than skipping the turn and letting a conserved budget pool believe it cost nothing. + +*** + +### ToolSpec + +#### Properties + +##### type + +> **type**: `"function"` + +##### function + +> **function**: `object` + +###### name + +> **name**: `string` + +###### description? + +> `optional` **description?**: `string` -> `readonly` `optional` **tolerance?**: `number` +###### parameters -Max fractional spread (spread/median) per channel for arms to count as equal-k. Default in - the impl (e.g. 0.05). A tighter tolerance = a stricter equal-compute claim. +> **parameters**: `unknown` *** -### PromotionGateOptions +### RouterToolLoopResult #### Properties -##### report +##### final -> **report**: [`BenchmarkReport`](#benchmarkreport) +> **final**: `string` -The HOLDOUT report — must carry per-task cells for both strategy names. +The model's final assistant text (the turn where it stopped calling tools, or the budget turn). -##### incumbent +##### turns -> **incumbent**: `string` +> **turns**: `number` -The incumbent champion's strategy name. +Inference turns spent (≤ maxTurns) — the equal-budget unit vs random@k. -##### candidate +##### toolCalls -> **candidate**: `string` +> **toolCalls**: `number` -The challenger's strategy name. +##### toolTrace -##### mode? +> **toolTrace**: `object`[] -> `optional` **mode?**: `"superiority"` \| `"non-inferiority"` +The behavior trace: each tool call + its result, in order. What a trace-analyst + steerer reads (behavior, never the verdict) to diagnose + redirect the next shot. -'superiority' (default): the candidate must score significantly BETTER. - 'non-inferiority': the candidate must prove its score is not worse than the - incumbent by more than `scoreTolerance` AND its cost savings are significant — - the gate for "same quality, cheaper" claims. +###### name -##### scoreTolerance? +> **name**: `string` -> `optional` **scoreTolerance?**: `number` +###### args -non-inferiority: the score CI lower bound must clear −scoreTolerance. Default 0.05. +> **args**: `string` -##### deltaThreshold? +###### result -> `optional` **deltaThreshold?**: `number` +> **result**: `string` -The CI lower bound on the paired lift must EXCEED this (score scale). Default 0. +##### usage -##### minPairedTasks? +> **usage**: `object` -> `optional` **minPairedTasks?**: `number` +###### input -Minimum paired tasks before significance can be claimed. Default 6 — below that - the bootstrap CI is too wide to separate a real lift from the per-task noise. +> **input**: `number` -##### statistic? +###### output -> `optional` **statistic?**: `"mean"` \| `"median"` +> **output**: `number` -Bootstrap statistic over the paired deltas. Default 'mean'. +##### messages -##### seed? +> **messages**: `Record`\<`string`, `unknown`\>[] -> `optional` **seed?**: `number` +The full conversation after the loop (seed + every assistant/tool turn). Lets a caller + CARRY the messages into the next shot (depth continuation) and read the trajectory. -Fixed by the substrate by default — the same report always yields the same verdict. +*** -##### resamples? +### BenchmarkConfig -> `optional` **resamples?**: `number` +#### Properties -*** +##### environment -### PromotionVerdict +> **environment**: [`AgenticSurface`](#agenticsurface) -#### Properties +The task domain (5 hooks). -##### promoted +##### tasks -> **promoted**: `boolean` +> **tasks**: [`AgenticTask`](#agentictask)[] -##### reason +The tasks to score across. -> **reason**: `"identical-champion"` \| `"few-tasks"` \| `"no-margin"` \| `"significant"` \| `"non-inferior-and-cheaper"` \| `"non-inferiority-unproven"` \| `"not-cheaper"` +##### worker -##### mode +> **worker**: [`AgenticOptions`](#agenticoptions) -> **mode**: `"superiority"` \| `"non-inferiority"` +The worker: model + router + (optional) the critic's instruction (the steerer knob). -##### n +##### strategies? -> **n**: `number` +> `optional` **strategies?**: [`Strategy`](#strategy-3)\<[`StrategyResult`](#strategyresult-1)\>[] -Paired tasks that carried both strategies' cells. +Which strategies to compare. Pass the built-ins (`refine`, `sample`) or your own. + Default: [sample, refine]. -##### lift +##### budget? -> **lift**: `object` +> `optional` **budget?**: `number` -Paired (candidate − incumbent) lift across the holdout tasks. `low` and `high` - are the bounds that carried the decision; `mean` and `median` are diagnostics. +Shots (refine) / width (sample) — the equal compute budget per strategy. Default 3. -###### mean +##### concurrency? -> **mean**: `number` +> `optional` **concurrency?**: `number` -###### median +Tasks scored in parallel. Default 3. -> **median**: `number` +##### onTask? -###### low +> `optional` **onTask?**: (`row`, `done`, `total`) => `void` -> **low**: `number` +Progress hook — fires as each task settles (the live-monitoring seam: append to a + progress file, render a tree, stream to a dashboard). `done` counts settled tasks. -###### high +###### Parameters -> **high**: `number` +###### row -##### costSavings? +[`BenchmarkTaskRow`](#benchmarktaskrow) -> `optional` **costSavings?**: `object` +###### done -non-inferiority mode: paired (incumbent − candidate) cost savings per task (usd). - Positive means the candidate is cheaper; `low` and `high` carried the decision. +`number` -###### mean +###### total -> **mean**: `number` +`number` -###### median +###### Returns -> **median**: `number` +`void` -###### low +##### hooks? -> **low**: `number` +> `optional` **hooks?**: [`RuntimeHooks`](index.md#runtimehooks) -###### high +Lifecycle observability — every spawn/settle of every cell's shots/analysts streams + here live (the watchdog/route-auditor seam, passed through to `runAgentic`). -> **high**: `number` +##### modelPreflight? -##### latency? +> `optional` **modelPreflight?**: `false` \| ((`model`, `worker`, `signal`) => `Promise`\<`void`\>) -> `optional` **latency?**: `object` +Model availability check before tasks start. -Paired (candidate − incumbent) wall-clock per task (ms) — negative = the candidate - is FASTER. Informational in every mode (never gates); the latency answer to "what - does this win actually cost the user?". +By default, live router workers send one one-token request per unique worker and analyst +model. Injected `worker.complete` transports skip the check. Pass `false` to disable it or a +callback to check each unique model through a custom transport. -###### mean +##### modelPreflightTimeoutMs? -> **mean**: `number` +> `optional` **modelPreflightTimeoutMs?**: `number` -###### median +Maximum time for each model availability check. Default 30 seconds. -> **median**: `number` +*** -###### low +### BenchmarkLift -> **low**: `number` +#### Properties -###### high +##### mean -> **high**: `number` +> **mean**: `number` -*** +Mean of paired deltas (refine − sample). -### ResolveSandboxClientOptions +##### low -#### Properties +> **low**: `number` -##### backend +##### high -> **backend**: `"router"` \| `"sandbox"` \| `"bridge"` \| `"local"` +> **high**: `number` -The execution transport for the driven loop. +##### n -##### sandboxClient? +> **n**: `number` -> `optional` **sandboxClient?**: [`SandboxClient`](#sandboxclient-5) +*** -`sandbox` backend: the caller's real Sandbox-backed client. Required for that backend. +### BenchmarkCell -##### bridge? +One strategy's outcome on one task — the per-task cell an optimizer consumes. -> `optional` **bridge?**: `object` +#### Properties -`bridge` backend: local cli-bridge transport. `bearer` + `model` required. +##### score -###### url? +> **score**: `number` -> `optional` **url?**: `string` +##### resolved -cli-bridge base URL. Defaults to `http://127.0.0.1:3355`. +> **resolved**: `boolean` -###### bearer +##### progression -> **bearer**: `string` +> **progression**: `number`[] -###### model +The progress curve (refine: score per shot; sample: best-so-far per rollout). -> **model**: `string` +##### usd -Bridge model id, doubling as the harness selector (e.g. `claude-code/sonnet`). +> **usd**: `number` -###### timeoutMs? +##### ms -> `optional` **timeoutMs?**: `number` +> **ms**: `number` -Per-turn deadline (ms). +##### tokens -##### router? +> **tokens**: `object` -> `optional` **router?**: `object` +###### input -`router` backend: router chat-completion transport. All three fields required. +> **input**: `number` -###### baseUrl +###### output -> **baseUrl**: `string` +> **output**: `number` -###### key +*** -> **key**: `string` +### BenchmarkTaskRow -###### model +#### Properties -> **model**: `string` +##### taskId -##### local? +> **taskId**: `string` -> `optional` **local?**: [`LocalSandboxClientOptions`](#localsandboxclientoptions) +##### cells? -`local` backend: same-host pseudo-box — the router brain drives a tool loop - with the profile's stdio MCP servers spawned as local children. +> `optional` **cells?**: `Record`\<`string`, [`BenchmarkCell`](#benchmarkcell)\> -*** +Per-strategy cells; absent when the task errored before completing all strategies. -### RouterConfig +##### errors? -#### Properties +> `optional` **errors?**: `Record`\<`string`, `string`\> -##### routerBaseUrl +Per-strategy failures on this task: the strategy competed, threw, and scored an + honest zero — it loses, it does not poison the row. The message is kept so a later + generation's author can see WHY a candidate died. -> **routerBaseUrl**: `string` +##### error? + +> `optional` **error?**: `string` + +Why the task was excluded (infra/setup failure) — never silently dropped. -##### routerKey +*** -> **routerKey**: `string` +### BenchmarkStrategySummary -##### model +#### Properties -> **model**: `string` +##### score -##### complete? +> **score**: `number` -> `optional` **complete?**: (`body`) => `Promise`\<`unknown`\> +Mean verifier score (0..1). -Optional completion transport. When set, `routerChatWithUsage` / `routerChatWithTools` call it -with the OpenAI-shape request body and use the parsed `/chat/completions` JSON it returns, -INSTEAD of `fetch(routerBaseUrl + '/chat/completions')`. When absent the fetch path runs -unchanged — the live router stays the default. The injection seam an offline benchmark uses to -drive the worker with no network: a deterministic in-process responder satisfies it, no server. +##### resolved -###### Parameters +> **resolved**: `number` -###### body +Fraction of tasks fully resolved. -`Record`\<`string`, `unknown`\> +##### usd -###### Returns +> **usd**: `number` -`Promise`\<`unknown`\> +Mean cost vector per task. -##### maxTokens? +##### ms -> `optional` **maxTokens?**: `number` +> **ms**: `number` -Ceiling for one completion, forwarded as `max_tokens`. Defaults to 8192. +*** -A REASONING model spends this budget on hidden thinking BEFORE it emits a visible token, so -the default can truncate one mid-thought and return no content at all — observed live with a -model that spent 8,188 of the 8,192 on reasoning and answered with nothing. Raise it for a -thinking model; the ceiling belongs to the router and model a caller chose, which is why it -lives here rather than on one call site. +### BenchmarkReport -##### stream? +Benchmark output: per-strategy means plus the full per-task × per-strategy losses table an optimizer mines. -> `optional` **stream?**: `boolean` +#### Properties -Take the tool-calling completion over SSE instead of one buffered POST. Off by default — -`routerChatWithTools` never streams, and every existing caller keeps the buffered transport -byte for byte. +##### n -Why it exists: a buffered POST holds one connection idle for the WHOLE completion, and a -supervisor turn is the longest completion in the system. An intermediary gateway with an -idle-read timeout kills that connection mid-completion (the 524/503 family). A streamed -response puts bytes on the wire from the first generated token on, so the connection is only -idle through prefill. It does NOT shorten prefill, so a gateway whose deadline is -time-to-FIRST-byte is unaffected; only an idle-timeout gateway is. +> **n**: `number` -Mutually exclusive with `complete`: the injected transport returns one parsed JSON body and has -no stream to read, so setting both throws rather than silently taking the buffered path. +##### excluded -WHICH PATHS CAN OPT IN. This flag is read in exactly one place (the private `chatWithTools` transport switch), so -every entry point that takes a caller-supplied `RouterConfig` honors it: `routerBrain`, -`routerToolLoop`, and `supervisorAgent` (which spreads `deps.router` into the brain's config — -the supervisor turn this exists for). Two production call sites build a `RouterConfig` literal -from their own options and therefore CANNOT express it today: the bench strategy's -`routerToolLoop` config in `strategy.ts` and the local sandbox client's `routerBrain` config in -`local-sandbox-client.ts`. Neither drives a supervisor-length turn; setting `stream` on a -config handed to either has no path to reach them, and they stay buffered. +> **excluded**: `number` -*** +##### perStrategy -### RouterChatResult +> **perStrategy**: `Record`\<`string`, [`BenchmarkStrategySummary`](#benchmarkstrategysummary)\> -#### Properties +Per-strategy means (keyed by strategy.name). -##### content +##### perTask -> **content**: `string` +> **perTask**: [`BenchmarkTaskRow`](#benchmarktaskrow)[] -The final answer, with any inline `...` block stripped into `reasoning`. +The full per-task × per-strategy table — the LOSSES an optimizer (GEPA, a + strategy-author, an operator) consumes. Includes errored tasks with the reason. -##### reasoning? +##### pareto -> `optional` **reasoning?**: `string` +> **pareto**: `string`[] -Thinking-model reasoning, when the provider surfaced it — either as a separate -`reasoning`/`reasoning_content` message field (OpenRouter style) or inlined into -`content` as a `` block (Groq style). Undefined for non-thinking models. -Downstream parsers that match single-token answers must read `content`, which is -clean either way; before this split, Groq-style inlining made the same model look -broken on one provider and fine on another. +The non-dominated strategies on (score ↑, $/task ↓) — collapse-last, per the canon: + a strategy that ties on score at half the cost WINS and a scalar would hide it. -##### usage? +##### refineVsSample? -> `optional` **usage?**: `object` +> `optional` **refineVsSample?**: [`BenchmarkLift`](#benchmarklift) -REAL usage, or undefined when the provider reported none. +The headline when both `refine` and `sample` ran: paired-bootstrap lift of refine over sample. -###### input +*** -> **input**: `number` +### RunAgentRoundsOptions -###### output +**`Experimental`** -> **output**: `number` +#### Type Parameters -##### costUsd? +##### Task -> `optional` **costUsd?**: `number` +`Task` -Derived from usage via `estimateCost` when the model is priced; else undefined. +##### Output -*** +`Output` -### RouterToolCall +##### Decision -A tool-call the model emitted (provider-neutral; mirrors the runtime's ToolCallRequest). +`Decision` #### Properties -##### id +##### driver -> **id**: `string` +> **driver**: [`Driver`](index.md#driver)\<`Task`, `Output`, `Decision`\> -##### name +**`Experimental`** -> **name**: `string` +##### agentRun? -##### arguments +> `optional` **agentRun?**: [`AgentRunSpec`](#agentrunspec)\<`Task`\> -> **arguments**: `string` +**`Experimental`** -Raw JSON arguments string as emitted by the model. +Single agent spec — every iteration uses this profile. Mutually +exclusive with `agentRuns`. -*** +##### agentRuns? -### RouterChatToolsResult +> `optional` **agentRuns?**: [`AgentRunSpec`](#agentrunspec)\<`Task`\>[] -#### Properties +**`Experimental`** -##### content +Multiple specs for heterogeneous fanout. The kernel round-robins +through them when the driver plans N tasks. Mutually exclusive with +`agentRun`. -> **content**: `string` \| `null` +##### output -##### toolCalls +> **output**: [`OutputAdapter`](#outputadapter)\<`Output`\> -> **toolCalls**: [`RouterToolCall`](#routertoolcall)[] +**`Experimental`** -##### usage? +##### validator? -> `optional` **usage?**: `object` +> `optional` **validator?**: [`Validator`](#validator-1)\<`Output`, `DefaultVerdict`\> -###### input +**`Experimental`** -> **input**: `number` +##### task -###### output +> **task**: `Task` -> **output**: `number` +**`Experimental`** -##### costUsd? +##### ctx -> `optional` **costUsd?**: `number` +> **ctx**: [`ExecCtx`](#execctx) -##### reasoning? +**`Experimental`** -> `optional` **reasoning?**: `string` +##### maxIterations? -Thinking-model reasoning, normalized the way `RouterChatResult.reasoning` is (a separate -`reasoning_content`/`reasoning` field, or an inline `` block split out of `content`). -Populated by the STREAMED path only — `routerChatWithTools` discards reasoning today and its -behavior is preserved unchanged, so a buffered turn still leaves this undefined. +> `optional` **maxIterations?**: `number` -##### finishReason? +**`Experimental`** -> `optional` **finishReason?**: `string` +Default 10. Hard cap on total iterations across all `plan()` rounds. -The provider's `finish_reason` for the turn (`'stop'`, `'tool_calls'`, `'length'`, …). -Populated by the STREAMED path only. `'length'` is the truncation signal the buffered path -cannot surface: it says the turn hit `max_tokens`, not that the model chose to stop. +##### maxConcurrency? -##### usageUnknown? +> `optional` **maxConcurrency?**: `number` -> `optional` **usageUnknown?**: `true` +**`Experimental`** -The turn happened and its token usage is UNKNOWN — not zero, not free. Set by the STREAMED -transport when the stream ran to completion without a single usage-bearing chunk, which means -the `stream_options.include_usage` contract was not honored upstream. +Default 4. In-flight worker cap within a single `plan()` batch. -It exists so a bare `usage: undefined` cannot read as a free turn: a metering caller branches -on this marker and records an UNKNOWN turn (see the coordination driver's `meteredBrain`), -rather than skipping the turn and letting a conserved budget pool believe it cost nothing. +##### runId? -*** +> `optional` **runId?**: `string` -### ToolSpec +**`Experimental`** -#### Properties +Pre-allocated id for trace correlation. Default = `loop-${random}`. +Surfaces as `runId` on every emitted `LoopTraceEvent`. -##### type +##### now? -> **type**: `"function"` +> `optional` **now?**: () => `number` -##### function +**`Experimental`** -> **function**: `object` +Clock override; default `Date.now`. Deterministic tests pass a +monotonic counter to stabilize iteration timing fields. -###### name +###### Returns -> **name**: `string` +`number` -###### description? +##### selectWinner? -> `optional` **description?**: `string` +> `optional` **selectWinner?**: (`iterations`) => [`LoopWinner`](#loopwinner)\<`Task`, `Output`\> \| `undefined` -###### parameters +**`Experimental`** -> **parameters**: `unknown` +Override the default winner selector (highest-valid-score, ties broken +by earliest iteration). -*** +###### Parameters -### RouterToolLoopResult +###### iterations -#### Properties +[`Iteration`](#iteration-1)\<`Task`, `Output`\>[] -##### final +###### Returns -> **final**: `string` +[`LoopWinner`](#loopwinner)\<`Task`, `Output`\> \| `undefined` -The model's final assistant text (the turn where it stopped calling tools, or the budget turn). +##### onWorkerBox? -##### turns +> `optional` **onWorkerBox?**: (`box`) => `void` -> **turns**: `number` +**`Experimental`** -Inference turns spent (≤ maxTurns) — the equal-budget unit vs random@k. +Same-sandbox driver mode — a kernel→caller out-channel, not a value handed +in. When set, the kernel keeps each finished worker box alive across the +`plan()` boundary and hands it here, so a same-sandbox planner +(one that reuses the worker's box) can stream its move INTO the +worker's live box — steering from the worker's real filesystem and state, +not just a history summary. The kernel owns teardown: every box kept alive +this way is destroyed at loop end (and the callback is invoked with +`undefined` then as a teardown sentinel). Without it, worker boxes are torn +down per-iteration (default) and a same-sandbox planner has nothing to +reuse. Intended for single-worker (refine) loops: under fanout every box is +still kept for teardown, but only the last-finishing box is handed here, so +a planner sees an arbitrary branch's filesystem — pair it with refine. -##### toolCalls +###### Parameters -> **toolCalls**: `number` +###### box -##### toolTrace +`SandboxInstance` \| `undefined` -> **toolTrace**: `object`[] +###### Returns -The behavior trace: each tool call + its result, in order. What a trace-analyst - steerer reads (behavior, never the verdict) to diagnose + redirect the next shot. +`void` -###### name +##### lineage? -> **name**: `string` +> `optional` **lineage?**: [`LoopLineageOptions`](#looplineageoptions) -###### args +**`Experimental`** -> **args**: `string` +Opt-in box-lineage controls. Default OFF — unset means every iteration +acquires a fresh box, streams once, and tears it down (today's behavior, +byte-identical). With `sessionContinuity` on, a refine round continues the +parent iteration's session on its live box; with `forkFanout` on (and a +fork-capable platform), a fanout round forks the parent's checkpoint so the +branches share a context prefix. The lineage owns every box it starts or +forks and tears them all down at loop end — so these paths are mutually +exclusive with `onWorkerBox`, which claims the same box-ownership channel. -###### result +*** -> **result**: `string` +### AcquireOptions -##### usage +**`Experimental`** -> **usage**: `object` +#### Properties -###### input +##### readyTimeoutMs? -> **input**: `number` +> `optional` **readyTimeoutMs?**: `number` -###### output +**`Experimental`** -> **output**: `number` +Total budget for the sandbox to reach `running`, covering on-demand node +cold-start. Default 600_000ms — matches the orchestrator's pending-host +registration window so we never give up before the platform itself would. -##### messages +##### pollIntervalMs? -> **messages**: `Record`\<`string`, `unknown`\>[] +> `optional` **pollIntervalMs?**: `number` -The full conversation after the loop (seed + every assistant/tool turn). Lets a caller - CARRY the messages into the next shot (depth continuation) and read the trajectory. +**`Experimental`** -*** +Poll interval while waiting for `running` / for the named sandbox to appear. -### BenchmarkConfig +##### signal? -#### Properties +> `optional` **signal?**: `AbortSignal` -##### environment +**`Experimental`** -> **environment**: [`AgenticSurface`](#agenticsurface) +Cancellation (user abort). Distinct from create-call timeouts. -The task domain (5 hooks). +##### name? -##### tasks +> `optional` **name?**: `string` -> **tasks**: [`AgenticTask`](#agentictask)[] +**`Experimental`** -The tasks to score across. +Stamp a name so a timed-out create is recoverable by lookup. Auto-generated if absent. -##### worker +##### now? -> **worker**: [`AgenticOptions`](#agenticoptions) +> `optional` **now?**: () => `number` -The worker: model + router + (optional) the critic's instruction (the steerer knob). +**`Experimental`** -##### strategies? +Clock override for deterministic tests. -> `optional` **strategies?**: [`Strategy`](#strategy-3)\<[`StrategyResult`](#strategyresult-1)\>[] +###### Returns -Which strategies to compare. Pass the built-ins (`refine`, `sample`) or your own. - Default: [sample, refine]. +`number` -##### budget? +##### sleep? -> `optional` **budget?**: `number` +> `optional` **sleep?**: (`ms`) => `Promise`\<`void`\> -Shots (refine) / width (sample) — the equal compute budget per strategy. Default 3. +**`Experimental`** -##### concurrency? +Sleep override for deterministic tests. -> `optional` **concurrency?**: `number` +###### Parameters -Tasks scored in parallel. Default 3. +###### ms -##### onTask? +`number` -> `optional` **onTask?**: (`row`, `done`, `total`) => `void` +###### Returns -Progress hook — fires as each task settles (the live-monitoring seam: append to a - progress file, render a tree, stream to a dashboard). `done` counts settled tasks. +`Promise`\<`void`\> -###### Parameters +*** -###### row +### SandboxCapabilities -[`BenchmarkTaskRow`](#benchmarktaskrow) +**`Experimental`** -###### done +What the loop kernel is allowed to know about a sandbox backend: a single +capability bit, never the backend's identity. `canFork` gates the +checkpoint+fork fanout path; everything else (session continuation) is a +universal SDK feature that needs no probe. -`number` +#### Properties -###### total +##### canFork -`number` +> **canFork**: `boolean` -###### Returns +**`Experimental`** -`void` +True only when `client.criuStatus()` returned `{ available: true }`. When +false, a fork-enabled fanout degrades to independent fresh boxes — same +result, no shared context prefix. -##### hooks? +*** -> `optional` **hooks?**: [`RuntimeHooks`](index.md#runtimehooks) +### CriuCapableClient -Lifecycle observability — every spawn/settle of every cell's shots/analysts streams - here live (the watchdog/route-auditor seam, passed through to `runAgentic`). +**`Experimental`** -##### modelPreflight? +Narrowed view of the optional CRIU probe. The loop-side `SandboxClient` +does not require `criuStatus`; this widens it optionally so the probe can be +read without importing sandbox-backend specifics. -> `optional` **modelPreflight?**: `false` \| ((`model`, `worker`, `signal`) => `Promise`\<`void`\>) +#### Properties -Model availability check before tasks start. +##### criuStatus? -By default, live router workers send one one-token request per unique worker and analyst -model. Injected `worker.complete` transports skip the check. Pass `false` to disable it or a -callback to check each unique model through a custom transport. +> `optional` **criuStatus?**: () => `Promise`\<\{ `available`: `boolean`; `criuVersion?`: `string`; `reason?`: `string`; \}\> -##### modelPreflightTimeoutMs? +**`Experimental`** -> `optional` **modelPreflightTimeoutMs?**: `number` +###### Returns -Maximum time for each model availability check. Default 30 seconds. +`Promise`\<\{ `available`: `boolean`; `criuVersion?`: `string`; `reason?`: `string`; \}\> *** -### BenchmarkLift +### SandboxToolPartState -#### Properties +**`Experimental`** -##### mean +Cross-event state for [mapSandboxToolEvent](#mapsandboxtoolevent). Sandbox backends emit a +tool invocation as MANY `message.part.updated` frames on the same call id +(pending → running → completed), so faithful projection needs per-call +status memory: one `tool_call` on first sighting, at most one `tool_result` +on the terminal transition, nothing on intermediate re-frames. Create one +state per turn via [createSandboxToolPartState](#createsandboxtoolpartstate). -> **mean**: `number` +#### Properties -Mean of paired deltas (refine − sample). +##### statusByCall -##### low +> **statusByCall**: `Map`\<`string`, `string`\> -> **low**: `number` +**`Experimental`** -##### high +Last seen status per tool call id. A terminal status is sticky — later + frames on a settled call project to nothing. -> **high**: `number` +##### seq -##### n +> **seq**: `number` -> **n**: `number` +**`Experimental`** + +Sequence for synthesized call ids when an event carries none. *** -### BenchmarkCell +### SandboxLineageHandle -One strategy's outcome on one task — the per-task cell an optimizer consumes. +**`Experimental`** + +A live box plus the session that threads its iterations together. Handed back +by `start`/`fork`, passed into `continue`/`fork` to descend from. Opaque to +the kernel beyond `box` (for placement/teardown) and `sessionId` (trace). #### Properties -##### score +##### box -> **score**: `number` +> **box**: `SandboxInstance` -##### resolved +**`Experimental`** -> **resolved**: `boolean` +The owned, running sandbox this handle drives. -##### progression +##### sessionId -> **progression**: `number`[] +> **sessionId**: `string` -The progress curve (refine: score per shot; sample: best-so-far per rollout). +**`Experimental`** -##### usd +Stable session id threaded through this box's `streamPrompt` calls. Minted +by the lineage on `start`; reused on `continue` so the server continues the +same conversation. A forked handle starts a fresh session on its new box — +the shared context comes from the checkpoint, not a shared session id. -> **usd**: `number` +*** -##### ms +### SandboxLineage -> **ms**: `number` +**`Experimental`** -##### tokens +Owns box + session handles for one loop run and offers the three +capability-gated lifecycle moves. Construct via `createSandboxLineage`. -> **tokens**: `object` +#### Methods -###### input +##### start() -> **input**: `number` +> **start**(`spec`, `prompt`, `signal`, `promptOptions?`): `Promise`\<\{ `handle`: [`SandboxLineageHandle`](#sandboxlineagehandle); `events`: `AsyncIterable`\<`SandboxEvent`\>; \}\> -###### output +**`Experimental`** -> **output**: `number` +Acquire a fresh box and begin a new session on it. Returns the handle and +the live `streamPrompt` iterable for the first turn (caller drains it). -*** +###### Parameters -### BenchmarkTaskRow +###### spec -#### Properties +[`AgentRunSpec`](#agentrunspec)\<`unknown`\> -##### taskId +###### prompt -> **taskId**: `string` +`string` -##### cells? +###### signal -> `optional` **cells?**: `Record`\<`string`, [`BenchmarkCell`](#benchmarkcell)\> +`AbortSignal` -Per-strategy cells; absent when the task errored before completing all strategies. +###### promptOptions? -##### errors? +`Omit`\<`PromptOptions`, `"signal"` \| `"sessionId"`\> -> `optional` **errors?**: `Record`\<`string`, `string`\> +###### Returns -Per-strategy failures on this task: the strategy competed, threw, and scored an - honest zero — it loses, it does not poison the row. The message is kept so a later - generation's author can see WHY a candidate died. +`Promise`\<\{ `handle`: [`SandboxLineageHandle`](#sandboxlineagehandle); `events`: `AsyncIterable`\<`SandboxEvent`\>; \}\> -##### error? +##### continue() -> `optional` **error?**: `string` +> **continue**(`handle`, `prompt`, `signal`, `promptOptions?`): `Promise`\<`AsyncIterable`\<`SandboxEvent`, `any`, `any`\>\> + +**`Experimental`** + +Continue an existing handle's session with one more turn on the SAME box. +The prior context is server-side; `prompt` is only the new turn. Asserts the +session is still known to the sandbox first (fail-loud) so a platform that +silently dropped the client-minted session id surfaces as an error instead +of a contextless turn the caller mistakes for a real continuation. -Why the task was excluded (infra/setup failure) — never silently dropped. +###### Parameters -*** +###### handle -### BenchmarkStrategySummary +[`SandboxLineageHandle`](#sandboxlineagehandle) -#### Properties +###### prompt -##### score +`string` -> **score**: `number` +###### signal -Mean verifier score (0..1). +`AbortSignal` -##### resolved +###### promptOptions? -> **resolved**: `number` +`Omit`\<`PromptOptions`, `"signal"` \| `"sessionId"`\> -Fraction of tasks fully resolved. +###### Returns -##### usd +`Promise`\<`AsyncIterable`\<`SandboxEvent`, `any`, `any`\>\> -> **usd**: `number` +##### fork() -Mean cost vector per task. +> **fork**(`parent`, `prompts`, `specs`, `signal`): `Promise`\<`object`[]\> -##### ms +**`Experimental`** -> **ms**: `number` +Branch `count` children from `parent`. When the platform can fork, each +child inherits `parent`'s checkpoint — and therefore the parent's IMAGE and +PROFILE: under a real fork `specs[i]` does NOT re-select a per-branch +profile (the SDK forks the running box, it can't swap the image). `specs[i]` +picks the per-branch profile ONLY on the degraded fresh-box path (no CRIU). +A heterogeneous-profile fanout therefore homogenizes to the parent's profile +when fork is available — pass a single shared spec for forked fanouts, or +use `random@k` (no fork) when branches must differ. Each child's first turn +streams `prompts[i]`. Child-box creation is bounded by `maxConcurrency`. -*** +###### Parameters -### BenchmarkReport +###### parent -Benchmark output: per-strategy means plus the full per-task × per-strategy losses table an optimizer mines. +[`SandboxLineageHandle`](#sandboxlineagehandle) -#### Properties +###### prompts -##### n +`string`[] -> **n**: `number` +###### specs -##### excluded +[`AgentRunSpec`](#agentrunspec)\<`unknown`\>[] -> **excluded**: `number` +###### signal -##### perStrategy +`AbortSignal` -> **perStrategy**: `Record`\<`string`, [`BenchmarkStrategySummary`](#benchmarkstrategysummary)\> +###### Returns -Per-strategy means (keyed by strategy.name). +`Promise`\<`object`[]\> -##### perTask +##### prune() -> **perTask**: [`BenchmarkTaskRow`](#benchmarktaskrow)[] +> **prune**(`keep`): `Promise`\<`void`\> -The full per-task × per-strategy table — the LOSSES an optimizer (GEPA, a - strategy-author, an operator) consumes. Includes errored tasks with the reason. +**`Experimental`** -##### pareto +Destroy every owned box whose handle is NOT in `keep`, freeing it before +loop end. The kernel calls this after a round when it can prove no future +round will descend from the pruned boxes (deterministic, monotonic branch +selection); boxes still reachable as a future branch source are retained. +Best-effort, bounded, parallel — a failed delete never throws. -> **pareto**: `string`[] +###### Parameters -The non-dominated strategies on (score ↑, $/task ↓) — collapse-last, per the canon: - a strategy that ties on score at half the cost WINS and a scalar would hide it. +###### keep -##### refineVsSample? +`Iterable`\<[`SandboxLineageHandle`](#sandboxlineagehandle)\> -> `optional` **refineVsSample?**: [`BenchmarkLift`](#benchmarklift) +###### Returns -The headline when both `refine` and `sample` ran: paired-bootstrap lift of refine over sample. +`Promise`\<`void`\> -*** +##### teardown() -### RunAgentRoundsOptions +> **teardown**(): `Promise`\<`void`\> **`Experimental`** -#### Type Parameters +Destroy every box this lineage owns. Best-effort, bounded, parallel. -##### Task +###### Returns -`Task` +`Promise`\<`void`\> -##### Output +*** -`Output` +### CheckpointCapableBox -##### Decision +**`Experimental`** -`Decision` +Loop-side widening of the box's optional checkpoint method. The +`SandboxClient`/`SandboxInstance` surface the kernel relies on does not +require checkpointing; this reads it optionally so the lineage can probe-gate +without importing sandbox-backend specifics. #### Properties -##### driver +##### checkpoint? -> **driver**: [`Driver`](index.md#driver)\<`Task`, `Output`, `Decision`\> +> `optional` **checkpoint?**: (`options?`) => `Promise`\<\{ `checkpointId`: `string`; \}\> **`Experimental`** -##### agentRun? +###### Parameters -> `optional` **agentRun?**: [`AgentRunSpec`](#agentrunspec)\<`Task`\> +###### options? -**`Experimental`** +###### leaveRunning? -Single agent spec — every iteration uses this profile. Mutually -exclusive with `agentRuns`. +`boolean` -##### agentRuns? +###### tags? -> `optional` **agentRuns?**: [`AgentRunSpec`](#agentrunspec)\<`Task`\>[] +`string`[] -**`Experimental`** +###### Returns -Multiple specs for heterogeneous fanout. The kernel round-robins -through them when the driver plans N tasks. Mutually exclusive with -`agentRun`. +`Promise`\<\{ `checkpointId`: `string`; \}\> -##### output +*** -> **output**: [`OutputAdapter`](#outputadapter)\<`Output`\> +### ForkCapableBox **`Experimental`** -##### validator? - -> `optional` **validator?**: [`Validator`](#validator-1)\<`Output`, `DefaultVerdict`\> +Loop-side widening of the box's optional fork method. -**`Experimental`** +#### Properties -##### task +##### fork? -> **task**: `Task` +> `optional` **fork?**: (`checkpointId`, `options?`) => `Promise`\<`SandboxInstance`\> **`Experimental`** -##### ctx +###### Parameters -> **ctx**: [`ExecCtx`](#execctx) +###### checkpointId -**`Experimental`** +`string` -##### maxIterations? +###### options? -> `optional` **maxIterations?**: `number` +###### name? -**`Experimental`** +`string` -Default 10. Hard cap on total iterations across all `plan()` rounds. +###### Returns -##### maxConcurrency? +`Promise`\<`SandboxInstance`\> -> `optional` **maxConcurrency?**: `number` +*** + +### SessionCapableBox **`Experimental`** -Default 4. In-flight worker cap within a single `plan()` batch. +Loop-side widening of the box's optional session accessor. The real +`SandboxInstance` exposes `session(id).status()`; the loop reads it optionally +so `continue` can assert session liveness without requiring it of the test +fakes. `status()` resolves `null` when the id is unknown to the sandbox. -##### runId? +#### Properties -> `optional` **runId?**: `string` +##### session? + +> `optional` **session?**: (`id`) => `object` **`Experimental`** -Pre-allocated id for trace correlation. Default = `loop-${random}`. -Surfaces as `runId` on every emitted `LoopTraceEvent`. +###### Parameters -##### now? +###### id -> `optional` **now?**: () => `number` +`string` -**`Experimental`** +###### Returns -Clock override; default `Date.now`. Deterministic tests pass a -monotonic counter to stabilize iteration timing fields. +`object` + +###### status + +> **status**: () => `Promise`\<`unknown`\> ###### Returns -`number` +`Promise`\<`unknown`\> -##### selectWinner? +*** -> `optional` **selectWinner?**: (`iterations`) => [`LoopWinner`](#loopwinner)\<`Task`, `Output`\> \| `undefined` +### TurnResult **`Experimental`** -Override the default winner selector (highest-valid-score, ties broken -by earliest iteration). - -###### Parameters +One finished turn over the artifact. A failed FS read is surfaced in `readError` +(never masked as an empty deliverable) so a caller distinguishes "agent produced +nothing" from a transport/FS fault. -###### iterations +#### Type Parameters -[`Iteration`](#iteration-1)\<`Task`, `Output`\>[] +##### Out -###### Returns +`Out` -[`LoopWinner`](#loopwinner)\<`Task`, `Output`\> \| `undefined` +#### Properties -##### onWorkerBox? +##### out -> `optional` **onWorkerBox?**: (`box`) => `void` +> **out**: `Out` **`Experimental`** -Same-sandbox driver mode — a kernel→caller out-channel, not a value handed -in. When set, the kernel keeps each finished worker box alive across the -`plan()` boundary and hands it here, so a same-sandbox planner -(one that reuses the worker's box) can stream its move INTO the -worker's live box — steering from the worker's real filesystem and state, -not just a history summary. The kernel owns teardown: every box kept alive -this way is destroyed at loop end (and the callback is invoked with -`undefined` then as a teardown sentinel). Without it, worker boxes are torn -down per-iteration (default) and a same-sandbox planner has nothing to -reuse. Intended for single-worker (refine) loops: under fanout every box is -still kept for teardown, but only the last-finishing box is handed here, so -a planner sees an arbitrary branch's filesystem — pair it with refine. +##### events -###### Parameters +> **events**: `SandboxEvent`[] -###### box +**`Experimental`** -`SandboxInstance` \| `undefined` +##### readError? -###### Returns +> `optional` **readError?**: `string` -`void` +**`Experimental`** -##### lineage? +*** -> `optional` **lineage?**: [`LoopLineageOptions`](#looplineageoptions) +### SandboxRun **`Experimental`** -Opt-in box-lineage controls. Default OFF — unset means every iteration -acquires a fresh box, streams once, and tears it down (today's behavior, -byte-identical). With `sessionContinuity` on, a refine round continues the -parent iteration's session on its live box; with `forkFanout` on (and a -fork-capable platform), a fanout round forks the parent's checkpoint so the -branches share a context prefix. The lineage owns every box it starts or -forks and tears them all down at loop end — so these paths are mutually -exclusive with `onWorkerBox`, which claims the same box-ownership channel. +A live run over ONE persistent artifact (box + session). Close it + when done — `close()` tears the box down. -*** +#### Type Parameters -### AcquireOptions +##### Out -**`Experimental`** +`Out` #### Properties -##### readyTimeoutMs? +##### box -> `optional` **readyTimeoutMs?**: `number` +> `readonly` **box**: `SandboxInstance` **`Experimental`** -Total budget for the sandbox to reach `running`, covering on-demand node -cold-start. Default 600_000ms — matches the orchestrator's pending-host -registration window so we never give up before the platform itself would. - -##### pollIntervalMs? +##### sessionId -> `optional` **pollIntervalMs?**: `number` +> `readonly` **sessionId**: `string` **`Experimental`** -Poll interval while waiting for `running` / for the named sandbox to appear. +#### Methods -##### signal? +##### start() -> `optional` **signal?**: `AbortSignal` +> **start**(`prompt`): `Promise`\<[`TurnResult`](#turnresult)\<`Out`\>\> **`Experimental`** -Cancellation (user abort). Distinct from create-call timeouts. +First turn over the fresh box (mints the session). Throws if already started. -##### name? +###### Parameters -> `optional` **name?**: `string` +###### prompt -**`Experimental`** +`string` -Stamp a name so a timed-out create is recoverable by lookup. Auto-generated if absent. +###### Returns -##### now? +`Promise`\<[`TurnResult`](#turnresult)\<`Out`\>\> -> `optional` **now?**: () => `number` +##### resume() -**`Experimental`** +> **resume**(`prompt`): `Promise`\<[`TurnResult`](#turnresult)\<`Out`\>\> -Clock override for deterministic tests. +**`Experimental`** -###### Returns +Continue THE SAME session over THE SAME artifact — a resumed turn/rollout. -`number` +###### Parameters -##### sleep? +###### prompt -> `optional` **sleep?**: (`ms`) => `Promise`\<`void`\> +`string` -**`Experimental`** +###### Returns -Sleep override for deterministic tests. +`Promise`\<[`TurnResult`](#turnresult)\<`Out`\>\> -###### Parameters +##### close() -###### ms +> **close**(): `Promise`\<`void`\> -`number` +**`Experimental`** ###### Returns @@ -5879,1661 +6835,1630 @@ Sleep override for deterministic tests. *** -### SandboxCapabilities - -**`Experimental`** +### OpenSandboxRunBeforeStartContext -What the loop kernel is allowed to know about a sandbox backend: a single -capability bit, never the backend's identity. `canFork` gates the -checkpoint+fork fanout path; everything else (session continuation) is a -universal SDK feature that needs no probe. +Context available after the box/session exists and before the first prompt is +drained. Intended for benchmark-owned workspace setup such as cloning a repo +into a fixed path. #### Properties -##### canFork +##### box -> **canFork**: `boolean` +> `readonly` **box**: `SandboxInstance` -**`Experimental`** +##### sessionId -True only when `client.criuStatus()` returned `{ available: true }`. When -false, a fork-enabled fanout degrades to independent fresh boxes — same -result, no shared context prefix. +> `readonly` **sessionId**: `string` + +##### signal + +> `readonly` **signal**: `AbortSignal` *** -### CriuCapableClient +### OpenSandboxRunOptions **`Experimental`** -Narrowed view of the optional CRIU probe. The loop-side `SandboxClient` -does not require `criuStatus`; this widens it optionally so the probe can be -read without importing sandbox-backend specifics. - #### Properties -##### criuStatus? +##### agentRun -> `optional` **criuStatus?**: () => `Promise`\<\{ `available`: `boolean`; `criuVersion?`: `string`; `reason?`: `string`; \}\> +> **agentRun**: [`AgentRunSpec`](#agentrunspec)\<`string`\> **`Experimental`** -###### Returns - -`Promise`\<\{ `available`: `boolean`; `criuVersion?`: `string`; `reason?`: `string`; \}\> +Profile + sandbox env/overrides. `sandboxOverrides.backend.type` is the harness. -*** +##### signal -### SandboxToolPartState +> **signal**: `AbortSignal` **`Experimental`** -Cross-event state for [mapSandboxToolEvent](#mapsandboxtoolevent). Sandbox backends emit a -tool invocation as MANY `message.part.updated` frames on the same call id -(pending → running → completed), so faithful projection needs per-call -status memory: one `tool_call` on first sighting, at most one `tool_result` -on the terminal transition, nothing on intermediate re-frames. Create one -state per turn via [createSandboxToolPartState](#createsandboxtoolpartstate). - -#### Properties - -##### statusByCall +##### hooks? -> **statusByCall**: `Map`\<`string`, `string`\> +> `optional` **hooks?**: [`RuntimeHooks`](index.md#runtimehooks) **`Experimental`** -Last seen status per tool call id. A terminal status is sticky — later - frames on a settled call project to nothing. +Optional execution-scoped observers. Hook failures never fail the run. -##### seq +##### runId? -> **seq**: `number` +> `optional` **runId?**: `string` **`Experimental`** -Sequence for synthesized call ids when an event carries none. +Stable run id for trace joins. Defaults to a short runtime-minted id. -*** +##### scenarioId? -### SandboxLineageHandle +> `optional` **scenarioId?**: `string` **`Experimental`** -A live box plus the session that threads its iterations together. Handed back -by `start`/`fork`, passed into `continue`/`fork` to descend from. Opaque to -the kernel beyond `box` (for placement/teardown) and `sessionId` (trace). - -#### Properties +Optional benchmark/scenario id carried into emitted hook events. -##### box +##### promptOptions? -> **box**: `SandboxInstance` +> `optional` **promptOptions?**: [`OpenSandboxRunPromptOptions`](#opensandboxrunpromptoptions) **`Experimental`** -The owned, running sandbox this handle drives. +Per-prompt sandbox SDK options forwarded to both `start()` and `resume()`. + The runtime still owns the session id and abort signal for each turn. -##### sessionId +##### beforeStart? -> **sessionId**: `string` +> `optional` **beforeStart?**: (`ctx`) => `void` \| `Promise`\<`void`\> **`Experimental`** -Stable session id threaded through this box's `streamPrompt` calls. Minted -by the lineage on `start`; reused on `continue` so the server continues the -same conversation. A forked handle starts a fresh session on its new box — -the shared context comes from the checkpoint, not a shared session id. +Optional pre-start workspace setup. Runs after `lineage.start()` creates the +box/session and before the first prompt stream is consumed. A thrown error +fails the turn before the agent spends tokens. -*** +###### Parameters -### SandboxLineage +###### ctx -**`Experimental`** +[`OpenSandboxRunBeforeStartContext`](#opensandboxrunbeforestartcontext) -Owns box + session handles for one loop run and offers the three -capability-gated lifecycle moves. Construct via `createSandboxLineage`. +###### Returns -#### Methods +`void` \| `Promise`\<`void`\> -##### start() +##### onSandboxEvent? -> **start**(`spec`, `prompt`, `signal`, `promptOptions?`): `Promise`\<\{ `handle`: [`SandboxLineageHandle`](#sandboxlineagehandle); `events`: `AsyncIterable`\<`SandboxEvent`\>; \}\> +> `optional` **onSandboxEvent?**: (`event`, `meta`) => `void` \| `PromiseLike`\<`void`\> **`Experimental`** -Acquire a fresh box and begin a new session on it. Returns the handle and -the live `streamPrompt` iterable for the first turn (caller drains it). +Receives a defensive copy of every streamed event. Observer work is +non-blocking; synchronous throws and rejected promises never fail the run. ###### Parameters -###### spec +###### event -[`AgentRunSpec`](#agentrunspec)\<`unknown`\> +`SandboxEvent` -###### prompt +###### meta -`string` +###### turnIndex -###### signal +`number` -`AbortSignal` +###### turnKind -###### promptOptions? +`"start"` \| `"resume"` -`Omit`\<`PromptOptions`, `"signal"` \| `"sessionId"`\> +###### agentRunName + +`string` ###### Returns -`Promise`\<\{ `handle`: [`SandboxLineageHandle`](#sandboxlineagehandle); `events`: `AsyncIterable`\<`SandboxEvent`\>; \}\> +`void` \| `PromiseLike`\<`void`\> -##### continue() +##### now? -> **continue**(`handle`, `prompt`, `signal`, `promptOptions?`): `Promise`\<`AsyncIterable`\<`SandboxEvent`, `any`, `any`\>\> +> `optional` **now?**: () => `number` **`Experimental`** -Continue an existing handle's session with one more turn on the SAME box. -The prior context is server-side; `prompt` is only the new turn. Asserts the -session is still known to the sandbox first (fail-loud) so a platform that -silently dropped the client-minted session id surfaces as an error instead -of a contextless turn the caller mistakes for a real continuation. +Test seam for deterministic hook timestamps. Defaults to `Date.now`. -###### Parameters +###### Returns -###### handle +`number` -[`SandboxLineageHandle`](#sandboxlineagehandle) +##### maxConcurrency? -###### prompt +> `optional` **maxConcurrency?**: `number` -`string` +**`Experimental`** -###### signal +Bounds box-creation bursts inside lineage fanout. Default from lineage. -`AbortSignal` +##### readRetryDelayMs? -###### promptOptions? +> `optional` **readRetryDelayMs?**: `number` -`Omit`\<`PromptOptions`, `"signal"` \| `"sessionId"`\> +**`Experimental`** -###### Returns +Base backoff (ms) for retrying a transient artifact `fs.read` failure; the i-th + retry waits `readRetryDelayMs * i`. Default 1000. Set 0 to disable the wait (tests). -`Promise`\<`AsyncIterable`\<`SandboxEvent`, `any`, `any`\>\> +*** -##### fork() +### StdioMcpServerSpec -> **fork**(`parent`, `prompts`, `specs`, `signal`): `Promise`\<`object`[]\> +#### Properties -**`Experimental`** +##### command -Branch `count` children from `parent`. When the platform can fork, each -child inherits `parent`'s checkpoint — and therefore the parent's IMAGE and -PROFILE: under a real fork `specs[i]` does NOT re-select a per-branch -profile (the SDK forks the running box, it can't swap the image). `specs[i]` -picks the per-branch profile ONLY on the degraded fresh-box path (no CRIU). -A heterogeneous-profile fanout therefore homogenizes to the parent's profile -when fork is available — pass a single shared spec for forked fanouts, or -use `random@k` (no fork) when branches must differ. Each child's first turn -streams `prompts[i]`. Child-box creation is bounded by `maxConcurrency`. +> **command**: `string` -###### Parameters +Command that starts the MCP server (stdio transport). -###### parent +##### args? -[`SandboxLineageHandle`](#sandboxlineagehandle) +> `optional` **args?**: `string`[] -###### prompts +##### cwd? -`string`[] +> `optional` **cwd?**: `string` + +Working directory the server starts in (a built candidate's worktree, typically). + +##### env? + +> `optional` **env?**: `Record`\<`string`, `string`\> + +Declared public env for the server process. Only a minimal non-sensitive +subset of the parent env is inherited. -###### specs +##### protectedEnv? -[`AgentRunSpec`](#agentrunspec)\<`unknown`\>[] +> `optional` **protectedEnv?**: `Record`\<`string`, `string`\> -###### signal +Sensitive env for the server process. These values override `env` and are +redacted from child-supplied errors, tool metadata, and tool results. -`AbortSignal` +##### timeoutMs? -###### Returns +> `optional` **timeoutMs?**: `number` -`Promise`\<`object`[]\> +Handshake AND per-request timeout (ms). Default 30s. -##### prune() +*** -> **prune**(`keep`): `Promise`\<`void`\> +### McpToolDescriptor -**`Experimental`** +#### Properties -Destroy every owned box whose handle is NOT in `keep`, freeing it before -loop end. The kernel calls this after a round when it can prove no future -round will descend from the pruned boxes (deterministic, monotonic branch -selection); boxes still reachable as a future branch source are retained. -Best-effort, bounded, parallel — a failed delete never throws. +##### name -###### Parameters +> **name**: `string` -###### keep +##### description? -`Iterable`\<[`SandboxLineageHandle`](#sandboxlineagehandle)\> +> `optional` **description?**: `string` -###### Returns +##### inputSchema? -`Promise`\<`void`\> +> `optional` **inputSchema?**: `unknown` -##### teardown() +*** -> **teardown**(): `Promise`\<`void`\> +### StdioMcpConnection -**`Experimental`** +#### Properties -Destroy every box this lineage owns. Best-effort, bounded, parallel. +##### tools -###### Returns +> `readonly` **tools**: readonly [`McpToolDescriptor`](#mcptooldescriptor)[] -`Promise`\<`void`\> +The tools the server exposed at connect time (`tools/list`). -*** +#### Methods -### CheckpointCapableBox +##### callTool() -**`Experimental`** +> **callTool**(`name`, `args`): `Promise`\<`string`\> -Loop-side widening of the box's optional checkpoint method. The -`SandboxClient`/`SandboxInstance` surface the kernel relies on does not -require checkpointing; this reads it optionally so the lineage can probe-gate -without importing sandbox-backend specifics. +`tools/call` → the result's text content. A JSON-RPC error / `isError` + result becomes an `ERROR: …` string (the agent's outcome); a dead + transport or timeout throws (an infra fault). -#### Properties +###### Parameters -##### checkpoint? +###### name -> `optional` **checkpoint?**: (`options?`) => `Promise`\<\{ `checkpointId`: `string`; \}\> +`string` -**`Experimental`** +###### args -###### Parameters +`Record`\<`string`, `unknown`\> -###### options? +###### Returns -###### leaveRunning? +`Promise`\<`string`\> -`boolean` +##### close() -###### tags? +> **close**(): `Promise`\<`void`\> -`string`[] +Kill the server child. Idempotent. ###### Returns -`Promise`\<\{ `checkpointId`: `string`; \}\> +`Promise`\<`void`\> *** -### ForkCapableBox - -**`Experimental`** - -Loop-side widening of the box's optional fork method. +### MaterializeLocalMcpOptions #### Properties -##### fork? +##### timeoutMs? -> `optional` **fork?**: (`checkpointId`, `options?`) => `Promise`\<`SandboxInstance`\> +> `optional` **timeoutMs?**: `number` -**`Experimental`** +Handshake / per-request timeout per server (ms). Default 30s. -###### Parameters +##### maxResultChars? -###### checkpointId +> `optional` **maxResultChars?**: `number` -`string` +Cap on a tool result's text fed back to the worker. Default 2000 chars. -###### options? +##### keys? -###### name? +> `optional` **keys?**: [`KeyProvider`](#keyprovider) -`string` +Resolves a server's DECLARED secrets at spawn time — env entries of kind + `secret-ref` (interface ≥0.40) and the legacy `metadata.secretEnv` map + (env var name → provider key name). The resolved values reach ONLY the + child process env — never the profile, the logs, or an error message. + Fail-closed: a server declaring secrets without a provider (or with a + missing key) throws instead of booting keyless. -###### Returns +##### profileSecurityPolicy? -`Promise`\<`SandboxInstance`\> +> `optional` **profileSecurityPolicy?**: `AgentProfileSecurityPolicy` -*** +Required trust decision for profiles that declare local MCP processes. +Omit to refuse all profile-controlled host execution. Passing +`allowLocalMcp: true` is only safe for an author-controlled profile: the +process receives this Runtime's filesystem and network privileges. -### SessionCapableBox +*** -**`Experimental`** +### LocalMcpMaterialization -Loop-side widening of the box's optional session accessor. The real -`SandboxInstance` exposes `session(id).status()`; the loop reads it optionally -so `continue` can assert session liveness without requiring it of the test -fakes. `status()` resolves `null` when the id is unknown to the sandbox. +The live same-host materialization of a profile's `mcp` surface. #### Properties -##### session? +##### tools -> `optional` **session?**: (`id`) => `object` +> **tools**: [`AgenticTool`](#agentictool)[] -**`Experimental`** +Worker-facing tool specs: namespaced `__`, provider-safe schemas. -###### Parameters +#### Methods -###### id +##### owns() -`string` +> **owns**(`name`): `boolean` -###### Returns +Whether `name` is one of this materialization's namespaced tools. -`object` +###### Parameters -###### status +###### name -> **status**: () => `Promise`\<`unknown`\> +`string` ###### Returns -`Promise`\<`unknown`\> - -*** - -### TurnResult +`boolean` -**`Experimental`** +##### call() -One finished turn over the artifact. A failed FS read is surfaced in `readError` -(never masked as an empty deliverable) so a caller distinguishes "agent produced -nothing" from a transport/FS fault. +> **call**(`name`, `args`): `Promise`\<`string`\> -#### Type Parameters +Route a namespaced call to its server's live stdio child. -##### Out +###### Parameters -`Out` +###### name -#### Properties +`string` -##### out +###### args -> **out**: `Out` +`Record`\<`string`, `unknown`\> -**`Experimental`** +###### Returns -##### events +`Promise`\<`string`\> -> **events**: `SandboxEvent`[] +##### close() -**`Experimental`** +> **close**(): `Promise`\<`void`\> -##### readError? +Kill every spawned server. Idempotent. -> `optional` **readError?**: `string` +###### Returns -**`Experimental`** +`Promise`\<`void`\> *** -### SandboxRun - -**`Experimental`** +### NaiveDriverOptions -A live run over ONE persistent artifact (box + session). Close it - when done — `close()` tears the box down. +Options for [naiveDriver](#naivedriver). #### Type Parameters -##### Out +##### Task -`Out` +`Task` #### Properties -##### box +##### continuation -> `readonly` **box**: `SandboxInstance` +> **continuation**: `string` -**`Experimental`** +The fixed continuation issued every round after shot 0. The same string is +sent whether the prior shot passed inspection or not — the naive driver +reads no part of the verdict. Domain text is the caller's; the substrate +supplies none. -##### sessionId +##### applyContinuation -> `readonly` **sessionId**: `string` +> **applyContinuation**: [`ApplyContinuation`](#applycontinuation)\<`Task`\> -**`Experimental`** +Folds `continuation` into the caller's Task shape for the next shot. -#### Methods +##### maxIterations -##### start() +> **maxIterations**: `number` -> **start**(`prompt`): `Promise`\<[`TurnResult`](#turnresult)\<`Out`\>\> +Hard shot cap. The loop stops refining once history reaches this length. -**`Experimental`** +##### name? -First turn over the fresh box (mints the session). Throws if already started. +> `optional` **name?**: `string` -###### Parameters +Trace-event identifier. Default `'naive'`. -###### prompt +*** -`string` +### DumbDriverOptions -###### Returns +Options for [dumbDriver](#dumbdriver). -`Promise`\<[`TurnResult`](#turnresult)\<`Out`\>\> +#### Type Parameters -##### resume() +##### Task -> **resume**(`prompt`): `Promise`\<[`TurnResult`](#turnresult)\<`Out`\>\> +`Task` -**`Experimental`** +#### Properties -Continue THE SAME session over THE SAME artifact — a resumed turn/rollout. +##### onPass -###### Parameters +> **onPass**: `string` + +Continuation issued when the prior shot's verdict is valid. In a +stop-on-pass loop this is rarely reached (a valid shot ends the loop), but +it is required so the driver is total over the pass/fail bit; pass a +confirmation/keep-going string. + +##### onFail + +> **onFail**: `string` + +Continuation issued when the prior shot's verdict is NOT valid. -###### prompt +##### applyContinuation -`string` +> **applyContinuation**: [`ApplyContinuation`](#applycontinuation)\<`Task`\> -###### Returns +Folds the chosen continuation into the caller's Task shape. -`Promise`\<[`TurnResult`](#turnresult)\<`Out`\>\> +##### maxIterations -##### close() +> **maxIterations**: `number` -> **close**(): `Promise`\<`void`\> +Hard shot cap. The loop stops refining once history reaches this length. -**`Experimental`** +##### name? -###### Returns +> `optional` **name?**: `string` -`Promise`\<`void`\> +Trace-event identifier. Default `'dumb'`. *** -### OpenSandboxRunBeforeStartContext - -Context available after the box/session exists and before the first prompt is -drained. Intended for benchmark-owned workspace setup such as cloning a repo -into a fixed path. +### AuthorStrategyOptions #### Properties -##### box +##### chat -> `readonly` **box**: `SandboxInstance` +> **chat**: `ChatClient` -##### sessionId +The model-call seam (agent-eval `createChatClient`). -> `readonly` **sessionId**: `string` +##### model? -##### signal +> `optional` **model?**: `string` -> `readonly` **signal**: `AbortSignal` +##### fallbackModel? -*** +> `optional` **fallbackModel?**: `string` -### OpenSandboxRunOptions +A NAMED fallback author tried once when the primary call fails or returns no code + block (thinking models time out at the edge on long authoring prompts, or return + empty content without `maxTokens`). Opt-in — absent means the primary's failure + propagates. -**`Experimental`** +##### contract? -#### Properties +> `optional` **contract?**: `string` -##### agentRun +The contract text shown to the author. Default `strategyAuthorContract`. The + meta-optimization coordinate: a GEPA/skill loop can evolve this text and gate each + variant on the same frozen holdout as any strategy. -> **agentRun**: [`AgentRunSpec`](#agentrunspec)\<`string`\> +##### environmentName -**`Experimental`** +> **environmentName**: `string` -Profile + sandbox env/overrides. `sandboxOverrides.backend.type` is the harness. +The environment the losses came from (orientation only — never the verifiers). -##### signal +##### lossesJson -> **signal**: `AbortSignal` +> **lossesJson**: `string` -**`Experimental`** +The per-task losses table (e.g. JSON.stringify(report.perTask)) — the gradient. -##### hooks? +##### budget -> `optional` **hooks?**: [`RuntimeHooks`](index.md#runtimehooks) +> **budget**: `number` -**`Experimental`** +The budget the strategy must respect (shots/width). -Optional execution-scoped observers. Hook failures never fail the run. +##### outDir -##### runId? +> **outDir**: `string` -> `optional` **runId?**: `string` +Where the authored module file is written (created if missing). -**`Experimental`** +##### temperature? -Stable run id for trace joins. Defaults to a short runtime-minted id. +> `optional` **temperature?**: `number` -##### scenarioId? +##### maxTokens? -> `optional` **scenarioId?**: `string` +> `optional` **maxTokens?**: `number` -**`Experimental`** +Completion cap — required by thinking-model authors that stream reasoning first. -Optional benchmark/scenario id carried into emitted hook events. +##### signal? -##### promptOptions? +> `optional` **signal?**: `AbortSignal` -> `optional` **promptOptions?**: [`OpenSandboxRunPromptOptions`](#opensandboxrunpromptoptions) +*** -**`Experimental`** +### AuthoredStrategy -Per-prompt sandbox SDK options forwarded to both `start()` and `resume()`. - The runtime still owns the session id and abort signal for each turn. +#### Properties -##### beforeStart? +##### strategy -> `optional` **beforeStart?**: (`ctx`) => `void` \| `Promise`\<`void`\> +> **strategy**: [`Strategy`](#strategy-3) -**`Experimental`** +##### file -Optional pre-start workspace setup. Runs after `lineage.start()` creates the -box/session and before the first prompt stream is consumed. A thrown error -fails the turn before the agent spends tokens. +> **file**: `string` -###### Parameters +##### code -###### ctx +> **code**: `string` -[`OpenSandboxRunBeforeStartContext`](#opensandboxrunbeforestartcontext) +*** -###### Returns +### EvolutionAuthor -`void` \| `Promise`\<`void`\> +#### Properties -##### onSandboxEvent? +##### chat -> `optional` **onSandboxEvent?**: (`event`, `meta`) => `void` \| `PromiseLike`\<`void`\> +> **chat**: `ChatClient` -**`Experimental`** +The model-call seam (agent-eval `createChatClient`). -Receives a defensive copy of every streamed event. Observer work is -non-blocking; synchronous throws and rejected promises never fail the run. +##### model? -###### Parameters +> `optional` **model?**: `string` -###### event +##### fallbackModel? -`SandboxEvent` +> `optional` **fallbackModel?**: `string` -###### meta +##### temperature? -###### turnIndex +> `optional` **temperature?**: `number` -`number` +##### maxTokens? -###### turnKind +> `optional` **maxTokens?**: `number` -`"start"` \| `"resume"` +*** -###### agentRunName +### StrategyEvolutionConfig -`string` +#### Properties -###### Returns +##### environment -`void` \| `PromiseLike`\<`void`\> +> **environment**: [`AgenticSurface`](#agenticsurface) -##### now? +##### tasks -> `optional` **now?**: () => `number` +> **tasks**: (`offset`, `n`) => `Promise`\<[`AgenticTask`](#agentictask)[]\> -**`Experimental`** +Task supply by DISJOINT slice: `(offset, n)` must return n tasks unique to that + offset range. Train draws [0, trainN); the holdout draws [trainN + holdoutOffset, + …) — tasks the search never touched. -Test seam for deterministic hook timestamps. Defaults to `Date.now`. +###### Parameters -###### Returns +###### offset `number` -##### maxConcurrency? - -> `optional` **maxConcurrency?**: `number` +###### n -**`Experimental`** +`number` -Bounds box-creation bursts inside lineage fanout. Default from lineage. +###### Returns -##### readRetryDelayMs? +`Promise`\<[`AgenticTask`](#agentictask)[]\> -> `optional` **readRetryDelayMs?**: `number` +##### trainN -**`Experimental`** +> **trainN**: `number` -Base backoff (ms) for retrying a transient artifact `fs.read` failure; the i-th - retry waits `readRetryDelayMs * i`. Default 1000. Set 0 to disable the wait (tests). +##### holdoutN -*** +> **holdoutN**: `number` -### StdioMcpServerSpec +##### holdoutOffset? -#### Properties +> `optional` **holdoutOffset?**: `number` -##### command +Extra offset past the train slice for the holdout draw (rotate across runs). -> **command**: `string` +##### worker -Command that starts the MCP server (stdio transport). +> **worker**: [`AgenticOptions`](#agenticoptions) -##### args? +##### modelPreflight? -> `optional` **args?**: `string`[] +> `optional` **modelPreflight?**: `false` \| ((`model`, `worker`, `signal`) => `Promise`\<`void`\>) -##### cwd? +Model availability check before the first benchmark phase. -> `optional` **cwd?**: `string` +A successful check is reused for the remaining phases in this evolution run. +See `BenchmarkConfig.modelPreflight`. -Working directory the server starts in (a built candidate's worktree, typically). +##### modelPreflightTimeoutMs? -##### env? +> `optional` **modelPreflightTimeoutMs?**: `number` -> `optional` **env?**: `Record`\<`string`, `string`\> +Maximum time for each model availability check. Default 30 seconds. -Declared public env for the server process. Only a minimal non-sensitive -subset of the parent env is inherited. +##### author -##### protectedEnv? +> **author**: [`EvolutionAuthor`](#evolutionauthor) -> `optional` **protectedEnv?**: `Record`\<`string`, `string`\> +##### budget? -Sensitive env for the server process. These values override `env` and are -redacted from child-supplied errors, tool metadata, and tool results. +> `optional` **budget?**: `number` -##### timeoutMs? +Rollouts (sample) / shots (refine) per strategy per task. Default 3. -> `optional` **timeoutMs?**: `number` +##### concurrency? -Handshake AND per-request timeout (ms). Default 30s. +> `optional` **concurrency?**: `number` -*** +##### generations? -### McpToolDescriptor +> `optional` **generations?**: `number` -#### Properties +Author→tournament rounds after gen0. Default 2. -##### name +##### populationSize? -> **name**: `string` +> `optional` **populationSize?**: `number` -##### description? +Authored candidates per generation. Default 2. -> `optional` **description?**: `string` +##### baselines? -##### inputSchema? +> `optional` **baselines?**: [`Strategy`](#strategy-3)\<[`StrategyResult`](#strategyresult-1)\>[] -> `optional` **inputSchema?**: `unknown` +The gen0 field. Default [sample, refine, sampleThenRefine]. -*** +##### objective? -### StdioMcpConnection +> `optional` **objective?**: `"score"` \| `"cost"` -#### Properties +What "better" means for PROMOTION. 'score' (default): the candidate must beat the + incumbent's score (superiority gate). 'cost': the candidate must prove score + NON-INFERIORITY (not worse by more than `scoreTolerance`) plus significant cost + savings — the "same quality, cheaper" objective. The author is told the objective + and sees per-task spend either way. -##### tools +##### scoreTolerance? -> `readonly` **tools**: readonly [`McpToolDescriptor`](#mcptooldescriptor)[] +> `optional` **scoreTolerance?**: `number` -The tools the server exposed at connect time (`tools/list`). +Cost objective: the score CI lower bound must clear −scoreTolerance. Default 0.05. -#### Methods +##### champion? -##### callTool() +> `optional` **champion?**: [`ChampionPolicy`](#championpolicy) -> **callTool**(`name`, `args`): `Promise`\<`string`\> +Search-side champion selection. Default 'costAware'. -`tools/call` → the result's text content. A JSON-RPC error / `isError` - result becomes an `ERROR: …` string (the agent's outcome); a dead - transport or timeout throws (an infra fault). +##### championEpsilon? -###### Parameters +> `optional` **championEpsilon?**: `number` -###### name +Score band treated as a tie under 'costAware'. Default 0.01. -`string` +##### outDir -###### args +> **outDir**: `string` -`Record`\<`string`, `unknown`\> +Where authored modules are written. -###### Returns +##### minPairedTasks? -`Promise`\<`string`\> +> `optional` **minPairedTasks?**: `number` -##### close() +Promotion-gate evidence floor (paired holdout tasks). -> **close**(): `Promise`\<`void`\> +##### band? -Kill the server child. Idempotent. +> `optional` **band?**: `object` -###### Returns +BAND-AWARE scoring — concentrate the measurement where lift is possible. + Holdout: draw `holdoutPoolN` candidate tasks and run `baselines[0]` once at the run + budget as an INDEPENDENT reference screen; keep tasks scoring ≤ `maxRefScore` + (headroom exists) and take the first `holdoutN`. Band membership is decided before + either finalist touches a task and both finalists then face the SAME tasks — the + estimand becomes "paired lift on headroom tasks", pre-registered by this config. + Train: champion selection ignores zero-spread tasks (every field strategy scored + identically — zero selection information, pure noise dilution). -`Promise`\<`void`\> +###### holdoutPoolN -*** +> **holdoutPoolN**: `number` -### MaterializeLocalMcpOptions +###### maxRefScore? -#### Properties +> `optional` **maxRefScore?**: `number` -##### timeoutMs? +Keep holdout tasks where the reference scores ≤ this. Default 0.99 — drop only + tasks the reference already solves fully (no headroom, a candidate can only tie). -> `optional` **timeoutMs?**: `number` +##### lossesDetail? -Handshake / per-request timeout per server (ms). Default 30s. +> `optional` **lossesDetail?**: `"exact"` \| `"binary"` -##### maxResultChars? +What the author learns from a tournament. 'exact' (default) = scores + progressions + per task; 'binary' = pass/fail only — the leakage-bounded channel (one bit per cell + per generation reaches the author from the evaluation data). -> `optional` **maxResultChars?**: `number` +##### reproducerCheck? -Cap on a tool result's text fed back to the worker. Default 2000 chars. +> `optional` **reproducerCheck?**: `object` -##### keys? +Reproducer certification (arXiv:2606.11045): when the final champion is AUTHORED, + compress it to a short natural-language summary, have a fresh author re-implement + from the summary alone (no losses, no code), and score the reproduction on the same + holdout. A reproduction gap is an overfitting signal (their detector: 100% + sensitivity / 91% specificity in the ML-agent setting) — recorded on the report, + never gate-blocking in v1. -> `optional` **keys?**: [`KeyProvider`](#keyprovider) +###### summaryMaxWords? -Resolves a server's DECLARED secrets at spawn time — env entries of kind - `secret-ref` (interface ≥0.40) and the legacy `metadata.secretEnv` map - (env var name → provider key name). The resolved values reach ONLY the - child process env — never the profile, the logs, or an error message. - Fail-closed: a server declaring secrets without a provider (or with a - missing key) throws instead of booting keyless. +> `optional` **summaryMaxWords?**: `number` -##### profileSecurityPolicy? +Word budget for the strategy summary. Default 64. -> `optional` **profileSecurityPolicy?**: `AgentProfileSecurityPolicy` +###### tolerance? -Required trust decision for profiles that declare local MCP processes. -Omit to refuse all profile-controlled host execution. Passing -`allowLocalMcp: true` is only safe for an author-controlled profile: the -process receives this Runtime's filesystem and network privileges. +> `optional` **tolerance?**: `number` -*** +Reproduction counts as faithful when reproducedScore ≥ championScore − tolerance. + Default 0.05. -### LocalMcpMaterialization +##### checkpoint? -The live same-host materialization of a profile's `mcp` surface. +> `optional` **checkpoint?**: `object` -#### Properties +Endurance: write the run state after every completed phase; with `resume`, a + restart skips completed phases (authored modules re-imported from their files). + Worst case after a mid-run death is re-paying ONE phase, never the run. -##### tools +###### path -> **tools**: [`AgenticTool`](#agentictool)[] +> **path**: `string` -Worker-facing tool specs: namespaced `__`, provider-safe schemas. +###### resume? -#### Methods +> `optional` **resume?**: `boolean` -##### owns() +##### onPhase? -> **owns**(`name`): `boolean` +> `optional` **onPhase?**: (`phase`) => `Promise`\<`void`\> -Whether `name` is one of this materialization's namespaced tools. +Called before each benchmark phase (gen0, gen1…, band-screen, holdout, reproduce). + The seam for environment recycling — no artifacts span phases, so a runner may + recreate a wedge-prone environment container here. ###### Parameters -###### name +###### phase `string` ###### Returns -`boolean` - -##### call() +`Promise`\<`void`\> -> **call**(`name`, `args`): `Promise`\<`string`\> +##### onTask? -Route a namespaced call to its server's live stdio child. +> `optional` **onTask?**: (`phase`, `row`, `done`, `total`) => `void` ###### Parameters -###### name +###### phase `string` -###### args - -`Record`\<`string`, `unknown`\> +###### row -###### Returns +[`BenchmarkTaskRow`](#benchmarktaskrow) -`Promise`\<`string`\> +###### done -##### close() +`number` -> **close**(): `Promise`\<`void`\> +###### total -Kill every spawned server. Idempotent. +`number` ###### Returns -`Promise`\<`void`\> +`void` + +##### hooks? + +> `optional` **hooks?**: [`RuntimeHooks`](index.md#runtimehooks) *** -### NaiveDriverOptions +### ChampionPick -Options for [naiveDriver](#naivedriver). +#### Properties -#### Type Parameters +##### name -##### Task +> **name**: `string` -`Task` +##### score -#### Properties +> **score**: `number` -##### continuation +##### usd -> **continuation**: `string` +> **usd**: `number` -The fixed continuation issued every round after shot 0. The same string is -sent whether the prior shot passed inspection or not — the naive driver -reads no part of the verdict. Domain text is the caller's; the substrate -supplies none. +*** -##### applyContinuation +### EvolutionCandidate -> **applyContinuation**: [`ApplyContinuation`](#applycontinuation)\<`Task`\> +#### Properties -Folds `continuation` into the caller's Task shape for the next shot. +##### name -##### maxIterations +> **name**: `string` -> **maxIterations**: `number` +##### file? -Hard shot cap. The loop stops refining once history reaches this length. +> `optional` **file?**: `string` -##### name? +##### gzipBits? -> `optional` **name?**: `string` +> `optional` **gzipBits?**: `number` -Trace-event identifier. Default `'naive'`. +##### codeChars? -*** +> `optional` **codeChars?**: `number` -### DumbDriverOptions +##### error? -Options for [dumbDriver](#dumbdriver). +> `optional` **error?**: `string` -#### Type Parameters +Present when this author attempt failed (recorded, never silent). -##### Task +*** -`Task` +### EvolutionGeneration #### Properties -##### onPass +##### generation -> **onPass**: `string` +> **generation**: `number` -Continuation issued when the prior shot's verdict is valid. In a -stop-on-pass loop this is rarely reached (a valid shot ends the loop), but -it is required so the driver is total over the pass/fail bit; pass a -confirmation/keep-going string. +##### candidates -##### onFail +> **candidates**: [`EvolutionCandidate`](#evolutioncandidate)[] -> **onFail**: `string` +##### report -Continuation issued when the prior shot's verdict is NOT valid. +> **report**: [`BenchmarkReport`](#benchmarkreport) -##### applyContinuation +##### champion -> **applyContinuation**: [`ApplyContinuation`](#applycontinuation)\<`Task`\> +> **champion**: [`ChampionPick`](#championpick) -Folds the chosen continuation into the caller's Task shape. +*** -##### maxIterations +### EvolutionArchiveNode -> **maxIterations**: `number` +#### Properties -Hard shot cap. The loop stops refining once history reaches this length. +##### name -##### name? +> **name**: `string` -> `optional` **name?**: `string` +##### source -Trace-event identifier. Default `'dumb'`. +> **source**: `"baseline"` \| `"authored"` -*** +##### generation -### AuthorStrategyOptions +> **generation**: `number` -#### Properties +##### parent? -##### chat +> `optional` **parent?**: `string` -> **chat**: `ChatClient` +The champion whose tournament losses this candidate was authored from. -The model-call seam (agent-eval `createChatClient`). +##### gzipBits? -##### model? +> `optional` **gzipBits?**: `number` -> `optional` **model?**: `string` +##### file? -##### fallbackModel? +> `optional` **file?**: `string` + +##### score + +> **score**: `number` + +Latest measured tournament result — 0 until the node's first tournament settles + (an authored node is created before its generation's benchmark runs). + +##### usd -> `optional` **fallbackModel?**: `string` +> **usd**: `number` -A NAMED fallback author tried once when the primary call fails or returns no code - block (thinking models time out at the edge on long authoring prompts, or return - empty content without `maxTokens`). Opt-in — absent means the primary's failure - propagates. +*** -##### contract? +### ReproductionCheck -> `optional` **contract?**: `string` +#### Properties -The contract text shown to the author. Default `strategyAuthorContract`. The - meta-optimization coordinate: a GEPA/skill loop can evolve this text and gate each - variant on the same frozen holdout as any strategy. +##### summary -##### environmentName +> **summary**: `string` -> **environmentName**: `string` +The compressed strategy description the reproducer implemented from. -The environment the losses came from (orientation only — never the verifiers). +##### reproducedName -##### lossesJson +> **reproducedName**: `string` -> **lossesJson**: `string` +##### file? -The per-task losses table (e.g. JSON.stringify(report.perTask)) — the gradient. +> `optional` **file?**: `string` -##### budget +##### championHoldoutScore -> **budget**: `number` +> **championHoldoutScore**: `number` -The budget the strategy must respect (shots/width). +##### reproducedHoldoutScore -##### outDir +> **reproducedHoldoutScore**: `number` -> **outDir**: `string` +##### gap -Where the authored module file is written (created if missing). +> **gap**: `number` -##### temperature? +champion − reproduced (positive = the reproduction fell short). -> `optional` **temperature?**: `number` +##### reproducible -##### maxTokens? +> **reproducible**: `boolean` -> `optional` **maxTokens?**: `number` +reproducedScore ≥ championScore − tolerance. A failed reproduction is an + overfitting signal: the champion's win did not fit through the summary. -Completion cap — required by thinking-model authors that stream reasoning first. +##### error? -##### signal? +> `optional` **error?**: `string` -> `optional` **signal?**: `AbortSignal` +Infra failure during reproduction (distinct from a semantic reproduction failure). *** -### AuthoredStrategy +### EvolutionBandInfo #### Properties -##### strategy +##### screened -> **strategy**: [`Strategy`](#strategy-3) +> **screened**: `number` -##### file +Tasks screened by the reference on the holdout pool. -> **file**: `string` +##### inBand -##### code +> **inBand**: `number` -> **code**: `string` +Tasks kept (reference score ≤ maxRefScore) before truncating to holdoutN. -*** +##### refScores -### EvolutionAuthor +> **refScores**: `object`[] -#### Properties +Reference scores per screened task (the screening record). -##### chat +###### taskId -> **chat**: `ChatClient` +> **taskId**: `string` -The model-call seam (agent-eval `createChatClient`). +###### score -##### model? +> **score**: `number` -> `optional` **model?**: `string` +*** -##### fallbackModel? +### EvolutionReport -> `optional` **fallbackModel?**: `string` +#### Properties -##### temperature? +##### gen0 -> `optional` **temperature?**: `number` +> **gen0**: [`BenchmarkReport`](#benchmarkreport) -##### maxTokens? +##### gen0Champion -> `optional` **maxTokens?**: `number` +> **gen0Champion**: [`ChampionPick`](#championpick) -*** +##### generations -### StrategyEvolutionConfig +> **generations**: [`EvolutionGeneration`](#evolutiongeneration)[] -#### Properties +##### archive -##### environment +> **archive**: [`EvolutionArchiveNode`](#evolutionarchivenode)[] -> **environment**: [`AgenticSurface`](#agenticsurface) +##### finalChampion -##### tasks +> **finalChampion**: [`ChampionPick`](#championpick) -> **tasks**: (`offset`, `n`) => `Promise`\<[`AgenticTask`](#agentictask)[]\> +##### holdout -Task supply by DISJOINT slice: `(offset, n)` must return n tasks unique to that - offset range. Train draws [0, trainN); the holdout draws [trainN + holdoutOffset, - …) — tasks the search never touched. +> **holdout**: [`BenchmarkReport`](#benchmarkreport) -###### Parameters +##### verdict -###### offset +> **verdict**: [`PromotionVerdict`](#promotionverdict) -`number` +##### band? -###### n +> `optional` **band?**: [`EvolutionBandInfo`](#evolutionbandinfo) -`number` +Present when band screening ran — the verdict's estimand is then "paired lift on + headroom tasks" (band membership fixed by the reference screen, pre-registered). -###### Returns +##### reproduction? -`Promise`\<[`AgenticTask`](#agentictask)[]\> +> `optional` **reproduction?**: [`ReproductionCheck`](#reproductioncheck) -##### trainN +Present when reproducerCheck ran (final champion was authored). -> **trainN**: `number` +##### trajectory -##### holdoutN +> **trajectory**: `object`[] -> **holdoutN**: `number` +SEARCH TELEMETRY, not evidence: each entry is that generation's own train-slice + re-measurement, so cross-generation deltas mix true drift with run-to-run variance + (entries are unpaired across generations). The only evidence-grade comparison in + this report is `verdict` — both finalists measured fresh, paired, on the holdout. -##### holdoutOffset? +###### generation -> `optional` **holdoutOffset?**: `number` +> **generation**: `number` -Extra offset past the train slice for the holdout draw (rotate across runs). +###### champion -##### worker +> **champion**: `string` -> **worker**: [`AgenticOptions`](#agenticoptions) +###### score -##### modelPreflight? +> **score**: `number` -> `optional` **modelPreflight?**: `false` \| ((`model`, `worker`, `signal`) => `Promise`\<`void`\>) +###### usd -Model availability check before the first benchmark phase. +> **usd**: `number` -A successful check is reused for the remaining phases in this evolution run. -See `BenchmarkConfig.modelPreflight`. +*** -##### modelPreflightTimeoutMs? +### AgenticTask -> `optional` **modelPreflightTimeoutMs?**: `number` +#### Properties -Maximum time for each model availability check. Default 30 seconds. +##### id -##### author +> `readonly` **id**: `string` -> **author**: [`EvolutionAuthor`](#evolutionauthor) +##### systemPrompt -##### budget? +> `readonly` **systemPrompt**: `string` -> `optional` **budget?**: `number` +##### userPrompt -Rollouts (sample) / shots (refine) per strategy per task. Default 3. +> `readonly` **userPrompt**: `string` -##### concurrency? +##### meta? -> `optional` **concurrency?**: `number` +> `readonly` `optional` **meta?**: `Record`\<`string`, `unknown`\> -##### generations? +Opaque domain payload the surface reads (EOPS: servers/verifiers/tools). Drivers never read it. -> `optional` **generations?**: `number` +*** -Author→tournament rounds after gen0. Default 2. +### ArtifactHandle -##### populationSize? +#### Properties -> `optional` **populationSize?**: `number` +##### id -Authored candidates per generation. Default 2. +> `readonly` **id**: `string` -##### baselines? +##### surface -> `optional` **baselines?**: [`Strategy`](#strategy-3)\<[`StrategyResult`](#strategyresult-1)\>[] +> `readonly` **surface**: `string` -The gen0 field. Default [sample, refine, sampleThenRefine]. +##### ctx? -##### objective? +> `readonly` `optional` **ctx?**: `unknown` -> `optional` **objective?**: `"score"` \| `"cost"` +Opaque per-artifact context the surface stashes (EOPS: the seeded gym server + db id). -What "better" means for PROMOTION. 'score' (default): the candidate must beat the - incumbent's score (superiority gate). 'cost': the candidate must prove score - NON-INFERIORITY (not worse by more than `scoreTolerance`) plus significant cost - savings — the "same quality, cheaper" objective. The author is told the objective - and sees per-task spend either way. +*** -##### scoreTolerance? +### AgenticTool -> `optional` **scoreTolerance?**: `number` +#### Properties -Cost objective: the score CI lower bound must clear −scoreTolerance. Default 0.05. +##### type -##### champion? +> `readonly` **type**: `"function"` -> `optional` **champion?**: [`ChampionPolicy`](#championpolicy) +##### function -Search-side champion selection. Default 'costAware'. +> `readonly` **function**: `object` -##### championEpsilon? +###### name -> `optional` **championEpsilon?**: `number` +> **name**: `string` -Score band treated as a tie under 'costAware'. Default 0.01. +###### description? -##### outDir +> `optional` **description?**: `string` -> **outDir**: `string` +###### parameters -Where authored modules are written. +> **parameters**: `Record`\<`string`, `unknown`\> -##### minPairedTasks? +*** -> `optional` **minPairedTasks?**: `number` +### SurfaceScore -Promotion-gate evidence floor (paired holdout tasks). +#### Properties -##### band? +##### passes -> `optional` **band?**: `object` +> **passes**: `number` -BAND-AWARE scoring — concentrate the measurement where lift is possible. - Holdout: draw `holdoutPoolN` candidate tasks and run `baselines[0]` once at the run - budget as an INDEPENDENT reference screen; keep tasks scoring ≤ `maxRefScore` - (headroom exists) and take the first `holdoutN`. Band membership is decided before - either finalist touches a task and both finalists then face the SAME tasks — the - estimand becomes "paired lift on headroom tasks", pre-registered by this config. - Train: champion selection ignores zero-spread tasks (every field strategy scored - identically — zero selection information, pure noise dilution). +##### total -###### holdoutPoolN +> **total**: `number` + +##### errored + +> **errored**: `number` -> **holdoutPoolN**: `number` +Checks excluded as malformed (data defect, not the agent). `total === 0` ⇒ unscoreable. -###### maxRefScore? +*** -> `optional` **maxRefScore?**: `number` +### AgenticSurface -Keep holdout tasks where the reference scores ≤ this. Default 0.99 — drop only - tasks the reference already solves fully (no headroom, a candidate can only tie). +A stateful, checkable environment an agent operates over with tools. Open behind one interface. -##### lossesDetail? +#### Properties -> `optional` **lossesDetail?**: `"exact"` \| `"binary"` +##### name -What the author learns from a tournament. 'exact' (default) = scores + progressions - per task; 'binary' = pass/fail only — the leakage-bounded channel (one bit per cell - per generation reaches the author from the evaluation data). +> `readonly` **name**: `string` -##### reproducerCheck? +#### Methods -> `optional` **reproducerCheck?**: `object` +##### open() -Reproducer certification (arXiv:2606.11045): when the final champion is AUTHORED, - compress it to a short natural-language summary, have a fresh author re-implement - from the summary alone (no losses, no code), and score the reproduction on the same - holdout. A reproduction gap is an overfitting signal (their detector: 100% - sensitivity / 91% specificity in the ML-agent setting) — recorded on the report, - never gate-blocking in v1. +> **open**(`task`): `Promise`\<[`ArtifactHandle`](#artifacthandle)\> -###### summaryMaxWords? +###### Parameters -> `optional` **summaryMaxWords?**: `number` +###### task -Word budget for the strategy summary. Default 64. +[`AgenticTask`](#agentictask) -###### tolerance? +###### Returns -> `optional` **tolerance?**: `number` +`Promise`\<[`ArtifactHandle`](#artifacthandle)\> -Reproduction counts as faithful when reproducedScore ≥ championScore − tolerance. - Default 0.05. +##### tools() -##### checkpoint? +> **tools**(`task`, `handle`): `Promise`\<[`AgenticTool`](#agentictool)[]\> -> `optional` **checkpoint?**: `object` +###### Parameters -Endurance: write the run state after every completed phase; with `resume`, a - restart skips completed phases (authored modules re-imported from their files). - Worst case after a mid-run death is re-paying ONE phase, never the run. +###### task -###### path +[`AgenticTask`](#agentictask) -> **path**: `string` +###### handle -###### resume? +[`ArtifactHandle`](#artifacthandle) -> `optional` **resume?**: `boolean` +###### Returns -##### onPhase? +`Promise`\<[`AgenticTool`](#agentictool)[]\> -> `optional` **onPhase?**: (`phase`) => `Promise`\<`void`\> +##### call() -Called before each benchmark phase (gen0, gen1…, band-screen, holdout, reproduce). - The seam for environment recycling — no artifacts span phases, so a runner may - recreate a wedge-prone environment container here. +> **call**(`handle`, `name`, `args`): `Promise`\<`string`\> ###### Parameters -###### phase +###### handle -`string` +[`ArtifactHandle`](#artifacthandle) -###### Returns +###### name -`Promise`\<`void`\> +`string` -##### onTask? +###### args -> `optional` **onTask?**: (`phase`, `row`, `done`, `total`) => `void` +`Record`\<`string`, `unknown`\> -###### Parameters +###### Returns -###### phase +`Promise`\<`string`\> -`string` +##### score() -###### row +> **score**(`task`, `handle`): `Promise`\<[`SurfaceScore`](#surfacescore)\> -[`BenchmarkTaskRow`](#benchmarktaskrow) +###### Parameters -###### done +###### task -`number` +[`AgenticTask`](#agentictask) -###### total +###### handle -`number` +[`ArtifactHandle`](#artifacthandle) ###### Returns -`void` +`Promise`\<[`SurfaceScore`](#surfacescore)\> -##### hooks? +##### close() -> `optional` **hooks?**: [`RuntimeHooks`](index.md#runtimehooks) +> **close**(`handle`): `Promise`\<`void`\> -*** +###### Parameters -### ChampionPick +###### handle -#### Properties +[`ArtifactHandle`](#artifacthandle) -##### name +###### Returns -> **name**: `string` +`Promise`\<`void`\> -##### score +*** -> **score**: `number` +### AgenticOptions -##### usd +#### Extended by -> **usd**: `number` +- [`RunAgenticOptions`](#runagenticoptions) -*** +#### Properties -### EvolutionCandidate +##### routerBaseUrl -#### Properties +> **routerBaseUrl**: `string` -##### name +##### routerKey -> **name**: `string` +> **routerKey**: `string` -##### file? +##### model -> `optional` **file?**: `string` +> **model**: `string` -##### gzipBits? +##### complete? -> `optional` **gzipBits?**: `number` +> `optional` **complete?**: (`body`) => `Promise`\<`unknown`\> -##### codeChars? +Optional completion transport (see `RouterConfig.complete`): when set, BOTH legs of an + offline run use it instead of `fetch`-ing the router — the worker's tool loop (threaded into + its `routerToolLoop` cfg) AND the analyst's critic (its `ChatClient` is bound to this same + transport). One injected responder serves both, as a localhost mock endpoint would. Absent ⇒ + the live router fetch path (the default). -> `optional` **codeChars?**: `number` +###### Parameters -##### error? +###### body -> `optional` **error?**: `string` +`Record`\<`string`, `unknown`\> -Present when this author attempt failed (recorded, never silent). +###### Returns -*** +`Promise`\<`unknown`\> -### EvolutionGeneration +##### temperature? -#### Properties +> `optional` **temperature?**: `number` -##### generation +##### maxTokens? -> **generation**: `number` +> `optional` **maxTokens?**: `number` -##### candidates +Completion cap per worker turn — REQUIRED for thinking models (they burn unbounded + budgets on reasoning and return empty content without it). Omitted ⇒ provider default. -> **candidates**: [`EvolutionCandidate`](#evolutioncandidate)[] +##### innerTurns? -##### report +> `optional` **innerTurns?**: `number` -> **report**: [`BenchmarkReport`](#benchmarkreport) +Turns the agent may take within ONE shot before the driver intervenes. -##### champion +##### analystInstruction? -> **champion**: [`ChampionPick`](#championpick) +> `optional` **analystInstruction?**: `string` -*** +The depth STEERER's analyst instruction (observe()'s system prompt). The knob a + prompt optimizer (GEPA) tunes — the analyst IS the steerer. Omitted ⇒ the default. -### EvolutionArchiveNode +##### analystModel? -#### Properties +> `optional` **analystModel?**: `string` -##### name +The critic's model — lets the analyst be a stronger (or cheaper) model than the + worker. Omitted ⇒ the worker's `model`. -> **name**: `string` +##### corpus? -##### source +> `optional` **corpus?**: [`Corpus`](#corpus-2) -> **source**: `"baseline"` \| `"authored"` +Across-run learning: when set, the analyst's observe() pass appends trace-derived + facts here (the flywheel write side). Read-back is opt-in via `corpusReadback` + because unconditional priming can pollute context on some domains. -##### generation +##### corpusTags? -> **generation**: `number` +> `optional` **corpusTags?**: `string`[] -##### parent? +Tags written onto learned facts (and used by the caller's priming query). -> `optional` **parent?**: `string` +##### corpusReadback? -The champion whose tournament losses this candidate was authored from. +> `optional` **corpusReadback?**: [`CorpusReadbackOptions`](#corpusreadbackoptions) -##### gzipBits? +In-context learning: when set, query `corpus` before each depth shot and inject + the top trace-derived facts as guidance for the active run. No corpus means no read-back. -> `optional` **gzipBits?**: `number` +*** -##### file? +### CorpusReadbackOptions -> `optional` **file?**: `string` +#### Properties -##### score +##### minConfidence? -> **score**: `number` +> `optional` **minConfidence?**: `number` -Latest measured tournament result — 0 until the node's first tournament settles - (an authored node is created before its generation's benchmark runs). +Minimum confidence for a fact to be injected. Default 0.7. -##### usd +##### tags? -> **usd**: `number` +> `optional` **tags?**: readonly `string`[] -*** +Extra tags a fact must carry, in addition to `corpusTags`. -### ReproductionCheck +##### maxFacts? -#### Properties +> `optional` **maxFacts?**: `number` -##### summary +Max facts injected per shot. Default 3. -> **summary**: `string` +##### includeOperatorFacts? -The compressed strategy description the reproducer implemented from. +> `optional` **includeOperatorFacts?**: `boolean` -##### reproducedName +Default false: only facts tagged `audience:agent` are injected into the worker. -> **reproducedName**: `string` +*** -##### file? +### StrategyShotResult -> `optional` **file?**: `string` +Measured result of one strategy shot. -##### championHoldoutScore +#### Properties -> **championHoldoutScore**: `number` +##### messages -##### reproducedHoldoutScore +> **messages**: [`StrategyMessage`](#strategymessage)[] -> **reproducedHoldoutScore**: `number` +##### score -##### gap +> **score**: `number` -> **gap**: `number` +##### passes -champion − reproduced (positive = the reproduction fell short). +> **passes**: `number` -##### reproducible +##### total -> **reproducible**: `boolean` +> **total**: `number` -reproducedScore ≥ championScore − tolerance. A failed reproduction is an - overfitting signal: the champion's win did not fit through the summary. +##### completions -##### error? +> **completions**: `number` -> `optional` **error?**: `string` +##### toolErrors -Infra failure during reproduction (distinct from a semantic reproduction failure). +> **toolErrors**: `number` *** -### EvolutionBandInfo +### AgenticRunResult #### Properties -##### screened +##### mode -> **screened**: `number` +> **mode**: `string` -Tasks screened by the reference on the holdout pool. +The strategy name (built-in 'depth'/'breadth' or a custom strategy's name). -##### inBand +##### score -> **inBand**: `number` +> **score**: `number` -Tasks kept (reference score ≤ maxRefScore) before truncating to holdoutN. +##### resolved -##### refScores +> **resolved**: `boolean` -> **refScores**: `object`[] +##### completions -Reference scores per screened task (the screening record). +> **completions**: `number` -###### taskId +##### progression -> **taskId**: `string` +> **progression**: `number`[] -###### score +DEPTH: score after each shot — the progress-over-rounds curve. BREADTH: best-so-far per rollout. -> **score**: `number` +##### shots -*** +> **shots**: `number` -### EvolutionReport +##### usd -#### Properties +> **usd**: `number` -##### gen0 +The cost vector, stamped by `runAgentic` from the Supervisor's conserved pool: real + router tokens, priced usd (0 when the model is unpriced — never fabricated), wall ms. -> **gen0**: [`BenchmarkReport`](#benchmarkreport) +##### ms -##### gen0Champion +> **ms**: `number` -> **gen0Champion**: [`ChampionPick`](#championpick) +##### tokens -##### generations +> **tokens**: `object` -> **generations**: [`EvolutionGeneration`](#evolutiongeneration)[] +###### input -##### archive +> **input**: `number` -> **archive**: [`EvolutionArchiveNode`](#evolutionarchivenode)[] +###### output -##### finalChampion +> **output**: `number` -> **finalChampion**: [`ChampionPick`](#championpick) +*** -##### holdout +### Strategy -> **holdout**: [`BenchmarkReport`](#benchmarkreport) +#### Type Parameters -##### verdict +##### Result -> **verdict**: [`PromotionVerdict`](#promotionverdict) +`Result` *extends* [`StrategyResult`](#strategyresult-1) = [`StrategyResult`](#strategyresult-1) -##### band? +#### Properties -> `optional` **band?**: [`EvolutionBandInfo`](#evolutionbandinfo) +##### name -Present when band screening ran — the verdict's estimand is then "paired lift on - headroom tasks" (band membership fixed by the reference screen, pre-registered). +> `readonly` **name**: `string` -##### reproduction? +#### Methods -> `optional` **reproduction?**: [`ReproductionCheck`](#reproductioncheck) +##### driver() -Present when reproducerCheck ran (final champion was authored). +> **driver**(`surface`, `task`, `opts`, `budget`): [`Agent`](#agent-1)\<`unknown`, [`Outcome`](#outcome-2)\<`unknown`\>\> -##### trajectory +###### Parameters -> **trajectory**: `object`[] +###### surface -SEARCH TELEMETRY, not evidence: each entry is that generation's own train-slice - re-measurement, so cross-generation deltas mix true drift with run-to-run variance - (entries are unpaired across generations). The only evidence-grade comparison in - this report is `verdict` — both finalists measured fresh, paired, on the holdout. +[`AgenticSurface`](#agenticsurface) -###### generation +###### task -> **generation**: `number` +[`AgenticTask`](#agentictask) -###### champion +###### opts -> **champion**: `string` +[`AgenticOptions`](#agenticoptions) -###### score +###### budget -> **score**: `number` +`number` -###### usd +###### Returns -> **usd**: `number` +[`Agent`](#agent-1)\<`unknown`, [`Outcome`](#outcome-2)\<`unknown`\>\> *** -### AgenticTask - -#### Properties - -##### id +### ShotPersona -> `readonly` **id**: `string` +A role for one shot — multi-agent loops (researcher + engineer, a panel of k + researchers) give each shot its own system prompt and optionally its own model. -##### systemPrompt +#### Properties -> `readonly` **systemPrompt**: `string` +##### systemPrompt? -##### userPrompt +> `optional` **systemPrompt?**: `string` -> `readonly` **userPrompt**: `string` +Replaces the task's systemPrompt for a FRESH shot; on a carried conversation it is + injected as a hand-off message (the transcript's earlier roles stay intact). -##### meta? +##### model? -> `readonly` `optional` **meta?**: `Record`\<`string`, `unknown`\> +> `optional` **model?**: `string` -Opaque domain payload the surface reads (EOPS: servers/verifiers/tools). Drivers never read it. +Per-shot model override (e.g. a stronger model for the engineer shot). *** -### ArtifactHandle +### ShotSpec #### Properties -##### id - -> `readonly` **id**: `string` - -##### surface - -> `readonly` **surface**: `string` +##### handle? -##### ctx? +> `optional` **handle?**: [`ArtifactHandle`](#artifacthandle) -> `readonly` `optional` **ctx?**: `unknown` +present ⇒ continue this artifact (depth); absent ⇒ the shot opens a fresh one (sample/restart). -Opaque per-artifact context the surface stashes (EOPS: the seeded gym server + db id). +##### messages? -*** +> `optional` **messages?**: [`StrategyMessage`](#strategymessage)[] -### AgenticTool +##### steer? -#### Properties +> `optional` **steer?**: `string` -##### type +##### persona? -> `readonly` **type**: `"function"` +> `optional` **persona?**: [`ShotPersona`](#shotpersona) -##### function +##### tools? -> `readonly` **function**: `object` +> `optional` **tools?**: `string`[] -###### name +Restrict THIS shot to a subset of the domain's tools (by name) — focus a shot on + the relevant capabilities. Restriction-only; unknown names throw. Omitted ⇒ all. -> **name**: `string` +*** -###### description? +### StrategyResult -> `optional` **description?**: `string` +#### Extended by -###### parameters +- [`StructuralRolloutResult`](#structuralrolloutresult) -> **parameters**: `Record`\<`string`, `unknown`\> +#### Properties -*** +##### score -### SurfaceScore +> **score**: `number` -#### Properties +##### resolved -##### passes +> **resolved**: `boolean` -> **passes**: `number` +##### completions -##### total +> **completions**: `number` -> **total**: `number` +##### progression -##### errored +> **progression**: `number`[] -> **errored**: `number` +##### shots -Checks excluded as malformed (data defect, not the agent). `total === 0` ⇒ unscoreable. +> **shots**: `number` *** -### AgenticSurface +### StrategyArtifacts -A stateful, checkable environment an agent operates over with tools. Open behind one interface. +Artifact lifecycle a strategy may manage itself — open/close ONLY. Raw `call`/`score` + are withheld: scores reach the body solely through `shot()`'s StrategyShotResult (the + harness-verified channel), so a body cannot peek the check or fabricate around it. #### Properties @@ -7557,67 +8482,114 @@ A stateful, checkable environment an agent operates over with tools. Open behind `Promise`\<[`ArtifactHandle`](#artifacthandle)\> -##### tools() +##### close() -> **tools**(`task`, `handle`): `Promise`\<[`AgenticTool`](#agentictool)[]\> +> **close**(`handle`): `Promise`\<`void`\> ###### Parameters -###### task - -[`AgenticTask`](#agentictask) - ###### handle [`ArtifactHandle`](#artifacthandle) ###### Returns -`Promise`\<[`AgenticTool`](#agentictool)[]\> +`Promise`\<`void`\> + +*** + +### StrategyCtx + +What a strategy body composes with: the artifact lifecycle, the budget, and the two steps. + +#### Properties + +##### surface + +> `readonly` **surface**: [`StrategyArtifacts`](#strategyartifacts) + +Open/close artifacts the body manages itself (e.g. one persistent handle for depth). + +##### task + +> `readonly` **task**: [`AgenticTask`](#agentictask) + +##### opts + +> `readonly` **opts**: [`AgenticOptions`](#agenticoptions) + +##### budget + +> `readonly` **budget**: `number` + +##### scope + +> `readonly` **scope**: [`Scope`](index.md#scope)\<[`Outcome`](#outcome-2)\<`unknown`\>\> + +#### Methods + +##### shot() + +> **shot**(`spec?`): `Promise`\<[`StrategyShotResult`](#strategyshotresult) \| `null`\> + +Run ONE worker shot; its harness-scored result, or null if it went down. + +###### Parameters + +###### spec? -##### call() +[`ShotSpec`](#shotspec) -> **call**(`handle`, `name`, `args`): `Promise`\<`string`\> +###### Returns -###### Parameters +`Promise`\<[`StrategyShotResult`](#strategyshotresult) \| `null`\> -###### handle +##### critique() -[`ArtifactHandle`](#artifacthandle) +> **critique**(`messages`): `Promise`\<`string` \| `null`\> -###### name +The firewalled critic reads the trajectory → a steer string, or null on COMPLETE/down. -`string` +###### Parameters -###### args +###### messages -`Record`\<`string`, `unknown`\> +[`StrategyMessage`](#strategymessage)[] ###### Returns -`Promise`\<`string`\> +`Promise`\<`string` \| `null`\> -##### score() +##### consult() -> **score**(`task`, `handle`): `Promise`\<[`SurfaceScore`](#surfacescore)\> +> **consult**(`messages`, `instruction`): `Promise`\<`string` \| `null`\> + +The RAW analyst channel: the firewalled critic answers `instruction` over the + trajectory verbatim — no findings extraction, so verdict-shaped formats + (CONTINUE/STOP decisions, calibrated predictions) survive. Same firewall: + trajectory in, never scores. Null when the analyst went down. ###### Parameters -###### task +###### messages -[`AgenticTask`](#agentictask) +[`StrategyMessage`](#strategymessage)[] -###### handle +###### instruction -[`ArtifactHandle`](#artifacthandle) +`string` ###### Returns -`Promise`\<[`SurfaceScore`](#surfacescore)\> +`Promise`\<`string` \| `null`\> -##### close() +##### listTools() -> **close**(`handle`): `Promise`\<`void`\> +> **listTools**(`handle`): `Promise`\<`object`[]\> + +The tools THIS artifact's task actually offers (names + descriptions only — never + the implementations). Tool sets vary per task on heterogeneous domains; a strategy + that restricts shots MUST select from this list, never from hardcoded names. ###### Parameters @@ -7627,15 +8599,21 @@ A stateful, checkable environment an agent operates over with tools. Open behind ###### Returns -`Promise`\<`void`\> +`Promise`\<`object`[]\> *** -### AgenticOptions +### RunAgenticOptions -#### Extended by +#### Extends -- [`RunAgenticOptions`](#runagenticoptions) +- [`AgenticOptions`](#agenticoptions) + +#### Type Parameters + +##### Result + +`Result` *extends* [`StrategyResult`](#strategyresult-1) = [`StrategyResult`](#strategyresult-1) #### Properties @@ -7643,14 +8621,26 @@ A stateful, checkable environment an agent operates over with tools. Open behind > **routerBaseUrl**: `string` +###### Inherited from + +[`AgenticOptions`](#agenticoptions).[`routerBaseUrl`](#routerbaseurl-1) + ##### routerKey > **routerKey**: `string` +###### Inherited from + +[`AgenticOptions`](#agenticoptions).[`routerKey`](#routerkey-1) + ##### model > **model**: `string` +###### Inherited from + +[`AgenticOptions`](#agenticoptions).[`model`](#model-7) + ##### complete? > `optional` **complete?**: (`body`) => `Promise`\<`unknown`\> @@ -7671,10 +8661,18 @@ Optional completion transport (see `RouterConfig.complete`): when set, BOTH legs `Promise`\<`unknown`\> +###### Inherited from + +[`AgenticOptions`](#agenticoptions).[`complete`](#complete-1) + ##### temperature? > `optional` **temperature?**: `number` +###### Inherited from + +[`AgenticOptions`](#agenticoptions).[`temperature`](#temperature-3) + ##### maxTokens? > `optional` **maxTokens?**: `number` @@ -7682,12 +8680,20 @@ Optional completion transport (see `RouterConfig.complete`): when set, BOTH legs Completion cap per worker turn — REQUIRED for thinking models (they burn unbounded budgets on reasoning and return empty content without it). Omitted ⇒ provider default. +###### Inherited from + +[`AgenticOptions`](#agenticoptions).[`maxTokens`](#maxtokens-3) + ##### innerTurns? > `optional` **innerTurns?**: `number` Turns the agent may take within ONE shot before the driver intervenes. +###### Inherited from + +[`AgenticOptions`](#agenticoptions).[`innerTurns`](#innerturns) + ##### analystInstruction? > `optional` **analystInstruction?**: `string` @@ -7695,6 +8701,10 @@ Turns the agent may take within ONE shot before the driver intervenes. The depth STEERER's analyst instruction (observe()'s system prompt). The knob a prompt optimizer (GEPA) tunes — the analyst IS the steerer. Omitted ⇒ the default. +###### Inherited from + +[`AgenticOptions`](#agenticoptions).[`analystInstruction`](#analystinstruction-2) + ##### analystModel? > `optional` **analystModel?**: `string` @@ -7702,6 +8712,10 @@ The depth STEERER's analyst instruction (observe()'s system prompt). The knob a The critic's model — lets the analyst be a stronger (or cheaper) model than the worker. Omitted ⇒ the worker's `model`. +###### Inherited from + +[`AgenticOptions`](#agenticoptions).[`analystModel`](#analystmodel) + ##### corpus? > `optional` **corpus?**: [`Corpus`](#corpus-2) @@ -7710,2430 +8724,2592 @@ Across-run learning: when set, the analyst's observe() pass appends trace-derive facts here (the flywheel write side). Read-back is opt-in via `corpusReadback` because unconditional priming can pollute context on some domains. +###### Inherited from + +[`AgenticOptions`](#agenticoptions).[`corpus`](#corpus-4) + ##### corpusTags? > `optional` **corpusTags?**: `string`[] Tags written onto learned facts (and used by the caller's priming query). -##### corpusReadback? +###### Inherited from + +[`AgenticOptions`](#agenticoptions).[`corpusTags`](#corpustags) + +##### corpusReadback? + +> `optional` **corpusReadback?**: [`CorpusReadbackOptions`](#corpusreadbackoptions) + +In-context learning: when set, query `corpus` before each depth shot and inject + the top trace-derived facts as guidance for the active run. No corpus means no read-back. + +###### Inherited from + +[`AgenticOptions`](#agenticoptions).[`corpusReadback`](#corpusreadback) + +##### surface + +> **surface**: [`AgenticSurface`](#agenticsurface) + +##### task + +> **task**: [`AgenticTask`](#agentictask) + +##### hooks? + +> `optional` **hooks?**: [`RuntimeHooks`](index.md#runtimehooks) + +Lifecycle observability — every spawn/settle (shots, analysts) streams here live. + The seam online watchdogs/route-auditors subscribe to. + +##### strategy? + +> `optional` **strategy?**: [`Strategy`](#strategy-3)\<`Result`\> + +A Strategy (the open way) — author/pass your own. Overrides `mode` when present. + +##### mode? + +> `optional` **mode?**: `"depth"` \| `"breadth"` + +Built-in shorthand: 'depth'→refine, 'breadth'→sample. Default 'depth'. + +##### budget + +> **budget**: `number` + +budget: refine→max shots; sample→rollout width. + +##### rootBudget? + +> `optional` **rootBudget?**: [`Budget`](index.md#budget-4) + +*** + +### StreamAgentTurnOptions + +**`Experimental`** + +#### Properties + +##### signal? + +> `optional` **signal?**: `AbortSignal` + +**`Experimental`** + +Caller-initiated cancellation. Terminates the stream with `final.status: 'aborted'`. + +##### timeoutMs? + +> `optional` **timeoutMs?**: `number` + +**`Experimental`** + +Wall-clock deadline for the whole turn in ms. An expired deadline aborts +the backend and terminates the stream with `final.status: 'failed'` +(a blown deadline is a turn failure, not a caller cancellation). + +##### preserveToolParts? + +> `optional` **preserveToolParts?**: `boolean` + +**`Experimental`** + +Opt-in tool-part projection for box and executor backends: sandbox tool +parts additionally surface in-stream as +`tool_call` / `tool_result` events (`mapSandboxToolEvent`), so a consumer +rendering tool activity needs no bespoke sandbox-event parser. Default +off — the stream vocabulary existing consumers see is unchanged. No-op +for the `chat` kind (its backend emits `RuntimeStreamEvent`s directly, +tool events included when the backend produces them). + +##### onRawEvent? + +> `optional` **onRawEvent?**: (`event`) => `void` \| `Promise`\<`void`\> + +**`Experimental`** + +Raw-event tap for box-kind backends: called (and awaited) with every +unmapped `SandboxEvent` BEFORE it is projected, so a consumer can read +parts the chat-UX projection drops (part ids, step markers, custom +backend events) without forking the mapper. Purely observational — it +cannot alter the mapped stream. Never called for the `chat` kind, which +has no sandbox events. + +###### Parameters + +###### event + +`SandboxEvent` + +###### Returns + +`void` \| `Promise`\<`void`\> + +*** + +### AgentTurnUsage + +**`Experimental`** + +Metered usage of one turn, summed over every cost-bearing event the backend +emitted. `input`/`output` are token counts (0 when the backend reported +none — the honest sum, never a fabricated estimate). `costUsd`/`model` are +present only when the backend actually reported them. + +#### Properties + +##### input + +> **input**: `number` + +**`Experimental`** + +##### output + +> **output**: `number` + +**`Experimental`** + +##### costUsd? + +> `optional` **costUsd?**: `number` + +**`Experimental`** + +##### model? + +> `optional` **model?**: `string` + +**`Experimental`** + +*** + +### CollectedAgentTurn + +**`Experimental`** -> `optional` **corpusReadback?**: [`CorpusReadbackOptions`](#corpusreadbackoptions) +A drained turn: the terminal summary plus every event the stream yielded. +`status`/`error` mirror the terminal `final` event so a failed or aborted +turn stays inspectable without re-scanning `events`. -In-context learning: when set, query `corpus` before each depth shot and inject - the top trace-derived facts as guidance for the active run. No corpus means no read-back. +#### Properties -*** +##### finalText -### CorpusReadbackOptions +> **finalText**: `string` -#### Properties +**`Experimental`** -##### minConfidence? +##### usage -> `optional` **minConfidence?**: `number` +> **usage**: [`AgentTurnUsage`](#agentturnusage) -Minimum confidence for a fact to be injected. Default 0.7. +**`Experimental`** -##### tags? +##### events -> `optional` **tags?**: readonly `string`[] +> **events**: [`RuntimeStreamEvent`](index.md#runtimestreamevent)[] -Extra tags a fact must carry, in addition to `corpusTags`. +**`Experimental`** -##### maxFacts? +##### status -> `optional` **maxFacts?**: `number` +> **status**: [`AgentTaskStatus`](index.md#agenttaskstatus) -Max facts injected per shot. Default 3. +**`Experimental`** -##### includeOperatorFacts? +##### error? -> `optional` **includeOperatorFacts?**: `boolean` +> `optional` **error?**: [`BackendErrorDetail`](index.md#backenderrordetail) -Default false: only facts tagged `audience:agent` are injected into the worker. +**`Experimental`** *** -### StrategyShotResult +### StructuralRolloutPolicy -Measured result of one strategy shot. +The rollout's compute recipe — promoted from the proven rigs' env vars (K/REPAIRS/ + TESTGEN/DIVERSE/TEMPERATURE). Defaults are the measured sweet spot: repair value + concentrates at low k (~+12pp at k=1, +1–3pp at k=5), so `k=5, repairRounds=2` is the + full recipe and `k=1, repairRounds=2` the low-compute preset. #### Properties -##### messages - -> **messages**: [`StrategyMessage`](#strategymessage)[] - -##### score +##### k -> **score**: `number` +> **k**: `number` -##### passes +Independent samples per task (selection breadth). -> **passes**: `number` +##### repairRounds -##### total +> **repairRounds**: `number` -> **total**: `number` +Repair shots after selection, each steered by the checks' failure output. -##### completions +##### testgen -> **completions**: `number` +> **testgen**: `number` -##### toolErrors +Model-authored visible checks requested per task; 0 disables authoring. -> **toolErrors**: `number` +##### diverse? -*** +> `optional` **diverse?**: `boolean` -### AgenticRunResult +Per-slot strategy-lens prefixes on the k samples (attacks the all-k-fail bucket). + Measured as a paired null (+0.6pp) — kept as an optional knob, off by default. -#### Properties +##### temperature? -##### mode +> `optional` **temperature?**: `number` -> **mode**: `string` +Sampling temperature for every shot of this strategy; omitted ⇒ the worker default. -The strategy name (built-in 'depth'/'breadth' or a custom strategy's name). +*** -##### score +### VisibleCheck -> **score**: `number` +One task-visible executable check (e.g. a single-line Python assert). -##### resolved +#### Properties -> **resolved**: `boolean` +##### code -##### completions +> **code**: `string` -> **completions**: `number` +##### kind -##### progression +> **kind**: `"authored"` \| `"official"` -> **progression**: `number`[] +'official' = shown in the task itself (docstring example, shown assert); + 'authored' = the model's own guess. Official outranks authored in selection. -DEPTH: score after each shot — the progress-over-rounds curve. BREADTH: best-so-far per rollout. +*** -##### shots +### CheckSourceCtx -> **shots**: `number` +What a CheckSource composes with. `consult` is the strategy family's raw analyst + channel (metered by the conserved pool, offline-injectable via `opts.complete`) — + check authoring goes through it rather than a bespoke model client. -##### usd +#### Properties -> **usd**: `number` +##### count -The cost vector, stamped by `runAgentic` from the Supervisor's conserved pool: real - router tokens, priced usd (0 when the model is unpriced — never fabricated), wall ms. +> **count**: `number` -##### ms +Authored-check budget for this task (`policy.testgen`). -> **ms**: `number` +##### entrySymbol? -##### tokens +> `optional` **entrySymbol?**: `string` -> **tokens**: `object` +The symbol authored checks must reference; undefined ⇒ authoring is skipped + (no guesses beats guesses pinned to nothing). -###### input +#### Methods -> **input**: `number` +##### consult() -###### output +> **consult**(`instruction`): `Promise`\<`string` \| `null`\> -> **output**: `number` +One metered LLM call: instruction in, reply text out, null when the channel went + down. The task's visible prompt is included by the channel itself. -*** +###### Parameters -### Strategy +###### instruction -#### Type Parameters +`string` -##### Result +###### Returns -`Result` *extends* [`StrategyResult`](#strategyresult-1) = [`StrategyResult`](#strategyresult-1) +`Promise`\<`string` \| `null`\> -#### Properties +*** -##### name +### CheckSource -> `readonly` **name**: `string` +Produces the task's visible checks. MUST derive them from agent-visible information + only, before any candidate exists — the strategy freezes the returned set for every + sample and repair round of the task. #### Methods -##### driver() +##### generate() -> **driver**(`surface`, `task`, `opts`, `budget`): [`Agent`](#agent-1)\<`unknown`, [`Outcome`](#outcome-1)\<`unknown`\>\> +> **generate**(`task`, `ctx`): `Promise`\<[`VisibleCheck`](#visiblecheck)[]\> ###### Parameters -###### surface - -[`AgenticSurface`](#agenticsurface) - ###### task [`AgenticTask`](#agentictask) -###### opts - -[`AgenticOptions`](#agenticoptions) - -###### budget +###### ctx -`number` +[`CheckSourceCtx`](#checksourcectx) ###### Returns -[`Agent`](#agent-1)\<`unknown`, [`Outcome`](#outcome-1)\<`unknown`\>\> +`Promise`\<[`VisibleCheck`](#visiblecheck)[]\> *** -### ShotPersona +### CheckOutcome -A role for one shot — multi-agent loops (researcher + engineer, a panel of k - researchers) give each shot its own system prompt and optionally its own model. +How one candidate fared against the frozen visible checks, split by check kind. #### Properties -##### systemPrompt? - -> `optional` **systemPrompt?**: `string` +##### passedOfficial -Replaces the task's systemPrompt for a FRESH shot; on a carried conversation it is - injected as a hand-off message (the transcript's earlier roles stay intact). +> **passedOfficial**: `number` -##### model? +##### totalOfficial -> `optional` **model?**: `string` +> **totalOfficial**: `number` -Per-shot model override (e.g. a stronger model for the engineer shot). +##### passedAuthored -*** +> **passedAuthored**: `number` -### ShotSpec +##### totalAuthored -#### Properties +> **totalAuthored**: `number` -##### handle? +##### failureOutput -> `optional` **handle?**: [`ArtifactHandle`](#artifacthandle) +> **failureOutput**: `string` -present ⇒ continue this artifact (depth); absent ⇒ the shot opens a fresh one (sample/restart). +The checks' failure report — the ONLY feedback the repair loop may see. -##### messages? +##### crashed? -> `optional` **messages?**: [`StrategyMessage`](#strategymessage)[] +> `optional` **crashed?**: `boolean` -##### steer? +True when the candidate crashed before any check could run — ranks below a + candidate that ran and failed everything. -> `optional` **steer?**: `string` +*** -##### persona? +### CheckExecChannel -> `optional` **persona?**: [`ShotPersona`](#shotpersona) +Minimal exec channel the default runner needs. `SandboxInstance` (and therefore + `ValidationCtx.box`) satisfies it structurally. -##### tools? +#### Methods -> `optional` **tools?**: `string`[] +##### exec() -Restrict THIS shot to a subset of the domain's tools (by name) — focus a shot on - the relevant capabilities. Restriction-only; unknown names throw. Omitted ⇒ all. +> **exec**(`command`, `options?`): `Promise`\<\{ `exitCode`: `number`; `stdout`: `string`; `stderr`: `string`; \}\> -*** +###### Parameters -### StrategyResult +###### command -#### Extended by +`string` -- [`StructuralRolloutResult`](#structuralrolloutresult) +###### options? -#### Properties +###### timeoutMs? -##### score +`number` -> **score**: `number` +###### Returns -##### resolved +`Promise`\<\{ `exitCode`: `number`; `stdout`: `string`; `stderr`: `string`; \}\> -> **resolved**: `boolean` +*** -##### completions +### CheckRunContext -> **completions**: `number` +#### Properties -##### progression +##### task -> **progression**: `number`[] +> **task**: [`AgenticTask`](#agentictask) -##### shots +##### box? -> **shots**: `number` +> `optional` **box?**: [`CheckExecChannel`](#checkexecchannel) -*** +Live exec channel for this run (`ValidationCtx.box` / a sandbox instance). -### StrategyArtifacts +##### signal? -Artifact lifecycle a strategy may manage itself — open/close ONLY. Raw `call`/`score` - are withheld: scores reach the body solely through `shot()`'s StrategyShotResult (the - harness-verified channel), so a body cannot peek the check or fabricate around it. +> `optional` **signal?**: `AbortSignal` -#### Properties +*** -##### name +### CheckRunner -> `readonly` **name**: `string` +Executes the frozen checks against one candidate. Implementations MUST fail loud + (throw) when they cannot execute — a silent zero poisons selection. #### Methods -##### open() +##### run() -> **open**(`task`): `Promise`\<[`ArtifactHandle`](#artifacthandle)\> +> **run**(`candidate`, `checks`, `ctx`): `Promise`\<[`CheckOutcome`](#checkoutcome)\> ###### Parameters -###### task - -[`AgenticTask`](#agentictask) - -###### Returns - -`Promise`\<[`ArtifactHandle`](#artifacthandle)\> +###### candidate -##### close() +`string` -> **close**(`handle`): `Promise`\<`void`\> +###### checks -###### Parameters +[`VisibleCheck`](#visiblecheck)[] -###### handle +###### ctx -[`ArtifactHandle`](#artifacthandle) +[`CheckRunContext`](#checkruncontext) ###### Returns -`Promise`\<`void`\> +`Promise`\<[`CheckOutcome`](#checkoutcome)\> *** -### StrategyCtx - -What a strategy body composes with: the artifact lifecycle, the budget, and the two steps. - -#### Properties +### StructuralRolloutResult -##### surface +The body's deliverable — a `StrategyResult` plus selection provenance. The extra + fields ride through `defineStrategy`'s deliverable spread onto `AgenticRunResult` + (score/resolved stay harness-verified, exactly as for every authored strategy). -> `readonly` **surface**: [`StrategyArtifacts`](#strategyartifacts) +#### Extends -Open/close artifacts the body manages itself (e.g. one persistent handle for depth). +- [`StrategyResult`](#strategyresult-1) -##### task +#### Properties -> `readonly` **task**: [`AgenticTask`](#agentictask) +##### score -##### opts +> **score**: `number` -> `readonly` **opts**: [`AgenticOptions`](#agenticoptions) +###### Inherited from -##### budget +[`StrategyResult`](#strategyresult-1).[`score`](#score-10) -> `readonly` **budget**: `number` +##### resolved -##### scope +> **resolved**: `boolean` -> `readonly` **scope**: [`Scope`](index.md#scope)\<[`Outcome`](#outcome-1)\<`unknown`\>\> +###### Inherited from -#### Methods +[`StrategyResult`](#strategyresult-1).[`resolved`](#resolved-4) -##### shot() +##### completions -> **shot**(`spec?`): `Promise`\<[`StrategyShotResult`](#strategyshotresult) \| `null`\> +> **completions**: `number` -Run ONE worker shot; its harness-scored result, or null if it went down. +###### Inherited from -###### Parameters +[`StrategyResult`](#strategyresult-1).[`completions`](#completions-2) -###### spec? +##### progression -[`ShotSpec`](#shotspec) +> **progression**: `number`[] -###### Returns +###### Inherited from -`Promise`\<[`StrategyShotResult`](#strategyshotresult) \| `null`\> +[`StrategyResult`](#strategyresult-1).[`progression`](#progression-2) -##### critique() +##### shots -> **critique**(`messages`): `Promise`\<`string` \| `null`\> +> **shots**: `number` -The firewalled critic reads the trajectory → a steer string, or null on COMPLETE/down. +###### Inherited from -###### Parameters +[`StrategyResult`](#strategyresult-1).[`shots`](#shots-3) -###### messages +##### artifact -[`StrategyMessage`](#strategymessage)[] +> **artifact**: `string` \| `null` -###### Returns +Exact selected candidate text passed to the visible checks, or null when no shot ran. -`Promise`\<`string` \| `null`\> +##### selection -##### consult() +> **selection**: [`SelectionReceipt`](#selectionreceipt)[] -> **consult**(`messages`, `instruction`): `Promise`\<`string` \| `null`\> +One receipt per scored candidate (k samples, then repairs), `SelectionReceipt` + shaped like the kernel's (`types.ts`), selector 'driver'. -The RAW analyst channel: the firewalled critic answers `instruction` over the - trajectory verbatim — no findings extraction, so verdict-shaped formats - (CONTINUE/STOP decisions, calibrated predictions) survive. Same firewall: - trajectory in, never scores. Null when the analyst went down. +##### repairStop -###### Parameters +> **repairStop**: [`RepairStop`](#repairstop) -###### messages +##### officialChecks -[`StrategyMessage`](#strategymessage)[] +> **officialChecks**: `number` -###### instruction +##### authoredChecks -`string` +> **authoredChecks**: `number` -###### Returns +*** -`Promise`\<`string` \| `null`\> +### StructuralRolloutConfig -##### listTools() +#### Properties -> **listTools**(`handle`): `Promise`\<`object`[]\> +##### policy? -The tools THIS artifact's task actually offers (names + descriptions only — never - the implementations). Tool sets vary per task on heterogeneous domains; a strategy - that restricts shots MUST select from this list, never from hardcoded names. +> `optional` **policy?**: `Partial`\<[`StructuralRolloutPolicy`](#structuralrolloutpolicy)\> -###### Parameters +Knobs; missing fields take the measured defaults (k=5, repairRounds=2, testgen=6). -###### handle +##### checkSource? -[`ArtifactHandle`](#artifacthandle) +> `optional` **checkSource?**: [`CheckSource`](#checksource) -###### Returns +Where the visible checks come from. Default: official checks from + `task.meta.visibleChecks` composed with `modelAuthoredChecks()`. -`Promise`\<`object`[]\> +##### checkRunner? -*** +> `optional` **checkRunner?**: [`CheckRunner`](#checkrunner) -### RunAgenticOptions +How candidates are measured. Default `sandboxCheckRunner()` — it needs an exec + channel (bind one to the runner, or pass `box` here) and fails loud without one. -#### Extends +##### box? -- [`AgenticOptions`](#agenticoptions) +> `optional` **box?**: [`CheckExecChannel`](#checkexecchannel) -#### Type Parameters +Exec channel threaded into every check run of this strategy (a sandbox instance / + `ValidationCtx.box`). The strategy seam itself carries no sandbox, so the caller + who owns one supplies it here or binds it into the runner. -##### Result +##### extractCandidate? -`Result` *extends* [`StrategyResult`](#strategyresult-1) = [`StrategyResult`](#strategyresult-1) +> `optional` **extractCandidate?**: (`messages`) => `string` -#### Properties +Candidate extraction from a shot's conversation. Default `defaultExtractCandidate`. -##### routerBaseUrl +###### Parameters -> **routerBaseUrl**: `string` +###### messages -###### Inherited from +readonly [`StructuralRolloutMessage`](#structuralrolloutmessage)[] -[`AgenticOptions`](#agenticoptions).[`routerBaseUrl`](#routerbaseurl-1) +###### Returns -##### routerKey +`string` -> **routerKey**: `string` +*** -###### Inherited from +### SurfaceWorkerOut -[`AgenticOptions`](#agenticoptions).[`routerKey`](#routerkey-1) +What a surface worker settles with — the surface verdict the driver + deliverable read. `resolved` is + the surface check's pass/fail (settled ⟺ resolved); `score` is the partial-credit fraction; `failing` + carries the tests this worker left red (so the analyst can target them). -##### model +#### Properties -> **model**: `string` +##### resolved -###### Inherited from +> `readonly` **resolved**: `boolean` -[`AgenticOptions`](#agenticoptions).[`model`](#model-7) +##### score -##### complete? +> `readonly` **score**: `number` -> `optional` **complete?**: (`body`) => `Promise`\<`unknown`\> +##### shots -Optional completion transport (see `RouterConfig.complete`): when set, BOTH legs of an - offline run use it instead of `fetch`-ing the router — the worker's tool loop (threaded into - its `routerToolLoop` cfg) AND the analyst's critic (its `ChatClient` is bound to this same - transport). One injected responder serves both, as a localhost mock endpoint would. Absent ⇒ - the live router fetch path (the default). +> `readonly` **shots**: `number` -###### Parameters +##### summary -###### body +> `readonly` **summary**: `string` -`Record`\<`string`, `unknown`\> +##### failing? -###### Returns +> `readonly` `optional` **failing?**: readonly `string`[] -`Promise`\<`unknown`\> +*** -###### Inherited from +### SurfaceWorkerConfig -[`AgenticOptions`](#agenticoptions).[`complete`](#complete-1) +How a worker runs the surface task (its router substrate + per-attempt bounds). -##### temperature? +#### Properties -> `optional` **temperature?**: `number` +##### routerBaseUrl -###### Inherited from +> `readonly` **routerBaseUrl**: `string` -[`AgenticOptions`](#agenticoptions).[`temperature`](#temperature-3) +##### routerKey -##### maxTokens? +> `readonly` **routerKey**: `string` -> `optional` **maxTokens?**: `number` +##### model -Completion cap per worker turn — REQUIRED for thinking models (they burn unbounded - budgets on reasoning and return empty content without it). Omitted ⇒ provider default. +> `readonly` **model**: `string` -###### Inherited from +##### maxTokens? -[`AgenticOptions`](#agenticoptions).[`maxTokens`](#maxtokens-3) +> `readonly` `optional` **maxTokens?**: `number` ##### innerTurns? -> `optional` **innerTurns?**: `number` +> `readonly` `optional` **innerTurns?**: `number` -Turns the agent may take within ONE shot before the driver intervenes. +##### budget? -###### Inherited from +> `readonly` `optional` **budget?**: `number` -[`AgenticOptions`](#agenticoptions).[`innerTurns`](#innerturns) +Refine-shot budget for ONE worker attempt (max steered shots). Default 1. -##### analystInstruction? +*** -> `optional` **analystInstruction?**: `string` +### SuperviseSurfaceOptions -The depth STEERER's analyst instruction (observe()'s system prompt). The knob a - prompt optimizer (GEPA) tunes — the analyst IS the steerer. Omitted ⇒ the default. +#### Properties -###### Inherited from +##### surface -[`AgenticOptions`](#agenticoptions).[`analystInstruction`](#analystinstruction-2) +> `readonly` **surface**: [`AgenticSurface`](#agenticsurface) -##### analystModel? +The graded surface workers solve (open/tools/call/score/close). -> `optional` **analystModel?**: `string` +##### worker -The critic's model — lets the analyst be a stronger (or cheaper) model than the - worker. Omitted ⇒ the worker's `model`. +> `readonly` **worker**: [`SurfaceWorkerConfig`](#surfaceworkerconfig) -###### Inherited from +Where/how each worker runs the surface task. -[`AgenticOptions`](#agenticoptions).[`analystModel`](#analystmodel) +##### budget? -##### corpus? +> `readonly` `optional` **budget?**: [`Budget`](index.md#budget-4) -> `optional` **corpus?**: [`Corpus`](#corpus-2) +The conserved compute pool for the whole supervised run. Default: sized off the worker's inner-loop + bounds for a handful of worker spawns — raise it to let the driver try more. -Across-run learning: when set, the analyst's observe() pass appends trace-derived - facts here (the flywheel write side). Read-back is opt-in via `corpusReadback` - because unconditional priming can pollute context on some domains. +##### router? -###### Inherited from +> `readonly` `optional` **router?**: [`RouterConfig`](#routerconfig) -[`AgenticOptions`](#agenticoptions).[`corpus`](#corpus-4) +The driver brain's router substrate (its own inference). Default: the worker's router + model — the + driver and workers share one router unless you separate them (e.g. a stronger driver model). -##### corpusTags? +##### analysts? -> `optional` **corpusTags?**: `string`[] +> `readonly` `optional` **analysts?**: [`AnalystRegistry`](index.md#analystregistry) \| `null` -Tags written onto learned facts (and used by the caller's priming query). +The self-improvement lens fed to the driver on each settled worker. Default `failuresAnalyst()` + (target the still-failing tests). Pass a custom registry to change it, or `null` to turn the + within-run self-improvement OFF (the driver sees raw settled outputs). -###### Inherited from +##### strategy? -[`AgenticOptions`](#agenticoptions).[`corpusTags`](#corpustags) +> `readonly` `optional` **strategy?**: [`Strategy`](#strategy-3)\<[`StrategyResult`](#strategyresult-1)\> -##### corpusReadback? +The strategy each worker runs over the surface. Default `refine` (iterate-with-feedback). -> `optional` **corpusReadback?**: [`CorpusReadbackOptions`](#corpusreadbackoptions) +##### maxLiveWorkers? -In-context learning: when set, query `corpus` before each depth shot and inject - the top trace-derived facts as guidance for the active run. No corpus means no read-back. +> `readonly` `optional` **maxLiveWorkers?**: `number` -###### Inherited from +Max workers live at once. Default 1 (serial — required when workers share a persistent artifact, so + they continue each other instead of racing the file). -[`AgenticOptions`](#agenticoptions).[`corpusReadback`](#corpusreadback) +*** -##### surface +### SuperviseSurfaceResult -> **surface**: [`AgenticSurface`](#agenticsurface) +The deployable outcome of a supervised surface run. -##### task +#### Properties -> **task**: [`AgenticTask`](#agentictask) +##### resolved -##### hooks? +> `readonly` **resolved**: `boolean` -> `optional` **hooks?**: [`RuntimeHooks`](index.md#runtimehooks) +##### score -Lifecycle observability — every spawn/settle (shots, analysts) streams here live. - The seam online watchdogs/route-auditors subscribe to. +> `readonly` **score**: `number` -##### strategy? +##### usd -> `optional` **strategy?**: [`Strategy`](#strategy-3)\<`Result`\> +> `readonly` **usd**: `number` -A Strategy (the open way) — author/pass your own. Overrides `mode` when present. +##### tokensIn -##### mode? +> `readonly` **tokensIn**: `number` -> `optional` **mode?**: `"depth"` \| `"breadth"` +##### tokensOut -Built-in shorthand: 'depth'→refine, 'breadth'→sample. Default 'depth'. +> `readonly` **tokensOut**: `number` -##### budget +##### ms -> **budget**: `number` +> `readonly` **ms**: `number` -budget: refine→max shots; sample→rollout width. +##### completions -##### rootBudget? +> `readonly` **completions**: `number` -> `optional` **rootBudget?**: [`Budget`](index.md#budget-4) +Total conserved-pool iterations = the driver + worker LLM rounds the run actually spent. *** -### StreamAgentTurnOptions +### ProfileRichnessThresholds -**`Experimental`** +Thresholds below which a system prompt is treated as a thin stub. Tunable per call. #### Properties -##### signal? - -> `optional` **signal?**: `AbortSignal` +##### minSystemPromptChars -**`Experimental`** +> `readonly` **minSystemPromptChars**: `number` -Caller-initiated cancellation. Terminates the stream with `final.status: 'aborted'`. +A prompt shorter than this many characters is thin (default 600). -##### timeoutMs? +##### minSystemPromptLines -> `optional` **timeoutMs?**: `number` +> `readonly` **minSystemPromptLines**: `number` -**`Experimental`** +A prompt with fewer than this many non-blank lines is thin (default 6). -Wall-clock deadline for the whole turn in ms. An expired deadline aborts -the backend and terminates the stream with `final.status: 'failed'` -(a blown deadline is a turn failure, not a caller cancellation). +*** -##### preserveToolParts? +### ProfileRichness -> `optional` **preserveToolParts?**: `boolean` +Per-field verdict on one authored profile — the raw material the bench renders + scores. -**`Experimental`** +#### Properties -Opt-in tool-part projection for box and executor backends: sandbox tool -parts additionally surface in-stream as -`tool_call` / `tool_result` events (`mapSandboxToolEvent`), so a consumer -rendering tool activity needs no bespoke sandbox-event parser. Default -off — the stream vocabulary existing consumers see is unchanged. No-op -for the `chat` kind (its backend emits `RuntimeStreamEvent`s directly, -tool events included when the backend produces them). +##### name -##### onRawEvent? +> `readonly` **name**: `string` -> `optional` **onRawEvent?**: (`event`) => `void` \| `Promise`\<`void`\> +##### systemPrompt -**`Experimental`** +> `readonly` **systemPrompt**: `string` -Raw-event tap for box-kind backends: called (and awaited) with every -unmapped `SandboxEvent` BEFORE it is projected, so a consumer can read -parts the chat-UX projection drops (part ids, step markers, custom -backend events) without forking the mapper. Purely observational — it -cannot alter the mapped stream. Never called for the `chat` kind, which -has no sandbox events. +The resolved system prompt (canonical `prompt.systemPrompt`, the sandbox `prompt.system` + convention, or a bare-string prompt — whichever the author used). -###### Parameters +##### systemPromptChars -###### event +> `readonly` **systemPromptChars**: `number` -`SandboxEvent` +##### systemPromptLines -###### Returns +> `readonly` **systemPromptLines**: `number` -`void` \| `Promise`\<`void`\> +##### sentenceCount -*** +> `readonly` **sentenceCount**: `number` -### AgentTurnUsage +##### hasDescription -**`Experimental`** +> `readonly` **hasDescription**: `boolean` -Metered usage of one turn, summed over every cost-bearing event the backend -emitted. `input`/`output` are token counts (0 when the backend reported -none — the honest sum, never a fabricated estimate). `costUsd`/`model` are -present only when the backend actually reported them. +##### hasTools -#### Properties +> `readonly` **hasTools**: `boolean` -##### input +##### hasSkills -> **input**: `number` +> `readonly` **hasSkills**: `boolean` -**`Experimental`** +##### hasMcp -##### output +> `readonly` **hasMcp**: `boolean` -> **output**: `number` +##### hasSubagents -**`Experimental`** +> `readonly` **hasSubagents**: `boolean` -##### costUsd? +##### richness -> `optional` **costUsd?**: `number` +> `readonly` **richness**: `number` -**`Experimental`** +0..1 — fraction of richness signals present (prompt-depth + the four levers). -##### model? +##### thin -> `optional` **model?**: `string` +> `readonly` **thin**: `boolean` -**`Experimental`** +True when the supervisor authored a stub instead of a real profile. -*** +##### reasons -### CollectedAgentTurn +> `readonly` **reasons**: `string`[] -**`Experimental`** +The specific reasons it is thin (empty when rich) — used in the finding's action. -A drained turn: the terminal summary plus every event the stream yielded. -`status`/`error` mirror the terminal `final` event so a failed or aborted -turn stays inspectable without re-scanning `events`. +*** -#### Properties +### ReservationTicket -##### finalText +Opaque, single-use reservation handle returned by `reserve` and consumed by + `reconcile`. Carries the reserved ceilings so reconciliation needs no lookup. -> **finalText**: `string` +#### Properties -**`Experimental`** +##### id -##### usage +> `readonly` **id**: `number` -> **usage**: [`AgentTurnUsage`](#agentturnusage) +##### reserved -**`Experimental`** +> `readonly` **reserved**: `object` -##### events +###### tokens -> **events**: [`RuntimeStreamEvent`](index.md#runtimestreamevent)[] +> `readonly` **tokens**: `number` -**`Experimental`** +###### usd -##### status +> `readonly` **usd**: `number` -> **status**: [`AgentTaskStatus`](index.md#agenttaskstatus) +###### iterations -**`Experimental`** +> `readonly` **iterations**: `number` -##### error? +###### usdBudgeted? -> `optional` **error?**: [`BackendErrorDetail`](index.md#backenderrordetail) +> `readonly` `optional` **usdBudgeted?**: `boolean` -**`Experimental`** +Whether the child's `Budget` actually declared `maxUsd`. `reserved.usd` is `0` for BOTH a +child that named no dollar ceiling and one that named `$0`, and the two settle differently: +an undeclared ceiling cannot be exceeded, so the child's real dollars are committed as +OBSERVED spend, while a declared ceiling of `$0` is a limit whose breach is fail-loud. +Optional so an externally constructed ticket stays valid; an absent flag is read as +`true` — the strict, fail-closed reading. *** -### StructuralRolloutPolicy +### BudgetPoolRestore -The rollout's compute recipe — promoted from the proven rigs' env vars (K/REPAIRS/ - TESTGEN/DIVERSE/TEMPERATURE). Defaults are the measured sweet spot: repair value - concentrates at low k (~+12pp at k=1, +1–3pp at k=5), so `k=5, repairRounds=2` is the - full recipe and `k=1, repairRounds=2` the low-compute preset. +State recovered from a prior process before new work is admitted. `committed` is measured spend +already present in the durable journal. Each `uncertainReservation` is a child that was recorded +as started but never recorded as settled: its full declared ceiling is charged conservatively, +while the public readout remains explicitly unknown. #### Properties -##### k +##### committed? -> **k**: `number` +> `readonly` `optional` **committed?**: [`Spend`](index.md#spend) -Independent samples per task (selection breadth). +##### uncertainReservations? -##### repairRounds +> `readonly` `optional` **uncertainReservations?**: readonly [`Budget`](index.md#budget-4)[] -> **repairRounds**: `number` +##### absoluteDeadlineMs? -Repair shots after selection, each steered by the checks' failure output. +> `readonly` `optional` **absoluteDeadlineMs?**: `number` -##### testgen +Original absolute deadline from the first process. It may never slide on restart. -> **testgen**: `number` +*** -Model-authored visible checks requested per task; 0 disables authoring. +### BudgetPool -##### diverse? +#### Methods -> `optional` **diverse?**: `boolean` +##### reserve() -Per-slot strategy-lens prefixes on the k samples (attacks the all-k-fail bucket). - Measured as a paired null (+0.6pp) — kept as an optional knob, off by default. +> **reserve**(`b`): \{ `ok`: `true`; `ticket`: [`ReservationTicket`](#reservationticket); \} \| \{ `ok`: `false`; `reason`: [`ReservationRejection`](#reservationrejection); \} + +Atomically reserve a child's full ceiling from the free balance. Fails closed +({ ok: false }) when the pool can't cover tokens, usd, or iterations — the +caller inspects `ok` before `ticket`. + +###### Parameters + +###### b + +[`Budget`](index.md#budget-4) + +###### Returns + +\{ `ok`: `true`; `ticket`: [`ReservationTicket`](#reservationticket); \} \| \{ `ok`: `false`; `reason`: [`ReservationRejection`](#reservationrejection); \} -##### temperature? +##### reconcile() -> `optional` **temperature?**: `number` +> **reconcile**(`ticket`, `spent`): `void` -Sampling temperature for every shot of this strategy; omitted ⇒ the worker default. +Release a reservation: commit the actual `spent`, refund the unspent remainder +to the free pool. Throws on an unknown or already-reconciled ticket (fail loud — +a double refund would silently break conservation). -*** +###### Parameters -### VisibleCheck +###### ticket -One task-visible executable check (e.g. a single-line Python assert). +[`ReservationTicket`](#reservationticket) -#### Properties +###### spent -##### code +[`Spend`](index.md#spend) -> **code**: `string` +###### Returns -##### kind +`void` -> **kind**: `"authored"` \| `"official"` +##### spendFrom() -'official' = shown in the task itself (docstring example, shown assert); - 'authored' = the model's own guess. Official outranks authored in selection. +> **spendFrom**(`events`): `Promise`\<[`Spend`](index.md#spend)\> -*** +Fold a normalized `UsageEvent` stream (or array) into a `Spend`. Tokens via + `addTokenUsage`, usd on its own channel, iterations from `'iteration'` events. + `ms` is left zero — wall-clock duration is the caller's to record, not the pool's. -### CheckSourceCtx +###### Parameters -What a CheckSource composes with. `consult` is the strategy family's raw analyst - channel (metered by the conserved pool, offline-injectable via `opts.complete`) — - check authoring goes through it rather than a bespoke model client. +###### events -#### Properties +`AsyncIterable`\<[`UsageEvent`](#usageevent), `any`, `any`\> \| [`UsageEvent`](#usageevent)[] -##### count +###### Returns -> **count**: `number` +`Promise`\<[`Spend`](index.md#spend)\> -Authored-check budget for this task (`policy.testgen`). +##### readout() -##### entrySymbol? +> **readout**(): [`BudgetReadout`](#budgetreadout) -> `optional` **entrySymbol?**: `string` +The current readout, reflecting all outstanding reservations. -The symbol authored checks must reference; undefined ⇒ authoring is skipped - (no guesses beats guesses pinned to nothing). +###### Returns -#### Methods +[`BudgetReadout`](#budgetreadout) -##### consult() +##### observe() -> **consult**(`instruction`): `Promise`\<`string` \| `null`\> +> **observe**(`spend`): `void` -One metered LLM call: instruction in, reply text out, null when the channel went - down. The task's visible prompt is included by the channel itself. +Record OBSERVED spend that did NOT go through reserve/reconcile — the driver's OWN inference +(its chat turns), which is real compute but not a spawned child. A direct `free → committed` +debit, so `total ≡ free + reserved + committed` is preserved: equal-k counts the driver's +tokens and the in-loop budget guard (`readout().tokensLeft`) sees them. `free` may go negative +when a run overspends — that is honest (the readout then signals exhaustion). It never throws: +the spend already happened, so accounting records reality; the in-loop guard prevents MORE. +The DURABLE record is the journal's `metered` event (written by `Scope.meter`); this debit +only makes the live `readout()` reflect driver inference for the in-loop guard. ###### Parameters -###### instruction +###### spend -`string` +[`Spend`](index.md#spend) ###### Returns -`Promise`\<`string` \| `null`\> +`void` -*** +##### assertNoOpenTickets() -### CheckSource +> **assertNoOpenTickets**(): `void` -Produces the task's visible checks. MUST derive them from agent-visible information - only, before any candidate exists — the strategy freezes the returned set for every - sample and repair round of the task. +Fail loud if any reservation is still open — the conserved-pool leak detector. Called at the + supervisor's join barrier: once every child has settled, no ticket may remain (a leaked + reservation would silently break `total ≡ free + reserved + committed`). -#### Methods +###### Returns -##### generate() +`void` -> **generate**(`task`, `ctx`): `Promise`\<[`VisibleCheck`](#visiblecheck)[]\> +*** -###### Parameters +### DeliverableSpec -###### task +The deployable completion oracle passed to [gateOnDeliverable](#gateondeliverable): a `check` that +decides DELIVERED (settles `valid` ⟺ it resolves true) plus an optional `describe` of +what the spawn was supposed to produce. The check reads the child's output — never the +model judging itself. -[`AgenticTask`](#agentictask) +#### Type Parameters -###### ctx +##### Out -[`CheckSourceCtx`](#checksourcectx) +`Out` = `unknown` -###### Returns +#### Properties -`Promise`\<[`VisibleCheck`](#visiblecheck)[]\> +##### check -*** +> **check**: (`out`) => `boolean` \| `Promise`\<`boolean`\> -### CheckOutcome +The deployable check that decides DELIVERED. `settled.valid ⟺ this resolves true`. -How one candidate fared against the frozen visible checks, split by check kind. +###### Parameters -#### Properties +###### out -##### passedOfficial +`Out` -> **passedOfficial**: `number` +###### Returns -##### totalOfficial +`boolean` \| `Promise`\<`boolean`\> -> **totalOfficial**: `number` +##### describe? -##### passedAuthored +> `optional` **describe?**: `string` -> **passedAuthored**: `number` +What the spawn was supposed to produce — surfaced in traces/reports. -##### totalAuthored +*** -> **totalAuthored**: `number` +### DriverAgentOptions -##### failureOutput +#### Properties -> **failureOutput**: `string` +##### name -The checks' failure report — the ONLY feedback the repair loop may see. +> `readonly` **name**: `string` -##### crashed? +##### brain -> `optional` **crashed?**: `boolean` +> `readonly` **brain**: [`ToolLoopChat`](#toolloopchat) -True when the candidate crashed before any check could run — ranks below a - candidate that ran and failed everything. +The driver-LLM seam — ONE inference turn over the conversation + the coordination tool specs + (the canonical `ToolLoopChat`): a scripted mock offline, the router's tool-calling in + production, or a sandboxed harness. The same seam every tool-loop uses; no bespoke shape. -*** +##### blobs -### CheckExecChannel +> `readonly` **blobs**: [`ResultBlobStore`](#resultblobstore) -Minimal exec channel the default runner needs. `SandboxInstance` (and therefore - `ValidationCtx.box`) satisfies it structurally. +Shared blob store — `observe_agent` reads settled outputs through it. -#### Methods +##### makeWorkerAgent -##### exec() +> `readonly` **makeWorkerAgent**: [`MakeWorkerAgent`](#makeworkeragent) -> **exec**(`command`, `options?`): `Promise`\<\{ `exitCode`: `number`; `stdout`: `string`; `stderr`: `string`; \}\> +Resolve a spawned `profile` to a worker LEAF or a driver child (the recursion seam). -###### Parameters +##### authorizeDownMessage? -###### command +> `readonly` `optional` **authorizeDownMessage?**: [`AuthorizeDownMessage`](#authorizedownmessage) -`string` +##### perWorker -###### options? +> `readonly` **perWorker**: [`Budget`](index.md#budget-4) -###### timeoutMs? +Per-child budget reserved from the conserved pool on each spawn. -`number` +##### deliverable? -###### Returns +> `readonly` `optional` **deliverable?**: [`DeliverableSpec`](#deliverablespec)\<`unknown`\> -`Promise`\<\{ `exitCode`: `number`; `stdout`: `string`; `stderr`: `string`; \}\> +Independent completion check for work the driver performs itself. When present, the driver + receives `submit_result`; the first passing submission ends the loop and becomes the output. -*** +##### maxLiveWorkers? -### CheckRunContext +> `readonly` `optional` **maxLiveWorkers?**: `number` -#### Properties +Hard cap on simultaneously-LIVE workers — `spawn_agent` fails closed once this many are in + flight (a concurrency fence on top of the conserved-pool fence). Omit/`<= 0` = no cap. -##### task +##### analysts? -> **task**: [`AgenticTask`](#agentictask) +> `readonly` `optional` **analysts?**: [`AnalystRegistry`](index.md#analystregistry) -##### box? +The analyst lenses available to the driver. Required for `analyzeOnSettle` (and `run_analyst`). + Unset → no analyst feed (status quo: the driver gets settled outputs, no findings). -> `optional` **box?**: [`CheckExecChannel`](#checkexecchannel) +##### analyzeOnSettle? -Live exec channel for this run (`ValidationCtx.box` / a sandbox instance). +> `readonly` `optional` **analyzeOnSettle?**: readonly `string`[] -##### signal? +Analyst kind ids run AUTOMATICALLY when a worker settles `done` — each result re-enters as a + `finding` the driver pulls and composes its next steer from. The UP-leg of the self-improving + loop. Omit/empty = no auto-analysis (status quo). Requires `analysts`. -> `optional` **signal?**: `AbortSignal` +##### watchWorkers? -*** +> `readonly` `optional` **watchWorkers?**: [`WorkerWatchOptions`](mcp.md#workerwatchoptions) -### CheckRunner +Run the ONLINE detector panel over each worker's LIVE tool trace and raise a `finding` the + moment it loops/error-storms — mid-run evidence to steer on, not a settle-time post-mortem. + Omit = no online watching. -Executes the frozen checks against one candidate. Implementations MUST fail loud - (throw) when they cannot execute — a silent zero poisons selection. +##### stallAfterMs? -#### Methods +> `readonly` `optional` **stallAfterMs?**: `number` -##### run() +Idle time after which `observe_agent` reports a worker as stalled (a derived read; nothing is + killed). Omit = the runtime default. -> **run**(`candidate`, `checks`, `ctx`): `Promise`\<[`CheckOutcome`](#checkoutcome)\> +##### systemPrompt -###### Parameters +> `readonly` **systemPrompt**: `string` \| ((`task`) => `string`) -###### candidate +The driver's stance — a string, or built from the task (the worker-driver prompt / + the generator). INJECTED so the prompt is a pluggable, optimizable role. -`string` +##### nodeTools? -###### checks +> `readonly` `optional` **nodeTools?**: readonly [`McpToolDescriptor`](mcp.md#mcptooldescriptor)[] -[`VisibleCheck`](#visiblecheck)[] +Product-selected tools already bound to this exact supervisor node. The same descriptors are + served over MCP for external supervisors; this arm projects them into router ToolSpecs. -###### ctx +##### extraTools? -[`CheckRunContext`](#checkruncontext) +> `readonly` `optional` **extraTools?**: readonly `object`[] -###### Returns +WORK tools the driver may call DIRECTLY (alongside the coordination verbs) — so the driver is + not a pure manager but a full agent that can ACT (do simple work itself) OR SPAWN (delegate). + Each is a router tool spec; their names must not collide with the coordination verbs. Pair with + `executeExtraTool`. Unset → coordination-only (the prior behavior). -`Promise`\<[`CheckOutcome`](#checkoutcome)\> +##### executeExtraTool? -*** +> `readonly` `optional` **executeExtraTool?**: (`name`, `args`) => `Promise`\<`string` \| `null` \| `undefined`\> -### StructuralRolloutResult +Runs an `extraTools` call. Returns a string result, or null/undefined to signal "not handled" + so the call falls through to the coordination dispatch. Required iff `extraTools` is set. -The body's deliverable — a `StrategyResult` plus selection provenance. The extra - fields ride through `defineStrategy`'s deliverable spread onto `AgenticRunResult` - (score/resolved stay harness-verified, exactly as for every authored strategy). +###### Parameters -#### Extends +###### name -- [`StrategyResult`](#strategyresult-1) +`string` -#### Properties +###### args -##### score +`Record`\<`string`, `unknown`\> -> **score**: `number` +###### Returns + +`Promise`\<`string` \| `null` \| `undefined`\> + +##### maxTurns? -###### Inherited from +> `readonly` `optional` **maxTurns?**: `number` -[`StrategyResult`](#strategyresult-1).[`score`](#score-10) +Max driver turns before the loop force-finalizes on the best settled child. Default 16. + `0` lifts the turn-COUNT cap: the loop is bounded instead by the conserved budget pool, + an absolute deadline, the driver's own stop, and abort (checked in-loop). A finite + anti-runaway tripwire still guards a degenerate driver that loops on a no-spawn tool. -##### resolved +##### now? -> **resolved**: `boolean` +> `readonly` `optional` **now?**: () => `number` -###### Inherited from +Injected clock for the in-loop absolute-deadline guard — keeps the deadline check + deterministic in tests. Defaults to `Date.now`. -[`StrategyResult`](#strategyresult-1).[`resolved`](#resolved-4) +###### Returns -##### completions +`number` -> **completions**: `number` +##### stopRule? -###### Inherited from +> `readonly` `optional` **stopRule?**: [`StopRule`](#stoprule-1) -[`StrategyResult`](#strategyresult-1).[`completions`](#completions-2) +PROGRESS-derived stop (mechanic D). Today a run ends on a ceiling — iterations, tokens, +dollars, deadline, turn cap — which answers "may it continue?" and never "is it still getting +anywhere?". A stop rule reads the run's own progress (best-so-far over settled work, time +since the last settle, the live worker feed) and ends a run that has stopped learning BEFORE +it exhausts a budget. -##### progression +Composes with, and can never override, the hard guards: `poolStarved` / `deadlinePassed` / +abort / the driver's own stop are evaluated first, so a rule can only ADD a stop. -> **progression**: `number`[] +THRESHOLDS are the caller's judgment, not this module's — build the rule with +`plateau({window, minDelta})` / `noProgressFor({...})` / `allWorkersStalled({...})` from +`supervise/stop-rules`. Omit ⇒ ceilings only (unchanged behavior). -###### Inherited from +##### onProgressStop? -[`StrategyResult`](#strategyresult-1).[`progression`](#progression-2) +> `readonly` `optional` **onProgressStop?**: (`reason`) => `void` -##### shots +Called once with the rule's reason when a `stopRule` ends the run — so a caller can record + WHY a run stopped early instead of inferring it from an unexhausted budget. -> **shots**: `number` +###### Parameters -###### Inherited from +###### reason -[`StrategyResult`](#strategyresult-1).[`shots`](#shots-3) +`string` -##### artifact +###### Returns -> **artifact**: `string` \| `null` +`void` -Exact selected candidate text passed to the visible checks, or null when no shot ran. +##### compaction? -##### selection +> `readonly` `optional` **compaction?**: [`ToolLoopCompactionOptions`](#toolloopcompactionoptions) -> **selection**: [`SelectionReceipt`](#selectionreceipt)[] +Give the driver brain a chapter-lifecycle on its OWN context window. The LLM-brain front doors + lose to a dumb-Ralph respawn because the brain re-bills its whole coordination transcript every + turn — the same context overflow a single steered agent suffers, one level up. With this set, + once the brain's running conversation exceeds `thresholdTokens` it distills the accumulated + history to a compact progress note and continues fresh: the supervisor analog of respawning + against external tracking state, except the live `Scope` roster IS the durable state. Default + off (no behavior change). `distill` defaults to a self-summary authored by the brain combined + with the factual settled-worker roster; override to supply your own. -One receipt per scored candidate (k samples, then repairs), `SelectionReceipt` - shaped like the kernel's (`types.ts`), selector 'driver'. +##### onEvent? -##### repairStop +> `readonly` `optional` **onEvent?**: (`event`, `record`) => `void` \| `Promise`\<`void`\> -> **repairStop**: [`RepairStop`](#repairstop) +Pass-through subscriber for every coordination bus event: settled/question/finding, + pre-delivery instruction receipts, and steer/answer delivery outcomes. A durable caller uses + this to append the coordination log. Omit = no observer. -##### officialChecks +###### Parameters -> **officialChecks**: `number` +###### event -##### authoredChecks +[`CoordinationEvent`](index.md#coordinationevent) -> **authoredChecks**: `number` +###### record -*** +[`BusRecord`](#busrecord)\<[`CoordinationEvent`](index.md#coordinationevent)\> -### StructuralRolloutConfig +###### Returns -#### Properties +`void` \| `Promise`\<`void`\> -##### policy? +##### replaySettlements? -> `optional` **policy?**: `Partial`\<[`StructuralRolloutPolicy`](#structuralrolloutpolicy)\> +> `readonly` `optional` **replaySettlements?**: `boolean` -Knobs; missing fields take the measured defaults (k=5, repairRounds=2, testgen=6). +Re-publish resume-time settlements through the awaited observer before the first brain turn. -##### checkSource? +##### priorCoordination? -> `optional` **checkSource?**: [`CheckSource`](#checksource) +> `readonly` `optional` **priorCoordination?**: [`PriorCoordination`](#priorcoordination-1) -Where the visible checks come from. Default: official checks from - `task.meta.visibleChecks` composed with `modelAuthoredChecks()`. +Questions, findings, and authorized continuation receipts loaded from a prior process. + Questions seed the ledger (`list_questions`, blocking-stop policy); all three feed the resume + brief. Continuation receipts are evidence only and are never auto-delivered. Omit = fresh. -##### checkRunner? +##### finalizer? -> `optional` **checkRunner?**: [`CheckRunner`](#checkrunner) +> `readonly` `optional` **finalizer?**: [`SupervisorFinalizer`](index.md#supervisorfinalizer) -How candidates are measured. Default `sandboxCheckRunner()` — it needs an exec - channel (bind one to the runner, or pass `box` here) and fails loud without one. +How the settled-worker ledger becomes the run's output. Default `bestDelivered` — the single + highest-scoring DELIVERED child (the exact keep-best every existing caller had). Runs under + the delivered-only invariant (`runFinalizer`): whatever the finalizer, an undelivered or + invalid child's output stays unreachable. -##### box? +##### inbox? -> `optional` **box?**: [`CheckExecChannel`](#checkexecchannel) +> `readonly` `optional` **inbox?**: [`Inbox`](#inbox-1) -Exec channel threaded into every check run of this strategy (a sandbox instance / - `ValidationCtx.box`). The strategy seam itself carries no sandbox, so the caller - who owns one supplies it here or binds it into the runner. +Optional shared manager inbox used by a wrapper that must accept messages before async node +setup finishes. Ordinary callers omit it and the driver owns a fresh inbox. -##### extractCandidate? +*** -> `optional` **extractCandidate?**: (`messages`) => `string` +### PriorCoordination -Candidate extraction from a shot's conversation. Default `defaultExtractCandidate`. +Coordination evidence loaded from prior processes of one durable supervised run. -###### Parameters +#### Properties -###### messages +##### ownerId? -readonly [`StructuralRolloutMessage`](#structuralrolloutmessage)[] +> `readonly` `optional` **ownerId?**: `string` -###### Returns +The owner filter used for this replay. Omitted only for the compatibility all-owner read. -`string` +##### questions -*** +> `readonly` **questions**: readonly [`QuestionRecord`](mcp.md#questionrecord)[] -### SurfaceWorkerOut +Every question the prior process raised, with answer-status folded in, raise order. -What a surface worker settles with — the surface verdict the driver + deliverable read. `resolved` is - the surface check's pass/fail (settled ⟺ resolved); `score` is the partial-credit fraction; `failing` - carries the tests this worker left red (so the analyst can target them). +##### findings -#### Properties +> `readonly` **findings**: readonly [`AnalystFindingEvent`](#analystfindingevent)[] -##### resolved +Every analyst finding the prior process published, publish order. -> `readonly` **resolved**: `boolean` +##### continuations -##### score +> `readonly` **continuations**: readonly [`ContinuationInstruction`](#continuationinstruction)[] -> `readonly` **score**: `number` +Every authorized continuation, in commit order. These are evidence, never replayed to a new +worker automatically. -##### shots +##### deliveryEvidence -> `readonly` **shots**: `number` +> `readonly` **deliveryEvidence**: readonly [`CoordinationDeliveryEvidence`](#coordinationdeliveryevidence)[] -##### summary +Delivery intent and result records in commit order, linked to receipts by `receiptId`. -> `readonly` **summary**: `string` +##### records -##### failing? +> `readonly` **records**: readonly [`BusRecord`](#busrecord)\<[`CoordinationEvent`](index.md#coordinationevent)\>[] -> `readonly` `optional` **failing?**: readonly `string`[] +Exact source-bus stamps in durable append order. Bus `seq` restarts with each process; append +order remains the cross-process replay order. *** -### SurfaceWorkerConfig +### CoordinationLog -How a worker runs the surface task (its router substrate + per-attempt bounds). +The durable coordination side-log seam. `append` records one bus event (kinds it does not + persist are ignored); `load` replays a run's prior records folded into `PriorCoordination`. -#### Properties +#### Methods -##### routerBaseUrl +##### append() -> `readonly` **routerBaseUrl**: `string` +> **append**(`runId`, `record`, `ownerId?`): `Promise`\<`void`\> -##### routerKey +###### Parameters -> `readonly` **routerKey**: `string` +###### runId -##### model +`string` -> `readonly` **model**: `string` +###### record -##### maxTokens? +[`BusRecord`](#busrecord)\<[`CoordinationEvent`](index.md#coordinationevent)\> -> `readonly` `optional` **maxTokens?**: `number` +###### ownerId? -##### innerTurns? +`string` -> `readonly` `optional` **innerTurns?**: `number` +###### Returns -##### budget? +`Promise`\<`void`\> -> `readonly` `optional` **budget?**: `number` +##### load() -Refine-shot budget for ONE worker attempt (max steered shots). Default 1. +> **load**(`runId`, `ownerId?`): `Promise`\<[`PriorCoordination`](#priorcoordination-1)\> -*** +###### Parameters -### SuperviseSurfaceOptions +###### runId -#### Properties +`string` -##### surface +###### ownerId? -> `readonly` **surface**: [`AgenticSurface`](#agenticsurface) +`string` -The graded surface workers solve (open/tools/call/score/close). +###### Returns -##### worker +`Promise`\<[`PriorCoordination`](#priorcoordination-1)\> -> `readonly` **worker**: [`SurfaceWorkerConfig`](#surfaceworkerconfig) +*** -Where/how each worker runs the surface task. +### CoordinationMcpHandle -##### budget? +#### Properties -> `readonly` `optional` **budget?**: [`Budget`](index.md#budget-4) +##### url -The conserved compute pool for the whole supervised run. Default: sized off the worker's inner-loop - bounds for a handful of worker spawns — raise it to let the driver try more. +> `readonly` **url**: `string` -##### router? +The URL an in-box harness mounts as `mcp.mcpServers.coordination.url`. -> `readonly` `optional` **router?**: [`RouterConfig`](#routerconfig) +##### port -The driver brain's router substrate (its own inference). Default: the worker's router + model — the - driver and workers share one router unless you separate them (e.g. a stronger driver model). +> `readonly` **port**: `number` -##### analysts? +##### submittedResult -> `readonly` `optional` **analysts?**: [`AnalystRegistry`](index.md#analystregistry) \| `null` +> **submittedResult**: () => \{ `result`: `unknown`; \} \| `undefined` -The self-improvement lens fed to the driver on each settled worker. Default `failuresAnalyst()` - (target the still-failing tests). Pass a custom registry to change it, or `null` to turn the - within-run self-improvement OFF (the driver sees raw settled outputs). +The first driver-authored result whose injected independent check passed. -##### strategy? +The first result whose injected independent check passed, if the driver submitted one. -> `readonly` `optional` **strategy?**: [`Strategy`](#strategy-3)\<[`StrategyResult`](#strategyresult-1)\> +###### Returns -The strategy each worker runs over the surface. Default `refine` (iterate-with-feedback). +\{ `result`: `unknown`; \} \| `undefined` -##### maxLiveWorkers? +##### drainResolved -> `readonly` `optional` **maxLiveWorkers?**: `number` +> **drainResolved**: () => `Promise`\<`number`\> -Max workers live at once. Default 1 (serial — required when workers share a persistent artifact, so - they continue each other instead of racing the file). +Post-loop drain of already-settled, unpulled children into the ledger — call before reading + `settled()` for a finalize, so a delivered child the harness never awaited is not lost. -*** +Post-loop drain: pull every ALREADY-settled, unpulled child into the ledger (publishing each +as a `settled` bus event for the audit trail) WITHOUT awaiting live children. The driver +calls this once its brain loop ends, so a delivered child the brain never awaited still +reaches `finalizeBestDelivered` — a gate-verified delivery must never be lost to the +driver's pull discipline. Analyst-on-settle hooks do NOT fire here (the driver has stopped; +nobody is left to read a finding, and analysts spend real compute). Returns the count. -### SuperviseSurfaceResult +###### Returns -The deployable outcome of a supervised surface run. +`Promise`\<`number`\> -#### Properties +##### history -##### resolved +> **history**: () => readonly [`BusRecord`](#busrecord)\<[`CoordinationEvent`](index.md#coordinationevent)\>[] -> `readonly` **resolved**: `boolean` +The full ordered bus-event log for current-process observability and audit evidence. -##### score +The full ordered log of every bus event — UP (settled / question / finding), authorized + instruction receipts, and DOWN delivery outcomes (steer / answer). Each record carries seq, + timestamp, and priority. A receipt is evidence and is never auto-delivered on restart. -> `readonly` **score**: `number` +###### Returns -##### usd +readonly [`BusRecord`](#busrecord)\<[`CoordinationEvent`](index.md#coordinationevent)\>[] -> `readonly` **usd**: `number` +##### stats -##### tokensIn +> **stats**: () => [`BusStats`](#busstats) -> `readonly` **tokensIn**: `number` +Bus throughput counters for live dashboards. -##### tokensOut +Bus throughput counters (published / pulled / by-kind) for live dashboards. -> `readonly` **tokensOut**: `number` +###### Returns -##### ms +[`BusStats`](#busstats) -> `readonly` **ms**: `number` +##### raiseFinding -##### completions +> **raiseFinding**: (`finding`) => `Promise`\<`void`\> -> `readonly` **completions**: `number` +Raise a `finding` on the bus from an online detector watching a worker's live pipe. -Total conserved-pool iterations = the driver + worker LLM rounds the run actually spent. +Raise a `finding` on the bus from outside the settle hook — the seam an ONLINE detector + (mid-run, on the worker pipe) uses to tell the driver "this worker is looping/erroring" the + moment it happens, instead of only at settle. Queued for `await_event` + pass-through. -*** +###### Parameters -### AuthoredProfile +###### finding -What the supervisor AUTHORS per sub-task — a worker recipe (a partial `AgentProfile`). +[`AnalystFindingEvent`](#analystfindingevent) -#### Properties +###### Returns -##### name +`Promise`\<`void`\> -> **name**: `string` +#### Methods -##### systemPrompt +##### settled() -> **systemPrompt**: `string` +> **settled**(): readonly [`SettledWorker`](mcp.md#settledworker)[] -The rich, task-specific instructions the supervisor wrote for THIS worker. +The coordination tools' settled-worker ledger (for the driver's finalize). -##### model? +###### Returns -> `optional` **model?**: `string` +readonly [`SettledWorker`](mcp.md#settledworker)[] -The model the supervisor chose for this sub-task (falls back to the run default). +##### isStopped() -*** +> **isStopped**(): `boolean` -### ProfileRichnessThresholds +###### Returns -Thresholds below which a system prompt is treated as a thin stub. Tunable per call. +`boolean` -#### Properties +##### close() -##### minSystemPromptChars +> **close**(): `Promise`\<`void`\> -> `readonly` **minSystemPromptChars**: `number` +###### Returns -A prompt shorter than this many characters is thin (default 600). +`Promise`\<`void`\> -##### minSystemPromptLines +*** -> `readonly` **minSystemPromptLines**: `number` +### DelegateOptions -A prompt with fewer than this many non-blank lines is thin (default 6). +Inputs to [delegate](#delegate). The intent is the first positional arg; everything here is optional + with sensible defaults, so the common call is `delegate(intent, { backend, router })`. -*** +#### Type Parameters -### ProfileRichness +##### Out -Per-field verdict on one authored profile — the raw material the bench renders + scores. +`Out` = `unknown` #### Properties -##### name +##### deliverable? -> `readonly` **name**: `string` +> `readonly` `optional` **deliverable?**: [`DeliverableSpec`](#deliverablespec)\<`Out`\> -##### systemPrompt +The completion oracle (settled ⟺ delivered) the authored workers settle against. Strongly + recommended — without it the supervisor trusts a worker's self-report. For a code intent, + `patchDelivered()` is the canonical example; for a free-form answer, a content check. -> `readonly` **systemPrompt**: `string` +##### backend? -The resolved system prompt (canonical `prompt.systemPrompt`, the sandbox `prompt.system` - convention, or a bare-string prompt — whichever the author used). +> `readonly` `optional` **backend?**: [`ExecutorConfig`](#executorconfig) -##### systemPromptChars +WHERE the authored workers run — the worker-execution backend (`router-tools` / `sandbox` / + `cli-worktree` / …). The supervisor authors the worker PROFILE; this is the substrate it runs + on. Provide this OR `makeWorkerAgent`-style wiring through `supervise()` is unavailable. -> `readonly` **systemPromptChars**: `number` +##### budget? -##### systemPromptLines +> `readonly` `optional` **budget?**: [`Budget`](index.md#budget-4) -> `readonly` **systemPromptLines**: `number` +The conserved compute pool for the whole delegation. Defaults to [defaultDelegateBudget](#defaultdelegatebudget). -##### sentenceCount +##### model? -> `readonly` **sentenceCount**: `number` +> `readonly` `optional` **model?**: `string` -##### hasDescription +The model the supervisor BRAIN runs on (the router model). The brain must tool-call + (`spawn_agent` / `await_event`), so a delegator model, not a hidden-reasoning model. -> `readonly` **hasDescription**: `boolean` +##### router? -##### hasTools +> `readonly` `optional` **router?**: [`RouterConfig`](#routerconfig) -> `readonly` **hasTools**: `boolean` +The supervisor brain's router substrate. REQUIRED for the default router-brained supervisor + (the brain is resolved from this), unless a test injects `brain` directly. `model` overrides + `router.model`. (Design delta vs the bare `supervise()` profile: the brain needs a router.) -##### hasSkills +##### brain? -> `readonly` **hasSkills**: `boolean` +> `readonly` `optional` **brain?**: [`ToolLoopChat`](#toolloopchat) -##### hasMcp +Inject the supervisor brain directly (tests / advanced) instead of resolving it from `router`. -> `readonly` **hasMcp**: `boolean` +##### supervisor? -##### hasSubagents +> `readonly` `optional` **supervisor?**: `object` -> `readonly` **hasSubagents**: `boolean` +Override the default authoring-supervisor profile (name / extra system-prompt stance). The + default already carries the authoring skill; override only to add a goal or rename. -##### richness +###### name? -> `readonly` **richness**: `number` +> `readonly` `optional` **name?**: `string` -0..1 — fraction of richness signals present (prompt-depth + the four levers). +###### systemPrompt? -##### thin +> `readonly` `optional` **systemPrompt?**: `string` -> `readonly` **thin**: `boolean` +##### allowedModels? -True when the supervisor authored a stub instead of a real profile. +> `readonly` `optional` **allowedModels?**: readonly `string`[] -##### reasons +Restrict the run to this subset of models (forwarded to `supervise()`). -> `readonly` **reasons**: `string`[] +##### runId? -The specific reasons it is thin (empty when rich) — used in the finding's action. +> `readonly` `optional` **runId?**: `string` *** -### ReservationTicket - -Opaque, single-use reservation handle returned by `reserve` and consumed by - `reconcile`. Carries the reserved ceilings so reconciliation needs no lookup. +### WatchTraceOptions #### Properties -##### id +##### detectors? -> `readonly` **id**: `number` +> `readonly` `optional` **detectors?**: readonly `StreamingDetector`[] -##### reserved +The detectors to run online. Defaults to a stuck-loop + error-streak panel. -> `readonly` **reserved**: `object` +##### onSignal? -###### tokens +> `readonly` `optional` **onSignal?**: (`signal`, `span`) => `void` \| `Promise`\<`void`\> -> `readonly` **tokens**: `number` +Fired for each signal a detector raises — the seam that raises a `finding` on the bus. -###### usd +###### Parameters -> `readonly` **usd**: `number` +###### signal -###### iterations +`DetectorSignal` -> `readonly` **iterations**: `number` +###### span -###### usdBudgeted? +`ToolSpan` -> `readonly` `optional` **usdBudgeted?**: `boolean` +###### Returns -Whether the child's `Budget` actually declared `maxUsd`. `reserved.usd` is `0` for BOTH a -child that named no dollar ceiling and one that named `$0`, and the two settle differently: -an undeclared ceiling cannot be exceeded, so the child's real dollars are committed as -OBSERVED spend, while a declared ceiling of `$0` is a limit whose breach is fail-loud. -Optional so an externally constructed ticket stays valid; an absent flag is read as -`true` — the strict, fail-closed reading. +`void` \| `Promise`\<`void`\> *** -### BudgetPool - -#### Methods - -##### reserve() - -> **reserve**(`b`): \{ `ok`: `true`; `ticket`: [`ReservationTicket`](#reservationticket); \} \| \{ `ok`: `false`; `reason`: [`ReservationRejection`](#reservationrejection); \} - -Atomically reserve a child's full ceiling from the free balance. Fails closed -({ ok: false }) when the pool can't cover tokens, usd, or iterations — the -caller inspects `ok` before `ticket`. - -###### Parameters - -###### b +### DispatchUnit -[`Budget`](index.md#budget-4) +One unit of queued work: the agent to run, its task, and the spawn options (budget + label). + `nextUnit` mints these lazily so a queue can be generated, re-ordered, or grown while the + dispatcher runs. -###### Returns +#### Type Parameters -\{ `ok`: `true`; `ticket`: [`ReservationTicket`](#reservationticket); \} \| \{ `ok`: `false`; `reason`: [`ReservationRejection`](#reservationrejection); \} +##### Out -##### reconcile() +`Out` -> **reconcile**(`ticket`, `spent`): `void` +#### Properties -Release a reservation: commit the actual `spent`, refund the unspent remainder -to the free pool. Throws on an unknown or already-reconciled ticket (fail loud — -a double refund would silently break conservation). +##### agent -###### Parameters +> `readonly` **agent**: [`Agent`](#agent-1)\<`unknown`, `Out`\> -###### ticket +##### task -[`ReservationTicket`](#reservationticket) +> `readonly` **task**: `unknown` -###### spent +##### opts -[`Spend`](index.md#spend) +> `readonly` **opts**: [`SpawnOpts`](#spawnopts) -###### Returns +*** -`void` +### RollingDispatchOptions -##### spendFrom() +#### Type Parameters -> **spendFrom**(`events`): `Promise`\<[`Spend`](index.md#spend)\> +##### Out -Fold a normalized `UsageEvent` stream (or array) into a `Spend`. Tokens via - `addTokenUsage`, usd on its own channel, iterations from `'iteration'` events. - `ms` is left zero — wall-clock duration is the caller's to record, not the pool's. +`Out` -###### Parameters +#### Properties -###### events +##### width -`AsyncIterable`\<[`UsageEvent`](#usageevent), `any`, `any`\> \| [`UsageEvent`](#usageevent)[] +> `readonly` **width**: `number` -###### Returns +How many children to hold in flight. Must be a positive integer. This is a SIMULTANEITY fence +only — the conserved pool still bounds total work, and a `width` larger than the pool can +afford simply hits `not-admitted` sooner. Derive it with `effectiveConcurrency` when the host +also runs a fleet-level box governor. -`Promise`\<[`Spend`](index.md#spend)\> +#### Methods -##### readout() +##### nextUnit() -> **readout**(): [`BudgetReadout`](#budgetreadout) +> **nextUnit**(): [`DispatchUnit`](#dispatchunit)\<`Out`\> \| `Promise`\<[`DispatchUnit`](#dispatchunit)\<`Out`\> \| `undefined`\> \| `undefined` -The current readout, reflecting all outstanding reservations. +Produce the next unit of work, or `undefined` when the queue is dry. Called only when a slot +is free, so a caller may compute the next unit from what has already settled (the point of a +refilling dispatcher: the queue is allowed to react). Never called after a stop. ###### Returns -[`BudgetReadout`](#budgetreadout) +[`DispatchUnit`](#dispatchunit)\<`Out`\> \| `Promise`\<[`DispatchUnit`](#dispatchunit)\<`Out`\> \| `undefined`\> \| `undefined` -##### observe() +##### onSettled()? -> **observe**(`spend`): `void` +> `optional` **onSettled**(`settled`): `void` \| `Promise`\<`void`\> -Record OBSERVED spend that did NOT go through reserve/reconcile — the driver's OWN inference -(its chat turns), which is real compute but not a spawned child. A direct `free → committed` -debit, so `total ≡ free + reserved + committed` is preserved: equal-k counts the driver's -tokens and the in-loop budget guard (`readout().tokensLeft`) sees them. `free` may go negative -when a run overspends — that is honest (the readout then signals exhaustion). It never throws: -the spend already happened, so accounting records reality; the in-loop guard prevents MORE. -The DURABLE record is the journal's `metered` event (written by `Scope.meter`); this debit -only makes the live `readout()` reflect driver inference for the in-loop guard. +Called once per settlement, in cursor order, BEFORE the freed slot is refilled — so an +`onSettled` that appends to the caller's queue is visible to the very next `nextUnit`. ###### Parameters -###### spend +###### settled -[`Spend`](index.md#spend) +[`Settled`](index.md#settled)\<`Out`\> ###### Returns -`void` +`void` \| `Promise`\<`void`\> -##### assertNoOpenTickets() +##### shouldStop()? -> **assertNoOpenTickets**(): `void` +> `optional` **shouldStop**(): `boolean` -Fail loud if any reservation is still open — the conserved-pool leak detector. Called at the - supervisor's join barrier: once every child has settled, no ticket may remain (a leaked - reservation would silently break `total ≡ free + reserved + committed`). +Consulted before each admission. `true` stops admitting; the already-live children are still +drained to completion (no orphan, no lost settlement). Use it for a progress/plateau rule. ###### Returns -`void` +`boolean` *** -### DeliverableSpec - -The deployable completion oracle passed to [gateOnDeliverable](#gateondeliverable): a `check` that -decides DELIVERED (settles `valid` ⟺ it resolves true) plus an optional `describe` of -what the spawn was supposed to produce. The check reads the child's output — never the -model judging itself. +### DispatchReport #### Type Parameters ##### Out -`Out` = `unknown` +`Out` #### Properties -##### check +##### settled -> **check**: (`out`) => `boolean` \| `Promise`\<`boolean`\> +> `readonly` **settled**: readonly [`Settled`](index.md#settled)\<`Out`\>[] -The deployable check that decides DELIVERED. `settled.valid ⟺ this resolves true`. +Every settlement, in the order `scope.next()` yielded them. -###### Parameters +##### admitted -###### out +> `readonly` **admitted**: `number` -`Out` +How many children this dispatcher admitted. -###### Returns +##### rejected -`boolean` \| `Promise`\<`boolean`\> +> `readonly` **rejected**: readonly `string`[] -##### describe? +Admission rejections, in order — `label: reason`. Non-empty ⇒ the pool or depth fenced. -> `optional` **describe?**: `string` +##### stopReason -What the spawn was supposed to produce — surfaced in traces/reports. +> `readonly` **stopReason**: [`DispatchStopReason`](#dispatchstopreason) + +##### peakLive + +> `readonly` **peakLive**: `number` + +The highest simultaneous live count actually reached — the number to compare against + `width` when asking "did the slots really stay full?" *** -### DriverAgentOptions +### ConcurrencyCaps + +The caps a host can set on simultaneous work. See the ledger in this module's header for what + each one actually bounds. #### Properties -##### name +##### maxLiveWorkers? -> `readonly` **name**: `string` +> `readonly` `optional` **maxLiveWorkers?**: `number` -##### brain +Supervisor level: max spawned-but-unsettled workers. -> `readonly` **brain**: [`ToolLoopChat`](#toolloopchat) +##### maxSandboxes? -The driver-LLM seam — ONE inference turn over the conversation + the coordination tool specs - (the canonical `ToolLoopChat`): a scripted mock offline, the router's tool-calling in - production, or a sandboxed harness. The same seam every tool-loop uses; no bespoke shape. +> `readonly` `optional` **maxSandboxes?**: `number` -##### blobs +Fleet level: max live sandboxes/boxes across the host process (a `ComputeGovernor`-style + cap). Applies to the worker layer, so it participates in the minimum. -> `readonly` **blobs**: [`ResultBlobStore`](#resultblobstore) +*** -Shared blob store — `observe_agent` reads settled outputs through it. +### BusEvent -##### makeWorkerAgent +Every bus event is a discriminated union member keyed by `type`. -> `readonly` **makeWorkerAgent**: [`MakeWorkerAgent`](#makeworkeragent) +#### Properties -Resolve a spawned `profile` to a worker LEAF or a driver child (the recursion seam). +##### type -##### perWorker +> `readonly` **type**: `string` -> `readonly` **perWorker**: [`Budget`](index.md#budget-4) +*** -Per-child budget reserved from the conserved pool on each spawn. +### BusRecord -##### deliverable? +A published event stamped for ordering and observability. `seq` is the monotonic publish index; + `priority` drives pull order (higher = bumped ahead); `at` is the wall-clock publish time (ms). -> `readonly` `optional` **deliverable?**: [`DeliverableSpec`](#deliverablespec)\<`unknown`\> +#### Type Parameters -Independent completion check for work the driver performs itself. When present, the driver - receives `submit_result`; the first passing submission ends the loop and becomes the output. +##### E -##### maxLiveWorkers? +`E` *extends* [`BusEvent`](#busevent) -> `readonly` `optional` **maxLiveWorkers?**: `number` +#### Properties -Hard cap on simultaneously-LIVE workers — `spawn_agent` fails closed once this many are in - flight (a concurrency fence on top of the conserved-pool fence). Omit/`<= 0` = no cap. +##### seq -##### analysts? +> `readonly` **seq**: `number` -> `readonly` `optional` **analysts?**: [`AnalystRegistry`](index.md#analystregistry) +##### at -The analyst lenses available to the driver. Required for `analyzeOnSettle` (and `run_analyst`). - Unset → no analyst feed (status quo: the driver gets settled outputs, no findings). +> `readonly` **at**: `number` -##### analyzeOnSettle? +##### priority -> `readonly` `optional` **analyzeOnSettle?**: readonly `string`[] +> `readonly` **priority**: `number` -Analyst kind ids run AUTOMATICALLY when a worker settles `done` — each result re-enters as a - `finding` the driver pulls and composes its next steer from. The UP-leg of the self-improving - loop. Omit/empty = no auto-analysis (status quo). Requires `analysts`. +##### event -##### watchWorkers? +> `readonly` **event**: `E` -> `readonly` `optional` **watchWorkers?**: [`WorkerWatchOptions`](mcp.md#workerwatchoptions) +*** -Run the ONLINE detector panel over each worker's LIVE tool trace and raise a `finding` the - moment it loops/error-storms — mid-run evidence to steer on, not a settle-time post-mortem. - Omit = no online watching. +### PublishOptions -##### stallAfterMs? +#### Properties -> `readonly` `optional` **stallAfterMs?**: `number` +##### priority? -Idle time after which `observe_agent` reports a worker as stalled (a derived read; nothing is - killed). Omit = the runtime default. +> `readonly` `optional` **priority?**: `number` -##### systemPrompt +Higher = pulled ahead of lower-priority queued events (default 0). A blocking question sets + this so it bumps to the front of the driver's inbox. -> `readonly` **systemPrompt**: `string` \| ((`task`) => `string`) +##### queue? -The driver's stance — a string, or built from the task (the worker-driver prompt / - the generator). INJECTED so the prompt is a pluggable, optimizable role. +> `readonly` `optional` **queue?**: `boolean` -##### extraTools? +Whether the event enters the pull queue (default true). Set `false` for record-only events — + the parent→child down-leg (steer / answer / resume): they belong in `history()` and reach + `subscribe` observers, but the parent must never `pull` its own outbound message back. -> `readonly` `optional` **extraTools?**: readonly `object`[] +*** -WORK tools the driver may call DIRECTLY (alongside the coordination verbs) — so the driver is - not a pure manager but a full agent that can ACT (do simple work itself) OR SPAWN (delegate). - Each is a router tool spec; their names must not collide with the coordination verbs. Pair with - `executeExtraTool`. Unset → coordination-only (the prior behavior). +### BusStats -##### executeExtraTool? +#### Properties -> `readonly` `optional` **executeExtraTool?**: (`name`, `args`) => `Promise`\<`string` \| `null` \| `undefined`\> +##### published -Runs an `extraTools` call. Returns a string result, or null/undefined to signal "not handled" - so the call falls through to the coordination dispatch. Required iff `extraTools` is set. +> `readonly` **published**: `number` -###### Parameters +##### pulled -###### name +> `readonly` **pulled**: `number` -`string` +##### byKind -###### args +> `readonly` **byKind**: `Readonly`\<`Record`\<`string`, `number`\>\> -`Record`\<`string`, `unknown`\> +Count published per event `type`. -###### Returns +*** -`Promise`\<`string` \| `null` \| `undefined`\> +### EventBus -##### maxTurns? +#### Type Parameters -> `readonly` `optional` **maxTurns?**: `number` +##### E -Max driver turns before the loop force-finalizes on the best settled child. Default 16. - `0` lifts the turn-COUNT cap: the loop is bounded instead by the conserved budget pool, - an absolute deadline, the driver's own stop, and abort (checked in-loop). A finite - anti-runaway tripwire still guards a degenerate driver that loops on a no-spawn tool. +`E` *extends* [`BusEvent`](#busevent) -##### now? +#### Methods -> `readonly` `optional` **now?**: () => `number` +##### publish() -Injected clock for the in-loop absolute-deadline guard — keeps the deadline check - deterministic in tests. Defaults to `Date.now`. +> **publish**(`event`, `opts?`): `Promise`\<[`BusRecord`](#busrecord)\<`E`\>\> -###### Returns +Stamp the event, await every subscriber in order, then make it pull-visible. A subscriber + failure leaves the event invisible and retrying the SAME event object reuses the exact stamp. + This lets an awaited product observer commit its record before a supervisor can consume it. -`number` +###### Parameters -##### stopRule? +###### event -> `readonly` `optional` **stopRule?**: [`StopRule`](#stoprule-1) +`E` -PROGRESS-derived stop (mechanic D). Today a run ends on a ceiling — iterations, tokens, -dollars, deadline, turn cap — which answers "may it continue?" and never "is it still getting -anywhere?". A stop rule reads the run's own progress (best-so-far over settled work, time -since the last settle, the live worker feed) and ends a run that has stopped learning BEFORE -it exhausts a budget. +###### opts? -Composes with, and can never override, the hard guards: `poolStarved` / `deadlinePassed` / -abort / the driver's own stop are evaluated first, so a rule can only ADD a stop. +[`PublishOptions`](#publishoptions) -THRESHOLDS are the caller's judgment, not this module's — build the rule with -`plateau({window, minDelta})` / `noProgressFor({...})` / `allWorkersStalled({...})` from -`supervise/stop-rules`. Omit ⇒ ceilings only (unchanged behavior). +###### Returns -##### onProgressStop? +`Promise`\<[`BusRecord`](#busrecord)\<`E`\>\> -> `readonly` `optional` **onProgressStop?**: (`reason`) => `void` +##### pull() -Called once with the rule's reason when a `stopRule` ends the run — so a caller can record - WHY a run stopped early instead of inferring it from an unexhausted budget. +> **pull**(`kinds?`): `E` \| `undefined` + +Remove and return the highest-priority QUEUED event whose type is in `kinds` (any if omitted), + ties broken FIFO by `seq`; `undefined` when nothing matches. ###### Parameters -###### reason +###### kinds? -`string` +readonly `E`\[`"type"`\][] ###### Returns -`void` +`E` \| `undefined` -##### compaction? +##### subscribe() -> `readonly` `optional` **compaction?**: [`ToolLoopCompactionOptions`](#toolloopcompactionoptions) +> **subscribe**(`handler`): () => `void` -Give the driver brain a chapter-lifecycle on its OWN context window. The LLM-brain front doors - lose to a dumb-Ralph respawn because the brain re-bills its whole coordination transcript every - turn — the same context overflow a single steered agent suffers, one level up. With this set, - once the brain's running conversation exceeds `thresholdTokens` it distills the accumulated - history to a compact progress note and continues fresh: the supervisor analog of respawning - against external tracking state, except the live `Scope` roster IS the durable state. Default - off (no behavior change). `distill` defaults to a self-summary authored by the brain combined - with the factual settled-worker roster; override to supply your own. +Register a pass-through handler; it receives the stamped record of every event published after + registration. Returns an unsubscribe fn. -##### onEvent? +###### Parameters + +###### handler + +(`record`) => `void` \| `Promise`\<`void`\> + +###### Returns + +() => `void` + +##### pending() -> `readonly` `optional` **onEvent?**: (`event`) => `void` \| `Promise`\<`void`\> +> **pending**(`kinds?`): `number` -Pass-through subscriber for every coordination bus event (settled / question / finding / - steer / answer) — what a durable caller hooks its coordination log onto. Omit = no observer. +Count of queued, not-yet-pulled events (filtered by `kinds` when given). ###### Parameters -###### event +###### kinds? -[`CoordinationEvent`](index.md#coordinationevent) +readonly `E`\[`"type"`\][] ###### Returns -`void` \| `Promise`\<`void`\> +`number` -##### priorCoordination? +##### history() -> `readonly` `optional` **priorCoordination?**: [`PriorCoordination`](#priorcoordination-1) +> **history**(): readonly [`BusRecord`](#busrecord)\<`E`\>[] -Questions + findings a durable coordination log replayed from a prior process of this run. - Questions seed the ledger (`list_questions`, blocking-stop policy); both feed the resume - brief. Omit = fresh (every run that is not a resume). +The full ordered log of every event published in this process (audit evidence, not replay). -##### finalizer? +###### Returns -> `readonly` `optional` **finalizer?**: [`SupervisorFinalizer`](index.md#supervisorfinalizer) +readonly [`BusRecord`](#busrecord)\<`E`\>[] -How the settled-worker ledger becomes the run's output. Default `bestDelivered` — the single - highest-scoring DELIVERED child (the exact keep-best every existing caller had). Runs under - the delivered-only invariant (`runFinalizer`): whatever the finalizer, an undelivered or - invalid child's output stays unreachable. +##### stats() -*** +> **stats**(): [`BusStats`](#busstats) -### PriorCoordination +Throughput counters for observability dashboards. -What a prior process's coordination log replays into a resumed driver. +###### Returns -#### Properties +[`BusStats`](#busstats) -##### questions +*** -> `readonly` **questions**: readonly [`QuestionRecord`](mcp.md#questionrecord)[] +### FinalizerSettled -Every question the prior process raised, with answer-status folded in, raise order. +One settled worker as the finalizer sees it — the ledger row (structural fields only). -##### findings +#### Properties -> `readonly` **findings**: readonly [`AnalystFindingEvent`](#analystfindingevent)[] +##### id -Every analyst finding the prior process published, publish order. +> `readonly` **id**: `string` -*** +##### status -### CoordinationLog +> `readonly` **status**: `"done"` \| `"down"` -The durable coordination side-log seam. `append` records one bus event (kinds it does not - persist are ignored); `load` replays a run's prior records folded into `PriorCoordination`. +##### score? -#### Methods +> `readonly` `optional` **score?**: `number` -##### append() +##### valid? -> **append**(`runId`, `event`, `at`): `Promise`\<`void`\> +> `readonly` `optional` **valid?**: `boolean` -###### Parameters +##### outRef? -###### runId +> `readonly` `optional` **outRef?**: `string` -`string` +##### reason? -###### event +> `readonly` `optional` **reason?**: `string` -[`CoordinationEvent`](index.md#coordinationevent) +*** -###### at +### DeliveredOutput -`string` +One DELIVERED child, materialized: settled `done`, oracle-passed, output rehydrated. `out` is + `undefined` only when the child settled without an `outRef` (no artifact to rehydrate). -###### Returns +#### Properties -`Promise`\<`void`\> +##### id -##### load() +> `readonly` **id**: `string` -> **load**(`runId`): `Promise`\<[`PriorCoordination`](#priorcoordination-1)\> +##### score? -###### Parameters +> `readonly` `optional` **score?**: `number` -###### runId +##### outRef? -`string` +> `readonly` `optional` **outRef?**: `string` -###### Returns +##### out? -`Promise`\<[`PriorCoordination`](#priorcoordination-1)\> +> `readonly` `optional` **out?**: `unknown` *** -### CoordinationMcpHandle +### InboxMessage -#### Properties +**`Experimental`** -##### url +The worker-side receive end of the down-leg: a per-worker inbox an executor exposes as +`Executor.deliver`. The driver's `steer_agent` / `answer_question` land here, +and the worker's agent loop drains them at two points (Drew's two delivery modes): -> `readonly` **url**: `string` + - QUEUED (default): the message accumulates and is FLUSHED at the next step boundary — folded + into the conversation before the next think. A worker is also forced to flush BEFORE it may + settle, so it can never finish while a steer/answer it never read is still pending. + - FORCEFUL (`interrupt: true`): trips `freshInterrupt()`'s signal so the loop can abort its + in-flight turn immediately, then re-plan with the message folded in — breaking the worker out + of a wrong path mid-task instead of waiting for it to finish the step. -The URL an in-box harness mounts as `mcp.mcpServers.coordination.url`. +`deliver` never throws — a malformed message is ignored and returns `false`, so no caller can +report delivery for bytes this inbox discarded. -##### port +#### Properties -> `readonly` **port**: `number` +##### kind -##### submittedResult +> `readonly` **kind**: `"steer"` \| `"answer"` -> **submittedResult**: () => \{ `result`: `unknown`; \} \| `undefined` +**`Experimental`** -The first driver-authored result whose injected independent check passed. +##### text -The first result whose injected independent check passed, if the driver submitted one. +> `readonly` **text**: `string` -###### Returns +**`Experimental`** -\{ `result`: `unknown`; \} \| `undefined` +##### interrupt -##### drainResolved +> `readonly` **interrupt**: `boolean` -> **drainResolved**: () => `Promise`\<`number`\> +**`Experimental`** -Post-loop drain of already-settled, unpulled children into the ledger — call before reading - `settled()` for a finalize, so a delivered child the harness never awaited is not lost. +Forceful messages abort the in-flight turn; queued ones wait for the boundary flush. -Post-loop drain: pull every ALREADY-settled, unpulled child into the ledger (publishing each -as a `settled` bus event for the audit trail) WITHOUT awaiting live children. The driver -calls this once its brain loop ends, so a delivered child the brain never awaited still -reaches `finalizeBestDelivered` — a gate-verified delivery must never be lost to the -driver's pull discipline. Analyst-on-settle hooks do NOT fire here (the driver has stopped; -nobody is left to read a finding, and analysts spend real compute). Returns the count. +##### questionId? -###### Returns +> `readonly` `optional` **questionId?**: `string` -`Promise`\<`number`\> +**`Experimental`** -##### history +Present for an `answer` — the question id it resolves. -> **history**: () => readonly [`BusRecord`](#busrecord)\<[`CoordinationEvent`](index.md#coordinationevent)\>[] +*** -The full ordered bus-event log — observability audit + replay trail. +### Inbox -The full ordered log of every bus event — UP (settled / question / finding) and DOWN - (steer / answer) — the observability audit + replay trail. Each record carries seq, - timestamp, and priority. +#### Methods -###### Returns +##### deliver() -readonly [`BusRecord`](#busrecord)\<[`CoordinationEvent`](index.md#coordinationevent)\>[] +> **deliver**(`msg`): `boolean` -##### stats +The `Executor.deliver` implementation. Returns false when the raw message is malformed and +therefore was not queued; callers must not acknowledge a message this inbox discarded. -> **stats**: () => [`BusStats`](#busstats) +###### Parameters -Bus throughput counters for live dashboards. +###### msg -Bus throughput counters (published / pulled / by-kind) for live dashboards. +`unknown` ###### Returns -[`BusStats`](#busstats) +`boolean` -##### raiseFinding +##### drain() -> **raiseFinding**: (`finding`) => `Promise`\<`void`\> +> **drain**(): [`InboxMessage`](#inboxmessage)[] -Raise a `finding` on the bus from an online detector watching a worker's live pipe. +Remove and return all pending messages (the flush). -Raise a `finding` on the bus from outside the settle hook — the seam an ONLINE detector - (mid-run, on the worker pipe) uses to tell the driver "this worker is looping/erroring" the - moment it happens, instead of only at settle. Queued for `await_event` + pass-through. +###### Returns -###### Parameters +[`InboxMessage`](#inboxmessage)[] -###### finding +##### pending() -[`AnalystFindingEvent`](#analystfindingevent) +> **pending**(): `number` ###### Returns -`Promise`\<`void`\> - -#### Methods +`number` -##### settled() +##### freshInterrupt() -> **settled**(): readonly [`SettledWorker`](mcp.md#settledworker)[] +> **freshInterrupt**(): `AbortSignal` -The coordination tools' settled-worker ledger (for the driver's finalize). +Open a fresh per-turn interrupt signal; a later forceful `deliver` aborts it. The loop links + this into the signal it passes to its inference call, then re-plans when it fires. ###### Returns -readonly [`SettledWorker`](mcp.md#settledworker)[] +`AbortSignal` -##### isStopped() +##### fold() -> **isStopped**(): `boolean` +> **fold**(`messages`): `string` -###### Returns +Render drained messages as ONE operator turn to fold into the worker's conversation. -`boolean` +###### Parameters -##### close() +###### messages -> **close**(): `Promise`\<`void`\> +readonly [`InboxMessage`](#inboxmessage)[] ###### Returns -`Promise`\<`void`\> +`string` *** -### DelegateOptions - -Inputs to [delegate](#delegate). The intent is the first positional arg; everything here is optional - with sensible defaults, so the common call is `delegate(intent, { backend, router })`. - -#### Type Parameters - -##### Out - -`Out` = `unknown` +### SupervisorSpanOptions #### Properties -##### deliverable? +##### runId -> `readonly` `optional` **deliverable?**: [`DeliverableSpec`](#deliverablespec)\<`Out`\> +> `readonly` **runId**: `string` -The completion oracle (settled ⟺ delivered) the authored workers settle against. Strongly - recommended — without it the supervisor trusts a worker's self-report. For a code intent, - `patchDelivered()` is the canonical example; for a free-form answer, a content check. +The supervised run id (`SupervisorOpts.runId`). Roots the trace, identifies the root span, and +is the parent lookup key for every depth-0 spawn (a root scope's `parentId` IS the run id). -##### backend? +##### exporter? -> `readonly` `optional` **backend?**: [`ExecutorConfig`](#executorconfig) +> `readonly` `optional` **exporter?**: [`OtelExporter`](index.md#otelexporter) -WHERE the authored workers run — the worker-execution backend (`router-tools` / `sandbox` / - `cli-worktree` / …). The supervisor authors the worker PROFILE; this is the substrate it runs - on. Provide this OR `makeWorkerAgent`-style wiring through `supervise()` is unavailable. +Bring your own exporter. It is FLUSHED but never shut down by `finish()` — a caller that owns +the exporter owns its lifecycle. Takes precedence over `exportConfig`. -##### budget? +##### exportConfig? -> `readonly` `optional` **budget?**: [`Budget`](index.md#budget-4) +> `readonly` `optional` **exportConfig?**: [`OtelExportConfig`](index.md#otelexportconfig) -The conserved compute pool for the whole delegation. Defaults to [defaultDelegateBudget](#defaultdelegatebudget). +Otherwise build one with [createOtelExporter](index.md#createotelexporter). With no `endpoint` here it reads +`OTEL_EXPORTER_OTLP_ENDPOINT`, and with neither it resolves to `undefined` — which makes +[createSupervisorSpanRecorder](#createsupervisorspanrecorder) return `undefined` and the run emit nothing. -##### model? +##### traceId? -> `readonly` `optional` **model?**: `string` +> `readonly` `optional` **traceId?**: `string` -The model the supervisor BRAIN runs on (the router model). The brain must tool-call - (`spawn_agent` / `await_event`), so a delegator model, not a hidden-reasoning model. +Trace id (32 hex chars). Pass the caller's own to JOIN an outer trace. Default: derived +deterministically from `runId`, so a resumed run lands in the SAME trace as the process that +started it. -##### router? +##### parentSpanId? -> `readonly` `optional` **router?**: [`RouterConfig`](#routerconfig) +> `readonly` `optional` **parentSpanId?**: `string` -The supervisor brain's router substrate. REQUIRED for the default router-brained supervisor - (the brain is resolved from this), unless a test injects `brain` directly. `model` overrides - `router.model`. (Design delta vs the bare `supervise()` profile: the brain needs a router.) +Parent span id (16 hex chars) to hang the run's root span under — an inherited delegation span. -##### brain? +##### agentName? -> `readonly` `optional` **brain?**: [`ToolLoopChat`](#toolloopchat) +> `readonly` `optional` **agentName?**: `string` -Inject the supervisor brain directly (tests / advanced) instead of resolving it from `router`. +`agent.name` on the root span. Default `'supervisor'`. -##### supervisor? +##### attributes? -> `readonly` `optional` **supervisor?**: `Partial`\<`Pick`\<[`SupervisorProfile`](#supervisorprofile), `"name"` \| `"systemPrompt"`\>\> +> `readonly` `optional` **attributes?**: [`SupervisorSpanAttributes`](#supervisorspanattributes) -Override the default authoring-supervisor profile (name / extra system-prompt stance). The - default already carries the authoring skill; override only to add a goal or rename. +Extra attributes stamped on every span this recorder emits (subject, workspace, campaign, …). -##### allowedModels? +##### now? -> `readonly` `optional` **allowedModels?**: readonly `string`[] +> `readonly` `optional` **now?**: () => `number` -Restrict the run to this subset of models (forwarded to `supervise()`). +Injectable clock; used only for the root span's start/end. Default `Date.now`. -##### runId? +###### Returns -> `readonly` `optional` **runId?**: `string` +`number` *** -### WatchTraceOptions +### SupervisorSpanOutcome -#### Properties +How the supervised run ended, as `finish()` records it on the root span. -##### detectors? +#### Properties -> `readonly` `optional` **detectors?**: readonly `StreamingDetector`[] +##### result? -The detectors to run online. Defaults to a stuck-loop + error-streak panel. +> `readonly` `optional` **result?**: [`SupervisedResult`](index.md#supervisedresult)\<`unknown`\> -##### onSignal? +##### error? -> `readonly` `optional` **onSignal?**: (`signal`, `span`) => `void` \| `Promise`\<`void`\> +> `readonly` `optional` **error?**: `unknown` -Fired for each signal a detector raises — the seam that raises a `finding` on the bus. +A rejection out of the run itself (the supervisor never resolved). -###### Parameters +*** -###### signal +### SupervisorSpanRecorder -`DetectorSignal` +#### Properties -###### span +##### hooks -`ToolSpan` +> `readonly` **hooks**: [`RuntimeHooks`](index.md#runtimehooks) -###### Returns +Attach to `SupervisorOpts.hooks` (compose with a caller's own via `composeRuntimeHooks`). -`void` \| `Promise`\<`void`\> +##### traceId -*** +> `readonly` **traceId**: `string` -### DispatchUnit +The trace every span of this run belongs to. -One unit of queued work: the agent to run, its task, and the spawn options (budget + label). - `nextUnit` mints these lazily so a queue can be generated, re-ordered, or grown while the - dispatcher runs. +##### rootSpanId -#### Type Parameters +> `readonly` **rootSpanId**: `string` -##### Out +The run's root span id — pass it to a child process to join this trace. -`Out` +##### workerTrace -#### Properties +> `readonly` **workerTrace**: [`WorkerTraceResolver`](#workertraceresolver) -##### agent +The trace context a worker spawned BY node `spawningNodeId` should inherit, so its own spans +join THIS trace under the span of the node that spawned it. Thread it to a run as +`SupervisorOpts.workerTrace` (`supervise()` does this whenever it builds a recorder) and the +`Scope` seeds it onto every child's `ExecutorContext`; a backend with an environment channel +stamps it with `workerTraceEnv`. An unknown node — one whose span was never opened — resolves +to the run's root span rather than to nothing, so a worker is never filed outside its own run. -> `readonly` **agent**: [`Agent`](#agent-1)\<`unknown`, `Out`\> +#### Methods -##### task +##### finish() -> `readonly` **task**: `unknown` +> **finish**(`outcome?`): `Promise`\<`void`\> -##### opts +Close the root span (and any node that never settled, marked as such), export, and flush. Safe +to call twice; never throws — a telemetry failure is not a run failure. -> `readonly` **opts**: [`SpawnOpts`](#spawnopts) +###### Parameters -*** +###### outcome? -### RollingDispatchOptions +[`SupervisorSpanOutcome`](#supervisorspanoutcome) -#### Type Parameters +###### Returns -##### Out +`Promise`\<`void`\> -`Out` +*** -#### Properties +### PatchDeliverableOptions -##### width +**`Experimental`** -> `readonly` **width**: `number` +#### Extends -How many children to hold in flight. Must be a positive integer. This is a SIMULTANEITY fence -only — the conserved pool still bounds total work, and a `width` larger than the pool can -afford simply hits `not-admitted` sooner. Derive it with `effectiveConcurrency` when the host -also runs a fleet-level box governor. +- `CoderCheckConstraints` -#### Methods +#### Extended by -##### nextUnit() +- [`WorktreeFanoutOptions`](#worktreefanoutoptions) -> **nextUnit**(): [`DispatchUnit`](#dispatchunit)\<`Out`\> \| `Promise`\<[`DispatchUnit`](#dispatchunit)\<`Out`\> \| `undefined`\> \| `undefined` +#### Properties -Produce the next unit of work, or `undefined` when the queue is dry. Called only when a slot -is free, so a caller may compute the next unit from what has already settled (the point of a -refilling dispatcher: the queue is allowed to react). Never called after a stop. +##### maxDiffLines? -###### Returns +> `optional` **maxDiffLines?**: `number` -[`DispatchUnit`](#dispatchunit)\<`Out`\> \| `Promise`\<[`DispatchUnit`](#dispatchunit)\<`Out`\> \| `undefined`\> \| `undefined` +**`Experimental`** -##### onSettled()? +Default 400. Hard cap; gate fails when exceeded. -> `optional` **onSettled**(`settled`): `void` \| `Promise`\<`void`\> +###### Inherited from -Called once per settlement, in cursor order, BEFORE the freed slot is refilled — so an -`onSettled` that appends to the caller's queue is visible to the very next `nextUnit`. +`CoderCheckConstraints.maxDiffLines` -###### Parameters +##### forbiddenPaths? -###### settled +> `optional` **forbiddenPaths?**: `string`[] -[`Settled`](index.md#settled)\<`Out`\> +**`Experimental`** -###### Returns +Literal path prefixes the patch must not touch. -`void` \| `Promise`\<`void`\> +###### Inherited from -##### shouldStop()? +`CoderCheckConstraints.forbiddenPaths` -> `optional` **shouldStop**(): `boolean` +##### require? -Consulted before each admission. `true` stops admitting; the already-live children are still -drained to completion (no orphan, no lost settlement). Use it for a progress/plateau rule. +> `optional` **require?**: readonly (`"tests"` \| `"typecheck"`)[] -###### Returns +**`Experimental`** -`boolean` +Which verification signals the gate REQUIRES to be present-and-passing. A required signal +that the artifact never derived (the command was not configured on the executor) fails the +gate closed. Unlisted signals default to passed-when-absent (the executor simply didn't run +that command). Default `[]` — gate on no-op / secret / forbidden / diff-size only. *** -### DispatchReport +### PiSeam -#### Type Parameters +How to launch pi in its out-of-process RPC mode, and how long to wait on it. -##### Out +#### Properties -`Out` +##### bin? -#### Properties +> `optional` **bin?**: `string` -##### settled +The pi executable (default `'pi'`). Anything on PATH or an absolute path. -> `readonly` **settled**: readonly [`Settled`](index.md#settled)\<`Out`\>[] +##### args? -Every settlement, in the order `scope.next()` yielded them. +> `optional` **args?**: readonly `string`[] -##### admitted +Extra args appended after `--mode rpc`. `--provider` / `--model` are added from `model`. -> `readonly` **admitted**: `number` +##### model? -How many children this dispatcher admitted. +> `optional` **model?**: `string` -##### rejected +`provider/model` or just `model` — split on the first `/` into pi's two flags. -> `readonly` **rejected**: readonly `string`[] +##### cwd? -Admission rejections, in order — `label: reason`. Non-empty ⇒ the pool or depth fenced. +> `optional` **cwd?**: `string` -##### stopReason +##### env? -> `readonly` **stopReason**: [`DispatchStopReason`](#dispatchstopreason) +> `optional` **env?**: `Record`\<`string`, `string`\> -##### peakLive +##### turnTimeoutMs? -> `readonly` **peakLive**: `number` +> `optional` **turnTimeoutMs?**: `number` -The highest simultaneous live count actually reached — the number to compare against - `width` when asking "did the slots really stay full?" +Wall-clock ceiling for one `prompt` (the wait for `agent_settled`). Omit = no timeout. + +##### activityWindow? + +> `optional` **activityWindow?**: `number` + +Newest-last activity window `progress()` reports. Default 12. *** -### ConcurrencyCaps +### PiExecutorOutput -The caps a host can set on simultaneous work. See the ledger in this module's header for what - each one actually bounds. +What one pi run reports about the terminal assistant turn, plus any derived MCP mount. #### Properties -##### maxLiveWorkers? +##### content -> `readonly` `optional` **maxLiveWorkers?**: `number` +> **content**: `string` -Supervisor level: max spawned-but-unsettled workers. +##### turns -##### maxSandboxes? +> **turns**: `number` -> `readonly` `optional` **maxSandboxes?**: `number` +##### mcp? -Fleet level: max live sandboxes/boxes across the host process (a `ComputeGovernor`-style - cap). Applies to the worker layer, so it participates in the minimum. +> `optional` **mcp?**: [`PiMcpReceipt`](#pimcpreceipt) + +Present only when `profile.mcp` declared at least one usable server. Records what pi was + actually given — including an extension this executor added that the profile did not list. *** -### BusEvent +### PiMcpMount -Every bus event is a discriminated union member keyed by `type`. +What the caller must call once pi has exited, and where the config landed. #### Properties -##### type - -> `readonly` **type**: `string` +##### configPath -*** +> **configPath**: `string` -### BusRecord +Absolute path of the file passed to `--mcp-config`. Unique to this worker execution. -A published event stamped for ordering and observability. `seq` is the monotonic publish index; - `priority` drives pull order (higher = bumped ahead); `at` is the wall-clock publish time (ms). +##### serverNames -#### Type Parameters +> **serverNames**: `string`[] -##### E +Server names actually written, post-filter — never the raw `profile.mcp` keys. -`E` *extends* [`BusEvent`](#busevent) +#### Methods -#### Properties +##### cleanup() -##### seq +> **cleanup**(): `void` -> `readonly` **seq**: `number` +Remove the private config directory. Idempotent; safe to call on any exit path. -##### at +###### Returns -> `readonly` **at**: `number` +`void` -##### priority +*** -> `readonly` **priority**: `number` +### PiMcpReceipt -##### event +What pi was actually given, as opposed to what the profile declared. This is the observable that +makes rule 2 above honest: `adapterInjected` is `true` exactly when this module added an extension +the caller did not ask for, because without it the MCP servers the caller DID ask for could not +have mounted. -> `readonly` **event**: `E` +#### Properties -*** +##### servers -### PublishOptions +> **servers**: `string`[] -#### Properties +Server names written into `configPath`, in declaration order. -##### priority? +##### configPath -> `readonly` `optional` **priority?**: `number` +> **configPath**: `string` -Higher = pulled ahead of lower-priority queued events (default 0). A blocking question sets - this so it bumps to the front of the driver's inbox. +Absolute path of the mounted config. -##### queue? +##### extensions -> `readonly` `optional` **queue?**: `boolean` +> **extensions**: `string`[] -Whether the event enters the pull queue (default true). Set `false` for record-only events — - the parent→child down-leg (steer / answer / resume): they belong in `history()` and reach - `subscribe` observers, but the parent must never `pull` its own outbound message back. +`extensions.pi.load` entries as pi received them, resolved to absolute entry files. -*** +##### adapterInjected -### BusStats +> **adapterInjected**: `boolean` -#### Properties +True when `pi-mcp-adapter` was absent from an explicit `load` array and was added here. -##### published +*** -> `readonly` **published**: `number` +### PiMcpPreparation -##### pulled +Everything `piExecutor` needs between "profile in hand" and "pi spawned". -> `readonly` **pulled**: `number` +#### Properties -##### byKind +##### args -> `readonly` **byKind**: `Readonly`\<`Record`\<`string`, `number`\>\> +> **args**: `string`[] -Count published per event `type`. +Extra argv for `pi`, in flag order: extension flags first, then `--mcp-config `. -*** +##### mount -### EventBus +> **mount**: [`PiMcpMount`](#pimcpmount) \| `null` -#### Type Parameters +Present only when at least one usable MCP server was declared. -##### E +##### receipt -`E` *extends* [`BusEvent`](#busevent) +> **receipt**: [`PiMcpReceipt`](#pimcpreceipt) \| `undefined` -#### Methods +Present only when a mount happened — the derived-versus-declared record. -##### publish() +*** -> **publish**(`event`, `opts?`): `Promise`\<[`BusRecord`](#busrecord)\<`E`\>\> +### PiMcpMountOptions -Stamp + queue the event, then deliver the stamped record to every subscriber in order. - Returns the stamped record. +Where one worker execution's private config directory is created, and what it is called. -###### Parameters +#### Properties -###### event +##### cwd? -`E` +> `optional` **cwd?**: `string` -###### opts? +The worker's own working directory (`PiSeam.cwd`). Omit when the seam names none: the config +then lands in the OS temp directory. It is NEVER written into `process.cwd()` — the operator's +own working directory is not a scratch space for a worker's config. -[`PublishOptions`](#publishoptions) +##### runId -###### Returns +> **runId**: `string` -`Promise`\<[`BusRecord`](#busrecord)\<`E`\>\> +The executor's per-execution run id, folded into the directory name so a directory that somehow +survives names the worker that left it. Uniqueness is NOT taken from this — `mkdtemp` provides +it — because two workers built from one factory in the same millisecond share a run id stem. -##### pull() +*** -> **pull**(`kinds?`): `E` \| `undefined` +### ActivityNote -Remove and return the highest-priority QUEUED event whose type is in `kinds` (any if omitted), - ties broken FIFO by `seq`; `undefined` when nothing matches. +The most recent activity the executor can name — one tool call, one turn, or a free-form note. + `label` is the tool/file/turn name; `detail` is a short, already-truncated descriptor (a path, + a command head) that a driver can read without pulling the whole transcript. -###### Parameters +#### Properties -###### kinds? +##### at -readonly `E`\[`"type"`\][] +> `readonly` **at**: `number` -###### Returns +##### kind -`E` \| `undefined` +> `readonly` **kind**: `"tool"` \| `"turn"` \| `"note"` -##### subscribe() +##### label -> **subscribe**(`handler`): () => `void` +> `readonly` **label**: `string` -Register a pass-through handler; it receives the stamped record of every event published after - registration. Returns an unsubscribe fn. +##### status? -###### Parameters +> `readonly` `optional` **status?**: `"error"` \| `"ok"` -###### handler +##### detail? -(`record`) => `void` \| `Promise`\<`void`\> +> `readonly` `optional` **detail?**: `string` -###### Returns +*** -() => `void` +### ExecutorProgress -##### pending() +What an executor OPTIONALLY adds to the scope-derived progress (`Executor.progress()`). Every + field is optional: an executor that knows only its own turn count reports only that. -> **pending**(`kinds?`): `number` +#### Properties -Count of queued, not-yet-pulled events (filtered by `kinds` when given). +##### turns? -###### Parameters +> `readonly` `optional` **turns?**: `number` -###### kinds? +The executor's own turn/step count when it is more meaningful than metered iterations. -readonly `E`\[`"type"`\][] +##### pendingMessages? -###### Returns +> `readonly` `optional` **pendingMessages?**: `number` -`number` +Steers/answers delivered but not yet folded into the worker's conversation. -##### history() +##### recentActivity? -> **history**(): readonly [`BusRecord`](#busrecord)\<`E`\>[] +> `readonly` `optional` **recentActivity?**: readonly [`ActivityNote`](#activitynote)[] -The full ordered log of every event ever published (the audit/replay trail). +Newest-last window of what the worker has been doing. -###### Returns +##### derived? -readonly [`BusRecord`](#busrecord)\<`E`\>[] +> `readonly` `optional` **derived?**: readonly `string`[] -##### stats() +What the executor CHANGED about what the caller declared, one short line each — an MCP config +it materialized, an extension it had to add for the caller's own servers to mount at all. -> **stats**(): [`BusStats`](#busstats) +Deliberately NOT part of `recentActivity`: that is a bounded newest-last ring, so a derived +change made before the first turn is evicted by turn 13 and gone by the time anyone looks. And +deliberately not only on the settled artifact: a run that fails on turn 40 never produces one, +yet "what was this worker actually given?" is exactly the question a failure raises. This +channel is append-only and readable at any moment, including from a run that never finishes. -Throughput counters for observability dashboards. +##### note? -###### Returns +> `readonly` `optional` **note?**: `string` -[`BusStats`](#busstats) +A one-line human-readable state ("turn 3, running tests"). *** -### FinalizerSettled +### WorkerProgress -One settled worker as the finalizer sees it — the ledger row (structural fields only). +The full live view of one worker, as `observe_agent` returns it mid-flight. #### Properties @@ -10143,2528 +11319,2496 @@ One settled worker as the finalizer sees it — the ledger row (structural field ##### status -> `readonly` **status**: `"done"` \| `"down"` - -##### score? - -> `readonly` `optional` **score?**: `number` - -##### valid? - -> `readonly` `optional` **valid?**: `boolean` - -##### outRef? +> `readonly` **status**: [`NodeStatus`](#nodestatus) -> `readonly` `optional` **outRef?**: `string` +##### live -##### reason? +> `readonly` **live**: `boolean` -> `readonly` `optional` **reason?**: `string` +True while the node is neither done, failed, nor cancelled — i.e. a steer could still land. -*** +##### steerable -### DeliveredOutput +> `readonly` **steerable**: `boolean` -One DELIVERED child, materialized: settled `done`, oracle-passed, output rehydrated. `out` is - `undefined` only when the child settled without an `outRef` (no artifact to rehydrate). +True when this worker's executor exposes an inbox (`Executor.deliver`) — i.e. `steer_agent` + can actually reach it. False means a steer would be recorded and dropped. -#### Properties +##### startedAt -##### id +> `readonly` **startedAt**: `number` -> `readonly` **id**: `string` +##### lastActivityAt -##### score? +> `readonly` **lastActivityAt**: `number` -> `readonly` `optional` **score?**: `number` +Epoch ms of the last metered usage event or executor-reported activity. -##### outRef? +##### idleMs -> `readonly` `optional` **outRef?**: `string` +> `readonly` **idleMs**: `number` -##### out? +##### stalled -> `readonly` `optional` **out?**: `unknown` +> `readonly` **stalled**: `boolean` -*** +##### stallAfterMs -### InboxMessage +> `readonly` **stallAfterMs**: `number` -**`Experimental`** +##### turns -The worker-side receive end of the down-leg: a per-worker inbox an executor exposes as -`Executor.deliver`. The driver's `steer_agent` / `answer_question` land here, -and the worker's agent loop drains them at two points (Drew's two delivery modes): +> `readonly` **turns**: `number` - - QUEUED (default): the message accumulates and is FLUSHED at the next step boundary — folded - into the conversation before the next think. A worker is also forced to flush BEFORE it may - settle, so it can never finish while a steer/answer it never read is still pending. - - FORCEFUL (`interrupt: true`): trips `freshInterrupt()`'s signal so the loop can abort its - in-flight turn immediately, then re-plan with the message folded in — breaking the worker out - of a wrong path mid-task instead of waiting for it to finish the step. +Metered iterations so far (the executor's own count when it reports one). -`deliver` never throws — a malformed message is ignored, per the `Executor.deliver` contract. +##### tokens -#### Properties +> `readonly` **tokens**: `object` -##### kind +###### input -> `readonly` **kind**: `"steer"` \| `"answer"` +> `readonly` **input**: `number` -**`Experimental`** +###### output -##### text +> `readonly` **output**: `number` -> `readonly` **text**: `string` +##### tokensKnown? -**`Experimental`** +> `readonly` `optional` **tokensKnown?**: `boolean` -##### interrupt +False when observed `tokens` is only a known subtotal, not a complete total — the worker did + work whose token count its provider never reported. The twin of `usdKnown`, carried for the + same reason: a driver reading this over `observe_agent` would otherwise read the subtotal as + the measurement and conclude a busy worker was cheap. -> `readonly` **interrupt**: `boolean` +##### usd -**`Experimental`** +> `readonly` **usd**: `number` -Forceful messages abort the in-flight turn; queued ones wait for the boundary flush. +##### usdKnown? -##### questionId? +> `readonly` `optional` **usdKnown?**: `boolean` -> `readonly` `optional` **questionId?**: `string` +False when observed dollar spend is only a known subtotal, not a complete total. -**`Experimental`** +##### pendingMessages -Present for an `answer` — the question id it resolves. +> `readonly` **pendingMessages**: `number` -*** +Steers delivered but not yet read by the worker. -### Inbox +##### recentActivity -#### Methods +> `readonly` **recentActivity**: readonly [`ActivityNote`](#activitynote)[] -##### deliver() +Newest-last window of tool/turn activity; empty when the executor exposes none. -> **deliver**(`msg`): `void` +##### derived? -The `Executor.deliver` implementation — accept a raw down-message from `Scope.send`. +> `readonly` `optional` **derived?**: readonly `string`[] -###### Parameters +What the executor changed about the caller's declaration; absent when it changed nothing. + Unlike `recentActivity` this is never evicted, so it still answers on a failed run. -###### msg +##### note? -`unknown` +> `readonly` `optional` **note?**: `string` -###### Returns +*** -`void` +### ActivityLog -##### drain() +A bounded newest-last ring of `ActivityNote`s an executor keeps to answer `progress()`. -> **drain**(): [`InboxMessage`](#inboxmessage)[] +#### Methods -Remove and return all pending messages (the flush). +##### push() -###### Returns +> **push**(`note`): `void` -[`InboxMessage`](#inboxmessage)[] +###### Parameters -##### pending() +###### note -> **pending**(): `number` +[`ActivityNote`](#activitynote) ###### Returns -`number` +`void` -##### freshInterrupt() +##### read() -> **freshInterrupt**(): `AbortSignal` +> **read**(): readonly [`ActivityNote`](#activitynote)[] -Open a fresh per-turn interrupt signal; a later forceful `deliver` aborts it. The loop links - this into the signal it passes to its inference call, then re-plans when it fires. +Newest-last, at most `limit` entries. ###### Returns -`AbortSignal` +readonly [`ActivityNote`](#activitynote)[] -##### fold() +##### last() -> **fold**(`messages`): `string` +> **last**(): [`ActivityNote`](#activitynote) \| `undefined` -Render drained messages as ONE operator turn to fold into the worker's conversation. +###### Returns -###### Parameters +[`ActivityNote`](#activitynote) \| `undefined` -###### messages +##### size() -readonly [`InboxMessage`](#inboxmessage)[] +> **size**(): `number` ###### Returns -`string` +`number` *** -### SupervisorSpanOptions - -#### Properties +### ScopeProgressInput -##### runId +The scope-side facts about a child, independent of whether its executor cooperates. -> `readonly` **runId**: `string` +#### Properties -The supervised run id (`SupervisorOpts.runId`). Roots the trace, identifies the root span, and -is the parent lookup key for every depth-0 spawn (a root scope's `parentId` IS the run id). +##### id -##### exporter? +> `readonly` **id**: `string` -> `readonly` `optional` **exporter?**: [`OtelExporter`](index.md#otelexporter) +##### status -Bring your own exporter. It is FLUSHED but never shut down by `finish()` — a caller that owns -the exporter owns its lifecycle. Takes precedence over `exportConfig`. +> `readonly` **status**: [`NodeStatus`](#nodestatus) -##### exportConfig? +##### steerable -> `readonly` `optional` **exportConfig?**: [`OtelExportConfig`](index.md#otelexportconfig) +> `readonly` **steerable**: `boolean` -Otherwise build one with [createOtelExporter](index.md#createotelexporter). With no `endpoint` here it reads -`OTEL_EXPORTER_OTLP_ENDPOINT`, and with neither it resolves to `undefined` — which makes -[createSupervisorSpanRecorder](#createsupervisorspanrecorder) return `undefined` and the run emit nothing. +##### startedAt -##### traceId? +> `readonly` **startedAt**: `number` -> `readonly` `optional` **traceId?**: `string` +##### lastActivityAt -Trace id (32 hex chars). Pass the caller's own to JOIN an outer trace. Default: derived -deterministically from `runId`, so a resumed run lands in the SAME trace as the process that -started it. +> `readonly` **lastActivityAt**: `number` -##### parentSpanId? +##### turns -> `readonly` `optional` **parentSpanId?**: `string` +> `readonly` **turns**: `number` -Parent span id (16 hex chars) to hang the run's root span under — an inherited delegation span. +##### tokens -##### agentName? +> `readonly` **tokens**: `object` -> `readonly` `optional` **agentName?**: `string` +###### input -`agent.name` on the root span. Default `'supervisor'`. +> `readonly` **input**: `number` -##### attributes? +###### output -> `readonly` `optional` **attributes?**: [`SupervisorSpanAttributes`](#supervisorspanattributes) +> `readonly` **output**: `number` -Extra attributes stamped on every span this recorder emits (subject, workspace, campaign, …). +##### tokensKnown? -##### now? +> `readonly` `optional` **tokensKnown?**: `boolean` -> `readonly` `optional` **now?**: () => `number` +##### usd -Injectable clock; used only for the root span's start/end. Default `Date.now`. +> `readonly` **usd**: `number` -###### Returns +##### usdKnown? -`number` +> `readonly` `optional` **usdKnown?**: `boolean` *** -### SupervisorSpanOutcome +### InMemoryRunContextOptions -How the supervised run ended, as `finish()` records it on the root span. +Options for a supervised run context. #### Properties -##### result? - -> `readonly` `optional` **result?**: [`SupervisedResult`](index.md#supervisedresult)\<`unknown`\> - -##### error? +##### withDriver? -> `readonly` `optional` **error?**: `unknown` +> `readonly` `optional` **withDriver?**: `boolean` -A rejection out of the run itself (the supervisor never resolved). +Wrap the executor registry with `withDriverExecutor` so a spawned child marked +`role: 'driver'` resolves to the recursive driver-executor (agents driving agents +over a nested `Scope` on the same conserved pool). Leave `false` for a flat tree of +leaf workers. Default `false`. *** -### SupervisorSpanRecorder - -#### Properties - -##### hooks - -> `readonly` **hooks**: [`RuntimeHooks`](index.md#runtimehooks) - -Attach to `SupervisorOpts.hooks` (compose with a caller's own via `composeRuntimeHooks`). - -##### traceId +### InMemoryRunContext -> `readonly` **traceId**: `string` +The bundle of stores a supervised run needs, shaped to spread into `SupervisorOpts`. +The fields are exactly `SupervisorOpts`' `journal` / `blobs` / `executors`. -The trace every span of this run belongs to. +#### Properties -##### rootSpanId +##### journal -> `readonly` **rootSpanId**: `string` +> `readonly` **journal**: [`SpawnJournal`](#spawnjournal) -The run's root span id — pass it to a child process to join this trace. +##### blobs -##### workerTrace +> `readonly` **blobs**: [`ResultBlobStore`](#resultblobstore) -> `readonly` **workerTrace**: [`WorkerTraceResolver`](#workertraceresolver) +##### executors -The trace context a worker spawned BY node `spawningNodeId` should inherit, so its own spans -join THIS trace under the span of the node that spawned it. Thread it to a run as -`SupervisorOpts.workerTrace` (`supervise()` does this whenever it builds a recorder) and the -`Scope` seeds it onto every child's `ExecutorContext`; a backend with an environment channel -stamps it with `workerTraceEnv`. An unknown node — one whose span was never opened — resolves -to the run's root span rather than to nothing, so a worker is never filed outside its own run. +> `readonly` **executors**: [`ExecutorRegistry`](index.md#executorregistry) -#### Methods +##### resume? -##### finish() +> `readonly` `optional` **resume?**: `boolean` -> **finish**(`outcome?`): `Promise`\<`void`\> +Present (and `true`) only on a DURABLE context (`createFileRunContext`), so spreading the +context into `SupervisorOpts` also opts the run into resume-first. An in-memory context +leaves it undefined: there is never a prior tree to resume, and the default stays fresh-run. -Close the root span (and any node that never settled, marked as such), export, and flush. Safe -to call twice; never throws — a telemetry failure is not a run failure. +##### coordinationLog? -###### Parameters +> `readonly` `optional` **coordinationLog?**: [`CoordinationLog`](#coordinationlog) -###### outcome? +Present only on a DURABLE context: the coordination side-log stores questions, analyst +findings, answer decisions, and authorized continuation receipts that the spawn journal does +not own. `supervise({ runDir })` appends them as they publish and loads them on resume. +Continuation receipts are evidence and are never auto-delivered to a replacement worker. +In-memory contexts have none: nothing outlives the process. -[`SupervisorSpanOutcome`](#supervisorspanoutcome) +*** -###### Returns +### WorkerSteerRequest -`Promise`\<`void`\> +One durable down-leg request appended to a worker's inbox file. -*** +#### Properties -### PatchDeliverableOptions +##### id -**`Experimental`** +> `readonly` **id**: `string` -#### Extends +##### at -- `CoderCheckConstraints` +> `readonly` **at**: `string` -#### Extended by +ISO timestamp of the append. -- [`WorktreeFanoutOptions`](#worktreefanoutoptions) +##### source -#### Properties +> `readonly` **source**: `string` -##### maxDiffLines? +Who asked — 'human', a brain label, a tool name. Provenance, not authorization. -> `optional` **maxDiffLines?**: `number` +##### worker -**`Experimental`** +> `readonly` **worker**: `string` -Default 400. Hard cap; gate fails when exceeded. +The worker LABEL the request targets (already resolved by the caller). -###### Inherited from +##### message -`CoderCheckConstraints.maxDiffLines` +> `readonly` **message**: `string` -##### forbiddenPaths? +*** -> `optional` **forbiddenPaths?**: `string`[] +### RouterSeam -**`Experimental`** +Router/inline connection seam. A direct OpenAI-compatible Router endpoint — +the cheapest leaf, no box, no tools. `model` overrides the profile's model +hint when present; otherwise the profile's `model.default` is required. -Literal path prefixes the patch must not touch. +#### Properties -###### Inherited from +##### routerBaseUrl -`CoderCheckConstraints.forbiddenPaths` +> **routerBaseUrl**: `string` -##### require? +##### routerKey -> `optional` **require?**: readonly (`"tests"` \| `"typecheck"`)[] +> **routerKey**: `string` -**`Experimental`** +##### model? -Which verification signals the gate REQUIRES to be present-and-passing. A required signal -that the artifact never derived (the command was not configured on the executor) fails the -gate closed. Unlisted signals default to passed-when-absent (the executor simply didn't run -that command). Default `[]` — gate on no-op / secret / forbidden / diff-size only. +> `optional` **model?**: `string` *** -### PiSeam +### SandboxSeam -How to launch pi in its out-of-process RPC mode, and how long to wait on it. +Sandbox executor seam. The `sandboxClient` the composed `runAgentRounds` creates +boxes through, plus the optional trace/run/lineage wiring forwarded into the +loop. `lineage` is opaque here (PR #150's `RunAgentRoundsOptions.lineage`): forwarded +forward-compatibly, never inspected — this executor does NOT reinvent +checkpoint/fork. #### Properties -##### bin? - -> `optional` **bin?**: `string` - -The pi executable (default `'pi'`). Anything on PATH or an absolute path. - -##### args? - -> `optional` **args?**: readonly `string`[] +##### sandboxClient -Extra args appended after `--mode rpc`. `--provider` / `--model` are added from `model`. +> **sandboxClient**: [`SandboxClient`](#sandboxclient-5) -##### model? +##### loopCtx? -> `optional` **model?**: `string` +> `optional` **loopCtx?**: `Partial`\<`Omit`\<[`ExecCtx`](#execctx), `"signal"` \| `"sandboxClient"`\>\> -`provider/model` or just `model` — split on the first `/` into pi's two flags. +Forwarded into the composed `runAgentRounds`'s `ctx` (trace emitter, run handle, etc.). -##### cwd? +##### lineage? -> `optional` **cwd?**: `string` +> `optional` **lineage?**: `unknown` -##### env? +PR #150 `RunAgentRoundsOptions.lineage` passthrough — opaque; forwarded, not parsed. -> `optional` **env?**: `Record`\<`string`, `string`\> +##### maxIterations? -##### turnTimeoutMs? +> `optional` **maxIterations?**: `number` -> `optional` **turnTimeoutMs?**: `number` +Hard cap on the composed loop's iterations. The budget pool reserves against + the spawn `Budget.maxIterations`; this is the leaf's own ceiling. Default 1. -Wall-clock ceiling for one `prompt` (the wait for `agent_settled`). Omit = no timeout. +##### steering? -##### activityWindow? +> `optional` **steering?**: [`SandboxSteeringOptions`](#sandboxsteeringoptions) -> `optional` **activityWindow?**: `number` +OPT-IN: run this worker as a multi-turn, STEERABLE session instead of the historical +single-shot `runAgentRounds` composition. Setting it gives the sandbox worker an `Executor.deliver` +inbox (so `Scope.send` / `steer_agent` actually reach it), a live tool-activity trace, and a +`progress()` read — turning the default cloud worker from something a supervisor can only +wait on into something it can watch and correct. -Newest-last activity window `progress()` reports. Default 12. +Absent, nothing changes: the same `runAgentRounds` leaf, no inbox, `steer_agent` still reports +`delivered:false`. Opt-in because a steerable worker holds ONE box across several turns, +which is a different resource profile from a fire-and-forget shot. *** -### PiExecutorOutput +### CliSeam -What one pi run reports about the terminal assistant turn, plus any derived MCP mount. +CLI subprocess seam. `bin` + `args` describe the Halo/RLM process to spawn. #### Properties -##### content +##### bin -> **content**: `string` +> **bin**: `string` -##### turns +##### args? -> **turns**: `number` +> `optional` **args?**: `string`[] -##### mcp? +##### env? -> `optional` **mcp?**: [`PiMcpReceipt`](#pimcpreceipt) +> `optional` **env?**: `Record`\<`string`, `string`\> -Present only when `profile.mcp` declared at least one usable server. Records what pi was - actually given — including an extension this executor added that the profile did not list. +Extra environment for the subprocess (merged over `process.env`). -*** +##### cwd? -### PiMcpMount +> `optional` **cwd?**: `string` -What the caller must call once pi has exited, and where the config landed. +Working directory for the subprocess. -#### Properties +*** -##### configPath +### CliWorktreeSeam -> **configPath**: `string` +cli-worktree seam. A supervisor-authored `AgentProfile` driving a local coding-harness CLI +(claude / codex / opencode) on its own git worktree — the leaf `createWorktreeCliExecutor` +named as data. `harness` + `repoRoot` are required; the task comes from `Executor.execute`. +`taskPrompt` remains an optional direct-call fallback for callers that execute with `undefined`. +The authored +`profile.prompt.systemPrompt` + `profile.model.default` reach the harness via the §1.5 +`harnessInvocation` mapper. Everything else mirrors `WorktreeCliExecutorOptions`. -Absolute path of the file passed to `--mcp-config`. Unique to this worker execution. +#### Properties -##### serverNames +##### repoRoot -> **serverNames**: `string`[] +> **repoRoot**: `string` -Server names actually written, post-filter — never the raw `profile.mcp` keys. +##### harness? -#### Methods +> `optional` **harness?**: [`LocalHarness`](mcp.md#localharness) -##### cleanup() +Local CLI harness transport. Omit when `bridge` is set. -> **cleanup**(): `void` +##### taskPrompt? -Remove the private config directory. Idempotent; safe to call on any exit path. +> `optional` **taskPrompt?**: `string` -###### Returns +##### runId? -`void` +> `optional` **runId?**: `string` -*** +##### baseRef? -### PiMcpReceipt +> `optional` **baseRef?**: `string` -What pi was actually given, as opposed to what the profile declared. This is the observable that -makes rule 2 above honest: `adapterInjected` is `true` exactly when this module added an extension -the caller did not ask for, because without it the MCP servers the caller DID ask for could not -have mounted. +##### harnessTimeoutMs? -#### Properties +> `optional` **harnessTimeoutMs?**: `number` -##### servers +##### codexReproducible? -> **servers**: `string`[] +> `optional` **codexReproducible?**: `boolean` -Server names written into `configPath`, in declaration order. +Isolated, network-off Codex execution with terminal JSONL usage capture. -##### configPath +##### codexReadDeniedPaths? -> **configPath**: `string` +> `optional` **codexReadDeniedPaths?**: readonly `string`[] -Absolute path of the mounted config. +Absolute host paths denied to reproducible Codex. -##### extensions +##### testCmd? -> **extensions**: `string`[] +> `optional` **testCmd?**: `string` -`extensions.pi.load` entries as pi received them, resolved to absolute entry files. +##### typecheckCmd? -##### adapterInjected +> `optional` **typecheckCmd?**: `string` -> **adapterInjected**: `boolean` +##### checkTimeoutMs? -True when `pi-mcp-adapter` was absent from an explicit `load` array and was added here. +> `optional` **checkTimeoutMs?**: `number` -*** +##### checkOutputCap? -### PiMcpPreparation +> `optional` **checkOutputCap?**: `number` -Everything `piExecutor` needs between "profile in hand" and "pi spawned". +##### budgetExempt? -#### Properties +> `optional` **budgetExempt?**: `boolean` -##### args +##### bridge? -> **args**: `string`[] +> `optional` **bridge?**: [`CliWorktreeBridgeSeam`](#cliworktreebridgeseam) -Extra argv for `pi`, in flag order: extension flags first, then `--mcp-config `. +Live cli-bridge transport inside the worktree. When set, the worktree leaf accepts + `deliver()` messages and resumes the same bridge session in this worktree cwd. -##### mount +##### runGit? -> **mount**: [`PiMcpMount`](#pimcpmount) \| `null` +> `optional` **runGit?**: [`GitRunner`](mcp.md#gitrunner) -Present only when at least one usable MCP server was declared. +Test seam — forwarded to worktree helpers. -##### receipt +##### runCommand? -> **receipt**: [`PiMcpReceipt`](#pimcpreceipt) \| `undefined` +> `optional` **runCommand?**: [`WorktreeCheckRunner`](index.md#worktreecheckrunner) -Present only when a mount happened — the derived-versus-declared record. +Test seam — forwarded to verification checks. *** -### PiMcpMountOptions - -Where one worker execution's private config directory is created, and what it is called. +### CliWorktreeBridgeSeam #### Properties -##### cwd? - -> `optional` **cwd?**: `string` - -The worker's own working directory (`PiSeam.cwd`). Omit when the seam names none: the config -then lands in the OS temp directory. It is NEVER written into `process.cwd()` — the operator's -own working directory is not a scratch space for a worker's config. - -##### runId +##### bridgeUrl -> **runId**: `string` +> **bridgeUrl**: `string` -The executor's per-execution run id, folded into the directory name so a directory that somehow -survives names the worker that left it. Uniqueness is NOT taken from this — `mkdtemp` provides -it — because two workers built from one factory in the same millisecond share a run id stem. +##### bridgeBearer -*** +> **bridgeBearer**: `string` -### ActivityNote +##### model? -The most recent activity the executor can name — one tool call, one turn, or a free-form note. - `label` is the tool/file/turn name; `detail` is a short, already-truncated descriptor (a path, - a command head) that a driver can read without pulling the whole transcript. +> `optional` **model?**: `string` -#### Properties +Bridge model/harness id. Defaults to the profile's model hint when omitted. -##### at +##### agentProfile? -> `readonly` **at**: `number` +> `optional` **agentProfile?**: `AgentProfile` -##### kind +Canonical profile overlay merged over the spawned profile. -> `readonly` **kind**: `"tool"` \| `"turn"` \| `"note"` +##### timeoutMs? -##### label +> `optional` **timeoutMs?**: `number` -> `readonly` **label**: `string` +##### sessionId? -##### status? +> `optional` **sessionId?**: `string` -> `readonly` `optional` **status?**: `"error"` \| `"ok"` +Stable cli-bridge session id. Defaults to `bridge-worktree-${runId}`. -##### detail? +##### maxTurns? -> `readonly` `optional` **detail?**: `string` +> `optional` **maxTurns?**: `number` *** -### ExecutorProgress - -What an executor OPTIONALLY adds to the scope-derived progress (`Executor.progress()`). Every - field is optional: an executor that knows only its own turn count reports only that. - -#### Properties +### BridgeSeam -##### turns? +cli-bridge seam. A local OpenAI-compatible bridge that fronts harness CLIs +(claude-code / opencode / kimi / pi) behind one HTTP surface; `model` doubles +as the harness selector (e.g. `claude-code/sonnet`, `opencode//`). +`agentProfile` is the bridge-dialect profile (metadata.disallowedTools, mcp) +forwarded verbatim per request — how an arm disables native tools or injects +a provider search MCP. -> `readonly` `optional` **turns?**: `number` +The executor opens a resumable cli-bridge session. `sessionId` identifies the +harness conversation across turns; each turn also receives its own durable run id. +A dropped HTTP reader reattaches to that exact run and explicit cancel is the only +operation allowed to stop it. Omit `sessionId` and the executor mints one per spawn. -The executor's own turn/step count when it is more meaningful than metered iterations. +#### Properties -##### pendingMessages? +##### bridgeUrl -> `readonly` `optional` **pendingMessages?**: `number` +> **bridgeUrl**: `string` -Steers/answers delivered but not yet folded into the worker's conversation. +##### bridgeBearer -##### recentActivity? +> **bridgeBearer**: `string` -> `readonly` `optional` **recentActivity?**: readonly [`ActivityNote`](#activitynote)[] +##### model? -Newest-last window of what the worker has been doing. +> `optional` **model?**: `string` -##### derived? +Fallback bridge wire id. A spawned profile may select its own harness and model. -> `readonly` `optional` **derived?**: readonly `string`[] +##### cwd? -What the executor CHANGED about what the caller declared, one short line each — an MCP config -it materialized, an extension it had to add for the caller's own servers to mount at all. +> `optional` **cwd?**: `string` -Deliberately NOT part of `recentActivity`: that is a bounded newest-last ring, so a derived -change made before the first turn is evicted by turn 13 and gone by the time anyone looks. And -deliberately not only on the settled artifact: a run that fails on turn 40 never produces one, -yet "what was this worker actually given?" is exactly the question a failure raises. This -channel is append-only and readable at any moment, including from a run that never finishes. +Optional working directory forwarded to cli-bridge and persisted with the session. -##### note? +##### agentProfile? -> `readonly` `optional` **note?**: `string` +> `optional` **agentProfile?**: `AgentProfile` -A one-line human-readable state ("turn 3, running tests"). +Canonical profile overlay merged over the spawned profile. -*** +##### timeoutMs? -### WorkerProgress +> `optional` **timeoutMs?**: `number` -The full live view of one worker, as `observe_agent` returns it mid-flight. +##### sessionId? -#### Properties +> `optional` **sessionId?**: `string` -##### id +Stable, caller-owned cli-bridge session id for harness-side resume. Defaults + to a freshly minted per-spawn id so each worker is its own resumable session. -> `readonly` **id**: `string` +##### maxTurns? -##### status +> `optional` **maxTurns?**: `number` -> `readonly` **status**: [`NodeStatus`](#nodestatus) +Per-resume-turn inference cap before the worker settles on its last output. + Mirrors `routerToolsInlineExecutor.maxTurns`; default 200 (runaway backstop). -##### live +*** -> `readonly` **live**: `boolean` +### ProviderSeam -True while the node is neither done, failed, nor cancelled — i.e. a steer could still land. +Generic environment provider executor config. External packages implement + `AgentEnvironmentProvider`; this built-in wrapper lets `createExecutor` + consume them as backend data while preserving the existing usage channel. -##### steerable +#### Extends -> `readonly` **steerable**: `boolean` +- [`ProviderExecutorOptions`](runtime/environment-provider.md#providerexecutoroptions) -True when this worker's executor exposes an inbox (`Executor.deliver`) — i.e. `steer_agent` - can actually reach it. False means a steer would be recorded and dropped. +#### Properties -##### startedAt +##### defaults? -> `readonly` **startedAt**: `number` +> `optional` **defaults?**: `Partial`\<`CreateAgentEnvironmentInput`\> -##### lastActivityAt +**`Experimental`** -> `readonly` **lastActivityAt**: `number` +###### Inherited from -Epoch ms of the last metered usage event or executor-reported activity. +[`ProviderExecutorOptions`](runtime/environment-provider.md#providerexecutoroptions).[`defaults`](runtime/environment-provider.md#defaults-1) -##### idleMs +##### runtime? -> `readonly` **idleMs**: `number` +> `optional` **runtime?**: [`Runtime`](#runtime-4) -##### stalled +**`Experimental`** -> `readonly` **stalled**: `boolean` +###### Inherited from -##### stallAfterMs +[`ProviderExecutorOptions`](runtime/environment-provider.md#providerexecutoroptions).[`runtime`](runtime/environment-provider.md#runtime) -> `readonly` **stallAfterMs**: `number` +##### destroyOnSettle? -##### turns +> `optional` **destroyOnSettle?**: `boolean` -> `readonly` **turns**: `number` +**`Experimental`** -Metered iterations so far (the executor's own count when it reports one). +###### Inherited from -##### tokens +[`ProviderExecutorOptions`](runtime/environment-provider.md#providerexecutoroptions).[`destroyOnSettle`](runtime/environment-provider.md#destroyonsettle) -> `readonly` **tokens**: `object` +##### requireTerminalEvent? -###### input +> `optional` **requireTerminalEvent?**: `boolean` -> `readonly` **input**: `number` +**`Experimental`** -###### output +###### Inherited from -> `readonly` **output**: `number` +[`ProviderExecutorOptions`](runtime/environment-provider.md#providerexecutoroptions).[`requireTerminalEvent`](runtime/environment-provider.md#requireterminalevent-1) -##### tokensKnown? +##### taskToTurn? -> `readonly` `optional` **tokensKnown?**: `boolean` +> `optional` **taskToTurn?**: (`task`, `specProfile`) => `AgentTurnInput` -False when observed `tokens` is only a known subtotal, not a complete total — the worker did - work whose token count its provider never reported. The twin of `usdKnown`, carried for the - same reason: a driver reading this over `observe_agent` would otherwise read the subtotal as - the measurement and conclude a busy worker was cheap. +**`Experimental`** -##### usd +###### Parameters -> `readonly` **usd**: `number` +###### task -##### usdKnown? +`unknown` -> `readonly` `optional` **usdKnown?**: `boolean` +###### specProfile -False when observed dollar spend is only a known subtotal, not a complete total. +`AgentProfile` -##### pendingMessages +###### Returns -> `readonly` **pendingMessages**: `number` +`AgentTurnInput` -Steers delivered but not yet read by the worker. +###### Inherited from -##### recentActivity +[`ProviderExecutorOptions`](runtime/environment-provider.md#providerexecutoroptions).[`taskToTurn`](runtime/environment-provider.md#tasktoturn) -> `readonly` **recentActivity**: readonly [`ActivityNote`](#activitynote)[] +##### provider -Newest-last window of tool/turn activity; empty when the executor exposes none. +> **provider**: `string` \| `AgentEnvironmentProvider` -##### derived? +##### registry? -> `readonly` `optional` **derived?**: readonly `string`[] +> `optional` **registry?**: [`AgentEnvironmentProviderRegistry`](runtime/environment-provider.md#agentenvironmentproviderregistry) -What the executor changed about the caller's declaration; absent when it changed nothing. - Unlike `recentActivity` this is never evicted, so it still answers on a failed run. +##### steering? -##### note? +> `optional` **steering?**: [`SandboxSteeringOptions`](#sandboxsteeringoptions) -> `readonly` `optional` **note?**: `string` +Compose the provider through the existing steerable sandbox session. +The exact profile must name its harness, and the provider must expose live +continuation plus session controls. The provider still owns environment +creation and session semantics. *** -### ActivityLog - -A bounded newest-last ring of `ActivityNote`s an executor keeps to answer `progress()`. +### RouterToolsSeam -#### Methods +Router seam WITH tool use — the tool-using router backend. Same direct +OpenAI-compatible endpoint as `RouterSeam`, but each turn passes `tools`; when +the model emits tool_calls they run via `executeToolCall` ON THIS HOST and the +results fold back as `tool` messages, repeating until the model answers without +a tool or `maxTurns` is hit. A real agentic loop, OFF-BOX — no sandbox, so it +is unaffected by a box's egress allowlist. One turn = one completion = the +equal-compute unit. `executeToolCall` receives the task so per-task tool +surfaces (e.g. a gym keyed by task) can dispatch correctly. -##### push() +#### Properties -> **push**(`note`): `void` +##### routerBaseUrl -###### Parameters +> **routerBaseUrl**: `string` -###### note +##### routerKey -[`ActivityNote`](#activitynote) +> **routerKey**: `string` -###### Returns +##### model? -`void` +> `optional` **model?**: `string` -##### read() +##### tools -> **read**(): readonly [`ActivityNote`](#activitynote)[] +> **tools**: readonly [`ToolSpec`](#toolspec)[] -Newest-last, at most `limit` entries. +##### executeToolCall -###### Returns +> **executeToolCall**: (`name`, `args`, `task`) => `Promise`\<`string`\> -readonly [`ActivityNote`](#activitynote)[] +###### Parameters -##### last() +###### name -> **last**(): [`ActivityNote`](#activitynote) \| `undefined` +`string` -###### Returns +###### args -[`ActivityNote`](#activitynote) \| `undefined` +`Record`\<`string`, `unknown`\> -##### size() +###### task -> **size**(): `number` +`unknown` ###### Returns -`number` +`Promise`\<`string`\> -*** +##### onToolStep? -### ScopeProgressInput +> `optional` **onToolStep?**: (`step`) => `void` -The scope-side facts about a child, independent of whether its executor cooperates. +Online observer of each tool step — the seam a `DetectorMonitor` taps to watch the live pipe + (raise a `finding` when the worker loops/errors). Called after every tool call resolves, with + real per-call wall-clock (`startedAt`/`endedAt`/`durationMs`) so a push `TraceSource` can carry + non-zero span durations onto the unified timeline. -#### Properties +###### Parameters -##### id +###### step -> `readonly` **id**: `string` +###### toolName -##### status +`string` -> `readonly` **status**: [`NodeStatus`](#nodestatus) +###### args -##### steerable +`Record`\<`string`, `unknown`\> -> `readonly` **steerable**: `boolean` +###### status -##### startedAt +`"error"` \| `"ok"` -> `readonly` **startedAt**: `number` +###### startedAt? -##### lastActivityAt +`number` -> `readonly` **lastActivityAt**: `number` +###### endedAt? -##### turns +`number` -> `readonly` **turns**: `number` +###### durationMs? -##### tokens +`number` -> `readonly` **tokens**: `object` +###### Returns -###### input +`void` -> `readonly` **input**: `number` +##### maxTurns? -###### output +> `optional` **maxTurns?**: `number` -> `readonly` **output**: `number` +Max inference turns. Default 200 (runaway backstop — set far above any + legitimate workflow). For tighter per-workflow limits use a cost budget + or wall-clock deadline at the call site. -##### tokensKnown? +*** -> `readonly` `optional` **tokensKnown?**: `boolean` +### SandboxSteeringOptions -##### usd +Opt-in configuration for the steerable sandbox worker (`SandboxSeam.steering`). Absent, the + sandbox executor keeps its historical single-shot `runAgentRounds` composition verbatim. -> `readonly` **usd**: `number` +#### Properties -##### usdKnown? +##### maxTurns? -> `readonly` `optional` **usdKnown?**: `boolean` +> `readonly` `optional` **maxTurns?**: `number` -*** +Max turns for one worker (turn 0 + folded steers). Default [DEFAULT\_SANDBOX\_STEERING\_MAX\_TURNS](#default_sandbox_steering_max_turns). -### InMemoryRunContextOptions +##### activityWindow? -Options for a supervised run context. +> `readonly` `optional` **activityWindow?**: `number` -#### Properties +How many recent tool/turn notes `progress()` reports. Default 12. -##### withDriver? +##### turnTimeoutMs? -> `readonly` `optional` **withDriver?**: `boolean` +> `readonly` `optional` **turnTimeoutMs?**: `number` -Wrap the executor registry with `withDriverExecutor` so a spawned child marked -`role: 'driver'` resolves to the recursive driver-executor (agents driving agents -over a nested `Scope` on the same conserved pool). Leave `false` for a flat tree of -leaf workers. Default `false`. +Per-turn wall-clock ceiling; the turn's stream is aborted when it elapses. *** -### InMemoryRunContext - -The bundle of stores a supervised run needs, shaped to spread into `SupervisorOpts`. -The fields are exactly `SupervisorOpts`' `journal` / `blobs` / `executors`. - -#### Properties - -##### journal +### SteerableSandboxSession -> `readonly` **journal**: [`SpawnJournal`](#spawnjournal) +What the steerable session exposes to its executor: the usage stream plus the live reads. -##### blobs +#### Methods -> `readonly` **blobs**: [`ResultBlobStore`](#resultblobstore) +##### stream() -##### executors +> **stream**(`task`, `signal`): `AsyncIterable`\<[`UsageEvent`](#usageevent)\> -> `readonly` **executors**: [`ExecutorRegistry`](index.md#executorregistry) +Drive the worker to settlement. `signal` is the spawn-scoped abort handed to `execute`. -##### resume? +###### Parameters -> `readonly` `optional` **resume?**: `boolean` +###### task -Present (and `true`) only on a DURABLE context (`createFileRunContext`), so spreading the -context into `SupervisorOpts` also opts the run into resume-first. An in-memory context -leaves it undefined: there is never a prior tree to resume, and the default stays fresh-run. +`unknown` -##### coordinationLog? +###### signal -> `readonly` `optional` **coordinationLog?**: [`CoordinationLog`](#coordinationlog) +`AbortSignal` -Present only on a DURABLE context: the coordination side-log (questions + analyst findings — -the bus messages the spawn journal does not record). `supervise({ runDir })` appends to it as -they publish and replays it on resume, so a restarted coordinator keeps them. In-memory -contexts have none: nothing outlives the process to replay into. +###### Returns -*** +`AsyncIterable`\<[`UsageEvent`](#usageevent)\> -### WorkerSteerRequest +##### progress() -One durable down-leg request appended to a worker's inbox file. +> **progress**(): [`ExecutorProgress`](#executorprogress) -#### Properties +###### Returns -##### id +[`ExecutorProgress`](#executorprogress) -> `readonly` **id**: `string` +##### traceSource() -##### at +> **traceSource**(): [`TraceSource`](#tracesource-1) -> `readonly` **at**: `string` +###### Returns -ISO timestamp of the append. +[`TraceSource`](#tracesource-1) -##### source +##### artifact() -> `readonly` **source**: `string` +> **artifact**(): \{ `outRef`: `string`; `out`: `unknown`; `spent`: [`Spend`](index.md#spend); \} \| `undefined` -Who asked — 'human', a brain label, a tool name. Provenance, not authorization. +###### Returns -##### worker +\{ `outRef`: `string`; `out`: `unknown`; `spent`: [`Spend`](index.md#spend); \} \| `undefined` -> `readonly` **worker**: `string` +##### teardown() -The worker LABEL the request targets (already resolved by the caller). +> **teardown**(): `Promise`\<`void`\> -##### message +###### Returns -> `readonly` **message**: `string` +`Promise`\<`void`\> *** -### RouterSeam - -Router/inline connection seam. A direct OpenAI-compatible Router endpoint — -the cheapest leaf, no box, no tools. `model` overrides the profile's model -hint when present; otherwise the profile's `model.default` is required. +### SteerableSandboxArgs #### Properties -##### routerBaseUrl - -> **routerBaseUrl**: `string` - -##### routerKey +##### controller -> **routerKey**: `string` +> `readonly` **controller**: `AbortController` -##### model? +##### profile -> `optional` **model?**: `string` +> `readonly` **profile**: `AgentProfile` -*** +##### harness -### SandboxSeam +> `readonly` **harness**: `BackendType` -Sandbox executor seam. The `sandboxClient` the composed `runAgentRounds` creates -boxes through, plus the optional trace/run/lineage wiring forwarded into the -loop. `lineage` is opaque here (PR #150's `RunAgentRoundsOptions.lineage`): forwarded -forward-compatibly, never inspected — this executor does NOT reinvent -checkpoint/fork. +##### sandboxClient -#### Properties +> `readonly` **sandboxClient**: [`SandboxClient`](#sandboxclient-5) -##### sandboxClient +##### inbox -> **sandboxClient**: [`SandboxClient`](#sandboxclient-5) +> `readonly` **inbox**: [`Inbox`](#inbox-1) -##### loopCtx? +##### taskToPrompt -> `optional` **loopCtx?**: `Partial`\<`Omit`\<[`ExecCtx`](#execctx), `"signal"` \| `"sandboxClient"`\>\> +> `readonly` **taskToPrompt**: (`task`) => `string` -Forwarded into the composed `runAgentRounds`'s `ctx` (trace emitter, run handle, etc.). +###### Parameters -##### lineage? +###### task -> `optional` **lineage?**: `unknown` +`unknown` -PR #150 `RunAgentRoundsOptions.lineage` passthrough — opaque; forwarded, not parsed. +###### Returns -##### maxIterations? +`string` -> `optional` **maxIterations?**: `number` +##### options? -Hard cap on the composed loop's iterations. The budget pool reserves against - the spawn `Budget.maxIterations`; this is the leaf's own ceiling. Default 1. +> `readonly` `optional` **options?**: [`SandboxSteeringOptions`](#sandboxsteeringoptions) -##### steering? +##### loopCtx? -> `optional` **steering?**: [`SandboxSteeringOptions`](#sandboxsteeringoptions) +> `readonly` `optional` **loopCtx?**: `Partial`\<`Omit`\<[`ExecCtx`](#execctx), `"signal"` \| `"sandboxClient"`\>\> -OPT-IN: run this worker as a multi-turn, STEERABLE session instead of the historical -single-shot `runAgentRounds` composition. Setting it gives the sandbox worker an `Executor.deliver` -inbox (so `Scope.send` / `steer_agent` actually reach it), a live tool-activity trace, and a -`progress()` read — turning the default cloud worker from something a supervisor can only -wait on into something it can watch and correct. +##### traceEnv? -Absent, nothing changes: the same `runAgentRounds` leaf, no inbox, `steer_agent` still reports -`delivered:false`. Opt-in because a steerable worker holds ONE box across several turns, -which is a different resource profile from a fire-and-forget shot. +> `readonly` `optional` **traceEnv?**: `Record`\<`string`, `string`\> -*** +Inherited `TRACE_ID` / `PARENT_SPAN_ID` for the box, merged into `CreateSandboxOptions.env` so +the remote worker's own spans join the supervisor's trace under the spawning node's span. +Absent when the run records no spans — the create options are then untouched. -### CliSeam +##### contentRef -CLI subprocess seam. `bin` + `args` describe the Halo/RLM process to spawn. +> `readonly` **contentRef**: (`prefix`, `value`) => `string` -#### Properties +###### Parameters -##### bin +###### prefix -> **bin**: `string` +`string` -##### args? +###### value -> `optional` **args?**: `string`[] +`unknown` -##### env? +###### Returns -> `optional` **env?**: `Record`\<`string`, `string`\> +`string` -Extra environment for the subprocess (merged over `process.env`). +##### now? -##### cwd? +> `readonly` `optional` **now?**: () => `number` -> `optional` **cwd?**: `string` +###### Returns -Working directory for the subprocess. +`number` *** -### CliWorktreeSeam +### ScopeArgs -cli-worktree seam. A supervisor-authored `AgentProfile` driving a local coding-harness CLI -(claude / codex / opencode) on its own git worktree — the leaf `createWorktreeCliExecutor` -named as data. `harness` + `repoRoot` + `taskPrompt` are required; the authored -`profile.prompt.systemPrompt` + `profile.model.default` reach the harness via the §1.5 -`harnessInvocation` mapper. Everything else mirrors `WorktreeCliExecutorOptions`. +Construction args for `createScope`. The supervisor threads the shared pool, journal, + blob store, and executor registry through; `depth`/`maxDepth` pair the runtime + recursion ceiling with the conserved pool (R3). #### Properties -##### repoRoot +##### parentId -> **repoRoot**: `string` +> `readonly` **parentId**: `string` -##### harness? +This scope's owning node id — children get `${parentId}:s${seq}` ids. -> `optional` **harness?**: [`LocalHarness`](mcp.md#localharness) +##### root -Local CLI harness transport. Omit when `bridge` is set. +> `readonly` **root**: `string` -##### taskPrompt +Journal/blob root key the supervisor `beginTree`'d. -> **taskPrompt**: `string` +##### pool -##### runId? +> `readonly` **pool**: [`BudgetPool`](#budgetpool) -> `optional` **runId?**: `string` +The reservation pool for this scope: the root total or one nested allocated partition. -##### baseRef? +##### journal -> `optional` **baseRef?**: `string` +> `readonly` **journal**: [`SpawnJournal`](#spawnjournal) -##### harnessTimeoutMs? +Append-only spawn journal; this scope writes `spawned` + `settled` records. -> `optional` **harnessTimeoutMs?**: `number` +##### blobs -##### codexReproducible? +> `readonly` **blobs**: [`ResultBlobStore`](#resultblobstore) -> `optional` **codexReproducible?**: `boolean` +Content-addressed result store backing `outRef` rehydration. -Isolated, network-off Codex execution with terminal JSONL usage capture. +##### executors -##### codexReadDeniedPaths? +> `readonly` **executors**: [`ExecutorRegistry`](index.md#executorregistry) -> `optional` **codexReadDeniedPaths?**: readonly `string`[] +The open executor resolver (BYO → router/inline → registered harness factory). -Absolute host paths denied to reproducible Codex. +##### probes? -##### testCmd? +> `readonly` `optional` **probes?**: [`WaitProbeRegistry`](#waitproberegistry) -> `optional` **testCmd?**: `string` +Predicate resolver for `poll` wait-states. Absent ⇒ `wait` refuses a `poll` with + `unknown-probe`; `timer` waits never touch it. -##### typecheckCmd? +##### waitSleep? -> `optional` **typecheckCmd?**: `string` +> `readonly` `optional` **waitSleep?**: (`ms`, `signal`) => `Promise`\<`void`\> -##### checkTimeoutMs? +Injected sleeper for wait-states — a test drives a week-long timer in microseconds. -> `optional` **checkTimeoutMs?**: `number` +###### Parameters -##### checkOutputCap? +###### ms -> `optional` **checkOutputCap?**: `number` +`number` -##### budgetExempt? +###### signal -> `optional` **budgetExempt?**: `boolean` +`AbortSignal` -##### bridge? +###### Returns -> `optional` **bridge?**: [`CliWorktreeBridgeSeam`](#cliworktreebridgeseam) +`Promise`\<`void`\> -Live cli-bridge transport inside the worktree. When set, the worktree leaf accepts - `deliver()` messages and resumes the same bridge session in this worktree cwd. +##### seams -##### runGit? +> `readonly` **seams**: `Readonly`\<`Record`\<`string`, `unknown`\>\> -> `optional` **runGit?**: [`GitRunner`](mcp.md#gitrunner) +Per-spawn executor-construction seams (sandbox client, router config, cli bin). -Test seam — forwarded to worktree helpers. +##### depth -##### runCommand? +> `readonly` **depth**: `number` -> `optional` **runCommand?**: [`WorktreeCheckRunner`](index.md#worktreecheckrunner) +This scope's recursion depth (root = 0). -Test seam — forwarded to verification checks. +##### maxDepth? -*** +> `readonly` `optional` **maxDepth?**: `number` -### CliWorktreeBridgeSeam +Runtime recursion-depth ceiling — a spawn past it fails closed `depth-exceeded`. -#### Properties +##### maxLiveWorkers? -##### bridgeUrl +> `readonly` `optional` **maxLiveWorkers?**: `number` -> **bridgeUrl**: `string` +Root-owned limit on live spawned workers across this scope and every nested scope. -##### bridgeBearer +##### signal -> **bridgeBearer**: `string` +> `readonly` **signal**: `AbortSignal` -##### model? +Abort signal for this scope; an abort cascades into every live child's executor. -> `optional` **model?**: `string` +##### now? -Bridge model/harness id. Defaults to the profile's model hint when omitted. +> `readonly` `optional` **now?**: () => `number` -##### agentProfile? +Injected clock — keeps the journal `at` timestamp deterministic in tests. -> `optional` **agentProfile?**: `Record`\<`string`, `unknown`\> +###### Returns -##### timeoutMs? +`number` + +##### hooks? + +> `readonly` `optional` **hooks?**: [`RuntimeHooks`](index.md#runtimehooks) + +Lifecycle stream sink. `spawn` emits `agent.spawn`, `next` emits `agent.child` — the + SAME stream `runAgentRounds`/`tool-loop` feed, so the recursive tree is ONE observable stream + (the topology viewer reads it). Undefined ⇒ the journal stays the only record. + +##### workerTrace? + +> `readonly` `optional` **workerTrace?**: [`WorkerTraceResolver`](#workertraceresolver) -> `optional` **timeoutMs?**: `number` +Trace context to hand down to each spawned worker (`SupervisorOpts.workerTrace`). Called with +THIS scope's own `parentId` — the node doing the spawning — and the resolved context is seeded +onto each child's `ExecutorContext` under `workerTraceSeamKey`. Absent (the untraced default) +⇒ no seam is seeded and no worker environment is touched. -##### sessionId? +##### resumeFrom? -> `optional` **sessionId?**: `string` +> `readonly` `optional` **resumeFrom?**: `object` -Stable cli-bridge session id. Defaults to `bridge-worktree-${runId}`. +Resume seam — set ONLY by the supervisor when `SupervisorOpts.resume` is on AND a non-empty +journal tree exists for this root. It carries the replayed committed work (so `scope.resume` +exposes it to a resume-aware `act`) and the recorded ordinal/cursor maxima the new counters +continue past, so a freshly-spawned child never reuses a journaled `seq`. Absent ⇒ fresh run. -##### maxTurns? +###### settled -> `optional` **maxTurns?**: `number` +> `readonly` **settled**: readonly [`Settled`](index.md#settled)\<`unknown`\>[] -*** +###### view -### BridgeSeam +> `readonly` **view**: [`TreeView`](#treeview) -cli-bridge seam. A local OpenAI-compatible bridge that fronts harness CLIs -(claude-code / opencode / kimi / pi) behind one HTTP surface; `model` doubles -as the harness selector (e.g. `claude-code/sonnet`, `opencode//`). -`agentProfile` is the bridge-dialect profile (metadata.disallowedTools, mcp) -forwarded verbatim per request — how an arm disables native tools or injects -a provider search MCP. +###### maxSpawnOrdinal -The executor opens a RESUMABLE cli-bridge session — structurally identical to the -sandbox executor's persistent box, just local. `sessionId` is the stable -caller-owned id cli-bridge maps to the harness's internal conversation id; a -follow-up steer/resume on the SAME id continues the SAME harness session (opencode -`-s`, claude `--resume`, …). Omit it and the executor mints a stable one per spawn. +> `readonly` **maxSpawnOrdinal**: `number` -#### Properties +Highest `spawned` ordinal already journaled; new spawns start at `+1`. -##### bridgeUrl +###### maxCursorSeq -> **bridgeUrl**: `string` +> `readonly` **maxCursorSeq**: `number` -##### bridgeBearer +Highest cursor `seq` already journaled; new settlements start at `+1`. -> **bridgeBearer**: `string` +###### maxWaitOrdinal -##### model +> `readonly` **maxWaitOrdinal**: `number` -> **model**: `string` +Highest `waiting` ordinal already journaled; new waits start at `+1`. -##### cwd? +###### waits -> `optional` **cwd?**: `string` +> `readonly` **waits**: readonly [`PendingWait`](#pendingwait)[] -Optional working directory forwarded to cli-bridge and persisted with the session. +Waits journaled as armed but never woken — re-armed (same node id, same absolute deadline) + when `wait` is called again with the SAME label. -##### agentProfile? +###### keys -> `optional` **agentProfile?**: `Record`\<`string`, `unknown`\> +> `readonly` **keys**: `ReadonlyMap`\<`string`, [`ResumedKeyState`](#resumedkeystate)\<`unknown`\>\> -##### timeoutMs? +Keyed assignments from the prior journal — what a keyed re-spawn resolves against. -> `optional` **timeoutMs?**: `number` +###### priorSpend -##### sessionId? +> `readonly` **priorSpend**: `object` -> `optional` **sessionId?**: `string` +Prior committed spend summed off the journal (settled child work + metered inference). -Stable, caller-owned cli-bridge session id for harness-side resume. Defaults - to a freshly minted per-spawn id so each worker is its own resumable session. +###### priorSpend.childWork -##### maxTurns? +> `readonly` **childWork**: [`Spend`](index.md#spend) -> `optional` **maxTurns?**: `number` +###### priorSpend.driverInference -Per-resume-turn inference cap before the worker settles on its last output. - Mirrors `routerToolsInlineExecutor.maxTurns`; default 200 (runaway backstop). +> `readonly` **driverInference**: [`Spend`](index.md#spend) *** -### ProviderSeam +### ProgressSample -Generic environment provider executor config. External packages implement - `AgentEnvironmentProvider`; this built-in wrapper lets `createExecutor` - consume them as backend data while preserving the existing usage channel. +One settled unit of work, reduced to what a stop rule reads. `objective` is the run's own + quality signal (a verdict score, a test pass-rate, a judge rating); `undefined` = this + settlement produced no measurable objective (it failed, or nothing scored it). -#### Extends +#### Properties -- [`ProviderExecutorOptions`](runtime/environment-provider.md#providerexecutoroptions) +##### id -#### Properties +> `readonly` **id**: `string` -##### defaults? +##### at -> `optional` **defaults?**: `Partial`\<`CreateAgentEnvironmentInput`\> +> `readonly` **at**: `number` -**`Experimental`** +Epoch ms the settlement was observed. -###### Inherited from +##### objective? -[`ProviderExecutorOptions`](runtime/environment-provider.md#providerexecutoroptions).[`defaults`](runtime/environment-provider.md#defaults-1) +> `readonly` `optional` **objective?**: `number` -##### runtime? +##### delivered -> `optional` **runtime?**: [`Runtime`](#runtime-2) +> `readonly` **delivered**: `boolean` -**`Experimental`** +True when the settlement passed its deliverable check — a scored-but-undelivered result is + not progress. -###### Inherited from +*** -[`ProviderExecutorOptions`](runtime/environment-provider.md#providerexecutoroptions).[`runtime`](runtime/environment-provider.md#runtime) +### ProgressView -##### destroyOnSettle? +The read-model a `StopRule` decides from — the run's progress, not its budget. -> `optional` **destroyOnSettle?**: `boolean` +#### Properties -**`Experimental`** +##### now -###### Inherited from +> `readonly` **now**: `number` -[`ProviderExecutorOptions`](runtime/environment-provider.md#providerexecutoroptions).[`destroyOnSettle`](runtime/environment-provider.md#destroyonsettle) +##### settles -##### requireTerminalEvent? +> `readonly` **settles**: `number` -> `optional` **requireTerminalEvent?**: `boolean` +Settlements observed so far, in the order they landed. -**`Experimental`** +##### delivered -###### Inherited from +> `readonly` **delivered**: `number` -[`ProviderExecutorOptions`](runtime/environment-provider.md#providerexecutoroptions).[`requireTerminalEvent`](runtime/environment-provider.md#requireterminalevent-1) +Of those, how many passed their deliverable check. -##### taskToTurn? +##### curve -> `optional` **taskToTurn?**: (`task`, `specProfile`) => `AgentTurnInput` +> `readonly` **curve**: readonly `number`[] -**`Experimental`** +Best-so-far objective after each settlement (`anytime.bestSoFar`). -###### Parameters +##### best -###### task +> `readonly` **best**: `number` -`unknown` +The current best objective; `0` when nothing has scored. -###### specProfile +##### auc -`AgentProfile` +> `readonly` **auc**: `number` -###### Returns +Mean of the best-so-far curve — how EARLY the run climbed (`anytime.areaUnderCurve`). -`AgentTurnInput` +##### lastSettleAt -###### Inherited from +> `readonly` **lastSettleAt**: `number` -[`ProviderExecutorOptions`](runtime/environment-provider.md#providerexecutoroptions).[`taskToTurn`](runtime/environment-provider.md#tasktoturn) +Epoch ms of the most recent settlement; `0` when none has landed. -##### provider +##### lastImprovementAt -> **provider**: `string` \| `AgentEnvironmentProvider` +> `readonly` **lastImprovementAt**: `number` -##### registry? +Epoch ms of the most recent improvement in best-so-far; `0` when none. -> `optional` **registry?**: [`AgentEnvironmentProviderRegistry`](runtime/environment-provider.md#agentenvironmentproviderregistry) +##### settlesSinceImprovement -##### steering? +> `readonly` **settlesSinceImprovement**: `number` -> `optional` **steering?**: [`SandboxSteeringOptions`](#sandboxsteeringoptions) +Settlements since the last improvement — `0` right after one improves. -Compose the provider through the existing steerable sandbox session. -The exact profile must name its harness, and the provider must expose live -continuation plus session controls. The provider still owns environment -creation and session semantics. +##### workers -*** +> `readonly` **workers**: readonly [`WorkerProgress`](#workerprogress)[] -### RouterToolsSeam +Live read of every non-terminal worker (the `Scope.progress` feed). Empty when the caller + supplied no scope. -Router seam WITH tool use — the tool-using router backend. Same direct -OpenAI-compatible endpoint as `RouterSeam`, but each turn passes `tools`; when -the model emits tool_calls they run via `executeToolCall` ON THIS HOST and the -results fold back as `tool` messages, repeating until the model answers without -a tool or `maxTurns` is hit. A real agentic loop, OFF-BOX — no sandbox, so it -is unaffected by a box's egress allowlist. One turn = one completion = the -equal-compute unit. `executeToolCall` receives the task so per-task tool -surfaces (e.g. a gym keyed by task) can dispatch correctly. +##### inFlight -#### Properties +> `readonly` **inFlight**: `number` -##### routerBaseUrl +Nodes running or acquiring. -> **routerBaseUrl**: `string` +##### waiting -##### routerKey +> `readonly` **waiting**: `number` -> **routerKey**: `string` +Armed wait-state nodes — deliberately separate from `inFlight`: a tree whose only remaining + nodes are waits is NOT stalled, it is waiting on the world. -##### model? +*** -> `optional` **model?**: `string` +### ProgressTracker -##### tools +Accumulates settlements and materializes a `ProgressView`. Idempotent by settlement id, so a + caller may re-push its whole roster every turn (the driver does exactly that) without + double-counting or moving a recorded timestamp. -> **tools**: readonly [`ToolSpec`](#toolspec)[] +#### Methods -##### executeToolCall +##### record() -> **executeToolCall**: (`name`, `args`, `task`) => `Promise`\<`string`\> +> **record**(`sample`): `boolean` + +Record a settlement. A second call with the same `id` is ignored. Returns true when it was + new. ###### Parameters -###### name +###### sample -`string` +[`ProgressSample`](#progresssample) -###### args +###### Returns -`Record`\<`string`, `unknown`\> +`boolean` -###### task +##### view() -`unknown` +> **view**(`scope?`, `opts?`): [`ProgressView`](#progressview) -###### Returns +Materialize the view. Pass the live `Scope` to include the worker feed and tree shape. -`Promise`\<`string`\> +###### Parameters -##### onToolStep? +###### scope? -> `optional` **onToolStep?**: (`step`) => `void` +[`Scope`](index.md#scope)\<`unknown`\> -Online observer of each tool step — the seam a `DetectorMonitor` taps to watch the live pipe - (raise a `finding` when the worker loops/errors). Called after every tool call resolves, with - real per-call wall-clock (`startedAt`/`endedAt`/`durationMs`) so a push `TraceSource` can carry - non-zero span durations onto the unified timeline. +###### opts? -###### Parameters +###### stallAfterMs? -###### step +`number` -###### toolName +###### Returns -`string` +[`ProgressView`](#progressview) -###### args +##### evaluate() -`Record`\<`string`, `unknown`\> +> **evaluate**(`rule`, `scope?`, `opts?`): [`StopDecision`](#stopdecision) -###### status +Evaluate a rule against the current view. -`"error"` \| `"ok"` +###### Parameters -###### startedAt? +###### rule -`number` +[`StopRule`](#stoprule-1) + +###### scope? -###### endedAt? +[`Scope`](index.md#scope)\<`unknown`\> -`number` +###### opts? -###### durationMs? +###### stallAfterMs? `number` ###### Returns -`void` - -##### maxTurns? - -> `optional` **maxTurns?**: `number` - -Max inference turns. Default 200 (runaway backstop — set far above any - legitimate workflow). For tighter per-workflow limits use a cost budget - or wall-clock deadline at the call site. +[`StopDecision`](#stopdecision) -*** +##### samples() -### SandboxSteeringOptions +> **samples**(): readonly [`ProgressSample`](#progresssample)[] -Opt-in configuration for the steerable sandbox worker (`SandboxSeam.steering`). Absent, the - sandbox executor keeps its historical single-shot `runAgentRounds` composition verbatim. +The samples recorded so far, in order. -#### Properties +###### Returns -##### maxTurns? +readonly [`ProgressSample`](#progresssample)[] -> `readonly` `optional` **maxTurns?**: `number` +*** -Max turns for one worker (turn 0 + folded steers). Default [DEFAULT\_SANDBOX\_STEERING\_MAX\_TURNS](#default_sandbox_steering_max_turns). +### ProgressTrackerOptions -##### activityWindow? +#### Properties -> `readonly` `optional` **activityWindow?**: `number` +##### now? -How many recent tool/turn notes `progress()` reports. Default 12. +> `readonly` `optional` **now?**: () => `number` -##### turnTimeoutMs? +Clock for `view().now`. Defaults to `Date.now`. -> `readonly` `optional` **turnTimeoutMs?**: `number` +###### Returns -Per-turn wall-clock ceiling; the turn's stream is aborted when it elapses. +`number` -*** +##### requireDelivered? -### SteerableSandboxSession +> `readonly` `optional` **requireDelivered?**: `boolean` -What the steerable session exposes to its executor: the usage stream plus the live reads. +Treat a settlement that did NOT pass its deliverable check as having no objective. Default + true — "scored 0.9 but never delivered" is not progress, and counting it as progress is the + exact way a plateau rule gets talked out of firing. -#### Methods +##### minImprovement? -##### stream() +> `readonly` `optional` **minImprovement?**: `number` -> **stream**(`task`, `signal`): `AsyncIterable`\<[`UsageEvent`](#usageevent)\> +How much the best-so-far must rise for a settlement to count as an IMPROVEMENT. Default 0 + (any strict rise counts). Raise it to ignore score noise. -Drive the worker to settlement. `signal` is the spawn-scoped abort handed to `execute`. +*** -###### Parameters +### NoProgressForOptions -###### task +#### Properties -`unknown` +##### ms? -###### signal +> `readonly` `optional` **ms?**: `number` -`AbortSignal` +Stop when this many ms have passed since the last SETTLEMENT. Omit to not bound on time. -###### Returns +##### settles? -`AsyncIterable`\<[`UsageEvent`](#usageevent)\> +> `readonly` `optional` **settles?**: `number` -##### progress() +Stop when this many settlements have landed with no improvement in best-so-far. Omit to not + bound on settles. -> **progress**(): [`ExecutorProgress`](#executorprogress) +##### minSettles? -###### Returns +> `readonly` `optional` **minSettles?**: `number` -[`ExecutorProgress`](#executorprogress) +Never stop before this many settlements have landed — the warm-up that stops a rule from + firing on an empty run. Default 1. -##### traceSource() +*** -> **traceSource**(): [`TraceSource`](#tracesource-1) +### PlateauOptions -###### Returns +#### Properties -[`TraceSource`](#tracesource-1) +##### window -##### artifact() +> `readonly` **window**: `number` -> **artifact**(): \{ `outRef`: `string`; `out`: `unknown`; `spent`: [`Spend`](index.md#spend); \} \| `undefined` +How many trailing settlements to judge. The rule fires when the whole window failed to lift + the best-so-far by more than `minDelta`. -###### Returns +##### minDelta -\{ `outRef`: `string`; `out`: `unknown`; `spent`: [`Spend`](index.md#spend); \} \| `undefined` +> `readonly` **minDelta**: `number` -##### teardown() +The rise that counts as an improvement — the domain's noise floor. `0` means any strict rise + counts. -> **teardown**(): `Promise`\<`void`\> +##### minSettles? -###### Returns +> `readonly` `optional` **minSettles?**: `number` -`Promise`\<`void`\> +Never fire before this many settlements. Defaults to `window` (so the first decision is made + on a full window, not on a partial one). *** -### SteerableSandboxArgs +### AllWorkersStalledOptions #### Properties -##### controller - -> `readonly` **controller**: `AbortController` - -##### profile +##### minWorkers? -> `readonly` **profile**: `AgentProfile` +> `readonly` `optional` **minWorkers?**: `number` -##### harness +Require at least this many live workers before the rule can fire — one stalled worker in a + one-worker tree is a weaker signal than a whole fleet going quiet. Default 1. -> `readonly` **harness**: `BackendType` +##### stallAfterMs? -##### sandboxClient +> `readonly` `optional` **stallAfterMs?**: `number` -> `readonly` **sandboxClient**: [`SandboxClient`](#sandboxclient-5) +Idle time that counts as stalled, passed through to the live progress read. Omit = the + runtime default (`DEFAULT_STALL_AFTER_MS`). -##### inbox +*** -> `readonly` **inbox**: [`Inbox`](#inbox) +### SuperviseRegistryTable -##### taskToPrompt +A name→value table, in this package's resolver-port shape (the same one `WaitProbeRegistry` + uses): construction stays the caller's, lookup stays lazy, and a table backed by a file, a + plugin loader, or a plain object all satisfy one interface. -> `readonly` **taskToPrompt**: (`task`) => `string` +#### Type Parameters -###### Parameters +##### T -###### task +`T` -`unknown` +#### Methods -###### Returns +##### resolve() -`string` +> **resolve**(`name`): `T` \| `undefined` -##### options? +###### Parameters -> `readonly` `optional` **options?**: [`SandboxSteeringOptions`](#sandboxsteeringoptions) +###### name -##### loopCtx? +`string` -> `readonly` `optional` **loopCtx?**: `Partial`\<`Omit`\<[`ExecCtx`](#execctx), `"signal"` \| `"sandboxClient"`\>\> +###### Returns -##### traceEnv? +`T` \| `undefined` -> `readonly` `optional` **traceEnv?**: `Record`\<`string`, `string`\> +*** -Inherited `TRACE_ID` / `PARENT_SPAN_ID` for the box, merged into `CreateSandboxOptions.env` so -the remote worker's own spans join the supervisor's trace under the spawning node's span. -Absent when the run records no spans — the create options are then untouched. +### SuperviseRegistry -##### contentRef +The name→value tables that make the four CODE-valued options expressible as run DATA. -> `readonly` **contentRef**: (`prefix`, `value`) => `string` +`deliverable` / `finalizer` / `analysts` / `probes` are functions and registries, so a recorded +run configuration (a JSON row, a campaign spec, a resumed run's options) cannot carry them — and +a run with no `deliverable` cannot return a `winner` at all outside the sandbox backend, because +the finalizer keeps only children whose oracle passed and nothing else writes that verdict. A +caller that owns the code registers it here once and names it from data thereafter. -###### Parameters +#### Properties -###### prefix +##### deliverables? -`string` +> `readonly` `optional` **deliverables?**: [`SuperviseRegistryTable`](#superviseregistrytable)\<[`DeliverableSpec`](#deliverablespec)\<`unknown`\>\> -###### value +##### finalizers? -`unknown` +> `readonly` `optional` **finalizers?**: [`SuperviseRegistryTable`](#superviseregistrytable)\<[`SupervisorFinalizer`](index.md#supervisorfinalizer)\> -###### Returns +##### analysts? -`string` +> `readonly` `optional` **analysts?**: [`SuperviseRegistryTable`](#superviseregistrytable)\<[`AnalystRegistry`](index.md#analystregistry)\> -##### now? +##### probes? -> `readonly` `optional` **now?**: () => `number` +> `readonly` `optional` **probes?**: [`SuperviseRegistryTable`](#superviseregistrytable)\<[`WaitProbeRegistry`](#waitproberegistry)\> -###### Returns +*** -`number` +### SuperviseOptions -*** +#### Properties -### ScopeArgs +##### budget -Construction args for `createScope`. The supervisor threads the shared pool, journal, - blob store, and executor registry through; `depth`/`maxDepth` pair the runtime - recursion ceiling with the conserved pool (R3). +> `readonly` **budget**: [`Budget`](index.md#budget-4) -#### Properties +The conserved compute pool for the whole run. -##### parentId +##### rootHandle? -> `readonly` **parentId**: `string` +> `readonly` `optional` **rootHandle?**: [`RootHandle`](#roothandle-1)\<`unknown`\> -This scope's owning node id — children get `${parentId}:s${seq}` ids. +Caller-created live handle for observing, steering, or cancelling this root manager. Runtime +attaches it before execution and detaches it after the join barrier. -##### root +##### signal? -> `readonly` **root**: `string` +> `readonly` `optional` **signal?**: `AbortSignal` -Journal/blob root key the supervisor `beginTree`'d. +Caller-owned cancellation for the complete recursive run. Aborting it cascades through the +root scope and every live child, including acquisition and backend execution. -##### pool +##### execution? -> `readonly` **pool**: [`BudgetPool`](#budgetpool) +> `readonly` `optional` **execution?**: [`AgentExecutionRef`](#agentexecutionref) -The shared conserved reservation pool (one per supervised run). +Trusted candidate and pursuit attribution for the root. The runtime derives profile/task +digests itself from the exact detached values it executes. -##### journal +##### backend? -> `readonly` **journal**: [`SpawnJournal`](#spawnjournal) +> `readonly` `optional` **backend?**: [`ExecutorConfig`](#executorconfig) -Append-only spawn journal; this scope writes `spawned` + `settled` records. +WHERE workers run — derives the worker seam. Provide this OR an explicit `makeWorkerAgent`. -##### blobs +##### deliverable? -> `readonly` **blobs**: [`ResultBlobStore`](#resultblobstore) +> `readonly` `optional` **deliverable?**: `string` \| [`DeliverableSpec`](#deliverablespec)\<`unknown`\> -Content-addressed result store backing `outRef` rehydration. +The independent completion check for backend-derived workers and direct supervisor + submissions. Strongly recommended: without it the supervisor cannot submit its own work and + backend-derived workers fall back to their own validity signal. A `string` names an entry in + `registry.deliverables`. -##### executors +##### resolveDeliverable? -> `readonly` **executors**: [`ExecutorRegistry`](index.md#executorregistry) +> `readonly` `optional` **resolveDeliverable?**: (`input`) => [`DeliverableSpec`](#deliverablespec)\<`unknown`\> \| `undefined` -The open executor resolver (BYO → router/inline → registered harness factory). +Resolve the completion check for one exact authorized backend-derived leaf. The callback runs +after spawn authorization and driver classification, receives a detached immutable context, +and may return `undefined` to use the run-wide `deliverable`. Driver profiles never call it. -##### probes? +###### Parameters -> `readonly` `optional` **probes?**: [`WaitProbeRegistry`](#waitproberegistry) +###### input -Predicate resolver for `poll` wait-states. Absent ⇒ `wait` refuses a `poll` with - `unknown-probe`; `timer` waits never touch it. +[`AuthorizedSpawnContext`](#authorizedspawncontext) -##### waitSleep? +###### Returns -> `readonly` `optional` **waitSleep?**: (`ms`, `signal`) => `Promise`\<`void`\> +[`DeliverableSpec`](#deliverablespec)\<`unknown`\> \| `undefined` -Injected sleeper for wait-states — a test drives a week-long timer in microseconds. +##### registry? -###### Parameters +> `readonly` `optional` **registry?**: [`SuperviseRegistry`](#superviseregistry) -###### ms +Name→value tables for the four code-valued options, so a recorded run configuration can name + them instead of carrying closures. See [SuperviseRegistry](#superviseregistry). -`number` +##### coordination? -###### signal +> `readonly` `optional` **coordination?**: [`CoordinationBinding`](#coordinationbinding) -`AbortSignal` +Where the coordination MCP binds when the supervisor is harness-driven. Omit = an ephemeral + port on `127.0.0.1`, which an off-host root cannot reach. A non-loopback host is refused + unless `allowUnauthenticatedRemote` acknowledges that the verbs are unauthenticated. -###### Returns +##### makeWorkerAgent? -`Promise`\<`void`\> +> `readonly` `optional` **makeWorkerAgent?**: [`MakeWorkerAgent`](#makeworkeragent) -##### seams +Override the worker seam directly (tests / advanced) instead of deriving it from `backend`. + This is caller-owned execution: profile security, spawn authorization, and recursive-driver + selection below apply only to the backend-derived worker path. `authorizeMessage` still + governs continuations sent through Runtime's coordination tools. -> `readonly` **seams**: `Readonly`\<`Record`\<`string`, `unknown`\>\> +##### driverBackend? -Per-spawn executor-construction seams (sandbox client, router config, cli bin). +> `readonly` `optional` **driverBackend?**: [`ExecutorConfig`](#executorconfig) -##### depth +Run harness-brained supervisors here. Automatic execution supports a local `bridge`; a remote + sandbox requires an explicit `driveHarness` with a reachable coordination relay or tunnel. + Defaults to `backend`; separate it when managers and workers use different services. -> `readonly` **depth**: `number` +##### profileSecurity? -This scope's recursion depth (root = 0). +> `readonly` `optional` **profileSecurity?**: `AgentProfileSecurityPolicy` -##### maxDepth? +Security policy applied to every manager-authored child profile before budget reservation. + The default blocks local and remote MCP, hooks, and connection grants. Pass an explicit + allowlist to grant remote MCP hosts or other author-controlled capabilities. -> `readonly` `optional` **maxDepth?**: `number` +##### authorizeSpawn? -Runtime recursion-depth ceiling — a spawn past it fails closed `depth-exceeded`. +> `readonly` `optional` **authorizeSpawn?**: (`input`) => [`AuthorizedSpawn`](#authorizedspawn) -##### signal +Product authority over one complete manager-authored spawn. The callback sees the detached, + immutable profile, task, budget, label, and key together, so approving a profile cannot + authorize a different task. Return the exact allowed profile (which may be narrowed) plus + trusted candidate/pursuit attribution, or throw to refuse the whole spawn before reservation. -> `readonly` **signal**: `AbortSignal` +###### Parameters -Abort signal for this scope; an abort cascades into every live child's executor. +###### input -##### now? +###### profile -> `readonly` `optional` **now?**: () => `number` +`AgentProfile` -Injected clock — keeps the journal `at` timestamp deterministic in tests. +###### parent -###### Returns +`AgentProfile` -`number` +###### parentIdentity -##### hooks? +[`NodeExecutionIdentity`](#nodeexecutionidentity) -> `readonly` `optional` **hooks?**: [`RuntimeHooks`](index.md#runtimehooks) +Trusted identity of the manager authorizing this exact child. -Lifecycle stream sink. `spawn` emits `agent.spawn`, `next` emits `agent.child` — the - SAME stream `runAgentRounds`/`tool-loop` feed, so the recursive tree is ONE observable stream - (the topology viewer reads it). Undefined ⇒ the journal stays the only record. +###### parentNodeId -##### workerTrace? +`string` -> `readonly` `optional` **workerTrace?**: [`WorkerTraceResolver`](#workertraceresolver) +Concrete manager node; never accepted from model-authored tool arguments. -Trace context to hand down to each spawned worker (`SupervisorOpts.workerTrace`). Called with -THIS scope's own `parentId` — the node doing the spawning — and the resolved context is seeded -onto each child's `ExecutorContext` under `workerTraceSeamKey`. Absent (the untraced default) -⇒ no seam is seeded and no worker environment is touched. +###### assignmentId -##### resumeFrom? +`string` -> `readonly` `optional` **resumeFrom?**: `object` +Stable manager-scoped assignment, including deterministic unkeyed siblings. -Resume seam — set ONLY by the supervisor when `SupervisorOpts.resume` is on AND a non-empty -journal tree exists for this root. It carries the replayed committed work (so `scope.resume` -exposes it to a resume-aware `act`) and the recorded ordinal/cursor maxima the new counters -continue past, so a freshly-spawned child never reuses a journaled `seq`. Absent ⇒ fresh run. +###### task -###### settled +`unknown` -> `readonly` **settled**: readonly [`Settled`](index.md#settled)\<`unknown`\>[] +###### budget -###### view +[`Budget`](index.md#budget-4) -> `readonly` **view**: [`TreeView`](#treeview) +###### label -###### maxSpawnOrdinal +`string` -> `readonly` **maxSpawnOrdinal**: `number` +###### key? -Highest `spawned` ordinal already journaled; new spawns start at `+1`. +`string` -###### maxCursorSeq +###### depth -> `readonly` **maxCursorSeq**: `number` +`number` -Highest cursor `seq` already journaled; new settlements start at `+1`. +###### Returns -###### maxWaitOrdinal +[`AuthorizedSpawn`](#authorizedspawn) -> `readonly` **maxWaitOrdinal**: `number` +##### authorizeMessage? -Highest `waiting` ordinal already journaled; new waits start at `+1`. +> `readonly` `optional` **authorizeMessage?**: (`input`) => [`AuthorizedDownMessage`](#authorizeddownmessage) -###### waits +Product authority over every continuation sent to a live child. When spawn authorization is +enabled, omitting this refuses steer/answer instructions instead of silently extending the +authorized task. The exact worker identity and detached bytes are recorded before delivery. -> `readonly` **waits**: readonly [`PendingWait`](#pendingwait)[] +###### Parameters -Waits journaled as armed but never woken — re-armed (same node id, same absolute deadline) - when `wait` is called again with the SAME label. +###### input -###### keys +[`DownMessageAuthorizationInput`](#downmessageauthorizationinput) & `object` -> `readonly` **keys**: `ReadonlyMap`\<`string`, [`ResumedKeyState`](#resumedkeystate)\<`unknown`\>\> +###### Returns -Keyed assignments from the prior journal — what a keyed re-spawn resolves against. +[`AuthorizedDownMessage`](#authorizeddownmessage) -###### priorSpend +##### isDriverProfile? -> `readonly` **priorSpend**: `object` +> `readonly` `optional` **isDriverProfile?**: (`input`) => `boolean` -Prior committed spend summed off the journal (settled child work + metered inference). +Decide whether an authorized child becomes another supervisor. By default only + `metadata.role === 'driver'` does. Products receive the same frozen post-authorization + context as `resolveDeliverable`, so trusted execution/assignment authority can override + model-authored metadata without a side channel. -###### priorSpend.childWork +###### Parameters -> `readonly` **childWork**: [`Spend`](index.md#spend) +###### input -###### priorSpend.driverInference +[`AuthorizedSpawnContext`](#authorizedspawncontext) -> `readonly` **driverInference**: [`Spend`](index.md#spend) +###### Returns -*** +`boolean` -### ProgressSample +##### router? -One settled unit of work, reduced to what a stop rule reads. `objective` is the run's own - quality signal (a verdict score, a test pass-rate, a judge rating); `undefined` = this - settlement produced no measurable objective (it failed, or nothing scored it). +> `readonly` `optional` **router?**: [`RouterConfig`](#routerconfig) -#### Properties +The supervisor's router substrate (`profile.harness` omitted or `cli-base`). The profile's + model wins. -##### id +##### brain? -> `readonly` **id**: `string` +> `readonly` `optional` **brain?**: [`ToolLoopChat`](#toolloopchat) -##### at +Inject the supervisor brain directly (tests / advanced). -> `readonly` **at**: `number` +##### driveHarness? -Epoch ms the settlement was observed. +> `readonly` `optional` **driveHarness?**: [`DriveHarness`](#driveharness-1) -##### objective? +Run an external-harness supervisor explicitly. Required for a remote sandbox; optional as a + caller-owned override for a local bridge. -> `readonly` `optional` **objective?**: `number` +##### resolveDriveHarness? -##### delivered +> `readonly` `optional` **resolveDriveHarness?**: [`ResolveDriveHarness`](#resolvedriveharness-1) -> `readonly` **delivered**: `boolean` +Resolve one custom external-harness session per trusted manager identity. Use this instead of +`driveHarness` when recursive managers must be independently steerable. -True when the settlement passed its deliverable check — a scored-but-undelivered result is - not progress. +##### driveHarnessMaterialization? -*** +> `readonly` `optional` **driveHarnessMaterialization?**: [`ProfileMaterializationContract`](agent.md#profilematerializationcontract) -### ProgressView +Required with a custom `driveHarness` or `resolveDriveHarness`: declares which complete +AgentProfile axes that path really applies. Built-in bridge driving supplies its own +full-profile contract. -The read-model a `StopRule` decides from — the run's progress, not its budget. +##### resolveSupervisorTools? -#### Properties +> `readonly` `optional` **resolveSupervisorTools?**: [`ResolveSupervisorTools`](#resolvesupervisortools-1) -##### now +Resolve product-owned tools from the exact trusted manager context. The same descriptors and +handlers are bound to router and external-harness managers; resolution happens once per node. +Each handler receives that manager scope's live cancellation signal in its trusted invocation +context, including recursive parent and root cascades. -> `readonly` **now**: `number` +##### onCoordinationEvent? -##### settles +> `readonly` `optional` **onCoordinationEvent?**: (`context`, `eventId`, `record`) => `void` \| `Promise`\<`void`\> -> `readonly` **settles**: `number` +Awaited product transaction hook for every coordination record. `eventId` is stable across a +lost acknowledgement and durable restart; the record is not pull-visible until this commits. -Settlements observed so far, in the order they landed. +###### Parameters -##### delivered +###### context -> `readonly` **delivered**: `number` +[`SupervisorNodeContext`](#supervisornodecontext) -Of those, how many passed their deliverable check. +###### eventId -##### curve +`` `sha256:${string}` `` -> `readonly` **curve**: readonly `number`[] +###### record -Best-so-far objective after each settlement (`anytime.bestSoFar`). +[`BusRecord`](#busrecord)\<[`CoordinationEvent`](index.md#coordinationevent)\> -##### best +###### Returns -> `readonly` **best**: `number` +`void` \| `Promise`\<`void`\> -The current best objective; `0` when nothing has scored. +##### extraTools? -##### auc +> `readonly` `optional` **extraTools?**: readonly `object`[] -> `readonly` **auc**: `number` +WORK tools the supervisor may call DIRECTLY — so a recursive atom can ACT (do simple work + itself) OR SPAWN (delegate when it needs parallelism), not be a pure manager. Pair with + `executeExtraTool`. Router arm only (`profile.harness` omitted or `cli-base`). -Mean of the best-so-far curve — how EARLY the run climbed (`anytime.areaUnderCurve`). +##### executeExtraTool? -##### lastSettleAt +> `readonly` `optional` **executeExtraTool?**: (`name`, `args`) => `Promise`\<`string` \| `null` \| `undefined`\> -> `readonly` **lastSettleAt**: `number` +Runs an `extraTools` call; null/undefined falls through to the coordination dispatch. -Epoch ms of the most recent settlement; `0` when none has landed. +###### Parameters -##### lastImprovementAt +###### name -> `readonly` **lastImprovementAt**: `number` +`string` -Epoch ms of the most recent improvement in best-so-far; `0` when none. +###### args -##### settlesSinceImprovement +`Record`\<`string`, `unknown`\> -> `readonly` **settlesSinceImprovement**: `number` +###### Returns -Settlements since the last improvement — `0` right after one improves. +`Promise`\<`string` \| `null` \| `undefined`\> -##### workers +##### perWorker? -> `readonly` **workers**: readonly [`WorkerProgress`](#workerprogress)[] +> `readonly` `optional` **perWorker?**: [`Budget`](index.md#budget-4) -Live read of every non-terminal worker (the `Scope.progress` feed). Empty when the caller - supplied no scope. +Per-child budget reserved on each spawn. Defaults to a quarter of the pool's tokens. -##### inFlight +##### maxLiveWorkers? -> `readonly` **inFlight**: `number` +> `readonly` `optional` **maxLiveWorkers?**: `number` -Nodes running or acquiring. +Hard cap on simultaneously executing spawned workers across the WHOLE recursive tree. The + root is excluded; nested drivers and leaves share one allocation, so recursion cannot multiply + the cap. Omit/`<= 0` = no cap (the conserved pool stays the only bound). -##### waiting +##### analysts? -> `readonly` **waiting**: `number` +> `readonly` `optional` **analysts?**: `string` \| [`AnalystRegistry`](index.md#analystregistry) -Armed wait-state nodes — deliberately separate from `inFlight`: a tree whose only remaining - nodes are waits is NOT stalled, it is waiting on the world. +Analyst lenses available to the driver. Required for `analyzeOnSettle`. Unset → status quo + (the driver receives settled worker outputs, no analyst findings). A `string` names an entry in + `registry.analysts`. -*** +##### analyzeOnSettle? -### ProgressTracker +> `readonly` `optional` **analyzeOnSettle?**: readonly `string`[] -Accumulates settlements and materializes a `ProgressView`. Idempotent by settlement id, so a - caller may re-push its whole roster every turn (the driver does exactly that) without - double-counting or moving a recorded timestamp. +Analyst kind ids run AUTOMATICALLY when a worker settles `done` — each re-enters as a `finding` + the driver pulls (`await_event`) and composes its next steer from. The self-improving UP-leg, + threaded to the driver at this level (propagate to sub-drivers via a recursive `makeWorkerAgent`). + Omit/empty = status quo (no analyst feed). Requires `analysts`. -#### Methods +##### watchWorkers? -##### record() +> `readonly` `optional` **watchWorkers?**: [`WorkerWatchOptions`](mcp.md#workerwatchoptions) -> **record**(`sample`): `boolean` +Watch every worker's LIVE tool trace with the online detector panel and raise a `finding` the +moment one loops or error-storms — so the supervisor learns it mid-run (via `await_event`) +instead of at settle. Pairs with a steerable worker: the finding is the evidence, `steer_agent` +is the correction. Requires a backend whose executor exposes a trace source (the steerable +sandbox worker and the pi wrapper do); other runtimes are simply not watched. -Record a settlement. A second call with the same `id` is ignored. Returns true when it was - new. +Omit = off (status quo — no online watching, no extra events). -###### Parameters +##### stallAfterMs? -###### sample +> `readonly` `optional` **stallAfterMs?**: `number` -[`ProgressSample`](#progresssample) +Idle time after which `observe_agent` reports a running worker as `stalled`. A derived read + at observation time — nothing is killed or retried. Omit = the runtime default. -###### Returns +##### blobs? -`boolean` +> `readonly` `optional` **blobs?**: [`ResultBlobStore`](#resultblobstore) -##### view() +Worker output store. Defaults to in-memory. -> **view**(`scope?`, `opts?`): [`ProgressView`](#progressview) +##### runDir? -Materialize the view. Pass the live `Scope` to include the worker feed and tree shape. +> `readonly` `optional` **runDir?**: `string` -###### Parameters +Make the run DURABLE: journal + result blobs + the coordination side-log are file-backed under +this directory (`createFileRunContext`), fsynced per write, and the supervisor reads the prior +tree first. Re-running with the same `runDir` AND the same `runId` resumes only when the exact +root profile/task identity and declared budget match. The original absolute deadline and prior +measured spend are restored before new admission. The built-in driver is resume-aware: children +that already settled, including their exact execution identities, are replayed onto +`Scope.resume` (and into the driver's settled ledger + its first context), keyed assignments +(`spawn_agent`'s `key`) resolve to their committed results instead of re-running, pending +waits re-arm on their original deadlines, and the coordination log loads prior questions, +findings, and instruction receipts. The router arm receives all three in its resume brief; the +external arm seeds prior questions while findings and receipts remain in the durable log. +Instruction receipts are evidence and are never delivered automatically to a replacement +worker. The final result spans both processes' work. Unset = in-memory, fresh every call. -###### scope? +The boundary that remains: work that was IN FLIGHT when the process died is not recovered — +the built-in executors cannot re-attach to a dead process's executions. Each such assignment +resumes as explicitly lost/in-doubt, its full declared reservation is charged conservatively, +and its token/dollar telemetry remains unknown. A retry is admitted only from safely remaining +capacity, so restart cannot mint a fresh budget or slide the original absolute deadline. -[`Scope`](index.md#scope)\<`unknown`\> +`runId` matters here: it defaults to the constant `'supervise'`, which is fine for a single +resumable run per directory but collides across concurrent runs sharing one `runDir`. -###### opts? +##### journal? -###### stallAfterMs? +> `readonly` `optional` **journal?**: [`SpawnJournal`](#spawnjournal) -`number` +Override the spawn journal directly (advanced; `runDir` is the ordinary durable path). Pair + with `blobs` — a journal whose result payloads live in a different store cannot replay. -###### Returns +##### probes? -[`ProgressView`](#progressview) +> `readonly` `optional` **probes?**: `string` \| [`WaitProbeRegistry`](#waitproberegistry) -##### evaluate() +Predicate registry for `poll` wait-states (`Scope.wait`). A `poll` names its predicate so the + wait survives a restart; this is what the name resolves against. Unset ⇒ `poll` waits are + refused `unknown-probe` and `timer` waits still work. A `string` names an entry in + `registry.probes`. -> **evaluate**(`rule`, `scope?`, `opts?`): [`StopDecision`](#stopdecision) +##### stopRule? -Evaluate a rule against the current view. +> `readonly` `optional` **stopRule?**: [`StopRule`](#stoprule-1) -###### Parameters +PROGRESS-derived stop rule (router-brained supervisor). Ends a run that has stopped LEARNING +before it exhausts a ceiling — the answer to "a run should end because it is done or stuck, +not because it ran out". It composes with the budget guards and can never override one. -###### rule +Build it from `supervise/stop-rules`: `plateau({window, minDelta})`, +`noProgressFor({ms, settles})`, `allWorkersStalled({...})`, combined with `anyOf`/`allOf`. The +thresholds are policy and stay with you; the enforcement lives in the runtime. Omit = ceilings +only (unchanged behavior). -[`StopRule`](#stoprule-1) +##### onProgressStop? -###### scope? +> `readonly` `optional` **onProgressStop?**: (`reason`) => `void` -[`Scope`](index.md#scope)\<`unknown`\> +One-shot notification of WHY a `stopRule` ended the run — so a caller records the reason + instead of inferring an early stop from an unexhausted budget. -###### opts? +###### Parameters -###### stallAfterMs? +###### reason -`number` +`string` ###### Returns -[`StopDecision`](#stopdecision) +`void` -##### samples() +##### maxDepth? -> **samples**(): readonly [`ProgressSample`](#progresssample)[] +> `readonly` `optional` **maxDepth?**: `number` -The samples recorded so far, in order. +##### maxTurns? -###### Returns +> `readonly` `optional` **maxTurns?**: `number` -readonly [`ProgressSample`](#progresssample)[] +##### compaction? -*** +> `readonly` `optional` **compaction?**: [`ToolLoopCompactionOptions`](#toolloopcompactionoptions) -### ProgressTrackerOptions +Give the supervisor brain a chapter-lifecycle on its OWN context window (router arm only): once + its coordination transcript exceeds `thresholdTokens` it distills to a compact progress note and + continues, instead of re-billing the whole transcript every turn (the cost that makes the LLM-brain + front door lose to a dumb-Ralph respawn). The live `Scope` roster is the durable state across + chapters. Default off. `distill` defaults to a brain self-summary + the settled-worker roster. -#### Properties +##### runId? + +> `readonly` `optional` **runId?**: `string` ##### now? > `readonly` `optional` **now?**: () => `number` -Clock for `view().now`. Defaults to `Date.now`. - ###### Returns `number` -##### requireDelivered? +##### allowedModels? -> `readonly` `optional` **requireDelivered?**: `boolean` +> `readonly` `optional` **allowedModels?**: readonly `string`[] -Treat a settlement that did NOT pass its deliverable check as having no objective. Default - true — "scored 0.9 but never delivered" is not progress, and counting it as progress is the - exact way a plateau rule gets talked out of firing. +Restrict the run to this subset of models. When set, every configured model — the + supervisor router model, the profile's model, and the backend's model — must be a member, + or `supervise()` throws a `ConfigError` before any compute is spent. Unset = unrestricted. -##### minImprovement? +##### finalizer? -> `readonly` `optional` **minImprovement?**: `number` +> `readonly` `optional` **finalizer?**: `string` \| [`SupervisorFinalizer`](index.md#supervisorfinalizer) -How much the best-so-far must rise for a settlement to count as an IMPROVEMENT. Default 0 - (any strict rise counts). Raise it to ignore score noise. +How the settled-worker ledger becomes the run's output. Default `bestDelivered` — the single + highest-scoring DELIVERED child (the exact behavior every existing caller had). Alternatives: + `collectDelivered` (every verified distinct output with provenance — a Pareto set / recorded + disagreement) or a custom `SupervisorFinalizer`. Whatever the finalizer, it operates on + structurally DELIVERED outputs only — an undelivered or invalid child stays ineligible. A + `string` names an entry in `registry.finalizers`. -*** +##### hooks? -### NoProgressForOptions +> `readonly` `optional` **hooks?**: [`RuntimeHooks`](index.md#runtimehooks) -#### Properties +Lifecycle observers for the whole recursive tree (`Scope` re-seeds them into every nested + scope). Composed with the `otel` recorder below when both are set. Omit = no observers, which + is the behavior every existing caller has. -##### ms? +##### otel? -> `readonly` `optional` **ms?**: `number` +> `readonly` `optional` **otel?**: `Omit`\<[`SupervisorSpanOptions`](#supervisorspanoptions), `"runId"` \| `"now"`\> -Stop when this many ms have passed since the last SETTLEMENT. Omit to not bound on time. +OPT-IN OTLP tracing: emit one span per supervised node (opened at spawn, closed at settle, +parented to its parent node's span) plus an `LLM` child span per metered driver turn, so the +tree is readable by any trace viewer instead of only by a journal parser. See `otel-spans.ts`. -##### settles? +Omit and the run emits nothing, allocates no recorder, and installs no hook — telemetry is +never a default. Present with no reachable endpoint (no `exportConfig.endpoint` and no +`OTEL_EXPORTER_OTLP_ENDPOINT`) is also a no-op. The spawn journal is untouched either way: +spans are telemetry, never the replay/resume record. -> `readonly` `optional` **settles?**: `number` +*** -Stop when this many settlements have landed with no improvement in best-so-far. Omit to not - bound on settles. +### AuthorizedSpawn -##### minSettles? +The product-authorized result for one complete spawn request. Attribution is never accepted +from the manager itself; it enters only through this trusted callback. -> `readonly` `optional` **minSettles?**: `number` +#### Properties -Never stop before this many settlements have landed — the warm-up that stops a rule from - firing on an empty run. Default 1. +##### profile + +> `readonly` **profile**: `AgentProfile` + +##### execution? + +> `readonly` `optional` **execution?**: [`AgentExecutionRef`](#agentexecutionref) *** -### PlateauOptions +### AuthorizedSpawnContext -#### Properties +Exact trusted context after a manager-authored spawn has passed product authorization. -##### window +#### Properties -> `readonly` **window**: `number` +##### profile -How many trailing settlements to judge. The rule fires when the whole window failed to lift - the best-so-far by more than `minDelta`. +> `readonly` **profile**: `AgentProfile` -##### minDelta +##### parent -> `readonly` **minDelta**: `number` +> `readonly` **parent**: `AgentProfile` -The rise that counts as an improvement — the domain's noise floor. `0` means any strict rise - counts. +##### parentIdentity -##### minSettles? +> `readonly` **parentIdentity**: [`NodeExecutionIdentity`](#nodeexecutionidentity) -> `readonly` `optional` **minSettles?**: `number` +##### execution -Never fire before this many settlements. Defaults to `window` (so the first decision is made - on a full window, not on a partial one). +> `readonly` **execution**: [`NodeExecutionIdentity`](#nodeexecutionidentity) -*** +##### parentNodeId -### AllWorkersStalledOptions +> `readonly` **parentNodeId**: `string` -#### Properties +##### assignmentId -##### minWorkers? +> `readonly` **assignmentId**: `string` -> `readonly` `optional` **minWorkers?**: `number` +##### task -Require at least this many live workers before the rule can fire — one stalled worker in a - one-worker tree is a weaker signal than a whole fleet going quiet. Default 1. +> `readonly` **task**: `unknown` -##### stallAfterMs? +##### budget -> `readonly` `optional` **stallAfterMs?**: `number` +> `readonly` **budget**: [`Budget`](index.md#budget-4) -Idle time that counts as stalled, passed through to the live progress read. Omit = the - runtime default (`DEFAULT_STALL_AFTER_MS`). +##### label -*** +> `readonly` **label**: `string` -### SuperviseRegistryTable +##### key? -A name→value table, in this package's resolver-port shape (the same one `WaitProbeRegistry` - uses): construction stays the caller's, lookup stays lazy, and a table backed by a file, a - plugin loader, or a plain object all satisfy one interface. +> `readonly` `optional` **key?**: `string` -#### Type Parameters +##### depth -##### T +> `readonly` **depth**: `number` -`T` +*** -#### Methods +### SupervisorProfile -##### resolve() +The supervisor's profile — the subset of an `AgentProfile` that selects + shapes its brain. +`harness` is the backend-as-data discriminant; `systemPrompt` is the standing instruction. -> **resolve**(`name`): `T` \| `undefined` +A canonical `AgentProfile` from `@tangle-network/agent-interface` satisfies this interface +structurally: its `model` is a hints OBJECT and its system prompt lives at `prompt.systemPrompt`, +so both spellings are accepted here and reduced by [resolveSupervisorProfile](#resolvesupervisorprofile). Before that, +a canonical profile's model object reached `RouterConfig.model` (a string) as an object and its +`prompt.systemPrompt` was dropped — a request the provider rejects, and a supervisor running the +default strategy while its profile named another. -###### Parameters +WHAT EACH ARM HONORS — the two brains read different amounts of a profile, so state it rather +than let a caller infer that a field took effect: -###### name + - ROUTER arm (`harness` null): only `name`, the resolved model id (`model`, or + `model.default`), and the resolved system prompt (`prompt.systemPrompt`/`systemPrompt` plus + `prompt.instructions` and `resources.instructions`) reach the brain. A full `AgentProfile`'s + `tools`, `mcp`, `permissions`, `resources.skills`/`files`, `hooks`, `modes`, `subagents`, + `model.provider`, `model.small` and `model.reasoningEffort` are NOT honored here: the router + brain is one `ToolLoopChat` over the coordination verbs, and neither of its two tool-calling + transports (`routerChatWithTools` buffered, `streamRouterChatWithTools` when + `RouterConfig.stream` is set) has a parameter for any of them. + - HARNESS arm (`harness` set): the WHOLE profile object is handed to `deps.driveHarness` + untouched, plus the resolved system prompt as a separate argument. Everything the profile + declares is the harness's to materialize; this module changes none of it. -`string` +#### Properties -###### Returns +##### name? -`T` \| `undefined` +> `readonly` `optional` **name?**: `string` -*** +##### harness? -### SuperviseRegistry +> `readonly` `optional` **harness?**: `string` \| `null` -The name→value tables that make the four CODE-valued options expressible as run DATA. +null/undefined/`cli-base` → router brain (in-process tool-loop); a coding-CLI harness → an + external harness brain. -`deliverable` / `finalizer` / `analysts` / `probes` are functions and registries, so a recorded -run configuration (a JSON row, a campaign spec, a resumed run's options) cannot carry them — and -a run with no `deliverable` cannot return a `winner` at all outside the sandbox backend, because -the finalizer keeps only children whose oracle passed and nothing else writes that verdict. A -caller that owns the code registers it here once and names it from data thereafter. +##### model? -#### Properties +> `readonly` `optional` **model?**: `string` \| `AgentProfileModelHints` -##### deliverables? +The router model when the brain is router-driven: a model id, or a canonical profile's model + hints whose `default` IS the id. Absent (including a hints object with no `default`) → the + deps router config's model applies. Other hints (`small`, `provider`, `reasoningEffort`) are + harness-arm material only. -> `readonly` `optional` **deliverables?**: [`SuperviseRegistryTable`](#superviseregistrytable)\<[`DeliverableSpec`](#deliverablespec)\<`unknown`\>\> +##### prompt? -##### finalizers? +> `readonly` `optional` **prompt?**: `AgentProfilePrompt` -> `readonly` `optional` **finalizers?**: [`SuperviseRegistryTable`](#superviseregistrytable)\<[`SupervisorFinalizer`](index.md#supervisorfinalizer)\> +Canonical `AgentProfile` prompt shaping. `prompt.systemPrompt` and the top-level `systemPrompt` + are the same standing instruction in two spellings; disagreeing values are a fault, not a pick. + `prompt.instructions` lines are appended to the resolved prompt, one per line. -##### analysts? +##### resources? -> `readonly` `optional` **analysts?**: [`SuperviseRegistryTable`](#superviseregistrytable)\<[`AnalystRegistry`](index.md#analystregistry)\> +> `readonly` `optional` **resources?**: `AgentProfileResources` -##### probes? +Canonical `AgentProfile` resources. Only `instructions` shapes the brain here (appended to the + resolved system prompt); every other resource is the harness's to materialize. -> `readonly` `optional` **probes?**: [`SuperviseRegistryTable`](#superviseregistrytable)\<[`WaitProbeRegistry`](#waitproberegistry)\> +##### systemPrompt? -*** +> `readonly` `optional` **systemPrompt?**: `string` -### SuperviseOptions +The standing instructions ("you delegate, you do not solve"). -#### Properties +*** -##### budget +### ResolvedSupervisorProfile -> `readonly` **budget**: [`Budget`](index.md#budget-4) +A `SupervisorProfile` reduced to the scalars the two brain arms consume. `modelId`/`systemPrompt` + stay `undefined` when the profile named none — the caller's fallback (`deps.router.model`, + the built-in default supervisor prompt) then applies, and this type cannot hide which happened. -The conserved compute pool for the whole run. + There is deliberately no `reasoningEffort` here: the router brain runs on `chatWithTools` (the + buffered/streamed switch in the router client), and neither transport has a `reasoning_effort` + parameter — only the chat-only `routerChatWithUsage` does — so a field carrying it would be a + public promise nothing keeps. `model.reasoningEffort` still reaches the harness arm inside the + profile. -##### backend? +#### Properties -> `readonly` `optional` **backend?**: [`ExecutorConfig`](#executorconfig) +##### name -WHERE workers run — derives the worker seam. Provide this OR an explicit `makeWorkerAgent`. +> `readonly` **name**: `string` -##### deliverable? +##### harness -> `readonly` `optional` **deliverable?**: `string` \| [`DeliverableSpec`](#deliverablespec)\<`unknown`\> +> `readonly` **harness**: `string` \| `null` -The independent completion check for backend-derived workers and direct supervisor - submissions. Strongly recommended: without it the supervisor cannot submit its own work and - backend-derived workers fall back to their own validity signal. A `string` names an entry in - `registry.deliverables`. +##### modelId? -##### registry? +> `readonly` `optional` **modelId?**: `string` -> `readonly` `optional` **registry?**: [`SuperviseRegistry`](#superviseregistry) +##### systemPrompt? -Name→value tables for the four code-valued options, so a recorded run configuration can name - them instead of carrying closures. See [SuperviseRegistry](#superviseregistry). +> `readonly` `optional` **systemPrompt?**: `string` -##### coordination? +*** -> `readonly` `optional` **coordination?**: [`CoordinationBinding`](#coordinationbinding) +### CoordinationBinding -Where the coordination MCP binds when the supervisor is harness-driven. Omit = an ephemeral - port on `127.0.0.1`, which an off-host root cannot reach. A non-loopback host is refused - unless `allowUnauthenticatedRemote` acknowledges that the verbs are unauthenticated. +Where the coordination MCP binds. Omit = an ephemeral port on `127.0.0.1` (the local-harness + default); set `host` when the root or the harness runs off-host. -##### makeWorkerAgent? +#### Properties -> `readonly` `optional` **makeWorkerAgent?**: [`MakeWorkerAgent`](#makeworkeragent) +##### host? -Override the worker seam directly (tests / advanced) instead of deriving it from `backend`. +> `readonly` `optional` **host?**: `string` -##### router? +##### port? -> `readonly` `optional` **router?**: [`RouterConfig`](#routerconfig) +> `readonly` `optional` **port?**: `number` -The supervisor's router substrate (`harness` null). The profile's model wins. +##### allowUnauthenticatedRemote? -##### brain? +> `readonly` `optional` **allowUnauthenticatedRemote?**: `boolean` -> `readonly` `optional` **brain?**: [`ToolLoopChat`](#toolloopchat) +Explicit acknowledgment required to bind a NON-loopback host — see + [assertCoordinationBinding](#assertcoordinationbinding) for what is being accepted. -Inject the supervisor brain directly (tests / advanced). +*** -##### driveHarness? +### SupervisorNodeContext -> `readonly` `optional` **driveHarness?**: [`DriveHarness`](#driveharness-1) +Trusted run/node identity Runtime binds to one manager. Model-authored tool arguments cannot + provide or replace any of these fields. -Run a sandboxed-harness supervisor (`harness` set). +#### Extended by -##### extraTools? +- [`SupervisorToolInvocationContext`](#supervisortoolinvocationcontext) -> `readonly` `optional` **extraTools?**: readonly `object`[] +#### Properties -WORK tools the supervisor may call DIRECTLY — so a recursive atom can ACT (do simple work - itself) OR SPAWN (delegate when it needs parallelism), not be a pure manager. Pair with - `executeExtraTool`. Router arm only (`harness` null). +##### runId -##### executeExtraTool? +> `readonly` **runId**: `string` -> `readonly` `optional` **executeExtraTool?**: (`name`, `args`) => `Promise`\<`string` \| `null` \| `undefined`\> +##### runNamespace -Runs an `extraTools` call; null/undefined falls through to the coordination dispatch. +> `readonly` **runNamespace**: `string` -###### Parameters +Stable across a durable restart; unique per in-memory invocation. -###### name +##### nodeId -`string` +> `readonly` **nodeId**: `string` -###### args +Concrete Scope node that owns this manager's coordination stream. -`Record`\<`string`, `unknown`\> +##### ownerId -###### Returns +> `readonly` **ownerId**: `string` -`Promise`\<`string` \| `null` \| `undefined`\> +Stable identity of this manager's coordination stream. -##### perWorker? +##### depth -> `readonly` `optional` **perWorker?**: [`Budget`](index.md#budget-4) +> `readonly` **depth**: `number` -Per-child budget reserved on each spawn. Defaults to a quarter of the pool's tokens. +##### identity -##### maxLiveWorkers? +> `readonly` **identity**: [`NodeExecutionIdentity`](#nodeexecutionidentity) -> `readonly` `optional` **maxLiveWorkers?**: `number` +##### assignmentId? -Hard cap on simultaneously-LIVE workers — `spawn_agent` fails closed once this many are in - flight. The conserved pool bounds TOTAL work; this bounds SIMULTANEOUS work (live boxes/ - sandboxes a real fleet runs at once). Omit/`<= 0` = no cap (the pool stays the only fence). +> `readonly` `optional` **assignmentId?**: `string` -##### analysts? +Assignment identity within the parent manager; absent only for the root. -> `readonly` `optional` **analysts?**: `string` \| [`AnalystRegistry`](index.md#analystregistry) +##### profile -Analyst lenses available to the driver. Required for `analyzeOnSettle`. Unset → status quo - (the driver receives settled worker outputs, no analyst findings). A `string` names an entry in - `registry.analysts`. +> `readonly` **profile**: [`SupervisorProfile`](#supervisorprofile) -##### analyzeOnSettle? +##### task -> `readonly` `optional` **analyzeOnSettle?**: readonly `string`[] +> `readonly` **task**: `unknown` -Analyst kind ids run AUTOMATICALLY when a worker settles `done` — each re-enters as a `finding` - the driver pulls (`await_event`) and composes its next steer from. The self-improving UP-leg, - threaded to the driver at this level (propagate to sub-drivers via a recursive `makeWorkerAgent`). - Omit/empty = status quo (no analyst feed). Requires `analysts`. +*** -##### watchWorkers? +### SupervisorToolInvocationContext -> `readonly` `optional` **watchWorkers?**: [`WorkerWatchOptions`](mcp.md#workerwatchoptions) +Trusted context for one product-tool invocation. The node identity remains the same detached, +immutable snapshot supplied to the resolver; `signal` is the one live control reference Runtime +adds. It aborts when this manager's scope is cancelled by the caller, RootHandle, deadline, +breaker, or a recursive parent. -Watch every worker's LIVE tool trace with the online detector panel and raise a `finding` the -moment one loops or error-storms — so the supervisor learns it mid-run (via `await_event`) -instead of at settle. Pairs with a steerable worker: the finding is the evidence, `steer_agent` -is the correction. Requires a backend whose executor exposes a trace source (the steerable -sandbox worker and the pi wrapper do); other runtimes are simply not watched. +#### Extends -Omit = off (status quo — no online watching, no extra events). +- [`SupervisorNodeContext`](#supervisornodecontext) -##### stallAfterMs? +#### Properties -> `readonly` `optional` **stallAfterMs?**: `number` +##### runId -Idle time after which `observe_agent` reports a running worker as `stalled`. A derived read - at observation time — nothing is killed or retried. Omit = the runtime default. +> `readonly` **runId**: `string` -##### blobs? +###### Inherited from -> `readonly` `optional` **blobs?**: [`ResultBlobStore`](#resultblobstore) +[`SupervisorNodeContext`](#supervisornodecontext).[`runId`](#runid-14) -Worker output store. Defaults to in-memory. +##### runNamespace -##### runDir? +> `readonly` **runNamespace**: `string` -> `readonly` `optional` **runDir?**: `string` +Stable across a durable restart; unique per in-memory invocation. -Make the run DURABLE: journal + result blobs + the coordination side-log are file-backed under -this directory (`createFileRunContext`), fsynced per write, and the supervisor reads the prior -tree first. Re-running with the same `runDir` AND the same `runId` resumes, and the built-in -driver is resume-AWARE out of the box: the children that already settled are replayed onto -`Scope.resume` (and into the driver's settled ledger + its first context), keyed assignments -(`spawn_agent`'s `key`) resolve to their committed results instead of re-running, pending -waits re-arm on their original deadlines, prior questions/findings replay from the -coordination log, and the finalize spans both processes' work. Unset = in-memory, fresh -every call. +###### Inherited from -The boundary that remains: work that was IN FLIGHT when the process died is not recovered — -the built-in executors cannot re-attach to a dead process's executions, so those assignments -resume as explicitly lost/in-doubt and re-run (reported, never silent). +[`SupervisorNodeContext`](#supervisornodecontext).[`runNamespace`](#runnamespace) -`runId` matters here: it defaults to the constant `'supervise'`, which is fine for a single -resumable run per directory but collides across concurrent runs sharing one `runDir`. +##### nodeId -##### journal? +> `readonly` **nodeId**: `string` -> `readonly` `optional` **journal?**: [`SpawnJournal`](#spawnjournal) +Concrete Scope node that owns this manager's coordination stream. -Override the spawn journal directly (advanced; `runDir` is the ordinary durable path). Pair - with `blobs` — a journal whose result payloads live in a different store cannot replay. +###### Inherited from -##### probes? +[`SupervisorNodeContext`](#supervisornodecontext).[`nodeId`](#nodeid-2) -> `readonly` `optional` **probes?**: `string` \| [`WaitProbeRegistry`](#waitproberegistry) +##### ownerId -Predicate registry for `poll` wait-states (`Scope.wait`). A `poll` names its predicate so the - wait survives a restart; this is what the name resolves against. Unset ⇒ `poll` waits are - refused `unknown-probe` and `timer` waits still work. A `string` names an entry in - `registry.probes`. +> `readonly` **ownerId**: `string` -##### stopRule? +Stable identity of this manager's coordination stream. -> `readonly` `optional` **stopRule?**: [`StopRule`](#stoprule-1) +###### Inherited from -PROGRESS-derived stop rule (router-brained supervisor). Ends a run that has stopped LEARNING -before it exhausts a ceiling — the answer to "a run should end because it is done or stuck, -not because it ran out". It composes with the budget guards and can never override one. +[`SupervisorNodeContext`](#supervisornodecontext).[`ownerId`](#ownerid-1) -Build it from `supervise/stop-rules`: `plateau({window, minDelta})`, -`noProgressFor({ms, settles})`, `allWorkersStalled({...})`, combined with `anyOf`/`allOf`. The -thresholds are policy and stay with you; the enforcement lives in the runtime. Omit = ceilings -only (unchanged behavior). +##### depth -##### onProgressStop? +> `readonly` **depth**: `number` -> `readonly` `optional` **onProgressStop?**: (`reason`) => `void` +###### Inherited from -One-shot notification of WHY a `stopRule` ended the run — so a caller records the reason - instead of inferring an early stop from an unexhausted budget. +[`SupervisorNodeContext`](#supervisornodecontext).[`depth`](#depth-2) -###### Parameters +##### identity -###### reason +> `readonly` **identity**: [`NodeExecutionIdentity`](#nodeexecutionidentity) -`string` +###### Inherited from -###### Returns +[`SupervisorNodeContext`](#supervisornodecontext).[`identity`](#identity-1) -`void` +##### assignmentId? -##### maxDepth? +> `readonly` `optional` **assignmentId?**: `string` -> `readonly` `optional` **maxDepth?**: `number` +Assignment identity within the parent manager; absent only for the root. -##### maxTurns? +###### Inherited from -> `readonly` `optional` **maxTurns?**: `number` +[`SupervisorNodeContext`](#supervisornodecontext).[`assignmentId`](#assignmentid-3) -##### compaction? +##### profile -> `readonly` `optional` **compaction?**: [`ToolLoopCompactionOptions`](#toolloopcompactionoptions) +> `readonly` **profile**: [`SupervisorProfile`](#supervisorprofile) -Give the supervisor brain a chapter-lifecycle on its OWN context window (router arm only): once - its coordination transcript exceeds `thresholdTokens` it distills to a compact progress note and - continues, instead of re-billing the whole transcript every turn (the cost that makes the LLM-brain - front door lose to a dumb-Ralph respawn). The live `Scope` roster is the durable state across - chapters. Default off. `distill` defaults to a brain self-summary + the settled-worker roster. +###### Inherited from -##### runId? +[`SupervisorNodeContext`](#supervisornodecontext).[`profile`](#profile-5) -> `readonly` `optional` **runId?**: `string` +##### task -##### now? +> `readonly` **task**: `unknown` -> `readonly` `optional` **now?**: () => `number` +###### Inherited from -###### Returns +[`SupervisorNodeContext`](#supervisornodecontext).[`task`](#task-25) -`number` +##### signal -##### allowedModels? +> `readonly` **signal**: `AbortSignal` -> `readonly` `optional` **allowedModels?**: readonly `string`[] +*** -Restrict the run to this subset of models. When set, every configured model — the - supervisor router model, the profile's model, and the backend's model — must be a member, - or `supervise()` throws a `ConfigError` before any compute is spent. Unset = unrestricted. +### SupervisorToolDescriptor -##### finalizer? +One product-owned tool. It reuses the canonical MCP descriptor fields while Runtime supplies + the trusted invocation context as a separate argument and binds the result for either + transport. Existing handlers remain compatible: the second argument only gains `signal`. -> `readonly` `optional` **finalizer?**: `string` \| [`SupervisorFinalizer`](index.md#supervisorfinalizer) +#### Extends -How the settled-worker ledger becomes the run's output. Default `bestDelivered` — the single - highest-scoring DELIVERED child (the exact behavior every existing caller had). Alternatives: - `collectDelivered` (every verified distinct output with provenance — a Pareto set / recorded - disagreement) or a custom `SupervisorFinalizer`. Whatever the finalizer, it operates on - structurally DELIVERED outputs only — an undelivered or invalid child stays ineligible. A - `string` names an entry in `registry.finalizers`. +- `Omit`\<[`McpToolDescriptor`](mcp.md#mcptooldescriptor), `"handler"`\> -##### hooks? +#### Properties -> `readonly` `optional` **hooks?**: [`RuntimeHooks`](index.md#runtimehooks) +##### name -Lifecycle observers for the whole recursive tree (`Scope` re-seeds them into every nested - scope). Composed with the `otel` recorder below when both are set. Omit = no observers, which - is the behavior every existing caller has. +> **name**: `string` -##### otel? +###### Inherited from -> `readonly` `optional` **otel?**: `Omit`\<[`SupervisorSpanOptions`](#supervisorspanoptions), `"runId"` \| `"now"`\> +[`McpToolDescriptor`](mcp.md#mcptooldescriptor).[`name`](mcp.md#name-2) -OPT-IN OTLP tracing: emit one span per supervised node (opened at spawn, closed at settle, -parented to its parent node's span) plus an `LLM` child span per metered driver turn, so the -tree is readable by any trace viewer instead of only by a journal parser. See `otel-spans.ts`. +##### description -Omit and the run emits nothing, allocates no recorder, and installs no hook — telemetry is -never a default. Present with no reachable endpoint (no `exportConfig.endpoint` and no -`OTEL_EXPORTER_OTLP_ENDPOINT`) is also a no-op. The spawn journal is untouched either way: -spans are telemetry, never the replay/resume record. +> **description**: `string` -*** +###### Inherited from -### SupervisorProfile +[`McpToolDescriptor`](mcp.md#mcptooldescriptor).[`description`](mcp.md#description) -The supervisor's profile — the subset of an `AgentProfile` that selects + shapes its brain. -`harness` is the backend-as-data discriminant; `systemPrompt` is the standing instruction. +##### inputSchema -A canonical `AgentProfile` from `@tangle-network/agent-interface` satisfies this interface -structurally: its `model` is a hints OBJECT and its system prompt lives at `prompt.systemPrompt`, -so both spellings are accepted here and reduced by [resolveSupervisorProfile](#resolvesupervisorprofile). Before that, -a canonical profile's model object reached `RouterConfig.model` (a string) as an object and its -`prompt.systemPrompt` was dropped — a request the provider rejects, and a supervisor running the -default strategy while its profile named another. +> **inputSchema**: `Record`\<`string`, `unknown`\> -WHAT EACH ARM HONORS — the two brains read different amounts of a profile, so state it rather -than let a caller infer that a field took effect: +###### Inherited from - - ROUTER arm (`harness` null): only `name`, the resolved model id (`model`, or - `model.default`), and the resolved system prompt (`prompt.systemPrompt`/`systemPrompt` plus - `prompt.instructions` and `resources.instructions`) reach the brain. A full `AgentProfile`'s - `tools`, `mcp`, `permissions`, `resources.skills`/`files`, `hooks`, `modes`, `subagents`, - `model.provider`, `model.small` and `model.reasoningEffort` are NOT honored here: the router - brain is one `ToolLoopChat` over the coordination verbs, and neither of its two tool-calling - transports (`routerChatWithTools` buffered, `streamRouterChatWithTools` when - `RouterConfig.stream` is set) has a parameter for any of them. - - HARNESS arm (`harness` set): the WHOLE profile object is handed to `deps.driveHarness` - untouched, plus the resolved system prompt as a separate argument. Everything the profile - declares is the harness's to materialize; this module changes none of it. +[`McpToolDescriptor`](mcp.md#mcptooldescriptor).[`inputSchema`](mcp.md#inputschema) -#### Properties +##### handler -##### name? +> `readonly` **handler**: (`raw`, `context`) => `Promise`\<`unknown`\> -> `readonly` `optional` **name?**: `string` +###### Parameters -##### harness? +###### raw -> `readonly` `optional` **harness?**: `string` \| `null` +`unknown` -null/undefined → router brain (in-process tool-loop); a coding-CLI harness → sandboxed brain. +###### context -##### model? +[`SupervisorToolInvocationContext`](#supervisortoolinvocationcontext) -> `readonly` `optional` **model?**: `string` \| `AgentProfileModelHints` +###### Returns -The router model when the brain is router-driven: a model id, or a canonical profile's model - hints whose `default` IS the id. Absent (including a hints object with no `default`) → the - deps router config's model applies. Other hints (`small`, `provider`, `reasoningEffort`) are - harness-arm material only. +`Promise`\<`unknown`\> -##### prompt? +*** -> `readonly` `optional` **prompt?**: `AgentProfilePrompt` +### DriveHarness() -Canonical `AgentProfile` prompt shaping. `prompt.systemPrompt` and the top-level `systemPrompt` - are the same standing instruction in two spellings; disagreeing values are a fault, not a pick. - `prompt.instructions` lines are appended to the resolved prompt, one per line. +How to run an external harness as the DRIVER, with the coordination verbs mounted — the substrate + seam the caller supplies (mirrors `makeWorkerAgent` for spawned children). It runs `profile` on + `task` in its backend (remote sandbox or local CLI bridge) with `coordinationMcpUrl` mounted as an MCP server, + so the harness calls spawn_agent / await_event / stop as native tools over the live scope. -##### resources? +> **DriveHarness**(`args`): `Promise`\<`void`\> -> `readonly` `optional` **resources?**: `AgentProfileResources` +How to run an external harness as the DRIVER, with the coordination verbs mounted — the substrate + seam the caller supplies (mirrors `makeWorkerAgent` for spawned children). It runs `profile` on + `task` in its backend (remote sandbox or local CLI bridge) with `coordinationMcpUrl` mounted as an MCP server, + so the harness calls spawn_agent / await_event / stop as native tools over the live scope. -Canonical `AgentProfile` resources. Only `instructions` shapes the brain here (appended to the - resolved system prompt); every other resource is the harness's to materialize. +#### Parameters -##### systemPrompt? +##### args -> `readonly` `optional` **systemPrompt?**: `string` +###### profile -The standing instructions ("you delegate, you do not solve"). +[`SupervisorProfile`](#supervisorprofile) -*** +The caller's profile, EXACTLY as passed to `supervisorAgent` — never rewritten. A canonical + `AgentProfile` stays schema-valid here (the canonical schema rejects unknown top-level keys, + so hoisting a resolved prompt onto it would make a profile its own validator refuses). -### ResolvedSupervisorProfile +###### systemPrompt? -A `SupervisorProfile` reduced to the scalars the two brain arms consume. `modelId`/`systemPrompt` - stay `undefined` when the profile named none — the caller's fallback (`deps.router.model`, - the built-in default supervisor prompt) then applies, and this type cannot hide which happened. +`string` - There is deliberately no `reasoningEffort` here: the router brain runs on `chatWithTools` (the - buffered/streamed switch in the router client), and neither transport has a `reasoning_effort` - parameter — only the chat-only `routerChatWithUsage` does — so a field carrying it would be a - public promise nothing keeps. `model.reasoningEffort` still reaches the harness arm inside the - profile. +The standing instruction assembled from the profile: its system prompt in either spelling, + plus the `prompt.instructions` and `resources.instructions` lines. Absent when the profile + names none — the harness's own default then applies. This, not `profile.systemPrompt`, is + what the harness should run under. -#### Properties +###### task -##### name +`unknown` -> `readonly` **name**: `string` +###### scope -##### harness +[`Scope`](index.md#scope)\<`unknown`\> -> `readonly` **harness**: `string` \| `null` +###### coordinationMcpUrl -##### modelId? +`string` -> `readonly` `optional` **modelId?**: `string` +###### coordinationTools -##### systemPrompt? +readonly `Omit`\<[`McpToolDescriptor`](mcp.md#mcptooldescriptor), `"handler"`\>[] -> `readonly` `optional` **systemPrompt?**: `string` +Data-only product tool surface mounted on the coordination MCP. Runtime-owned drivers include + this in their materialization evidence without persisting executable handlers. -*** +#### Returns -### CoordinationBinding +`Promise`\<`void`\> -Where the coordination MCP binds. Omit = an ephemeral port on `127.0.0.1` (the local-harness - default); set `host` when the root or the harness runs off-host. +#### Methods -#### Properties +##### deliver()? -##### host? +> `optional` **deliver**(`message`): `boolean` -> `readonly` `optional` **host?**: `string` +Optional live inbox for the manager session this adapter currently drives. Return `false` +when no executor inbox is active instead of claiming a message was delivered. -##### port? +###### Parameters -> `readonly` `optional` **port?**: `number` +###### message -##### allowUnauthenticatedRemote? +`unknown` -> `readonly` `optional` **allowUnauthenticatedRemote?**: `boolean` +###### Returns -Explicit acknowledgment required to bind a NON-loopback host — see - [assertCoordinationBinding](#assertcoordinationbinding) for what is being accepted. +`boolean` *** @@ -12682,6 +13826,12 @@ Explicit acknowledgment required to bind a NON-loopback host — see Resolve a spawned worker `profile` to a leaf agent — the recursion seam (same for both arms). +##### authorizeDownMessage? + +> `readonly` `optional` **authorizeDownMessage?**: [`AuthorizeDownMessage`](#authorizedownmessage) + +Product authorization for every down-leg continuation to a child. + ##### perWorker > `readonly` **perWorker**: [`Budget`](index.md#budget-4) @@ -12706,7 +13856,8 @@ Hard cap on simultaneously-LIVE workers across both arms — `spawn_agent` fails > `readonly` `optional` **router?**: [`RouterConfig`](#routerconfig) -Router substrate for a router-brained supervisor (`harness` null). The profile's model wins. +Router substrate for a router-brained supervisor (`harness` omitted or `cli-base`). The + profile's model wins. ##### brain? @@ -12718,7 +13869,32 @@ Inject the brain directly (tests / advanced) instead of resolving `routerBrain` > `readonly` `optional` **driveHarness?**: [`DriveHarness`](#driveharness-1) -Required for a sandboxed-harness supervisor (`harness` set): runs the harness as the driver. +Required to run an external-harness supervisor: runs the harness as the driver. + +##### nodeContext? + +> `readonly` `optional` **nodeContext?**: [`SupervisorNodeContextSeed`](#supervisornodecontextseed) + +Trusted identity for this manager. Required with node-scoped tools or observation. + +##### resolveSupervisorTools? + +> `readonly` `optional` **resolveSupervisorTools?**: [`ResolveSupervisorTools`](#resolvesupervisortools-1) + +Resolve product-owned tools for this exact manager. Static `extraTools` remain a router-only + compatibility seam and deliberately receive no new recursive authority. + +##### observeNodeEvent? + +> `readonly` `optional` **observeNodeEvent?**: [`ObserveSupervisorNodeEvent`](#observesupervisornodeevent) + +Awaited product observation, enriched with this manager's actual live node context. + +##### replaySettlements? + +> `readonly` `optional` **replaySettlements?**: `boolean` + +Replay resume-time settlements through `observeNodeEvent` before the manager starts. ##### extraTools? @@ -12812,7 +13988,7 @@ Give the supervisor brain a chapter-lifecycle on its OWN context window (router ##### onEvent? -> `readonly` `optional` **onEvent?**: (`event`) => `void` \| `Promise`\<`void`\> +> `readonly` `optional` **onEvent?**: (`event`, `record`) => `void` \| `Promise`\<`void`\> Pass-through subscriber for every coordination bus event (both arms) — the seam a durable caller hooks its coordination log onto. @@ -12823,6 +13999,10 @@ Pass-through subscriber for every coordination bus event (both arms) — the sea [`CoordinationEvent`](index.md#coordinationevent) +###### record + +[`BusRecord`](#busrecord)\<[`CoordinationEvent`](index.md#coordinationevent)\> + ###### Returns `void` \| `Promise`\<`void`\> @@ -12831,8 +14011,21 @@ Pass-through subscriber for every coordination bus event (both arms) — the sea > `readonly` `optional` **priorCoordination?**: [`PriorCoordination`](#priorcoordination-1) -Questions + findings replayed from a prior process of this run (a durable coordination log). - Router arm: seeds the question ledger + the resume brief. Sandbox arm: seeds the ledger. +Questions, findings, and authorized continuation receipts loaded from a prior process. + Router arm: questions seed the ledger and all evidence enters the resume brief. External arm: + questions seed the ledger; receipts remain durable evidence and are never auto-delivered. + +##### loadPriorCoordination? + +> `readonly` `optional` **loadPriorCoordination?**: () => `Promise`\<[`PriorCoordination`](#priorcoordination-1)\> + +Deferred owner-scoped replay for a recursive supervisor. Its stable owner is known while the +parent authorizes the child, but loading remains asynchronous; Runtime calls this before the +nested brain can publish or act on coordination state. + +###### Returns + +`Promise`\<[`PriorCoordination`](#priorcoordination-1)\> ##### finalizer? @@ -12845,12 +14038,28 @@ How the settled ledger becomes the run's output (both arms). Default `bestDelive > `readonly` `optional` **coordination?**: [`CoordinationBinding`](#coordinationbinding) -Where the coordination MCP binds (sandbox arm). Omit = an ephemeral loopback port, which is +Where the coordination MCP binds (external arm). Omit = an ephemeral loopback port, which is unreachable from an off-host harness. A non-loopback host fails closed — see [assertCoordinationBinding](#assertcoordinationbinding). *** +### WorkerToolTraceArtifact + +Bytes stored under `WorkerTraceEvidence.traceRef`. + +#### Properties + +##### schemaVersion + +> `readonly` **schemaVersion**: `1` + +##### spans + +> `readonly` **spans**: readonly `ToolSpan`[] + +*** + ### ToolStepInput #### Properties @@ -13058,6 +14267,41 @@ unordered collection. `scope.next()` delivers strictly in recorded `seq` order. `Promise`\<`Out`\> +##### deliver()? + +> `optional` **deliver**(`msg`): `boolean` \| `void` + +Optional manager inbox. A parent or attached `RootHandle` uses this to deliver the same raw +down-message accepted by executor inboxes. Return `false` when the manager has no live receive +path; returning `true` means the message was accepted for the current manager session. + +###### Parameters + +###### msg + +`unknown` + +###### Returns + +`boolean` \| `void` + +*** + +### ExecutorAccounting + +Split used by a recursive executor when journaled child work differs from the full amount +reconciled against its parent reservation. + +#### Properties + +##### reported + +> `readonly` **reported**: [`Spend`](index.md#spend) + +##### reservation + +> `readonly` **reservation**: [`Spend`](index.md#spend) + *** ### ExecutorResult @@ -13090,6 +14334,182 @@ Terminal artifact of a one-shot `Executor.execute`. *** +### AgentExecutionRef + +Caller-owned identity beyond the exact profile/task bytes Scope can compute itself. + +#### Extended by + +- [`NodeExecutionIdentity`](#nodeexecutionidentity) + +#### Properties + +##### candidateDigest? + +> `readonly` `optional` **candidateDigest?**: `` `sha256:${string}` `` + +##### correlation? + +> `readonly` `optional` **correlation?**: `Readonly`\<`Record`\<`string`, `string`\>\> + +*** + +### NodeExecutionIdentity + +Durable identity of one realized node. Missing digests mean the input was not canonical JSON. + +#### Extends + +- [`AgentExecutionRef`](#agentexecutionref) + +#### Properties + +##### candidateDigest? + +> `readonly` `optional` **candidateDigest?**: `` `sha256:${string}` `` + +###### Inherited from + +[`AgentExecutionRef`](#agentexecutionref).[`candidateDigest`](#candidatedigest) + +##### correlation? + +> `readonly` `optional` **correlation?**: `Readonly`\<`Record`\<`string`, `string`\>\> + +###### Inherited from + +[`AgentExecutionRef`](#agentexecutionref).[`correlation`](#correlation) + +##### profileDigest? + +> `readonly` `optional` **profileDigest?**: `` `sha256:${string}` `` + +##### taskDigest? + +> `readonly` `optional` **taskDigest?**: `` `sha256:${string}` `` + +*** + +### MaterializedExecutionIdentity + +External execution identity that operators can use to join this node to its backend. + +#### Properties + +##### kind + +> `readonly` **kind**: `string` + +Backend-native identity kind, for example `request`, `session`, `run`, `process`, or `tree`. + +##### id + +> `readonly` **id**: `string` + +*** + +### ExecutorMaterialization + +Data-only declaration from trusted executor code about the exact sealed plan `execute` uses. +Scope snapshots this value and computes the durable receipt; callers never provide digests. + +#### Properties + +##### effectiveProfile + +> `readonly` **effectiveProfile**: `AgentProfile` + +Complete profile after trusted runtime-owned attachments or backend overlays were applied. + +##### backend + +> `readonly` **backend**: `string` + +Concrete backend or harness selected for this run. + +##### model + +> `readonly` **model**: [`MaterializedModelIdentity`](#materializedmodelidentity) + +Exact selected model, or an explicit unknown reason. + +##### execution + +> `readonly` **execution**: [`MaterializedExecutionIdentity`](#materializedexecutionidentity) + +Backend-native session/run/request/process identity. + +##### materializer + +> `readonly` **materializer**: `string` + +Named implementation that turns the effective profile into executable backend inputs. + +##### plan + +> `readonly` **plan**: `unknown` + +Finite JSON describing the exact materialization plan. Persisted by digest only. + +##### platformAttachments? + +> `readonly` `optional` **platformAttachments?**: `unknown` + +Trusted runtime-only attachments, such as the coordination MCP. Persisted by digest only. + +*** + +### ExecutorExecutionBinding + +Volatile execution routing that is true for one attempt but is not profile identity. The full +binding is hashed and discarded; only the safe structural descriptor is journaled. + +#### Properties + +##### attemptId + +> `readonly` **attemptId**: `string` + +##### binding + +> `readonly` **binding**: `unknown` + +##### descriptor + +> `readonly` **descriptor**: `Readonly`\<`Record`\<`string`, `string` \| `number` \| `boolean` \| `null`\>\> + +*** + +### ExecutorNodeContext + +Kernel-owned context for the concrete supervised node a factory is constructing. + +#### Properties + +##### rootId + +> `readonly` **rootId**: `string` + +##### parentId + +> `readonly` **parentId**: `string` + +##### nodeId + +> `readonly` **nodeId**: `string` + +##### attemptId + +> `readonly` **attemptId**: `string` + +Kernel-minted identity for this concrete execution attempt. + +##### identity? + +> `readonly` `optional` **identity?**: [`NodeExecutionIdentity`](#nodeexecutionidentity) + +*** + ### ExecutorContext Construction context handed to a `ExecutorFactory` — the seams a built-in needs @@ -13102,6 +14522,12 @@ Construction context handed to a `ExecutorFactory` — the seams a built-in need > `readonly` **signal**: `AbortSignal` +##### node? + +> `readonly` `optional` **node?**: [`ExecutorNodeContext`](#executornodecontext) + +Present when Scope constructs the executor for a supervised node. + ##### seams > `readonly` **seams**: `Readonly`\<`Record`\<`string`, `unknown`\>\> @@ -13122,6 +14548,13 @@ Opaque seams the registry threads through; a built-in narrows what it needs. > `readonly` **label**: `string` +##### assignmentId? + +> `readonly` `optional` **assignmentId?**: `string` + +Manager-scoped semantic assignment identity. Unlike `key`, this names every spawn, including +unkeyed siblings, so product traces can join authorization, node, and backend execution. + ##### restart? > `readonly` `optional` **restart?**: [`Restart`](#restart) @@ -13173,6 +14606,30 @@ mid-acquire never leaks (M1). > `readonly` **status**: [`NodeStatus`](#nodestatus) +##### assignmentId? + +> `readonly` `optional` **assignmentId?**: `string` + +Manager-scoped assignment identity supplied at admission. + +##### identity? + +> `readonly` `optional` **identity?**: [`NodeExecutionIdentity`](#nodeexecutionidentity) + +Durable identity of the authorized profile/task/candidate represented by this handle. + +##### materialization? + +> `readonly` `optional` **materialization?**: [`ProfileMaterializationReceipt`](#profilematerializationreceipt) + +Stable execution plan once Runtime has committed it. + +##### executionBindings? + +> `readonly` `optional` **executionBindings?**: readonly [`ExecutionBindingReceipt`](#executionbindingreceipt)[] + +Immutable per-attempt backend bindings committed so far, oldest first. + ##### \_\_out? > `readonly` `optional` **\_\_out?**: `Out` @@ -13277,7 +14734,13 @@ What the journal proves about one keyed assignment at resume time. ##### label -> `readonly` **label**: `string` +> `readonly` **label**: `string` + +##### identity? + +> `readonly` `optional` **identity?**: [`NodeExecutionIdentity`](#nodeexecutionidentity) + +Identity recorded when this key was first admitted. Every reuse must match it exactly. ##### state @@ -13293,6 +14756,10 @@ The rehydrated settlement; absent exactly when `state` is `'in-doubt'`. ### NodeSnapshot +#### Extended by + +- [`SpawnForestNode`](#spawnforestnode) + #### Properties ##### id @@ -13313,12 +14780,46 @@ The rehydrated settlement; absent exactly when `state` is `'in-doubt'`. ##### runtime -> `readonly` **runtime**: [`Runtime`](#runtime-2) +> `readonly` **runtime**: [`Runtime`](#runtime-4) ##### budget > `readonly` **budget**: [`Budget`](index.md#budget-4) +##### ownedTreeRoot? + +> `readonly` `optional` **ownedTreeRoot?**: `string` + +Exact nested journal tree owned by this node, when Runtime attested recursive ownership. + +##### assignmentId? + +> `readonly` `optional` **assignmentId?**: `string` + +Manager-scoped assignment identity, including deterministic ids for unkeyed siblings. + +##### identity? + +> `readonly` `optional` **identity?**: [`NodeExecutionIdentity`](#nodeexecutionidentity) + +##### materialization? + +> `readonly` `optional` **materialization?**: [`ProfileMaterializationReceipt`](#profilematerializationreceipt) + +Kernel-owned execution evidence. `unknown` is distinct from a known zero/empty plan. + +##### executionBindings? + +> `readonly` `optional` **executionBindings?**: readonly [`ExecutionBindingReceipt`](#executionbindingreceipt)[] + +Immutable attempt bindings, oldest first. A retried/resumed node may have more than one. + +##### settledAt? + +> `readonly` `optional` **settledAt?**: `number` + +Epoch ms of the terminal journal record; absent while live or when legacy evidence lacks it. + ##### spent > `readonly` **spent**: [`Spend`](index.md#spend) @@ -13331,6 +14832,12 @@ Conserved spend so far for this node. `outRef` once the node is `done` (the replay/result pointer). +##### trace? + +> `readonly` `optional` **trace?**: [`WorkerTraceEvidence`](index.md#workertraceevidence) + +Present on terminal executor nodes; legacy records carry an explicit unavailable reason. + *** ### TreeView @@ -13475,6 +14982,19 @@ Content-addressed result blobs (the `outRef` → artifact map) backing the repla The root conserved-pool ceiling (tokens + usd + iterations + deadline). +##### rootIdentity? + +> `readonly` `optional` **rootIdentity?**: [`NodeExecutionIdentity`](#nodeexecutionidentity) + +Exact root profile/task identity supplied by the one-call composition surface. + +##### rootMaterialization? + +> `readonly` `optional` **rootMaterialization?**: [`RootMaterialization`](#rootmaterialization) + +Trusted composition evidence for a root whose `act` drives an external backend. A generic + root omits it and is durably marked unknown; model-facing Scope never receives this writer. + ##### runId > `readonly` **runId**: `string` @@ -13513,6 +15033,13 @@ Predicate resolution for `poll` wait-states (`Scope.wait`). A `poll` names its p Runtime recursion-depth ceiling (paired with the conserved pool per R3). +##### maxLiveWorkers? + +> `readonly` `optional` **maxLiveWorkers?**: `number` + +Hard tree-wide cap on simultaneously executing spawned workers. The root is excluded; every + nested driver and leaf shares this one allocation. Omit/`<= 0` leaves worker count uncapped. + ##### maxRestarts? > `readonly` `optional` **maxRestarts?**: `number` @@ -13595,8 +15122,92 @@ its fields. ### RootHandle -Live root handle — the substrate a chat/pi-viz client attaches to (Q2). `signal` - delivers an out-of-band message to the running root; `view()` materializes the tree. +Live root handle — a chat/pi-viz client uses it to inspect and control one root run. + +#### Extended by + +- [`SteerableRootHandle`](#steerableroothandle) + +#### Type Parameters + +##### Out + +`Out` + +#### Properties + +##### \_\_out? + +> `readonly` `optional` **\_\_out?**: `Out` + +Phantom: binds the handle to the supervised run's output type. Type-only — never + present at runtime; lets `attach(h: RootHandle)` stay output-typed. + +#### Methods + +##### view() + +> **view**(): [`TreeView`](#treeview) + +###### Returns + +[`TreeView`](#treeview) + +##### deliver()? + +> `optional` **deliver**(`msg`): `boolean` + +Optional for structural compatibility with existing view/signal/abort wrappers. Handles +minted by `createRootHandle` implement the required form in `SteerableRootHandle`. + +###### Parameters + +###### msg + +`unknown` + +###### Returns + +`boolean` + +##### signal() + +> **signal**(`msg`): `void` + +###### Parameters + +###### msg + +[`RootSignal`](#rootsignal) + +###### Returns + +`void` + +##### abort() + +> **abort**(`reason?`): `void` + +###### Parameters + +###### reason? + +`string` + +###### Returns + +`void` + +*** + +### SteerableRootHandle + +A Runtime-minted root handle that can deliver raw steering or answers to a live manager inbox. +Delivery returns `false` when the manager has no receive path; detached calls fail loud. + +#### Extends + +- [`RootHandle`](#roothandle-1)\<`Out`\> #### Type Parameters @@ -13613,6 +15224,10 @@ Live root handle — the substrate a chat/pi-viz client attaches to (Q2). `signa Phantom: binds the handle to the supervised run's output type. Type-only — never present at runtime; lets `attach(h: RootHandle)` stay output-typed. +###### Inherited from + +[`RootHandle`](#roothandle-1).[`__out`](#__out-1) + #### Methods ##### view() @@ -13623,6 +15238,10 @@ Phantom: binds the handle to the supervised run's output type. Type-only — nev [`TreeView`](#treeview) +###### Inherited from + +[`RootHandle`](#roothandle-1).[`view`](#view-3) + ##### signal() > **signal**(`msg`): `void` @@ -13637,6 +15256,10 @@ Phantom: binds the handle to the supervised run's output type. Type-only — nev `void` +###### Inherited from + +[`RootHandle`](#roothandle-1).[`signal`](#signal-16) + ##### abort() > **abort**(`reason?`): `void` @@ -13651,6 +15274,31 @@ Phantom: binds the handle to the supervised run's output type. Type-only — nev `void` +###### Inherited from + +[`RootHandle`](#roothandle-1).[`abort`](#abort-1) + +##### deliver() + +> **deliver**(`msg`): `boolean` + +Optional for structural compatibility with existing view/signal/abort wrappers. Handles +minted by `createRootHandle` implement the required form in `SteerableRootHandle`. + +###### Parameters + +###### msg + +`unknown` + +###### Returns + +`boolean` + +###### Overrides + +[`RootHandle`](#roothandle-1).[`deliver`](#deliver-3) + *** ### WidenGate @@ -13693,7 +15341,7 @@ Default impl returns false for every settlement (flat — never widens). ###### budget -`Readonly`\<\{ `tokensLeft`: `number`; `usdLeft`: `number`; `usdCapped`: `boolean`; `deadlineMs`: `number`; `reservedTokens`: `number`; `tokensKnown?`: `boolean`; \}\> +`Readonly`\<\{ `tokensLeft`: `number`; `tokensKnown`: `boolean`; `usdLeft`: `number`; `usdCapped`: `boolean`; `usdKnown`: `boolean`; `iterationsLeft`: `number`; `deadlineMs`: `number`; `reservedTokens`: `number`; \}\> ###### Returns @@ -13936,10 +15584,9 @@ Absolute path to the git checkout the worktree is cut from. **`Experimental`** The supervisor-authored prompt/model plus materializable structural resources. -`model.default` selects the one-shot model; `small`, `provider`, and `metadata` remain hints. -Resource failures are fatal regardless of `resources.failOnError`. -Tools, permissions, connections, confidential execution, modes, and extensions fail closed. -Harness-specific nested controls that the pinned materializer cannot preserve also fail closed. +`model.default` selects the one-shot model. Routing-only model hints, placement concerns, +provider extensions, and `resources.failOnError` fail before execution because this path +cannot honor them. Harness-specific values the materializer cannot preserve also fail closed. ##### harness @@ -13949,13 +15596,14 @@ Harness-specific nested controls that the pinned materializer cannot preserve al Local CLI for this leaf. This explicit choice overrides `profile.harness`. -##### taskPrompt +##### taskPrompt? -> **taskPrompt**: `string` +> `optional` **taskPrompt?**: `string` **`Experimental`** -The per-task instruction handed to the harness (composed under the system prompt). +Default instruction for direct `execute(undefined, signal)` calls. An execution-time task + is authoritative. Omit when the caller always supplies the task to `execute`. ##### runId? @@ -14135,6 +15783,31 @@ The supervisor-authored `AgentProfile` (systemPrompt + model reach the harness v Which local harness CLI drives this leaf. +##### budgetExempt? + +> `optional` **budgetExempt?**: `boolean` + +**`Experimental`** + +Require measured usage from this leaf. Budgeted supervision refuses the default unmetered + local-CLI mode; set false only when the selected runner actually returns token usage. + +##### codexReproducible? + +> `optional` **codexReproducible?**: `boolean` + +**`Experimental`** + +Run Codex through its measured, isolated JSONL path. This implies `budgetExempt: false`. + +##### codexReadDeniedPaths? + +> `optional` **codexReadDeniedPaths?**: readonly `string`[] + +**`Experimental`** + +Host paths denied to a reproducible Codex leaf. + ##### runId? > `optional` **runId?**: `string` @@ -15976,15 +17649,45 @@ Present when a commit was attempted (valid, or `commitOnInvalid`). ## Type Aliases +### DownMessageDeliveryOutcome + +> **DownMessageDeliveryOutcome** = `"delivered"` \| `"unknown-worker"` \| `"already-settled"` \| `"runtime-has-no-inbox"` \| `"scope-stopped"` \| `"runtime-error"` + +The exact result of one parent→child delivery attempt. + +*** + +### AuthorizeDownMessage + +> **AuthorizeDownMessage** = (`input`) => [`AuthorizedDownMessage`](#authorizeddownmessage) + +Product decision over an exact continuation before it is durably recorded or delivered. + +#### Parameters + +##### input + +[`DownMessageAuthorizationInput`](#downmessageauthorizationinput) + +#### Returns + +[`AuthorizedDownMessage`](#authorizeddownmessage) + +*** + ### MakeWorkerAgent -> **MakeWorkerAgent** = (`profile`) => [`Agent`](#agent-1)\<`unknown`, `unknown`\> +> **MakeWorkerAgent** = (`profile`, `context?`) => [`Agent`](#agent-1)\<`unknown`, `unknown`\> #### Parameters ##### profile -`unknown` +`AgentProfile` + +##### context? + +[`WorkerSpawnContext`](#workerspawncontext) #### Returns @@ -16161,7 +17864,7 @@ Builds a frozen `Persona`, failing loud on the executors-supplied invariant (nei ### LoopShape -> **LoopShape**\<`Task`, `D`\> = (`ctx`) => [`Agent`](#agent-1)\<`Task`, [`Outcome`](#outcome-1)\<`D`\>\> +> **LoopShape**\<`Task`, `D`\> = (`ctx`) => [`Agent`](#agent-1)\<`Task`, [`Outcome`](#outcome-2)\<`D`\>\> A reusable act-body factory. Given the persona's content + seams (`ShapeContext`), it returns the root `Agent>` whose `act` decomposes the task, fans out @@ -16187,13 +17890,13 @@ synthesizes the terminal `Outcome`. The shape is STRUCTURE; the persona is CO #### Returns -[`Agent`](#agent-1)\<`Task`, [`Outcome`](#outcome-1)\<`D`\>\> +[`Agent`](#agent-1)\<`Task`, [`Outcome`](#outcome-2)\<`D`\>\> *** ### RunPersonified -> **RunPersonified** = \<`Task`, `D`\>(`options`) => `Promise`\<[`SupervisedResult`](index.md#supervisedresult)\<[`Outcome`](#outcome-1)\<`D`\>\>\> +> **RunPersonified** = \<`Task`, `D`\>(`options`) => `Promise`\<[`SupervisedResult`](index.md#supervisedresult)\<[`Outcome`](#outcome-2)\<`D`\>\>\> The composed run signature. @@ -16215,7 +17918,7 @@ The composed run signature. #### Returns -`Promise`\<[`SupervisedResult`](index.md#supervisedresult)\<[`Outcome`](#outcome-1)\<`D`\>\>\> +`Promise`\<[`SupervisedResult`](index.md#supervisedresult)\<[`Outcome`](#outcome-2)\<`D`\>\>\> *** @@ -16273,7 +17976,7 @@ the persona carries the domain. ### FanoutWinnerSelector -> **FanoutWinnerSelector**\<`D`\> = (`iterations`) => \{ `output?`: [`Outcome`](#outcome-1)\<`D`\>; \} \| `undefined` +> **FanoutWinnerSelector**\<`D`\> = (`iterations`) => \{ `output?`: [`Outcome`](#outcome-2)\<`D`\>; \} \| `undefined` A winner-selection strategy: argmax/sort over the gathered child iterations (each output is the child's `Outcome`), returning the chosen iteration or `undefined` when none qualifies. @@ -16288,11 +17991,11 @@ A winner-selection strategy: argmax/sort over the gathered child iterations (eac ##### iterations -[`Iteration`](#iteration-1)\<`unknown`, [`Outcome`](#outcome-1)\<`D`\>\>[] +[`Iteration`](#iteration-1)\<`unknown`, [`Outcome`](#outcome-2)\<`D`\>\>[] #### Returns -\{ `output?`: [`Outcome`](#outcome-1)\<`D`\>; \} \| `undefined` +\{ `output?`: [`Outcome`](#outcome-2)\<`D`\>; \} \| `undefined` *** @@ -16567,7 +18270,7 @@ judge/verdict/score scheme is rejected. Fail loud — a tainted finding aborts. ##### root -[`NodeId`](#nodeid-1) +[`NodeId`](#nodeid-5) ##### options? @@ -16826,13 +18529,39 @@ Provider-neutral conversation records read by structural candidate extraction. *** +### AuthoredProfile + +> **AuthoredProfile** = `AgentProfile` & `object` + +What the supervisor AUTHORS per sub-task: one complete canonical profile whose name and + task-specific system prompt are present. Every other `AgentProfile` axis is preserved exactly. + +#### Type Declaration + +##### name + +> `readonly` **name**: `string` + +##### prompt + +> `readonly` **prompt**: `AgentProfilePrompt` & `object` + +###### Type Declaration + +###### systemPrompt + +> `readonly` **systemPrompt**: `string` + +*** + ### BudgetReadout -> **BudgetReadout** = `Readonly`\<\{ `tokensLeft`: `number`; `tokensKnown?`: `boolean`; `usdLeft`: `number`; `usdCapped`: `boolean`; `deadlineMs`: `number`; `reservedTokens`: `number`; \}\> +> **BudgetReadout** = `Readonly`\<\{ `tokensLeft`: `number`; `tokensKnown`: `boolean`; `usdLeft`: `number`; `usdCapped`: `boolean`; `usdKnown`: `boolean`; `iterationsLeft`: `number`; `deadlineMs`: `number`; `reservedTokens`: `number`; \}\> Post-reservation pool readout — the shape `Scope.budget` exposes. `tokensLeft`, `usdLeft`, and `reservedTokens` reflect committed-but-unsettled reservations; `deadlineMs` is the ABSOLUTE wall-clock deadline (0 when the root set none). + `iterationsLeft` is the remaining iteration capacity. `usdCapped` distinguishes a real `usdLeft <= 0` exhaustion from an uncapped pool (which always reads `usdLeft: 0`) — the in-loop guard needs it to bound a usd-capped driver. @@ -16848,6 +18577,24 @@ Why a reservation was refused. `budget-exhausted` means the pool ran out of a ch *** +### CoordinationOwnerId + +> **CoordinationOwnerId** = `string` + +Stable identity of the supervisor that owns one coordination stream. High-level supervision +derives it from the exact root/child execution identity plus its parent assignment. + +*** + +### CoordinationDeliveryEvidence + +> **CoordinationDeliveryEvidence** = `Extract`\<[`CoordinationEvent`](index.md#coordinationevent), \{ `type`: `"delivery-attempt"` \| `"steer"` \| `"answer"`; \}\> + +Durable delivery evidence retained in commit order. An attempt without a later event carrying +the same `receiptId` has an unknown outcome after a crash and is never replayed. + +*** + ### DispatchStopReason > **DispatchStopReason** = `"drained"` \| `"not-admitted"` \| `"stopped"` \| `"aborted"` @@ -16914,51 +18661,90 @@ Evaluated from the progress feed, never from the budget. Pure and synchronous: i *** -### DriveHarness +### DeliverableResolutionInput -> **DriveHarness** = (`args`) => `Promise`\<`void`\> +> **DeliverableResolutionInput** = [`AuthorizedSpawnContext`](#authorizedspawncontext) -How to run a sandboxed harness as the DRIVER, with the coordination verbs mounted — the substrate - seam the caller supplies (mirrors `makeWorkerAgent` for spawned children). It runs `profile` on - `task` in its backend (sandbox / cli-bridge) with `coordinationMcpUrl` mounted as an MCP server, - so the harness calls spawn_agent / await_event / stop as native tools over the live scope. +Exact trusted context for selecting one backend-derived leaf's completion check. + +*** + +### SupervisorNodeContextSeed + +> **SupervisorNodeContextSeed** = `Omit`\<[`SupervisorNodeContext`](#supervisornodecontext), `"nodeId"` \| `"profile"` \| `"task"`\> + +Context known before `Agent.act`; Runtime adds the concrete node, profile, and task. + +*** + +### ResolveSupervisorTools + +> **ResolveSupervisorTools** = (`context`) => `ReadonlyArray`\<[`SupervisorToolDescriptor`](#supervisortooldescriptor)\> \| `Promise`\<`ReadonlyArray`\<[`SupervisorToolDescriptor`](#supervisortooldescriptor)\>\> + +Product policy for the tools one exact supervisor node may call. Resolved once per node. #### Parameters -##### args +##### context -###### profile +[`SupervisorNodeContext`](#supervisornodecontext) -[`SupervisorProfile`](#supervisorprofile) +#### Returns + +`ReadonlyArray`\<[`SupervisorToolDescriptor`](#supervisortooldescriptor)\> \| `Promise`\<`ReadonlyArray`\<[`SupervisorToolDescriptor`](#supervisortooldescriptor)\>\> + +*** + +### ObserveSupervisorNodeEvent + +> **ObserveSupervisorNodeEvent** = (`context`, `event`, `record`) => `void` \| `Promise`\<`void`\> + +Context-aware observer used internally to bind product transactions to the actual live node. + +#### Parameters + +##### context + +[`SupervisorNodeContext`](#supervisornodecontext) + +##### event + +[`CoordinationEvent`](index.md#coordinationevent) + +##### record + +[`BusRecord`](#busrecord)\<[`CoordinationEvent`](index.md#coordinationevent)\> + +#### Returns + +`void` \| `Promise`\<`void`\> + +*** -The caller's profile, EXACTLY as passed to `supervisorAgent` — never rewritten. A canonical - `AgentProfile` stays schema-valid here (the canonical schema rejects unknown top-level keys, - so hoisting a resolved prompt onto it would make a profile its own validator refuses). +### DriveHarnessOwnerContext -###### systemPrompt? +> **DriveHarnessOwnerContext** = `Omit`\<[`SupervisorNodeContext`](#supervisornodecontext), `"nodeId"`\> -`string` +Trusted manager identity available before its external harness starts. A product uses this to +return one independently steerable harness session per recursive manager. -The standing instruction assembled from the profile: its system prompt in either spelling, - plus the `prompt.instructions` and `resources.instructions` lines. Absent when the profile - names none — the harness's own default then applies. This, not `profile.systemPrompt`, is what - the harness should run under. +*** -###### task +### ResolveDriveHarness -`unknown` +> **ResolveDriveHarness** = (`context`) => [`DriveHarness`](#driveharness-1) -###### scope +Resolve an external harness for one exact Runtime-owned manager identity. -[`Scope`](index.md#scope)\<`unknown`\> +#### Parameters -###### coordinationMcpUrl +##### context -`string` +[`DriveHarnessOwnerContext`](#driveharnessownercontext) #### Returns -`Promise`\<`void`\> +[`DriveHarness`](#driveharness-1) *** @@ -17023,6 +18809,75 @@ External executors can register additional runtime strings without widening this *** +### MaterializedModelIdentity + +> **MaterializedModelIdentity** = \{ `status`: `"known"`; `id`: `string`; \} \| \{ `status`: `"unknown"`; `reason`: `string`; \} + +A named model carried into an execution, or an explicit reason the exact model is unknowable. + +*** + +### UnknownMaterializationReason + +> **UnknownMaterializationReason** = `"executor-did-not-report"` \| `"invalid-executor-report"` \| `"root-agent-did-not-report"` + +Why exact materialization evidence is unavailable for a node. + +*** + +### ProfileMaterializationReceipt + +> **ProfileMaterializationReceipt** = \{ `status`: `"known"`; `authoredProfileDigest`: `Sha256Digest`; `effectiveProfileDigest`: `Sha256Digest`; `materializationPlanDigest`: `Sha256Digest`; `platformAttachmentsDigest?`: `Sha256Digest`; `runtime`: [`Runtime`](#runtime-4); `backend`: `string`; `model`: [`MaterializedModelIdentity`](#materializedmodelidentity); `execution`: [`MaterializedExecutionIdentity`](#materializedexecutionidentity); `materializer`: `string`; \} \| \{ `status`: `"unknown"`; `authoredProfileDigest?`: `Sha256Digest`; `runtime`: [`Runtime`](#runtime-4); `reason`: [`UnknownMaterializationReason`](#unknownmaterializationreason); \} + +What the kernel can prove about one node's actual execution plan. + +*** + +### ExecutionBindingReceipt + +> **ExecutionBindingReceipt** = \{ `status`: `"known"`; `attemptId`: `string`; `materializationReceiptDigest`: `Sha256Digest`; `bindingDigest`: `Sha256Digest`; `descriptor`: `Readonly`\<`Record`\<`string`, `string` \| `number` \| `boolean` \| `null`\>\>; \} \| \{ `status`: `"unknown"`; `attemptId`: `string`; `materializationReceiptDigest`: `Sha256Digest`; `reason`: [`UnknownMaterializationReason`](#unknownmaterializationreason); \} + +One attempt's immutable link from a stable materialization plan to its actual transport. + +*** + +### RootMaterialization + +> **RootMaterialization** = \{ `runtime`: [`Runtime`](#runtime-4); `declaration`: [`ExecutorMaterialization`](#executormaterialization); `binding`: `Omit`\<[`ExecutorExecutionBinding`](#executorexecutionbinding), `"attemptId"`\>; \} \| \{ `runtime`: [`Runtime`](#runtime-4); `declaration`: `"deferred"`; `authoredProfile`: `AgentProfile`; \} + +Trusted root composition evidence. Generic `Agent.act` roots omit this and remain unknown. + +#### Union Members + +##### Type Literal + +\{ `runtime`: [`Runtime`](#runtime-4); `declaration`: [`ExecutorMaterialization`](#executormaterialization); `binding`: `Omit`\<[`ExecutorExecutionBinding`](#executorexecutionbinding), `"attemptId"`\>; \} + +*** + +##### Type Literal + +\{ `runtime`: [`Runtime`](#runtime-4); `declaration`: `"deferred"`; `authoredProfile`: `AgentProfile`; \} + +###### runtime + +> `readonly` **runtime**: [`Runtime`](#runtime-4) + +The runtime-owned external adapter will publish the exact declaration after its dynamic +platform attachment (for example a coordination URL) exists and before paid work starts. + +###### declaration + +> `readonly` **declaration**: `"deferred"` + +###### authoredProfile + +> `readonly` **authoredProfile**: `AgentProfile` + +Exact admitted profile used to validate the stable effective identity at publication. + +*** + ### ExecutorFactory > **ExecutorFactory**\<`Out`\> = (`spec`, `ctx`) => [`Executor`](index.md#executor-2)\<`Out`\> @@ -17083,11 +18938,11 @@ Deterministic node id — `${parent}:s${seq}` from the cursor order, never wall- ### SpawnRejection -> **SpawnRejection** = `"budget-exhausted"` \| `"usd-unbudgeted"` \| `"depth-exceeded"` \| `"duplicate-key"` +> **SpawnRejection** = `"budget-exhausted"` \| `"usd-unbudgeted"` \| `"depth-exceeded"` \| `"duplicate-key"` \| `"invalid-identity"` \| `"key-conflict"` \| `"max-live-workers"` \| `"scope-aborted"` Fail-closed spawn rejections: an exhausted pool, a dollar request against a root that budgets - no dollars, an exceeded recursion ceiling, or a `key` that is still LIVE in this scope (the - same assignment may not run twice concurrently). + no dollars, an exceeded recursion ceiling, a full tree-wide worker allocation, or a `key` that + is still LIVE in this scope (the same assignment may not run twice concurrently). `usd-unbudgeted` is separate from `budget-exhausted` because the two call for opposite responses: an exhausted pool may admit a smaller request, while an unbudgeted dollar channel @@ -17097,7 +18952,7 @@ Fail-closed spawn rejections: an exhausted pool, a dollar request against a root ### SpawnPrior -> **SpawnPrior**\<`Out`\> = \{ `state`: `"completed"`; `settled`: [`Settled`](index.md#settled)\<`Out`\> & `object`; \} \| \{ `state`: `"retried"`; `priorId`: [`NodeId`](#nodeid-1); `reason`: `string`; \} \| \{ `state`: `"lost"`; `priorId`: [`NodeId`](#nodeid-1); \} +> **SpawnPrior**\<`Out`\> = \{ `state`: `"completed"`; `settled`: [`Settled`](index.md#settled)\<`Out`\> & `object`; \} \| \{ `state`: `"retried"`; `priorId`: [`NodeId`](#nodeid-5); `reason`: `string`; \} \| \{ `state`: `"lost"`; `priorId`: [`NodeId`](#nodeid-5); \} What a KEYED spawn resolved to when the key had a prior attempt. Absent on a fresh key (and on every unkeyed spawn). `'completed'` is the exactly-once path: NOTHING was spawned — the handle @@ -17105,8 +18960,10 @@ references the prior settled node and `settled` is the committed result. `'retri `'lost'` DID spawn fresh: the prior attempt settled `down` (retried) or was journaled as started but never settled — the process died with it in flight and the built-in executors cannot re-attach to a dead process's work, so the result is explicitly in doubt (lost), never -silently duplicated. An executor that CAN re-attach to a still-running external execution (a -live sandbox box) extends this union with an adoption state; none of the built-ins can today. +silently duplicated. On restart, an in-doubt attempt's full declared reservation is charged and +its telemetry remains unknown; a fresh retry is admitted only from safely remaining capacity. +An executor that CAN re-attach to a still-running external execution extends this union with an +adoption state; none of the built-ins can today. #### Type Parameters @@ -17118,7 +18975,7 @@ live sandbox box) extends this union with an adoption state; none of the built-i ### SpawnEvent -> **SpawnEvent** = \{ `kind`: `"spawned"`; `id`: [`NodeId`](#nodeid-1); `parent?`: [`NodeId`](#nodeid-1); `label`: `string`; `key?`: `string`; `budget`: [`Budget`](index.md#budget-4); `runtime`: [`Runtime`](#runtime-2); `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"settled"`; `id`: [`NodeId`](#nodeid-1); `status`: `"done"` \| `"down"`; `outRef?`: `string`; `verdict?`: `DefaultVerdict`; `spent`: [`Spend`](index.md#spend); `infra?`: `boolean`; `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"cancelled"`; `id`: [`NodeId`](#nodeid-1); `reason`: `string`; `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"waiting"`; `id`: [`NodeId`](#nodeid-1); `parent?`: [`NodeId`](#nodeid-1); `label`: `string`; `spec`: [`WaitSpec`](#waitspec); `armedAt`: `number`; `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"woken"`; `id`: [`NodeId`](#nodeid-1); `by`: `"fired"` \| `"timeout"` \| `"cancelled"`; `outRef?`: `string`; `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"metered"`; `id`: [`NodeId`](#nodeid-1); `spend`: [`Spend`](index.md#spend); `seq`: `number`; `at`: `string`; \} +> **SpawnEvent** = \{ `kind`: `"spawned"`; `id`: [`NodeId`](#nodeid-5); `parent?`: [`NodeId`](#nodeid-5); `label`: `string`; `key?`: `string`; `assignmentId?`: `string`; `budget`: [`Budget`](index.md#budget-4); `runtime`: [`Runtime`](#runtime-4); `ownedTreeRoot?`: [`NodeId`](#nodeid-5); `identity?`: [`NodeExecutionIdentity`](#nodeexecutionidentity); `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"execution-bound"`; `id`: [`NodeId`](#nodeid-5); `binding`: [`ExecutionBindingReceipt`](#executionbindingreceipt); `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"materialized"`; `id`: [`NodeId`](#nodeid-5); `receipt`: [`ProfileMaterializationReceipt`](#profilematerializationreceipt); `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"settled"`; `id`: [`NodeId`](#nodeid-5); `status`: `"done"` \| `"down"`; `outRef?`: `string`; `verdict?`: `DefaultVerdict`; `spent`: [`Spend`](index.md#spend); `infra?`: `boolean`; `reason?`: `string`; `trace?`: [`WorkerTraceEvidence`](index.md#workertraceevidence); `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"cancelled"`; `id`: [`NodeId`](#nodeid-5); `reason`: `string`; `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"waiting"`; `id`: [`NodeId`](#nodeid-5); `parent?`: [`NodeId`](#nodeid-5); `label`: `string`; `spec`: [`WaitSpec`](#waitspec); `armedAt`: `number`; `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"woken"`; `id`: [`NodeId`](#nodeid-5); `by`: `"fired"` \| `"timeout"` \| `"cancelled"`; `outRef?`: `string`; `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"metered"`; `id`: [`NodeId`](#nodeid-5); `spend`: [`Spend`](index.md#spend); `seq`: `number`; `at`: `string`; \} Journaled spawn-tree events (B1/B2). `seq` is the cursor order; `at` is an ISO timestamp for human inspection only (NOT a replay input). @@ -17127,7 +18984,7 @@ Journaled spawn-tree events (B1/B2). `seq` is the cursor order; `at` is an ISO ##### Type Literal -\{ `kind`: `"spawned"`; `id`: [`NodeId`](#nodeid-1); `parent?`: [`NodeId`](#nodeid-1); `label`: `string`; `key?`: `string`; `budget`: [`Budget`](index.md#budget-4); `runtime`: [`Runtime`](#runtime-2); `seq`: `number`; `at`: `string`; \} +\{ `kind`: `"spawned"`; `id`: [`NodeId`](#nodeid-5); `parent?`: [`NodeId`](#nodeid-5); `label`: `string`; `key?`: `string`; `assignmentId?`: `string`; `budget`: [`Budget`](index.md#budget-4); `runtime`: [`Runtime`](#runtime-4); `ownedTreeRoot?`: [`NodeId`](#nodeid-5); `identity?`: [`NodeExecutionIdentity`](#nodeexecutionidentity); `seq`: `number`; `at`: `string`; \} ###### kind @@ -17135,11 +18992,11 @@ Journaled spawn-tree events (B1/B2). `seq` is the cursor order; `at` is an ISO ###### id -> **id**: [`NodeId`](#nodeid-1) +> **id**: [`NodeId`](#nodeid-5) ###### parent? -> `optional` **parent?**: [`NodeId`](#nodeid-1) +> `optional` **parent?**: [`NodeId`](#nodeid-5) ###### label @@ -17152,13 +19009,90 @@ Journaled spawn-tree events (B1/B2). `seq` is the cursor order; `at` is an ISO The semantic spawn key (`SpawnOpts.key`), when the spawn carried one — what a resumed run matches to resolve the same assignment to its committed result. +###### assignmentId? + +> `optional` **assignmentId?**: `string` + +Manager-scoped assignment identity used to join unkeyed and keyed work alike. + ###### budget > **budget**: [`Budget`](index.md#budget-4) ###### runtime -> **runtime**: [`Runtime`](#runtime-2) +> **runtime**: [`Runtime`](#runtime-4) + +###### ownedTreeRoot? + +> `optional` **ownedTreeRoot?**: [`NodeId`](#nodeid-5) + +Exact nested journal tree this node owns. Runtime writes this only after privately +attesting the executor as a recursive scope owner. Its absence means no tree is followed, +including records written before this field existed and caller leaves named `driver`. + +###### identity? + +> `optional` **identity?**: [`NodeExecutionIdentity`](#nodeexecutionidentity) + +Exact profile/task digests plus trusted candidate/campaign attribution when available. + +###### seq + +> **seq**: `number` + +###### at + +> **at**: `string` + +*** + +##### Type Literal + +\{ `kind`: `"execution-bound"`; `id`: [`NodeId`](#nodeid-5); `binding`: [`ExecutionBindingReceipt`](#executionbindingreceipt); `seq`: `number`; `at`: `string`; \} + +###### kind + +> **kind**: `"execution-bound"` + +Volatile transport/session binding for exactly one attempt. The full binding is retained +only by digest; descriptor fields are safe structural labels, never credential-bearing URLs. + +###### id + +> **id**: [`NodeId`](#nodeid-5) + +###### binding + +> **binding**: [`ExecutionBindingReceipt`](#executionbindingreceipt) + +###### seq + +> **seq**: `number` + +###### at + +> **at**: `string` + +*** + +##### Type Literal + +\{ `kind`: `"materialized"`; `id`: [`NodeId`](#nodeid-5); `receipt`: [`ProfileMaterializationReceipt`](#profilematerializationreceipt); `seq`: `number`; `at`: `string`; \} + +###### kind + +> **kind**: `"materialized"` + +Trusted runtime transformation from the authorized profile to actual wire bytes. + +###### id + +> **id**: [`NodeId`](#nodeid-5) + +###### receipt + +> **receipt**: [`ProfileMaterializationReceipt`](#profilematerializationreceipt) ###### seq @@ -17172,7 +19106,7 @@ The semantic spawn key (`SpawnOpts.key`), when the spawn carried one — what a ##### Type Literal -\{ `kind`: `"settled"`; `id`: [`NodeId`](#nodeid-1); `status`: `"done"` \| `"down"`; `outRef?`: `string`; `verdict?`: `DefaultVerdict`; `spent`: [`Spend`](index.md#spend); `infra?`: `boolean`; `seq`: `number`; `at`: `string`; \} +\{ `kind`: `"settled"`; `id`: [`NodeId`](#nodeid-5); `status`: `"done"` \| `"down"`; `outRef?`: `string`; `verdict?`: `DefaultVerdict`; `spent`: [`Spend`](index.md#spend); `infra?`: `boolean`; `reason?`: `string`; `trace?`: [`WorkerTraceEvidence`](index.md#workertraceevidence); `seq`: `number`; `at`: `string`; \} ###### kind @@ -17180,7 +19114,7 @@ The semantic spawn key (`SpawnOpts.key`), when the spawn carried one — what a ###### id -> **id**: [`NodeId`](#nodeid-1) +> **id**: [`NodeId`](#nodeid-5) ###### status @@ -17204,6 +19138,19 @@ Content-addressed result pointer; rehydrates `out` from `ResultBlobStore`. > `optional` **infra?**: `boolean` +###### reason? + +> `optional` **reason?**: `string` + +Exact child failure. Present on every new `status: 'down'` record; optional only so +journals written before this field existed remain replayable. + +###### trace? + +> `optional` **trace?**: [`WorkerTraceEvidence`](index.md#workertraceevidence) + +Structured tool evidence. Optional only for journals written before trace capture. + ###### seq > **seq**: `number` @@ -17216,13 +19163,13 @@ Content-addressed result pointer; rehydrates `out` from `ResultBlobStore`. ##### Type Literal -\{ `kind`: `"cancelled"`; `id`: [`NodeId`](#nodeid-1); `reason`: `string`; `seq`: `number`; `at`: `string`; \} +\{ `kind`: `"cancelled"`; `id`: [`NodeId`](#nodeid-5); `reason`: `string`; `seq`: `number`; `at`: `string`; \} *** ##### Type Literal -\{ `kind`: `"waiting"`; `id`: [`NodeId`](#nodeid-1); `parent?`: [`NodeId`](#nodeid-1); `label`: `string`; `spec`: [`WaitSpec`](#waitspec); `armedAt`: `number`; `seq`: `number`; `at`: `string`; \} +\{ `kind`: `"waiting"`; `id`: [`NodeId`](#nodeid-5); `parent?`: [`NodeId`](#nodeid-5); `label`: `string`; `spec`: [`WaitSpec`](#waitspec); `armedAt`: `number`; `seq`: `number`; `at`: `string`; \} ###### kind @@ -17235,11 +19182,11 @@ A wait-state node was ARMED. Lives in the SPAWN-ORDINAL namespace (`seq` is the ###### id -> **id**: [`NodeId`](#nodeid-1) +> **id**: [`NodeId`](#nodeid-5) ###### parent? -> `optional` **parent?**: [`NodeId`](#nodeid-1) +> `optional` **parent?**: [`NodeId`](#nodeid-5) ###### label @@ -17265,7 +19212,7 @@ A wait-state node was ARMED. Lives in the SPAWN-ORDINAL namespace (`seq` is the ##### Type Literal -\{ `kind`: `"woken"`; `id`: [`NodeId`](#nodeid-1); `by`: `"fired"` \| `"timeout"` \| `"cancelled"`; `outRef?`: `string`; `seq`: `number`; `at`: `string`; \} +\{ `kind`: `"woken"`; `id`: [`NodeId`](#nodeid-5); `by`: `"fired"` \| `"timeout"` \| `"cancelled"`; `outRef?`: `string`; `seq`: `number`; `at`: `string`; \} ###### kind @@ -17278,7 +19225,7 @@ A wait-state node SETTLED — the cursor-namespace twin of `settled`, kept disti ###### id -> **id**: [`NodeId`](#nodeid-1) +> **id**: [`NodeId`](#nodeid-5) ###### by @@ -17300,7 +19247,7 @@ A wait-state node SETTLED — the cursor-namespace twin of `settled`, kept disti ##### Type Literal -\{ `kind`: `"metered"`; `id`: [`NodeId`](#nodeid-1); `spend`: [`Spend`](index.md#spend); `seq`: `number`; `at`: `string`; \} +\{ `kind`: `"metered"`; `id`: [`NodeId`](#nodeid-5); `spend`: [`Spend`](index.md#spend); `seq`: `number`; `at`: `string`; \} ###### kind @@ -17316,7 +19263,7 @@ A driver's OWN inference spend, journaled separately from spawned-child work — ###### id -> **id**: [`NodeId`](#nodeid-1) +> **id**: [`NodeId`](#nodeid-5) ###### spend @@ -17777,7 +19724,7 @@ an empty collection is a no-winner, not a winner wrapping `[]`. ### PI\_RUNTIME -> `const` **PI\_RUNTIME**: [`Runtime`](#runtime-2) = `'pi'` +> `const` **PI\_RUNTIME**: [`Runtime`](#runtime-4) = `'pi'` The runtime name `piExecutor` registers under. @@ -17852,6 +19799,23 @@ Ceiling on continuation turns. Turn 0 is the task; every later turn is a folded *** +### DEFAULT\_AUTHORED\_PROFILE\_SECURITY\_POLICY + +> `const` **DEFAULT\_AUTHORED\_PROFILE\_SECURITY\_POLICY**: `AgentProfileSecurityPolicy` + +Manager-authored profiles are untrusted until product policy says otherwise. Remote MCP and +ambient connection grants therefore fail closed by default, in addition to local MCP and hooks. + +*** + +### WORKER\_TOOL\_TRACE\_SCHEMA\_VERSION + +> `const` **WORKER\_TOOL\_TRACE\_SCHEMA\_VERSION**: `1` + +Schema version for content-addressed worker tool-trace artifacts. + +*** + ### EVIDENCE\_MAX\_CHARS > `const` **EVIDENCE\_MAX\_CHARS**: `3000` = `3000` @@ -17890,13 +19854,7 @@ same arrangement `nestedScopeSeamKey` uses. > **contentAddress**(`artifact`): `string` -Mint the content-addressed `outRef` for a result artifact: `sha256:` over a -stable JSON encoding. Producers call this to derive the `outRef` they journal and -`put`; the FS/in-mem stores re-derive it on `put` to verify the supplied ref -matches (fail loud on a mismatch — a forged ref breaks the replay invariant). - -Stable encoding: object keys are sorted recursively so two structurally-equal -artifacts hash identically regardless of key insertion order. +Stable content address shared by result and trace artifacts. #### Parameters @@ -17910,6 +19868,42 @@ artifacts hash identically regardless of key insertion order. *** +### loadSpawnForest() + +> **loadSpawnForest**(`journal`, `root`): `Promise`\<[`SpawnForest`](#spawnforest)\> + +Load every journal tree owned by one recursive supervision run and flatten its nodes/events. + +Nested driver tree keys are a Runtime implementation detail; callers should use this reader +instead of deriving or scanning keys themselves. The reader follows only the explicit +`ownedTreeRoot` written after Runtime privately attested a recursive executor; the open runtime +string `driver` is never treated as ownership. Legacy records without `ownedTreeRoot` are +intentionally treated as leaves rather than guessing or scanning convention-derived keys. +This preserves each tree's independent cursor namespace on flattened events. +A driver whose subtree was never begun is reported in `missingTrees`; any spawned non-root node +without a terminal record is reported in `inDoubt`, matching resume's conservative lost-work +interpretation. + +This is a cold/quiescent reader, not a transaction across an actively mutating file. Every value +returned is a detached immutable snapshot, so later journal writes or caller mutation cannot +change the result already observed. + +#### Parameters + +##### journal + +[`SpawnJournal`](#spawnjournal) + +##### root + +`string` + +#### Returns + +`Promise`\<[`SpawnForest`](#spawnforest)\> + +*** + ### replaySpawnTree() > **replaySpawnTree**(`journal`, `blobs`, `root`): `Promise`\<[`Settled`](index.md#settled)\<`unknown`\>[]\> @@ -18763,7 +20757,7 @@ Fail loud (no silent empty findings): ##### scope -[`Scope`](index.md#scope)\<[`Outcome`](#outcome-1)\<`D`\>\> +[`Scope`](index.md#scope)\<[`Outcome`](#outcome-2)\<`D`\>\> ##### options @@ -18840,7 +20834,7 @@ readonly `AnalystFinding`[] ##### settledSoFar -readonly [`Settled`](index.md#settled)\<[`Outcome`](#outcome-1)\<`D`\>\>[] +readonly [`Settled`](index.md#settled)\<[`Outcome`](#outcome-2)\<`D`\>\>[] #### Returns @@ -19195,7 +21189,7 @@ unrunnable — refuse it at definition time, not at the first spawn. Pure; no I/ ### runPersonified() -> **runPersonified**\<`Task`, `D`\>(`options`): `Promise`\<[`SupervisedResult`](index.md#supervisedresult)\<[`Outcome`](#outcome-1)\<`D`\>\>\> +> **runPersonified**\<`Task`, `D`\>(`options`): `Promise`\<[`SupervisedResult`](index.md#supervisedresult)\<[`Outcome`](#outcome-2)\<`D`\>\>\> Compose the persona + chosen shape onto a fresh keystone `Supervisor`. Resolves the shape (a factory verbatim, or a registered name through `builtinShapes`), applies it to a @@ -19221,7 +21215,7 @@ default-shape fallback. #### Returns -`Promise`\<[`SupervisedResult`](index.md#supervisedresult)\<[`Outcome`](#outcome-1)\<`D`\>\>\> +`Promise`\<[`SupervisedResult`](index.md#supervisedresult)\<[`Outcome`](#outcome-2)\<`D`\>\>\> *** @@ -20310,7 +22304,7 @@ Multi-generation strategy search: author candidates from tournament losses, play ### depthStrategy() -> **depthStrategy**(`surface`, `task`, `opts`, `cfg`): [`Agent`](#agent-1)\<`unknown`, [`Outcome`](#outcome-1)\<`unknown`\>\> +> **depthStrategy**(`surface`, `task`, `opts`, `cfg`): [`Agent`](#agent-1)\<`unknown`, [`Outcome`](#outcome-2)\<`unknown`\>\> DEPTH: one persistent artifact, carried across analyst-steered shots. @@ -20336,13 +22330,13 @@ DEPTH: one persistent artifact, carried across analyst-steered shots. #### Returns -[`Agent`](#agent-1)\<`unknown`, [`Outcome`](#outcome-1)\<`unknown`\>\> +[`Agent`](#agent-1)\<`unknown`, [`Outcome`](#outcome-2)\<`unknown`\>\> *** ### breadthStrategy() -> **breadthStrategy**(`_surface`, `task`, `opts`, `cfg`): [`Agent`](#agent-1)\<`unknown`, [`Outcome`](#outcome-1)\<`unknown`\>\> +> **breadthStrategy**(`_surface`, `task`, `opts`, `cfg`): [`Agent`](#agent-1)\<`unknown`, [`Outcome`](#outcome-2)\<`unknown`\>\> BREADTH: K independent rollouts (each own artifact), verifier picks the best. @@ -20368,7 +22362,7 @@ BREADTH: K independent rollouts (each own artifact), verifier picks the best. #### Returns -[`Agent`](#agent-1)\<`unknown`, [`Outcome`](#outcome-1)\<`unknown`\>\> +[`Agent`](#agent-1)\<`unknown`, [`Outcome`](#outcome-2)\<`unknown`\>\> *** @@ -20870,8 +22864,8 @@ The supervisor SKILL — the how-to the supervisor reads (its system prompt). TH > **authoredWorker**(`profile`, `opts`): [`Agent`](#agent-1)\<`unknown`, `unknown`\> -Build a worker AGENT from a profile the supervisor authored: the authored `systemPrompt` + - `model` shape the worker's one model call; the deliverable gates settlement (valid ⟺ delivered). +Build a router-only worker from an authored profile. This helper executes the prompt/model axes; + use `workerFromBackend` for full materialization of tools, MCP, resources, hooks, and subagents. #### Parameters @@ -20984,12 +22978,13 @@ Fold a normalized `UsageEvent` array into a `Spend`. Tokens and usd are separate ### createBudgetPool() -> **createBudgetPool**(`root`, `now?`): [`BudgetPool`](#budgetpool) +> **createBudgetPool**(`root`, `now?`, `restore?`): [`BudgetPool`](#budgetpool) Create a conserved reservation pool from a root `Budget`. `now()` is injected so the deadline readout is deterministic; defaults to `Date.now` for non-test callers. The -absolute deadline is fixed at construction (`now() + budget.deadlineMs`) so the -readout's `deadlineMs` is a stable wall-clock instant, not a shrinking remainder. +absolute deadline for a fresh pool is fixed at construction (`now() + budget.deadlineMs`). A +restored pool instead retains `restore.absoluteDeadlineMs`, so restart never slides the original +wall-clock limit. The readout is an absolute instant, not a shrinking remainder. #### Parameters @@ -21001,6 +22996,10 @@ readout's `deadlineMs` is a stable wall-clock instant, not a shrinking remainder () => `number` +##### restore? + +[`BudgetPoolRestore`](#budgetpoolrestore) = `{}` + #### Returns [`BudgetPool`](#budgetpool) @@ -21109,6 +23108,10 @@ Stand up the coordination MCP over a live scope. The HOST address is `127.0.0.1` [`MakeWorkerAgent`](#makeworkeragent) +###### authorizeDownMessage? + +[`AuthorizeDownMessage`](#authorizedownmessage) + ###### perWorker [`Budget`](index.md#budget-4) @@ -21179,9 +23182,16 @@ Idle time after which `observe_agent` reports a worker as stalled. ###### onEvent? -(`event`) => `void` \| `Promise`\<`void`\> +(`event`, `record`) => `void` \| `Promise`\<`void`\> + +Pass-through subscriber for every bus event, including pre-delivery instruction receipts and +steer/answer delivery outcomes. + +###### replaySettlements? + +`boolean` -Pass-through subscriber for every bus event (settled / question / finding). +Re-publish resume-time settlements through the awaited observer before this server listens. ###### questionPolicy? @@ -21193,6 +23203,13 @@ readonly [`QuestionRecord`](mcp.md#questionrecord)[] Questions replayed from a prior process of this run — seeds the question ledger. +###### nodeTools? + +readonly [`McpToolDescriptor`](mcp.md#mcptooldescriptor)[] + +Product-selected tools already bound to this exact supervisor node. They share this server + with the coordination verbs, so the existing MCP duplicate-name guard applies before listen. + #### Returns `Promise`\<[`CoordinationMcpHandle`](#coordinationmcphandle)\> @@ -21490,7 +23507,7 @@ readonly [`FinalizerSettled`](#finalizersettled)[] ###### budget -`Readonly`\<\{ `tokensLeft`: `number`; `usdLeft`: `number`; `usdCapped`: `boolean`; `deadlineMs`: `number`; `reservedTokens`: `number`; `tokensKnown?`: `boolean`; \}\> +`Readonly`\<\{ `tokensLeft`: `number`; `tokensKnown`: `boolean`; `usdLeft`: `number`; `usdCapped`: `boolean`; `usdKnown`: `boolean`; `iterationsLeft`: `number`; `deadlineMs`: `number`; `reservedTokens`: `number`; \}\> #### Returns @@ -21500,13 +23517,13 @@ readonly [`FinalizerSettled`](#finalizersettled)[] ### createInbox() -> **createInbox**(): [`Inbox`](#inbox) +> **createInbox**(): [`Inbox`](#inbox-1) Create the worker-side inbox for the down-leg: the driver's `steer_agent` / `answer_question` messages queue here and the worker's loop drains them at step boundaries and before settle. #### Returns -[`Inbox`](#inbox) +[`Inbox`](#inbox-1) *** @@ -21534,6 +23551,29 @@ readonly `string`[] \| `undefined` *** +### assertProfileModelsAllowed() + +> **assertProfileModelsAllowed**(`profile`, `allowed`): `void` + +Check every canonical model-bearing field in a complete profile, including the models a +backend may select for cheap work, named subagents, or modes. + +#### Parameters + +##### profile + +`AgentProfile` + +##### allowed + +readonly `string`[] \| `undefined` + +#### Returns + +`void` + +*** + ### createSupervisorSpanRecorder() > **createSupervisorSpanRecorder**(`opts`): [`SupervisorSpanRecorder`](#supervisorspanrecorder) \| `undefined` @@ -21766,8 +23806,9 @@ resumes when it is re-run with the SAME `runId` and the SAME `dir`: the committe back on `Scope.resume` (rehydrated by `replaySpawnTree`) instead of being re-executed. Layout: `${dir}/spawn-journal.jsonl` (one JSONL record per event), `${dir}/blobs/` (one -content-addressed JSON file per settled result), and `${dir}/coordination-log.jsonl` (questions -+ findings, replayed into a resumed driver). The directory is created on first write. +content-addressed JSON file per settled result), and `${dir}/coordination-log.jsonl` +(questions, findings, answer decisions, and authorized continuation receipts retained as +evidence). The directory is created on first write. Opt-in by construction — `createInMemoryRunContext()` is unchanged and stays the default, so no existing consumer writes to disk or resumes unless it asks for this. @@ -22022,8 +24063,8 @@ factory)` for any additional runtime — and a BYO `AgentSpec.executor` resolves without touching the registry at all. NOT a closed switch; registration + BYO ARE the extension points. -`resolve` precedence (frozen in `ExecutorRegistry`): a BYO `spec.executor` → -`harness === null` → the `'router'` factory; else a registered factory for the +`resolve` precedence (frozen in `ExecutorRegistry`): a BYO `spec.executorFactory` → +`spec.executor` → `harness === null` → the `'router'` factory; else a registered factory for the harness-derived runtime (`'sandbox'` for any `BackendType`); else fail loud. #### Returns @@ -22407,6 +24448,98 @@ Create a supervisor that owns one recursive agent execution tree. *** +### createRootHandle() + +> **createRootHandle**\<`Out`\>(): [`SteerableRootHandle`](#steerableroothandle)\<`Out`\> + +Mint a `RootHandle` plus its supervisor-private control. The handle is the substrate a +chat/pi-viz client attaches to (Q2): `view()` reads the live tree, `signal()` delivers +an out-of-band message, `abort()` cascades. Before `run` binds it (and after `run` +unbinds it) the handle is fail-loud: a client that talks to a handle that is not +driving a live run gets a typed error, never a silent no-op. + +#### Type Parameters + +##### Out + +`Out` + +#### Returns + +[`SteerableRootHandle`](#steerableroothandle)\<`Out`\> + +*** + +### captureWorkerTraceEvidence() + +> **captureWorkerTraceEvidence**(`readSource`, `blobs`, `executed`): `Promise`\<[`WorkerTraceEvidence`](index.md#workertraceevidence)\> + +Collect and persist one executor's structured tool trace without changing its task outcome. + +#### Parameters + +##### readSource + +(() => [`TraceSource`](#tracesource-1) \| `undefined`) \| `undefined` + +##### blobs + +[`ResultBlobStore`](#resultblobstore) + +##### executed + +`boolean` + +#### Returns + +`Promise`\<[`WorkerTraceEvidence`](index.md#workertraceevidence)\> + +*** + +### workerTraceAnalysisStore() + +> **workerTraceAnalysisStore**(`evidence`, `blobs`): `Promise`\<`TraceAnalysisStore`\> + +Rehydrate exact persisted spans through agent-eval's one bounded trace-analysis adapter. + +#### Parameters + +##### evidence + +[`WorkerTraceEvidence`](index.md#workertraceevidence) + +##### blobs + +`Pick`\<[`ResultBlobStore`](#resultblobstore), `"get"`\> + +#### Returns + +`Promise`\<`TraceAnalysisStore`\> + +*** + +### parseWorkerToolTraceArtifact() + +> **parseWorkerToolTraceArtifact**(`value`, `traceRef?`): [`WorkerToolTraceArtifact`](#workertooltraceartifact) + +Validate a stored trace artifact before an analyst or replay trusts it. + +#### Parameters + +##### value + +`unknown` + +##### traceRef? + +`string` = `''` + +#### Returns + +[`WorkerToolTraceArtifact`](#workertooltraceartifact) + +*** + ### decodeToolPart() > **decodeToolPart**(`part`, `harness?`): [`ToolStepInput`](#toolstepinput) \| `undefined` @@ -22861,8 +24994,9 @@ caller's own seam env so a deliberately-set id wins (see the precedence note abo Build a worktree-CLI leaf `Executor`. Per-spawn (a fresh worktree + abort + teardown each), so a fanout of N profiles = N parallel worktrees that never clobber each other. -Fail-loud: an empty `repoRoot`/`harness`/`taskPrompt` throws at construction. `resultArtifact()` -before `execute()` resolves throws. +Fail-loud: an empty `repoRoot`/`harness` or an explicitly empty `taskPrompt` throws at +construction. Calling `execute(undefined, signal)` without a configured prompt throws before a +worktree is created. `resultArtifact()` before `execute()` resolves throws. #### Parameters @@ -23196,6 +25330,18 @@ Re-exports [Supervisor](index.md#supervisor) *** +### WorkerTraceEvidence + +Re-exports [WorkerTraceEvidence](index.md#workertraceevidence) + +*** + +### WorkerTraceUnavailableReason + +Re-exports [WorkerTraceUnavailableReason](index.md#workertraceunavailablereason) + +*** + ### Driver Re-exports [Driver](index.md#driver) diff --git a/docs/api/runtime/environment-provider.md b/docs/api/runtime/environment-provider.md index 197fb4b4..d0525fba 100644 --- a/docs/api/runtime/environment-provider.md +++ b/docs/api/runtime/environment-provider.md @@ -268,7 +268,7 @@ Options for running a provider as a supervise-mode executor. ##### runtime? -> `optional` **runtime?**: [`Runtime`](../runtime.md#runtime-2) +> `optional` **runtime?**: [`Runtime`](../runtime.md#runtime-4) **`Experimental`** diff --git a/docs/architecture.md b/docs/architecture.md index 8ebeca7e..313b3998 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -357,13 +357,12 @@ A **leaf** is an `act` that returns without touching `scope`. A **driver** is an that spawns children and reacts to them. Same type — the role is behavior, not a class (the full prose is §1). -The `Scope` it runs inside is **5 verbs** (`types.ts`) — a budget-conserving reactive -nursery: +The `Scope` it runs inside is the budget-conserving reactive control surface (`types.ts`): ``` scope ───────────────────────────────────────────────────────────────────────────────────── │ - ├─ spawn(agent, task, {budget,label}) → {ok,handle} | {ok:false, 'budget-exhausted'|'depth-exceeded'} + ├─ spawn(agent, task, {budget,label,key?}) → {ok,handle,prior?} | {ok:false, SpawnRejection} │ reserves budget ATOMICALLY from a conserved pool, fail-closed ⟸ THE equal-compute invariant │ ├─ next() → Promise the WAKE cursor: resolves as each child settles, in seq order @@ -372,20 +371,22 @@ nursery: ├─ send(nodeId, msg) → bool STEER a running child (next-instruction / interrupt) │ in-process = direct call · across a sandbox = the SAME verb as an MCP tool │ - ├─ view → TreeView the live tree (in-memory, O(live)) — what the topology viewer renders - └─ budget → {tokensLeft, usdLeft, deadlineMs, reservedTokens} + ├─ wait(spec) durable timer or named-predicate wait + ├─ progress(nodeId) / traceSource(nodeId) explicit live observation + ├─ meter(spend) / recordMaterialization(receipt) runtime accounting and wire-profile evidence + ├─ view / workerCapacity live tree and shared execution slots + └─ budget → {tokensLeft,tokensKnown,usdLeft,usdKnown,iterationsLeft,deadlineMs,reservedTokens} ``` Two facts make this the whole game: -- `spawn` **reserves** from a shared pool and refunds the unspent remainder on settle, so - `Σk(treatment) ≡ Σk(blind)` by construction — no arm can buy more compute - (`supervise/budget.ts`). -- `next()` is the *only* way to observe a child, so a driver reacts to **settlements**, - never reaches inside a child. +- `spawn` **reserves** from one root total and refunds the unspent remainder on settle. + A nested driver partitions only its reserved allocation, then reconciles the whole subtree once, so `Σk(treatment) ≡ Σk(blind)` by construction — no arm can buy more compute (`supervise/budget.ts`). +- `next()` is the only path that consumes a child's terminal result. + Live observation is explicit and read-only through `progress` and `traceSource`; neither can manufacture a settlement. -The ask/answer edges of the question/command hierarchy are **built** — `ask_parent` up -and `answer_question` down (`src/mcp/tools/coordination.ts:159-160`), priority-queued on -the event bus; salience filtering and the cross-box durable mailbox are not. See **§13.6**. +The ask/answer edges of the question/command hierarchy are **built** — `ask_parent` up and `answer_question` down (`src/mcp/tools/coordination.ts`), priority-queued on the event bus. +Every steer/answer authorization receipt is committed before delivery and retained as restart evidence, but Runtime never auto-delivers that old instruction to a replacement worker. +Salience filtering and the cross-box durable mailbox are not built; see **§13.6**. ### 13.2 The tree — drivers of drivers, one recursive atom @@ -418,12 +419,9 @@ the event bus; salience filtering and the cross-box durable mailbox are not. See → equal-compute holds at EVERY depth (`supervise/budget.ts`) ``` -- **REAL** — one recursive `Agent` node, not two types: `Agent.act(task, scope)` in - `src/runtime/supervise/types.ts:49`. The roles are the *same* atom; a node is a - "driver" only because its tools spawn children. A child whose `act` calls - `scope.spawn` is a driver too, with its **own sub-scope** (depth+1, bounded by - `maxDepth` + the *same* pool) — recursion isn't a feature, it's the absence of a - base case (`supervise/supervisor.ts`, `supervise/scope.ts`). +- **REAL** — one recursive `Agent` node, not two types: `Agent.act(task, scope)` in `src/runtime/supervise/types.ts:49`. + The roles are the *same* atom; a node is a "driver" only because its tools spawn children. + A child whose `act` calls `scope.spawn` is a driver too, with its **own sub-scope** (depth+1, bounded by `maxDepth` + a partition of the same root total) — recursion isn't a feature, it's the absence of a base case (`supervise/supervisor.ts`, `supervise/scope.ts`). - **REAL** — the **leaf** at the bottom is where a real coding harness runs, opaque and self-parallelizing internally; the `runAgentRounds` kernel (`src/runtime/run-loop.ts`) is composed as one leaf execution backend. Everything above it is the same `act`/`Scope` @@ -437,12 +435,17 @@ the event bus; salience filtering and the cross-box durable mailbox are not. See and `canonical-api.md` §1.5): a supervisor's intelligence is *writing full AgentProfiles for its children*. The coordination toolbox `spawn_agent` carries the child profile (`src/mcp/tools/coordination.ts`). -- The in-process driver brain is `driverAgent` - (`supervise/coordination-driver.ts`) running the owned tool-loop executor - `routerToolsInlineExecutor` (`supervise/runtime.ts`). A driver/supervisor's brain is - driven from its `AgentProfile` (tools = the coordination verbs); inferring the brain - entirely from the profile so a driver is *just* a profile with zero special cases is - not yet wired end-to-end. +- The in-process driver brain is `driverAgent` (`supervise/coordination-driver.ts`) running the owned tool-loop executor `routerToolsInlineExecutor` (`supervise/runtime.ts`). + A driver/supervisor's brain is driven from its `AgentProfile`: prompt + model for the deliberately narrow in-process router arm, or the complete materialized profile for an external-harness arm. +- **REAL** — `supervise(profile, task, { backend })` validates and freezes every authored child profile before budget reservation, applies shared security plus optional product authorization, and preserves the authorized profile through execution (`supervise/supervise.ts`). + A child marked `metadata.role: 'driver'` recursively becomes another supervisor over the same budget; every other child resolves to a leaf. +- **REAL** — a local external-harness supervisor runs automatically through a `bridge` `driverBackend ?? backend` with the live coordination MCP injected under one reserved alias. + Its own tools, resources, MCP servers, hooks, subagents, permissions, modes, prompt, and model remain profile data sent to that backend (`supervise/supervise.ts`, `supervise/runtime.ts`). + A pre-execution `materialized` journal event binds the authored-profile, effective-profile, and platform-attachment digests to the node that ran. +- **LIMIT** — a remote sandbox cannot reach the loopback coordination server automatically. + It needs an explicit `driveHarness` that provides a reachable relay or tunnel. +- **LIMIT** — the in-process router arm has no environment in which to materialize profile resources, hooks, subagents, permissions, or modes. + It executes prompt + model and uses explicit `extraTools`; choose an external backend when the other profile axes must run. ### 13.3 The within-run self-improvement loop (§1's agent-driver, drawn) @@ -556,14 +559,15 @@ not agent-to-agent messaging. **Built** (`src/mcp/tools/coordination.ts`, `src/runtime/supervise/event-bus.ts`, `src/runtime/supervise/inbox.ts`): -- `ask_parent` up + `answer_question` down (`src/mcp/tools/coordination.ts:159-160`) — +- `ask_parent` up + `answer_question` down (`src/mcp/tools/coordination.ts`) — a blocking question rides the ONE typed pipe, **priority-queued** ahead of queued settles/findings (the event bus); the answer routes down to the child's inbox. -- `steer_worker` — the down-leg for any live worker (instruction / correction / +- `steer_agent` — the down-leg for any live worker (instruction / correction / continuation); queued messages flush at step boundaries AND before the worker may - settle; a forceful `steer_worker({interrupt:true})` aborts the in-flight turn (the + settle; a forceful `steer_agent({interrupt:true})` aborts the in-flight turn (the inbox). -- `notify` up — every settle/decision is teed upward on the lifecycle hook stream. +- `agent.spawn` / `agent.child` lifecycle events — every spawn and consumed settlement is + sent to the runtime hook stream. **Not built:** the **salience tag** on decisions (so the top doesn't drown), the cross-box durable mailbox (§13.9), budget-pause-while-awaiting. @@ -648,17 +652,23 @@ within-run column splits into in-flight and across-round). with the three timescales as internal composition — so "are we improving skills in the loop?" has one place to look — is not yet wired. -### 13.9 Durability — by design, not yet end-to-end +### 13.9 Durability — exact at each implemented boundary ``` - same box : in-process queue ── REAL (tested) - cross box : durable mailbox on the parent's box ── designed (the interface is ready) + same process : in-process event queue ── REAL (tested) + same host : file journal + blobs + coordination log across restart ── REAL (tested) + cross box : durable mailbox on the parent's box ── designed ``` - **REAL** — the event bus is transport-agnostic *on purpose*: same box → the in-process queue; cross box → the SAME publish/pull/subscribe surface backed by a durable mailbox on the parent's box (`supervise/event-bus.ts`). The data structure is already shaped for durability. +- **REAL** — `supervise(..., { runDir, runId })` restores committed settlements, exact profile/task/candidate identity, measured spend, pending waits, and the original absolute deadline from the file-backed stores. + The coordination log restores prior questions, findings, and authorized instruction receipts; the in-process router receives all three in its resume brief, while an external manager receives prior questions and the other evidence remains in the durable log. + An active child that lacks a terminal record is charged at its full declared reservation and its token/dollar telemetry remains explicitly unknown; a retry can use only safely remaining capacity. + Authorized instruction receipts are evidence and are never auto-delivered to a new worker. +- **LIMIT** — built-in executors do not reattach work that was active when their process died. - **designed, not built** — the cross-box (distributed-sandbox) durable binding: in-process is real and tested, the cross-box transport is the thin unbuilt part, so the up-flow can survive across distributed boxes and restarts. diff --git a/docs/canonical-api.md b/docs/canonical-api.md index 29a453d2..60145481 100644 --- a/docs/canonical-api.md +++ b/docs/canonical-api.md @@ -4,7 +4,7 @@ Generated signatures and the complete export list live in docs/api/. Run pnpm docs:freshness after editing this file. --> -> **Version 0.116.0.** +> **Version 0.117.0.** > [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path. > `agent-eval` must satisfy `>=0.139.2 <0.140.0`. > `sandbox` must satisfy `>=0.15.0 <0.16.0`. diff --git a/examples/ablation-suite/ablation.ts b/examples/ablation-suite/ablation.ts index 4e547745..90b5372a 100644 --- a/examples/ablation-suite/ablation.ts +++ b/examples/ablation-suite/ablation.ts @@ -209,32 +209,38 @@ export async function runAblation(opts: { // pool, with the analyst up-leg on. `superviseSurface` reports the deployable outcome + // the FULL conserved spend (driver inference + all worker work: $, tokens, latency). `shots` // stays 0 — a multi-worker supervised run has no single refine-shot count (N/A, not a real zero). - const sup = await superviseSurface({ name: 'driver', systemPrompt: driverPrompt }, t, { - surface: counter, - worker: { - routerBaseUrl: opts.worker.routerBaseUrl, - routerKey: opts.worker.routerKey, - model: opts.worker.model, - ...(opts.worker.maxTokens !== undefined ? { maxTokens: opts.worker.maxTokens } : {}), - ...(opts.worker.innerTurns !== undefined - ? { innerTurns: opts.worker.innerTurns } - : {}), - budget: arm.knobs.budget, - }, - budget: { - // Pool for the driver's turns PLUS several worker spawns (each reserves ~innerTurns+2 - // iterations) so the spawn-targeted-worker loop runs, not stall after one. The autopsy - // measures the real cost; this is intentionally not equal-k. - maxIterations: arm.knobs.budget * ((opts.worker.innerTurns ?? 6) + 2) + 16, - maxTokens: (opts.worker.maxTokens ?? 4000) * Math.max(4, arm.knobs.budget * 3), + const sup = await superviseSurface( + { name: 'driver', prompt: { systemPrompt: driverPrompt } }, + t, + { + surface: counter, + worker: { + routerBaseUrl: opts.worker.routerBaseUrl, + routerKey: opts.worker.routerKey, + model: opts.worker.model, + ...(opts.worker.maxTokens !== undefined + ? { maxTokens: opts.worker.maxTokens } + : {}), + ...(opts.worker.innerTurns !== undefined + ? { innerTurns: opts.worker.innerTurns } + : {}), + budget: arm.knobs.budget, + }, + budget: { + // Pool for the driver's turns PLUS several worker spawns (each reserves ~innerTurns+2 + // iterations) so the spawn-targeted-worker loop runs, not stall after one. The autopsy + // measures the real cost; this is intentionally not equal-k. + maxIterations: arm.knobs.budget * ((opts.worker.innerTurns ?? 6) + 2) + 16, + maxTokens: (opts.worker.maxTokens ?? 4000) * Math.max(4, arm.knobs.budget * 3), + }, + router: { + routerBaseUrl: supervisorRouter.baseUrl, + routerKey: supervisorRouter.apiKey, + model: supervisorRouter.model, + }, + analysts: failuresAnalyst(), }, - router: { - routerBaseUrl: supervisorRouter.baseUrl, - routerKey: supervisorRouter.apiKey, - model: supervisorRouter.model, - }, - analysts: failuresAnalyst(), - }) + ) if (sup.resolved) resolved++ scoreSum += sup.score perTask.push(sup.resolved ? 1 : 0) diff --git a/examples/ablation-suite/gepa-driver-prompt.ts b/examples/ablation-suite/gepa-driver-prompt.ts index fddc8b59..d02bb7df 100644 --- a/examples/ablation-suite/gepa-driver-prompt.ts +++ b/examples/ablation-suite/gepa-driver-prompt.ts @@ -130,7 +130,7 @@ export async function optimizeDriverPrompt(opts: { model: supervisorRouter.model, signal: ctx.signal, execute: () => - superviseSurface({ name: 'driver', systemPrompt }, scenario.task, { + superviseSurface({ name: 'driver', prompt: { systemPrompt } }, scenario.task, { surface, worker, // A small conserved pool: enough for the driver's turns plus several worker spawns so the diff --git a/examples/supervise/supervise.ts b/examples/supervise/supervise.ts index b0e7ebeb..fa0eace9 100644 --- a/examples/supervise/supervise.ts +++ b/examples/supervise/supervise.ts @@ -32,18 +32,20 @@ async function main(): Promise { const result = await supervise( { name: 'supervisor', - harness: null, // router brain (the supervisor reasons spawn/await/stop over the router's tool-calling) + harness: 'cli-base', // in-process router brain (the supervisor calls spawn/await/stop) // This demo overrides the shipped `defaultSupervisorPrompt` on purpose: the default tells a // supervisor to do SMALL work itself, but this supervisor has no work tools and the completion // oracle only credits a DELIVERED child — so we force the delegation path the example teaches. // Real supervisors with work tools want the default (do-small-work-yourself / spawn-when-large). - systemPrompt: - 'You are a supervisor. Produce the deliverable by delegating:\n' + - '1. Call spawn_agent with a worker profile and the task.\n' + - '2. Then call await_event and WAIT for that worker to settle — never call stop while a ' + - 'worker is still running, or its result is lost.\n' + - '3. Once a worker has delivered, call stop.\n' + - "Do not answer the task yourself — only a spawned worker's output counts as delivered.", + prompt: { + systemPrompt: + 'You are a supervisor. Produce the deliverable by delegating:\n' + + '1. Call spawn_agent with a worker profile and the task.\n' + + '2. Then call await_event and WAIT for that worker to settle — never call stop while a ' + + 'worker is still running, or its result is lost.\n' + + '3. Once a worker has delivered, call stop.\n' + + "Do not answer the task yourself — only a spawned worker's output counts as delivered.", + }, }, 'Produce the exact line: READY', { diff --git a/examples/supervisor-loop/run.ts b/examples/supervisor-loop/run.ts index 6b91cabf..d5499040 100644 --- a/examples/supervisor-loop/run.ts +++ b/examples/supervisor-loop/run.ts @@ -34,10 +34,12 @@ async function main(): Promise { const result = await supervise( { name: 'supervisor', - harness: null, - systemPrompt: - 'You are a supervisor. Spawn one worker session to produce the required line, await it with ' + - 'await_event, and stop once a worker delivered (valid). Do not answer yourself.', + harness: 'cli-base', + prompt: { + systemPrompt: + 'You are a supervisor. Spawn one worker session to produce the required line, await it ' + + 'with await_event, and stop once a worker delivered (valid). Do not answer yourself.', + }, }, demoGoal, { diff --git a/examples/supervisor-loop/shared.ts b/examples/supervisor-loop/shared.ts index bc10d36c..e137b935 100644 --- a/examples/supervisor-loop/shared.ts +++ b/examples/supervisor-loop/shared.ts @@ -57,7 +57,10 @@ export function scriptedSupervisorChat(workerCount: number, labelPrefix = 'solve { name: 'spawn_agent', arguments: { - profile: { name: `${labelPrefix}-${i}`, systemPrompt: `Emit ${expectedAnswer}.` }, + profile: { + name: `${labelPrefix}-${i}`, + prompt: { systemPrompt: `Emit ${expectedAnswer}.` }, + }, task: `Emit the exact line ${expectedAnswer} and nothing else.`, label: `${labelPrefix}-${i}`, }, diff --git a/package.json b/package.json index 855dccb3..08f85ed2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-runtime", - "version": "0.116.0", + "version": "0.117.0", "description": "Shared task-lifecycle skeleton for agents: a recursive loop kernel for chat turns, one-shot tasks, and multi-attempt loops, with trace capture and eval-gated self-improvement. Domain behavior lives in adapters; scoring and ship-gates in @tangle-network/agent-eval.", "homepage": "https://github.com/tangle-network/agent-runtime#readme", "repository": { diff --git a/src/agent/index.ts b/src/agent/index.ts index 6ee24759..1c3fdd63 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -34,6 +34,7 @@ export { createSurfaceImprovementProposer } from './improvement-adapter' export type { AgentProfileMaterializationAxis, AssertProfileMaterializationOptions, + CanonicalAgentProfileMaterializationAxis, DefineProfileMaterializationContractOptions, KnownAgentProfileMaterializationAxis, ProfileMaterializationContract, @@ -43,12 +44,18 @@ export type { export { AGENT_PROFILE_MATERIALIZATION_AXES, assertProfileMaterialization, + controlProfileMaterialization, defineProfileMaterializationContract, + fullProfileMaterialization, + profileMaterializationAxes, + promptControlProfileMaterialization, + promptModelProfileMaterialization, promptOnlyProfileMaterialization, promptResourceProfileMaterialization, renderProfileMaterializationIssues, sandboxActProfileMaterialization, validateProfileMaterialization, + worktreeCliProfileMaterialization, } from './profile-materialization' export type { CreateSandboxActOptions, SandboxActComposeOverrides } from './sandbox-act' export { createSandboxAct } from './sandbox-act' diff --git a/src/agent/profile-materialization.ts b/src/agent/profile-materialization.ts index 805df62a..0869faa7 100644 --- a/src/agent/profile-materialization.ts +++ b/src/agent/profile-materialization.ts @@ -1,9 +1,11 @@ import { AGENT_PROFILE_MATERIALIZATION_AXES, type CanonicalAgentProfileMaterializationAxis, + profileMaterializationAxes, } from '@tangle-network/agent-interface' import { ValidationError } from '../errors' +export type { CanonicalAgentProfileMaterializationAxis } /** * The canonical AgentProfile leaves, re-exported from `@tangle-network/agent-interface`. * @@ -11,7 +13,7 @@ import { ValidationError } from '../errors' * contract must name every leaf it carries, because claiming a compound parent while dropping one * of its children is exactly the silent-drop this module exists to catch. */ -export { AGENT_PROFILE_MATERIALIZATION_AXES } +export { AGENT_PROFILE_MATERIALIZATION_AXES, profileMaterializationAxes } export type KnownAgentProfileMaterializationAxis = CanonicalAgentProfileMaterializationAxis @@ -84,34 +86,45 @@ const compoundAxisLeaves: Record { diff --git a/src/durable/content-address.ts b/src/durable/content-address.ts new file mode 100644 index 00000000..6ff89ffb --- /dev/null +++ b/src/durable/content-address.ts @@ -0,0 +1,16 @@ +import { createHash } from 'node:crypto' + +/** Stable content address shared by result and trace artifacts. */ +export function contentAddress(artifact: unknown): string { + const hex = createHash('sha256').update(stableStringify(artifact), 'utf-8').digest('hex') + return `sha256:${hex}` +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null' + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]` + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}` +} diff --git a/src/durable/jsonl-file.ts b/src/durable/jsonl-file.ts new file mode 100644 index 00000000..c0cedb73 --- /dev/null +++ b/src/durable/jsonl-file.ts @@ -0,0 +1,71 @@ +import type { FileHandle } from 'node:fs/promises' + +/** Parse an append-only JSONL file without treating a torn final write as committed data. + * A malformed newline-terminated or non-final record is corruption and fails loud. */ +export function parseCommittedJsonLines(text: string, source: string): T[] { + const lines = text.split('\n') + const finalIndex = lines.length - 1 + const records: T[] = [] + + for (const [index, line] of lines.entries()) { + if (line.length === 0) continue + try { + records.push(JSON.parse(line) as T) + } catch (cause) { + const isInvalidUnterminatedTail = index === finalIndex && !text.endsWith('\n') + if (isInvalidUnterminatedTail) break + throw new Error(`${source}: malformed JSONL record at line ${index + 1}`, { cause }) + } + } + + return records +} + +/** FileHandle.write may legally make a short write. Loop until every byte is appended. */ +export async function writeAllBytes( + handle: Pick, + value: string | Uint8Array, +): Promise { + const bytes = typeof value === 'string' ? Buffer.from(value) : Buffer.from(value) + let offset = 0 + while (offset < bytes.byteLength) { + const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset, null) + if (bytesWritten <= 0) { + throw new Error(`append-only file write made no progress at byte ${offset}`) + } + offset += bytesWritten + } +} + +/** Prepare a recovered JSONL file for its next append. A valid unterminated final value is retained + * and needs a separator; an invalid unterminated tail was never committed and is truncated. */ +export async function prepareJsonlAppend(path: string): Promise { + const fs = await import('node:fs/promises') + let bytes: Buffer + try { + bytes = await fs.readFile(path) + } catch (error) { + if (isNoEntError(error)) return false + throw error + } + if (bytes.byteLength === 0 || bytes[bytes.byteLength - 1] === 0x0a) return false + + const lastNewline = bytes.lastIndexOf(0x0a) + const tail = bytes.subarray(lastNewline + 1).toString('utf8') + try { + JSON.parse(tail) + return true + } catch { + await fs.truncate(path, lastNewline + 1) + return false + } +} + +function isNoEntError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code: unknown }).code === 'ENOENT' + ) +} diff --git a/src/durable/spawn-journal.ts b/src/durable/spawn-journal.ts index 03ea9fb0..23d4fc40 100644 --- a/src/durable/spawn-journal.ts +++ b/src/durable/spawn-journal.ts @@ -20,8 +20,11 @@ * @experimental */ -import { createHash } from 'node:crypto' +import { detachedSnapshot } from '../runtime/supervise/snapshot' +import { workerTraceAnalysisStore } from '../runtime/supervise/trace-evidence' +import { nestedDriverTreeRoot } from '../runtime/supervise/tree-key' import type { + NodeExecutionIdentity, NodeId, NodeSnapshot, NodeStatus, @@ -35,6 +38,58 @@ import type { } from '../runtime/supervise/types' import type { PendingWait } from '../runtime/supervise/wait' import { zeroTokenUsage } from '../runtime/util' +import { contentAddress } from './content-address' +import { parseCommittedJsonLines, prepareJsonlAppend, writeAllBytes } from './jsonl-file' + +export { contentAddress } from './content-address' + +/** One journal tree in a recursively loaded supervision forest. */ +export interface SpawnForestTree { + readonly root: NodeId + /** Driver node that owns this tree; absent for the requested root tree. */ + readonly ownerNodeId?: NodeId + /** Journal tree containing `ownerNodeId`; absent for the requested root tree. */ + readonly parentTreeRoot?: NodeId + readonly events: ReadonlyArray + readonly view: TreeView +} + +/** One event with the journal tree that establishes its cursor namespace. */ +export interface SpawnForestEvent { + readonly treeRoot: NodeId + readonly event: SpawnEvent +} + +/** One flattened node with the journal tree that owns its records. */ +export interface SpawnForestNode extends NodeSnapshot { + readonly treeRoot: NodeId +} + +/** A spawned worker with no terminal record in a cold snapshot. Resume treats the same state as + * in-doubt and conservatively retains its reservation. Root nodes and armed waits are excluded. */ +export interface SpawnForestInDoubtNode { + readonly treeRoot: NodeId + readonly nodeId: NodeId + readonly label: string + readonly runtime: Runtime +} + +/** A driver spawn whose owned journal tree was never begun before the process stopped. */ +export interface SpawnForestMissingTree { + readonly parentTreeRoot: NodeId + readonly ownerNodeId: NodeId + readonly root: NodeId +} + +/** Complete cold-readable view of one recursive supervision run. */ +export interface SpawnForest { + readonly root: NodeId + readonly trees: ReadonlyArray + readonly nodes: ReadonlyArray + readonly events: ReadonlyArray + readonly inDoubt: ReadonlyArray + readonly missingTrees: ReadonlyArray +} // ── Content addressing ────────────────────────────────────────────────────── @@ -47,20 +102,6 @@ import { zeroTokenUsage } from '../runtime/util' * Stable encoding: object keys are sorted recursively so two structurally-equal * artifacts hash identically regardless of key insertion order. */ -export function contentAddress(artifact: unknown): string { - const hex = createHash('sha256').update(stableStringify(artifact), 'utf-8').digest('hex') - return `sha256:${hex}` -} - -function stableStringify(value: unknown): string { - if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null' - if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]` - const entries = Object.entries(value as Record) - .filter(([, v]) => v !== undefined) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}` -} - // ── Result blob store ───────────────────────────────────────────────────────── /** @@ -178,6 +219,8 @@ export class InMemorySpawnJournal implements SpawnJournal { * writes never loses an acknowledged event. */ export class FileSpawnJournal implements SpawnJournal { + private appendTail: Promise = Promise.resolve() + constructor(private readonly path: string) {} async loadTree(root: NodeId): Promise { @@ -189,11 +232,9 @@ export class FileSpawnJournal implements SpawnJournal { if (isNoEntError(err)) return undefined throw err } - const lines = text.split('\n').filter((line) => line.length > 0) let begun = false const events: SpawnEvent[] = [] - for (const line of lines) { - const record = JSON.parse(line) as SpawnJournalRecord + for (const record of parseCommittedJsonLines(text, this.path)) { if (record.root !== root) continue if (record.kind === 'begin') { begun = true @@ -241,21 +282,26 @@ export class FileSpawnJournal implements SpawnJournal { if (isNoEntError(err)) return undefined throw err } - const lines = text.split('\n').filter((line) => line.length > 0) - for (const line of lines) { - const record = JSON.parse(line) as SpawnJournalRecord + for (const record of parseCommittedJsonLines(text, this.path)) { if (record.root === root && record.kind === 'begin') return record.at } return undefined } private async appendRecord(record: SpawnJournalRecord): Promise { + const append = this.appendTail.then(() => this.writeRecord(record)) + this.appendTail = append.catch(() => undefined) + return append + } + + private async writeRecord(record: SpawnJournalRecord): Promise { const fs = await import('node:fs/promises') const path = await import('node:path') await fs.mkdir(path.dirname(this.path), { recursive: true }) + const needsSeparator = await prepareJsonlAppend(this.path) const fh = await fs.open(this.path, 'a') try { - await fh.write(`${JSON.stringify(record)}\n`) + await writeAllBytes(fh, `${needsSeparator ? '\n' : ''}${JSON.stringify(record)}\n`) await fh.sync() } finally { await fh.close() @@ -263,6 +309,116 @@ export class FileSpawnJournal implements SpawnJournal { } } +/** + * Load every journal tree owned by one recursive supervision run and flatten its nodes/events. + * + * Nested driver tree keys are a Runtime implementation detail; callers should use this reader + * instead of deriving or scanning keys themselves. The reader follows only the explicit + * `ownedTreeRoot` written after Runtime privately attested a recursive executor; the open runtime + * string `driver` is never treated as ownership. Legacy records without `ownedTreeRoot` are + * intentionally treated as leaves rather than guessing or scanning convention-derived keys. + * This preserves each tree's independent cursor namespace on flattened events. + * A driver whose subtree was never begun is reported in `missingTrees`; any spawned non-root node + * without a terminal record is reported in `inDoubt`, matching resume's conservative lost-work + * interpretation. + * + * This is a cold/quiescent reader, not a transaction across an actively mutating file. Every value + * returned is a detached immutable snapshot, so later journal writes or caller mutation cannot + * change the result already observed. + */ +export async function loadSpawnForest(journal: SpawnJournal, root: NodeId): Promise { + const rootEvents = await journal.loadTree(root) + if (rootEvents === undefined) { + throw new Error(`loadSpawnForest: no journaled tree for root '${root}'`) + } + + const trees: SpawnForestTree[] = [] + const nodes: SpawnForestNode[] = [] + const events: SpawnForestEvent[] = [] + const inDoubt: SpawnForestInDoubtNode[] = [] + const missingTrees: SpawnForestMissingTree[] = [] + const visited = new Set() + const queue: Array<{ + readonly root: NodeId + readonly events: SpawnEvent[] + readonly ownerNodeId?: NodeId + readonly parentTreeRoot?: NodeId + }> = [{ root, events: rootEvents }] + + for (let cursor = 0; cursor < queue.length; cursor += 1) { + const current = queue[cursor]! + if (visited.has(current.root)) { + throw new Error(`loadSpawnForest: recursive journal tree '${current.root}' was reached twice`) + } + visited.add(current.root) + const stableEvents = detachedSnapshot( + current.events, + `loadSpawnForest tree ${JSON.stringify(current.root)}`, + ) + const view = detachedSnapshot( + materializeTreeView([...stableEvents]), + `loadSpawnForest view ${JSON.stringify(current.root)}`, + ) + trees.push({ + root: current.root, + ...(current.ownerNodeId === undefined ? {} : { ownerNodeId: current.ownerNodeId }), + ...(current.parentTreeRoot === undefined ? {} : { parentTreeRoot: current.parentTreeRoot }), + events: stableEvents, + view, + }) + nodes.push(...view.nodes.map((node) => ({ treeRoot: current.root, ...node }))) + events.push(...stableEvents.map((event) => ({ treeRoot: current.root, event }))) + + const terminal = new Set() + for (const event of stableEvents) { + if (event.kind === 'settled' || event.kind === 'cancelled') terminal.add(event.id) + } + const spawns = stableEvents + .filter( + (event): event is Extract => event.kind === 'spawned', + ) + .sort((a, b) => a.seq - b.seq || a.id.localeCompare(b.id)) + for (const spawn of spawns) { + if (spawn.parent !== undefined && !terminal.has(spawn.id)) { + inDoubt.push({ + treeRoot: current.root, + nodeId: spawn.id, + label: spawn.label, + runtime: spawn.runtime, + }) + } + if (spawn.ownedTreeRoot === undefined) continue + const expectedRoot = nestedDriverTreeRoot(current.root, spawn.id) + if (spawn.ownedTreeRoot !== expectedRoot) { + throw new Error( + `loadSpawnForest: node '${spawn.id}' owns non-canonical tree '${spawn.ownedTreeRoot}'; expected '${expectedRoot}'`, + ) + } + const nestedRoot = spawn.ownedTreeRoot + const nestedEvents = await journal.loadTree(nestedRoot) + if (nestedEvents === undefined) { + missingTrees.push({ + parentTreeRoot: current.root, + ownerNodeId: spawn.id, + root: nestedRoot, + }) + continue + } + queue.push({ + root: nestedRoot, + events: nestedEvents, + ownerNodeId: spawn.id, + parentTreeRoot: current.root, + }) + } + } + + return detachedSnapshot( + { root, trees, nodes, events, inDoubt, missingTrees }, + 'loadSpawnForest result', + ) +} + type SpawnJournalRecord = | { kind: 'begin'; root: NodeId; at: string } | { kind: 'event'; root: NodeId; event: SpawnEvent } @@ -276,6 +432,37 @@ type SpawnJournalRecord = * ordinal legitimately equals a later `settled` cursor seq and is not a collision. */ function assertSeqUnique(root: NodeId, events: SpawnEvent[], ev: SpawnEvent): void { + if (ev.kind === 'materialized') { + if (events.some((event) => event.kind === 'materialized' && event.id === ev.id)) { + throw new Error( + `spawn journal corrupted: duplicate materialization receipt for node '${ev.id}' in tree '${root}'`, + ) + } + if (!events.some((event) => event.kind === 'spawned' && event.id === ev.id)) { + throw new Error( + `spawn journal corrupted: materialization for node '${ev.id}' precedes its spawn in tree '${root}'`, + ) + } + } + if (ev.kind === 'execution-bound') { + if ( + events.some( + (event) => + event.kind === 'execution-bound' && + event.id === ev.id && + event.binding.attemptId === ev.binding.attemptId, + ) + ) { + throw new Error( + `spawn journal corrupted: duplicate execution binding for node '${ev.id}' attempt '${ev.binding.attemptId}' in tree '${root}'`, + ) + } + if (!events.some((event) => event.kind === 'materialized' && event.id === ev.id)) { + throw new Error( + `spawn journal corrupted: execution binding for node '${ev.id}' precedes materialization in tree '${root}'`, + ) + } + } // `spawned` (ordinal namespace), `waiting` (the wait-ordinal namespace — it CREATES a node, it // does not settle one), and `metered` (informational spend, no settlement order) live outside // the cursor-uniqueness namespace replay relies on. `woken` IS a settlement and does not. @@ -292,7 +479,13 @@ function assertSeqUnique(root: NodeId, events: SpawnEvent[], ev: SpawnEvent): vo * ordering rests on. The single predicate both the guard's sides read, so a new event kind is * classified once. */ function outsideCursorNamespace(ev: SpawnEvent): boolean { - return ev.kind === 'spawned' || ev.kind === 'waiting' || ev.kind === 'metered' + return ( + ev.kind === 'spawned' || + ev.kind === 'waiting' || + ev.kind === 'metered' || + ev.kind === 'materialized' || + ev.kind === 'execution-bound' + ) } // ── Replay executor (build step 7) ─────────────────────────────────────────────── @@ -319,24 +512,58 @@ export async function replaySpawnTree( } const ordered = [...events].sort((a, b) => a.seq - b.seq) const labels = new Map() + const assignmentIds = new Map() + const identities = new Map() + const materializations = new Map() + const executionBindings = new Map< + NodeId, + NonNullable[number][] + >() for (const ev of ordered) { if (ev.kind === 'spawned' || ev.kind === 'waiting') labels.set(ev.id, ev.label) + if (ev.kind === 'spawned' && ev.assignmentId !== undefined) { + assignmentIds.set(ev.id, ev.assignmentId) + } + if (ev.kind === 'spawned' && ev.identity !== undefined) { + identities.set(ev.id, copyFrozenIdentity(ev.identity)) + } + if (ev.kind === 'materialized') materializations.set(ev.id, ev.receipt) + if (ev.kind === 'execution-bound') { + const bindings = executionBindings.get(ev.id) ?? [] + bindings.push(ev.binding) + executionBindings.set(ev.id, bindings) + } + } + const handleFor = (id: NodeId, status: NodeStatus) => + replayHandle(id, labels.get(id) ?? id, status, { + assignmentId: assignmentIds.get(id), + identity: identities.get(id), + materialization: materializations.get(id), + executionBindings: executionBindings.get(id), + }) + const settlementTime = (at: string): { readonly settledAt?: number } => { + const settledAt = Date.parse(at) + return Number.isFinite(settledAt) ? { settledAt } : {} } const settled: Settled[] = [] for (const ev of ordered) { if (ev.kind === 'spawned') continue if (ev.kind === 'waiting') continue // arms a wait node; `woken` is its settlement if (ev.kind === 'metered') continue // a spend record, not a settlement — irrelevant to replay + if (ev.kind === 'materialized') continue // wire receipt, not a settlement + if (ev.kind === 'execution-bound') continue // attempt transport, not a settlement if (ev.kind === 'woken') { // A wait that was cancelled carries no outcome blob — it replays as a `down`, exactly as a // cancelled worker does. A fired/timed-out wait rehydrates its `WaitOutcome` and costs zero. if (ev.by === 'cancelled' || ev.outRef === undefined) { settled.push({ kind: 'down', - handle: replayHandle(ev.id, labels.get(ev.id) ?? ev.id, 'cancelled'), + handle: handleFor(ev.id, 'cancelled'), reason: 'wait cancelled', infra: false, restartCount: 0, + trace: { status: 'unavailable', reason: 'not-an-executor' }, + ...settlementTime(ev.at), seq: ev.seq, }) continue @@ -349,10 +576,12 @@ export async function replaySpawnTree( } settled.push({ kind: 'done', - handle: replayHandle(ev.id, labels.get(ev.id) ?? ev.id, 'done'), + handle: handleFor(ev.id, 'done'), out: outcome, outRef: ev.outRef, spent: zeroSpend(), + trace: { status: 'unavailable', reason: 'not-an-executor' }, + ...settlementTime(ev.at), seq: ev.seq, }) continue @@ -360,21 +589,29 @@ export async function replaySpawnTree( if (ev.kind === 'cancelled') { settled.push({ kind: 'down', - handle: replayHandle(ev.id, labels.get(ev.id) ?? ev.id, 'cancelled'), + handle: handleFor(ev.id, 'cancelled'), reason: ev.reason, infra: false, restartCount: 0, + trace: { status: 'unavailable', reason: 'execution-did-not-start' }, + ...settlementTime(ev.at), seq: ev.seq, }) continue } if (ev.status === 'down') { + const trace = traceEvidenceFor(ev) + if (trace.status === 'available') await workerTraceAnalysisStore(trace, blobs) settled.push({ kind: 'down', - handle: replayHandle(ev.id, labels.get(ev.id) ?? ev.id, 'failed'), - reason: ev.verdict?.notes ?? 'child down', + handle: handleFor(ev.id, 'failed'), + // `reason` is written by every current scope. The verdict fallback preserves the + // pre-field convention and the generic text keeps still-older reasonless journals usable. + reason: ev.reason ?? ev.verdict?.notes ?? 'child down', infra: ev.infra === true, restartCount: 0, + trace, + ...settlementTime(ev.at), seq: ev.seq, }) continue @@ -391,28 +628,66 @@ export async function replaySpawnTree( `replaySpawnTree: blob store has no artifact for outRef '${ev.outRef}' (node '${ev.id}', seq ${ev.seq})`, ) } + const trace = traceEvidenceFor(ev) + if (trace.status === 'available') await workerTraceAnalysisStore(trace, blobs) settled.push({ kind: 'done', - handle: replayHandle(ev.id, labels.get(ev.id) ?? ev.id, 'done'), + handle: handleFor(ev.id, 'done'), out, outRef: ev.outRef, verdict: ev.verdict, spent: ev.spent, + trace, + ...settlementTime(ev.at), seq: ev.seq, }) } return settled } -function replayHandle(id: NodeId, label: string, status: NodeStatus) { - return { +function replayHandle( + id: NodeId, + label: string, + status: NodeStatus, + evidence: { + readonly assignmentId?: string + readonly identity?: NodeExecutionIdentity + readonly materialization?: NodeSnapshot['materialization'] + readonly executionBindings?: ReadonlyArray< + NonNullable[number] + > + } = {}, +) { + return Object.freeze({ id, label, status, + ...(evidence.assignmentId === undefined ? {} : { assignmentId: evidence.assignmentId }), + ...(evidence.identity === undefined ? {} : { identity: evidence.identity }), + ...(evidence.materialization === undefined + ? {} + : { materialization: evidence.materialization }), + ...(evidence.executionBindings === undefined + ? {} + : { executionBindings: Object.freeze([...evidence.executionBindings]) }), abort() { throw new Error(`cannot abort node '${id}': replayed handles are terminal, not live`) }, - } + }) +} + +/** A replayed handle must not retain mutable references into the loaded journal event. */ +function copyFrozenIdentity(identity: NodeExecutionIdentity): NodeExecutionIdentity { + const correlation = + identity.correlation === undefined ? undefined : Object.freeze({ ...identity.correlation }) + return Object.freeze({ + ...(identity.profileDigest === undefined ? {} : { profileDigest: identity.profileDigest }), + ...(identity.taskDigest === undefined ? {} : { taskDigest: identity.taskDigest }), + ...(identity.candidateDigest === undefined + ? {} + : { candidateDigest: identity.candidateDigest }), + ...(correlation === undefined ? {} : { correlation }), + }) } /** @@ -435,7 +710,14 @@ export function materializeTreeView(events: SpawnEvent[]): TreeView { ) .sort((a, b) => a.seq - b.seq) const settlements = events - .filter((ev) => ev.kind !== 'spawned' && ev.kind !== 'waiting' && ev.kind !== 'metered') + .filter( + (ev) => + ev.kind !== 'spawned' && + ev.kind !== 'waiting' && + ev.kind !== 'metered' && + ev.kind !== 'materialized' && + ev.kind !== 'execution-bound', + ) .sort((a, b) => a.seq - b.seq) for (const ev of spawns) { if (ev.kind === 'waiting') { @@ -460,6 +742,9 @@ export function materializeTreeView(events: SpawnEvent[]): TreeView { status: 'pending', runtime: ev.runtime, budget: ev.budget, + ...(ev.ownedTreeRoot === undefined ? {} : { ownedTreeRoot: ev.ownedTreeRoot }), + ...(ev.assignmentId === undefined ? {} : { assignmentId: ev.assignmentId }), + ...(ev.identity ? { identity: ev.identity } : {}), spent: zeroSpend(), }) } @@ -469,15 +754,38 @@ export function materializeTreeView(events: SpawnEvent[]): TreeView { node.status = ev.status === 'done' ? 'done' : 'failed' node.spent = ev.spent node.outRef = ev.outRef + node.trace = traceEvidenceFor(ev) + const settledAt = Date.parse(ev.at) + if (Number.isFinite(settledAt)) node.settledAt = settledAt } else if (ev.kind === 'woken') { const node = requireNode(nodes, ev.id) node.status = ev.by === 'cancelled' ? 'cancelled' : 'done' node.outRef = ev.outRef + node.trace = { status: 'unavailable', reason: 'not-an-executor' } + const settledAt = Date.parse(ev.at) + if (Number.isFinite(settledAt)) node.settledAt = settledAt } else { const node = requireNode(nodes, ev.id) node.status = 'cancelled' + node.trace = { status: 'unavailable', reason: 'execution-did-not-start' } + const settledAt = Date.parse(ev.at) + if (Number.isFinite(settledAt)) node.settledAt = settledAt } } + // Materialization is node evidence, not a settlement. Fold it after node creation and before + // freezing the view; exactly one receipt per node is enforced by the journal corruption guard. + for (const ev of events) { + if (ev.kind !== 'materialized') continue + const node = requireNode(nodes, ev.id) + node.materialization = ev.receipt + node.runtime = ev.receipt.runtime + } + for (const ev of events) { + if (ev.kind !== 'execution-bound') continue + const node = requireNode(nodes, ev.id) + node.executionBindings ??= [] + node.executionBindings.push(ev.binding) + } // Driver inference: a separate pass so it accumulates ONTO the settled child-work base (no // dependence on metered-vs-settled seq order) without touching node status. for (const ev of events) { @@ -523,8 +831,15 @@ interface MutableSnapshot { status: NodeStatus runtime: Runtime budget: NodeSnapshot['budget'] + ownedTreeRoot?: NodeSnapshot['ownedTreeRoot'] + assignmentId?: string + identity?: NodeSnapshot['identity'] + materialization?: NodeSnapshot['materialization'] + executionBindings?: NonNullable[number][] spent: Spend outRef?: string + trace?: NodeSnapshot['trace'] + settledAt?: number } function zeroSpend(): Spend { @@ -536,6 +851,7 @@ function addJournalSpend(a: Spend, b: Spend): Spend { return { iterations: a.iterations + b.iterations, tokens: { input: a.tokens.input + b.tokens.input, output: a.tokens.output + b.tokens.output }, + ...(a.tokensKnown === false || b.tokensKnown === false ? { tokensKnown: false } : {}), usd: a.usd + b.usd, ...(a.tokensKnown === false || b.tokensKnown === false ? { tokensKnown: false } : {}), ...(a.usdKnown === false || b.usdKnown === false ? { usdKnown: false } : {}), @@ -559,11 +875,30 @@ function freezeSnapshot(node: MutableSnapshot): NodeSnapshot { status: node.status, runtime: node.runtime, budget: node.budget, + ownedTreeRoot: node.ownedTreeRoot, + assignmentId: node.assignmentId, + identity: node.identity, + materialization: node.materialization, + executionBindings: + node.executionBindings === undefined ? undefined : Object.freeze([...node.executionBindings]), spent: node.spent, outRef: node.outRef, + trace: node.trace, + settledAt: node.settledAt, } } +function traceEvidenceFor( + event: Extract, +): NonNullable { + return ( + event.trace ?? { + status: 'unavailable', + reason: 'legacy-settlement-without-trace-evidence', + } + ) +} + function isNoEntError(err: unknown): boolean { return ( typeof err === 'object' && diff --git a/src/index.ts b/src/index.ts index 99ccd202..0afa2dec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -217,6 +217,8 @@ export type { SupervisedResult, Supervisor, SupervisorFinalizer, + WorkerTraceEvidence, + WorkerTraceUnavailableReason, } from './runtime' // ── Runtime hooks ──────────────────────────────────────────────────── export type { diff --git a/src/knowledge/supervised-update.ts b/src/knowledge/supervised-update.ts index b1df286b..46f8fd2e 100644 --- a/src/knowledge/supervised-update.ts +++ b/src/knowledge/supervised-update.ts @@ -137,8 +137,8 @@ export async function runSupervisedKnowledgeUpdate( const profile: SupervisorProfile = { name: 'knowledge-research-supervisor', - model: options.supervisorModel, - systemPrompt, + ...(options.supervisorModel ? { model: { default: options.supervisorModel } } : {}), + prompt: { systemPrompt }, } const run = options.runSupervised ?? supervise const task = formatSupervisedKnowledgeTask(options) diff --git a/src/mcp/index.ts b/src/mcp/index.ts index e5acd203..fc22fc9c 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -150,11 +150,17 @@ export { export { type AnalystFindingEvent, type AnalystRegistry, + type AuthorizeDownMessage, + type AuthorizedDownMessage, + type ContinuationInstruction, type CoordinationEvent, type CoordinationTools, type CoordinationToolsOptions, createCoordinationTools, DEFAULT_AWAIT_EVENT_TIMEOUT_MS, + type DownMessageAuthorizationInput, + type DownMessageDeliveryAttempt, + type DownMessageDeliveryOutcome, type DownMessageEvent, type MakeWorkerAgent, type Question, @@ -165,6 +171,7 @@ export { type QuestionRecord, type QuestionUrgency, type SettledWorker, + type WorkerSpawnContext, type WorkerWatchOptions, } from './tools/coordination' export { diff --git a/src/mcp/tools/coordination.ts b/src/mcp/tools/coordination.ts index 3f0774bc..2dee1c0d 100644 --- a/src/mcp/tools/coordination.ts +++ b/src/mcp/tools/coordination.ts @@ -8,32 +8,59 @@ * @experimental */ -import { agentProfileSchema } from '@tangle-network/agent-interface' +import { randomUUID } from 'node:crypto' +import type { TraceAnalysisStore } from '@tangle-network/agent-eval' +import { + type AgentProfile, + agentProfileSchema, + canonicalCandidateDigest, +} from '@tangle-network/agent-interface' import type { + AgentExecutionRef, Budget, + ExecutionBindingReceipt, + NodeExecutionIdentity, + ProfileMaterializationReceipt, ResultBlobStore, Scope, Settled, + Spend, Agent as SuperviseAgent, + WorkerTraceEvidence, } from '../../runtime' +import { assertValidBudget } from '../../runtime/supervise/budget' import type { DeliverableSpec } from '../../runtime/supervise/completion-gate' import { type WatchTraceOptions, watchTrace } from '../../runtime/supervise/detector-monitor' import { freeSlots } from '../../runtime/supervise/dispatch' import { type BusRecord, type BusStats, createEventBus } from '../../runtime/supervise/event-bus' import type { WorkerProgress } from '../../runtime/supervise/progress' +import { workerTraceAnalysisStore } from '../../runtime/supervise/trace-evidence' import type { McpToolDescriptor } from '../server' /** A worker the driver has drained via `await_event`. */ export interface SettledWorker { readonly id: string readonly status: 'done' | 'down' + /** Stable manager-scoped assignment, including deterministic unkeyed siblings. */ + readonly assignmentId?: string + /** Exact profile/task/candidate identity authorized for this node. */ + readonly identity?: NodeExecutionIdentity + /** Stable effective execution plan, or an explicit unknown receipt. */ + readonly materialization?: ProfileMaterializationReceipt + /** Backend bindings for each attempt, in durable oldest-first order. */ + readonly executionBindings?: ReadonlyArray + /** Conserved spend. Missing means unavailable; unknown accounting remains explicitly unknown. */ + readonly spent?: Spend readonly score?: number readonly valid?: boolean readonly outRef?: string readonly reason?: string - /** Epoch ms the ledger recorded this settlement — the resolution a progress-based stop rule - * needs to answer "how long since anything landed?" without inventing a timestamp at read - * time. Stamped when the cursor yields the settlement, not when a reader first looks. */ + /** Structured tool-call evidence, never the worker's final prose. */ + readonly trace: WorkerTraceEvidence + /** True when projected from a prior process of the same durable run. */ + readonly resumed?: boolean + /** Epoch ms from the durable terminal record — the resolution a progress-based stop rule needs + * to answer "how long since anything landed?" without inventing a timestamp at read time. */ readonly settledAt?: number } @@ -71,7 +98,7 @@ export type QuestionPolicy = 'auto' | 'mustDecide' | 'bubble' | 'failClosed' export interface AnalystRegistry { readonly kinds: ReadonlyArray<{ id: string; description: string; area: string }> - readonly run: (kindId: string, trace: unknown) => Promise + readonly run: (kindId: string, trace: TraceAnalysisStore) => Promise } /** A trace-analyst result re-entered as a message on the bus (the `finding` event kind). */ @@ -81,25 +108,110 @@ export interface AnalystFindingEvent { readonly findings: unknown } -/** A parent→child message (the down-leg): recorded for observability, delivered via the child inbox, - * never pulled back by the parent. `delivered` mirrors whether the live child accepted it. */ +/** The exact result of one parent→child delivery attempt. */ +export type DownMessageDeliveryOutcome = + | 'delivered' + | 'unknown-worker' + | 'already-settled' + | 'runtime-has-no-inbox' + | 'scope-stopped' + | 'runtime-error' + +/** A durable marker written after authorization and immediately before Runtime calls `Scope.send`. + * If a process dies with this marker but no matching outcome, delivery is unknown and is never + * replayed automatically. */ +export interface DownMessageDeliveryAttempt { + readonly receiptId: string + readonly kind: 'steer' | 'answer' + readonly toWorker: string + readonly instructionDigest: string + readonly interrupt: boolean + readonly questionId?: string +} + +/** A parent→child delivery result (the down-leg): recorded for observability, never pulled back by + * the parent. `receiptId` and `instructionDigest` link it to the pre-delivery authorization receipt + * and attempt marker. */ export interface DownMessageEvent { + readonly receiptId: string readonly toWorker: string readonly instruction: string + readonly instructionDigest: string readonly delivered: boolean + readonly outcome: DownMessageDeliveryOutcome + readonly error?: string +} + +/** Durable authorization receipt written before a continuation reaches a worker. */ +export interface ContinuationInstruction { + readonly receiptId: string + readonly kind: 'steer' | 'answer' + readonly toWorker: string + readonly instruction: string + readonly instructionDigest: string + readonly workerIdentity?: NodeExecutionIdentity + readonly interrupt: boolean + readonly questionId?: string } +/** Detached continuation bytes and exact worker identity presented to product authorization before + * Runtime records or delivers a steer/answer. */ +export interface DownMessageAuthorizationInput { + readonly kind: 'steer' | 'answer' + readonly workerId: string + readonly workerIdentity: NodeExecutionIdentity + readonly instruction: string + readonly interrupt: boolean + readonly questionId?: string +} + +/** Product-authorized continuation bytes. Returning a narrowed instruction replaces the proposed + * bytes; throwing refuses delivery. */ +export interface AuthorizedDownMessage { + readonly instruction: string +} + +/** Product decision over an exact continuation before it is durably recorded or delivered. */ +export type AuthorizeDownMessage = (input: DownMessageAuthorizationInput) => AuthorizedDownMessage + /** Every message on the one typed pipe. UP (child→parent): question / settled / finding — queued for - * the driver to `pull`. DOWN (parent→child): steer / answer — record-only (history + subscribers), - * routed to the child inbox. New kinds are additive. */ + * the driver to `pull`. An `instruction` is the pre-delivery authorization receipt and is retained + * as evidence. DOWN (parent→child): steer / answer — record-only (history + subscribers), routed + * to the child inbox. Receipts are never auto-delivered on restart. New kinds are additive. */ export type CoordinationEvent = | { readonly type: 'question'; readonly question: QuestionRecord } | { readonly type: 'settled'; readonly worker: SettledWorker } | { readonly type: 'finding'; readonly finding: AnalystFindingEvent } | { readonly type: 'steer'; readonly down: DownMessageEvent } | { readonly type: 'answer'; readonly down: DownMessageEvent; readonly questionId: string } + | { readonly type: 'instruction'; readonly instruction: ContinuationInstruction } + | { readonly type: 'delivery-attempt'; readonly attempt: DownMessageDeliveryAttempt } -export type MakeWorkerAgent = (profile: unknown) => SuperviseAgent +/** Immutable task, allocation, identity attribution, and semantic key supplied while a manager's + * complete worker profile is prepared for one spawn. */ +export interface WorkerSpawnContext { + /** Stable assignment identity within this manager. A semantic key wins; otherwise Runtime mints + * the manager's deterministic pre-factory spawn ordinal so identical unkeyed siblings stay + * isolated and can recover by issuing the same assignments in the same order. */ + readonly assignmentId: string + /** Trusted concrete manager node authorizing this spawn. Never accepted from model arguments. */ + readonly parentNodeId: string + /** The exact allocation this node receives after the tool's optional override is merged. */ + readonly budget: Budget + /** Detached, deeply immutable task bytes from this spawn request. */ + readonly task: unknown + /** Exact trace label selected for this spawn. */ + readonly label: string + /** Semantic restart key, when the manager supplied one. */ + readonly key?: string + /** Trusted candidate/campaign attribution attached by product authorization. */ + readonly execution?: AgentExecutionRef +} + +export type MakeWorkerAgent = ( + profile: AgentProfile, + context?: WorkerSpawnContext, +) => SuperviseAgent export interface CoordinationToolsOptions { readonly scope: Scope @@ -113,7 +225,17 @@ export interface CoordinationToolsOptions { */ readonly deliverable?: DeliverableSpec readonly analysts?: AnalystRegistry - readonly onEvent?: (event: CoordinationEvent) => void | Promise + /** Event-first for source compatibility; the second argument is its exact bus ordering stamp. */ + readonly onEvent?: ( + event: CoordinationEvent, + record: BusRecord, + ) => void | Promise + /** Re-publish resumed settlements through the awaited observer before the driver starts. This is + * the crash-window recovery path for product transactions; off preserves low-level legacy reads. */ + readonly replaySettlements?: boolean + /** Authorize each continuation against the exact worker identity. The returned instruction is + * detached, recorded durably through `onEvent`, and only then delivered. */ + readonly authorizeDownMessage?: AuthorizeDownMessage readonly questionPolicy?: QuestionPolicy /** Analyst kind ids to run AUTOMATICALLY when a worker settles `done` (the analyst-on-settle * hook). Each result is published as a `finding` event on the bus — pass-through to subscribers @@ -124,7 +246,8 @@ export interface CoordinationToolsOptions { * counts the scope's non-terminal nodes and fails closed (`error: 'max-live-workers'`) BEFORE * reserving from the pool when the cap is already met — a concurrency fence on top of the * conserved-budget fence (the pool bounds total work; this bounds simultaneous work, e.g. live - * sandboxes/boxes). Omit or `<= 0` = no cap (the prior behavior; the pool stays the only fence). */ + * sandboxes/boxes). A tree-wide limit owned by `Scope` takes precedence when present; this field + * is the local form for a caller-owned scope. Omit or `<= 0` = no local cap. */ readonly maxLiveWorkers?: number /** Max wall-clock ms a single `await_event` call may block waiting on a live worker to settle * before it returns a non-error `{ pending: true, live }` snapshot and lets the caller re-poll. @@ -187,15 +310,17 @@ export const DEFAULT_AWAIT_EVENT_TIMEOUT_MS = 15_000 */ export interface CoordinationTools { readonly tools: McpToolDescriptor[] + /** Commit any resume-time event replay before a supervisor can reason or an MCP can listen. */ + ready(): Promise isStopped(): boolean stopReason(): string | undefined /** The first result whose injected independent check passed, if the driver submitted one. */ submittedResult(): { readonly result: unknown } | undefined settled(): ReadonlyArray questions(): ReadonlyArray - /** The full ordered log of every bus event — UP (settled / question / finding) and DOWN - * (steer / answer) — the observability audit + replay trail. Each record carries seq, - * timestamp, and priority. */ + /** The full ordered log of every bus event — UP (settled / question / finding), authorized + * instruction receipts, and DOWN delivery outcomes (steer / answer). Each record carries seq, + * timestamp, and priority. A receipt is evidence and is never auto-delivered on restart. */ history(): ReadonlyArray> /** Bus throughput counters (published / pulled / by-kind) for live dashboards. */ stats(): BusStats @@ -449,15 +574,6 @@ function spawnProfileArg(): Record { return spawnProfileArgCache } -/** Freeze a JSON-Schema tree. The derived schema is plain JSON data with no cycles. */ -function deepFreeze(value: T): T { - if (value && typeof value === 'object' && !Object.isFrozen(value)) { - Object.freeze(value) - for (const nested of Object.values(value)) deepFreeze(nested) - } - return value -} - /** Build the driver's MCP tools over a live scope. */ export function createCoordinationTools(opts: CoordinationToolsOptions): CoordinationTools { const deliverable = opts.deliverable @@ -475,35 +591,102 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin // slot, so the fence must not hold it back. `keyByWorker` is what lets a settlement find its key. const completedKeys = new Set() const keyByWorker = new Map() + // `Scope` advances its node/cursor ordinals on resume, but assignment identity belongs to this + // manager. Seed it independently from durable evidence so a restarted manager never calls new + // work `ordinal:0` when a prior process already used that assignment. Use the maximum rather + // than the number of rows: journals can contain gaps, keyed assignments, and legacy/custom ids. + let unkeyedAssignmentOrdinal = nextUnkeyedAssignmentOrdinal(opts.scope) for (const [key, prior] of opts.scope.resume?.keys ?? []) { if (prior.state === 'completed') completedKeys.add(key) } - // A resumed scope's replayed settlements enter the ledger AT CONSTRUCTION, so `settled()` — and - // therefore the finalize that reads it — spans processes exactly as the journal does. They are - // NOT re-published on the bus: the driver's resume brief already lists them, and the scope - // cursor only yields THIS process's children, so nothing double-counts. - for (const s of opts.scope.resume?.settled ?? []) { - ledger.push( - s.kind === 'done' + const nodeForWorker = (id: string) => + opts.scope.view.nodes.find((node) => node.id === id) ?? + opts.scope.resume?.view.nodes.find((node) => node.id === id) + + const projectSettled = (settled: Settled, resumed = false): SettledWorker => { + const node = nodeForWorker(settled.handle.id) + const assignmentId = settled.handle.assignmentId ?? node?.assignmentId + const identity = settled.handle.identity ?? node?.identity + const materialization = settled.handle.materialization ?? node?.materialization + const executionBindings = settled.handle.executionBindings ?? node?.executionBindings + const settledAt = settled.settledAt ?? node?.settledAt + const trace = + settled.trace ?? + node?.trace ?? + ({ + status: 'unavailable', + reason: 'legacy-settlement-without-trace-evidence', + } as const) + const common = { + id: settled.handle.id, + ...(assignmentId === undefined ? {} : { assignmentId }), + ...(identity === undefined ? {} : { identity }), + ...(materialization === undefined ? {} : { materialization }), + ...(executionBindings === undefined ? {} : { executionBindings }), + ...(settledAt === undefined ? {} : { settledAt }), + trace, + ...(resumed ? { resumed: true as const } : {}), + } + return deepFreezeDetached( + settled.kind === 'done' ? { - id: s.handle.id, + ...common, status: 'done', - score: s.verdict?.score ?? 0, - valid: s.verdict?.valid ?? false, - outRef: s.outRef, + spent: settled.spent, + ...(settled.verdict?.score === undefined ? {} : { score: settled.verdict.score }), + ...(settled.verdict?.valid === undefined ? {} : { valid: settled.verdict.valid }), + outRef: settled.outRef, } - : { id: s.handle.id, status: 'down', reason: s.reason }, + : { + ...common, + status: 'down', + ...(node?.spent === undefined ? {} : { spent: node.spent }), + reason: settled.reason, + }, ) } - // The one child→parent pipe. `onEvent` (back-compat) becomes a pass-through subscriber receiving - // the bare event, so every kind — question, settled, finding — reaches it immediately, and the - // driver pulls queued findings / questions via `await_event`. + // A resumed scope's replayed settlements enter the ledger AT CONSTRUCTION, so `settled()` — and + // therefore the finalize that reads it — spans processes exactly as the journal does. + const resumedWorkers: SettledWorker[] = [] + for (const s of opts.scope.resume?.settled ?? []) { + const worker = projectSettled(s, true) + resumedWorkers.push(worker) + ledger.push(worker) + } + + // The one child→parent pipe. Keep the event-first callback and also pass its exact stamp, so a + // durable subscriber retains causal sequence, original timestamp, and priority. const bus = createEventBus() if (opts.onEvent) { const cb = opts.onEvent - bus.subscribe((rec) => cb(rec.event)) + bus.subscribe((rec) => cb(rec.event, rec)) + } + // A settlement can be durable in the spawn journal while the process dies before the product + // observer acknowledges it. Opted-in high-level callers replay those events at least once. Keep + // each frozen event object across an in-process retry so EventBus reuses its exact BusRecord. + const resumeEvents = opts.replaySettlements + ? resumedWorkers.map((worker) => + deepFreezeDetached({ type: 'settled', worker }), + ) + : [] + let resumeEventIndex = 0 + let readyInFlight: Promise | undefined + const ready = (): Promise => { + if (resumeEventIndex >= resumeEvents.length) return Promise.resolve() + if (readyInFlight) return readyInFlight + readyInFlight = (async () => { + while (resumeEventIndex < resumeEvents.length) { + const event = resumeEvents[resumeEventIndex] + if (!event) break + await bus.publish(event) + resumeEventIndex += 1 + } + })().finally(() => { + readyInFlight = undefined + }) + return readyInFlight } // Urgency → bus priority: a blocking question is bumped ahead of queued settles/findings so the @@ -540,7 +723,7 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin const maxTokens = field('maxTokens') const maxUsd = field('maxUsd') const deadlineMs = field('deadlineMs') - return { + const merged: Budget = { maxIterations: maxIterations ?? base.maxIterations, maxTokens: maxTokens ?? base.maxTokens, ...((maxUsd ?? base.maxUsd) === undefined ? {} : { maxUsd: maxUsd ?? base.maxUsd }), @@ -548,6 +731,8 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin ? {} : { deadlineMs: deadlineMs ?? base.deadlineMs }), } + assertValidBudget(merged, 'coordination tools: budget') + return merged } const level = (v: unknown): Question['level'] => { if (v === 'worker' || v === 'driver' || v === 'loop') return v @@ -560,28 +745,52 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin ) } - const recordSettled = (s: Settled): SettledWorker => { - const settledAt = Date.now() + const commitSettled = (s: Settled, w: SettledWorker): void => { // A keyed assignment that just delivered is complete for the rest of this run, so a later // spawn under the same key resolves for free instead of being held behind the live-worker // fence (it starts no worker, so it occupies no slot). const settledKey = keyByWorker.get(s.handle.id) if (settledKey !== undefined && s.kind === 'done') completedKeys.add(settledKey) - const w: SettledWorker = - s.kind === 'done' - ? { - id: s.handle.id, - status: 'done', - score: s.verdict?.score ?? 0, - valid: s.verdict?.valid ?? false, - outRef: s.outRef, - settledAt, - } - : { id: s.handle.id, status: 'down', reason: s.reason, settledAt } ledger.push(w) // A settled worker's trace source is finished; drop the online subscription with it. unwatchWorker(w.id) - return w + } + + // `Scope.next()` is once-only, while an awaited observer may commit and lose its acknowledgement. + // Retain the exact event until publication succeeds so the next await retries rather than losing + // the settlement between the spawn journal and the product transaction. + let pendingSettlement: + | { + readonly settled: Settled + readonly worker: SettledWorker + readonly event: CoordinationEvent + readonly analyze: boolean + } + | undefined + + const flushPendingSettlement = async (): Promise => { + const pending = pendingSettlement + if (!pending) return false + await bus.publish(pending.event) + commitSettled(pending.settled, pending.worker) + pendingSettlement = undefined + if ( + pending.analyze && + pending.worker.status === 'done' && + pending.worker.trace.status === 'available' && + opts.analysts && + opts.analyzeOnSettle?.length + ) { + const trace = await workerTraceAnalysisStore(pending.worker.trace, opts.blobs) + for (const analyst of opts.analyzeOnSettle) { + const findings = await opts.analysts.run(analyst, trace) + await bus.publish({ + type: 'finding', + finding: { fromWorker: pending.worker.id, analyst, findings }, + }) + } + } + return true } // Producer: drain exactly one settlement from the scope cursor onto the bus (a `settled` event), @@ -589,18 +798,18 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin // publish its result as a `finding`. Returns false when the cursor is idle (no live workers). The // cursor is a once-per-child source, so a settlement is produced at most once. const drainSettlement = async (): Promise => { - const s = await opts.scope.next() - if (!s) return false - const w = recordSettled(s) - await bus.publish({ type: 'settled', worker: w }) - if (w.status === 'done' && w.outRef && opts.analysts && opts.analyzeOnSettle?.length) { - const trace = await opts.blobs.get(w.outRef) - for (const analyst of opts.analyzeOnSettle) { - const findings = await opts.analysts.run(analyst, trace) - await bus.publish({ type: 'finding', finding: { fromWorker: w.id, analyst, findings } }) + if (!pendingSettlement) { + const settled = await opts.scope.next() + if (!settled) return false + const worker = projectSettled(settled) + pendingSettlement = { + settled, + worker, + event: deepFreezeDetached({ type: 'settled', worker }), + analyze: true, } } - return true + return flushPendingSettlement() } // Post-loop drain: every ALREADY-settled, unpulled child enters the ledger + audit trail. No @@ -609,10 +818,18 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin const drainResolved = async (): Promise => { let drained = 0 for (;;) { - const s = await opts.scope.nextResolved() - if (!s) return drained - const w = recordSettled(s) - await bus.publish({ type: 'settled', worker: w }) + if (!pendingSettlement) { + const settled = await opts.scope.nextResolved() + if (!settled) return drained + const worker = projectSettled(settled) + pendingSettlement = { + settled, + worker, + event: deepFreezeDetached({ type: 'settled', worker }), + analyze: false, + } + } + await flushPendingSettlement() drained += 1 } } @@ -635,30 +852,143 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin ) } + const authorizeInstruction = ( + kind: 'steer' | 'answer', + workerId: string, + instruction: string, + interrupt: boolean, + questionId?: string, + ): ContinuationInstruction => { + const workerIdentity = opts.scope.view.nodes.find((node) => node.id === workerId)?.identity + let authorizedInstruction = instruction + if (opts.authorizeDownMessage) { + if (workerIdentity === undefined) { + throw new Error( + `coordination tools: cannot authorize ${kind} for worker ${JSON.stringify(workerId)} without durable identity`, + ) + } + const decision = deepFreezeDetached( + opts.authorizeDownMessage( + deepFreezeDetached({ + kind, + workerId, + workerIdentity, + instruction, + interrupt, + ...(questionId !== undefined ? { questionId } : {}), + }), + ), + ) + if ( + typeof decision !== 'object' || + decision === null || + Array.isArray(decision) || + typeof decision.instruction !== 'string' || + decision.instruction.length === 0 + ) { + throw new Error('coordination tools: authorizeDownMessage must return an instruction') + } + authorizedInstruction = decision.instruction + } + return deepFreezeDetached({ + receiptId: randomUUID(), + kind, + toWorker: workerId, + instruction: authorizedInstruction, + instructionDigest: canonicalCandidateDigest(authorizedInstruction), + ...(workerIdentity !== undefined ? { workerIdentity } : {}), + interrupt, + ...(questionId !== undefined ? { questionId } : {}), + }) + } + + /** Publish before `scope.send`: an awaited durable subscriber therefore commits the exact bytes + * before the worker can observe them. */ + const recordInstruction = async (instruction: ContinuationInstruction): Promise => { + await bus.publish({ type: 'instruction', instruction }, { queue: false }) + } + + /** Commit delivery intent after the authorization receipt and before `Scope.send`. An attempt with + * no matching outcome after a crash is explicitly unknown and must never be replayed. */ + const recordDeliveryAttempt = async ( + instruction: ContinuationInstruction, + ): Promise => { + const attempt = deepFreezeDetached({ + receiptId: instruction.receiptId, + kind: instruction.kind, + toWorker: instruction.toWorker, + instructionDigest: instruction.instructionDigest, + interrupt: instruction.interrupt, + ...(instruction.questionId !== undefined ? { questionId: instruction.questionId } : {}), + }) + await bus.publish({ type: 'delivery-attempt', attempt }, { queue: false }) + return attempt + } + + const deliveryOutcome = (workerId: string, delivered: boolean): DownMessageDeliveryOutcome => { + if (delivered) return 'delivered' + if (opts.scope.signal.aborted) return 'scope-stopped' + const node = opts.scope.view.nodes.find((candidate) => candidate.id === workerId) + if (!node) return 'unknown-worker' + if (!isLive(node.status)) return 'already-settled' + return 'runtime-has-no-inbox' + } + + const attemptDelivery = async ( + instruction: ContinuationInstruction, + message: unknown, + ): Promise => { + await recordDeliveryAttempt(instruction) + let delivered = false + let outcome: DownMessageDeliveryOutcome + let error: string | undefined + try { + delivered = opts.scope.send(instruction.toWorker, message) + outcome = deliveryOutcome(instruction.toWorker, delivered) + } catch (cause) { + outcome = 'runtime-error' + error = cause instanceof Error ? cause.message : String(cause) + } + const down = deepFreezeDetached({ + receiptId: instruction.receiptId, + toWorker: instruction.toWorker, + instruction: instruction.instruction, + instructionDigest: instruction.instructionDigest, + delivered, + outcome, + ...(error !== undefined ? { error } : {}), + }) + if (instruction.kind === 'answer') { + await sendDown('answer', down, str(instruction.questionId, 'questionId')) + } else { + await sendDown('steer', down) + } + if (error !== undefined) throw new Error(`coordination tools: delivery failed: ${error}`) + return down + } + // Consumer projection: the wire shape the driver sees for a pulled bus event. const projectEvent = (ev: CoordinationEvent): Record => { if (ev.type === 'settled') { - const w = ev.worker - return w.status === 'done' - ? { - type: 'settled', - settled: w.id, - status: 'done', - score: w.score, - valid: w.valid, - outRef: w.outRef, - } - : { type: 'settled', settled: w.id, status: 'down', reason: w.reason } + const { id, status, ...evidence } = ev.worker + return { type: 'settled', settled: id, status, ...evidence } } if (ev.type === 'question') return { type: 'question', question: ev.question } if (ev.type === 'finding') return { type: 'finding', ...ev.finding } if (ev.type === 'answer') return { type: 'answer', ...ev.down, questionId: ev.questionId } + if (ev.type === 'instruction') return { type: 'instruction', ...ev.instruction } + if (ev.type === 'delivery-attempt') return { type: 'delivery-attempt', ...ev.attempt } // Down-leg `steer` is record-only (never queued), so the driver never pulls it; project // defensively for completeness. return { type: ev.type, ...ev.down } } - const nextQuestionId = (from: string): string => `${from}:q${questionSeq++}` + const nextQuestionId = (from: string): string => { + for (;;) { + const id = `${from}:q${questionSeq++}` + if (!questions.some((question) => question.id === id)) return id + } + } const normalizeQuestion = (q: QuestionInput, fallbackFrom: string): Question => { const from = str(q.from ?? fallbackFrom, 'from') return { @@ -736,21 +1066,49 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin }) } - // Count workers that are LIVE — spawned but not yet settled — off the scope's in-memory live set - // (O(live), synchronous). The terminal statuses are done/failed/cancelled; everything else - // (pending/acquiring/running) is still in flight. This is the concurrency fence's input. + // A supervised tree exposes one shared capacity reading; a caller-owned legacy scope falls back + // to this toolbox's direct-child count. The shared reading is what prevents each nested manager + // from multiplying the same cap independently. const maxLiveWorkers = opts.maxLiveWorkers const isLive = (status: string): boolean => status !== 'done' && status !== 'failed' && status !== 'cancelled' - const liveWorkerCount = (): number => opts.scope.view.nodes.filter((n) => isLive(n.status)).length + const localLiveWorkerCount = (): number => + opts.scope.view.nodes.filter((n) => isLive(n.status)).length + const sharedWorkerCapacity = (): Scope['workerCapacity'] | undefined => { + const scope = opts.scope as Partial> + return scope.workerCapacity + } + const usesTreeWideLimit = (): boolean => { + const capacity = sharedWorkerCapacity() + return capacity !== undefined && capacity.freeSlots !== null + } + const liveWorkerCount = (): number => + usesTreeWideLimit() + ? (sharedWorkerCapacity()?.live ?? localLiveWorkerCount()) + : localLiveWorkerCount() // A snapshot of every still-in-flight worker — the liveness signal a bounded `await_event` // returns when its wait elapses, so the supervisor can tell "worker still running, keep waiting" // apart from "nothing is happening" (the distinction it lost when the unbounded await erred out). - const liveSnapshot = (): Array<{ id: string; status: string; spent: unknown }> => - opts.scope.view.nodes - .filter((n) => isLive(n.status)) - .map((n) => ({ id: n.id, status: n.status, spent: n.spent })) + const projectNodeEvidence = ( + node: Scope['view']['nodes'][number], + resumed = false, + ): Record => ({ + id: node.id, + status: node.status, + ...(node.assignmentId === undefined ? {} : { assignmentId: node.assignmentId }), + ...(node.identity === undefined ? {} : { identity: node.identity }), + ...(node.materialization === undefined ? {} : { materialization: node.materialization }), + ...(node.executionBindings === undefined ? {} : { executionBindings: node.executionBindings }), + spent: node.spent, + ...(node.settledAt === undefined ? {} : { settledAt: node.settledAt }), + ...(node.outRef === undefined ? {} : { outRef: node.outRef }), + ...(node.trace === undefined ? {} : { trace: node.trace }), + ...(resumed ? { resumed: true } : {}), + }) + + const liveSnapshot = (): Array> => + opts.scope.view.nodes.filter((n) => isLive(n.status)).map((n) => projectNodeEvidence(n)) // How many workers the driver could open RIGHT NOW without hitting the simultaneity fence, or // `null` when no cap is set (the conserved pool is then the only fence, so there is no finite @@ -758,7 +1116,10 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin // so filling N slots meant emitting N blind tool calls with no feedback telling it to — the // mechanical reason a 5-worker run peaked at 2 live workers. Policy (whether to fill) stays with // the driver; this is only the reading. - const freeWorkerSlots = (): number | null => freeSlots(liveWorkerCount(), maxLiveWorkers) + const freeWorkerSlots = (): number | null => + usesTreeWideLimit() + ? (sharedWorkerCapacity()?.freeSlots ?? null) + : freeSlots(localLiveWorkerCount(), maxLiveWorkers) // The LIVE read of one worker. Guarded because `createCoordinationTools` is bound to a `Scope` // it did not construct — an older or hand-rolled scope may not implement `progress` at all, and @@ -901,10 +1262,10 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin 'Optional per-spawn budget that merges over the per-worker default (per field). ' + 'Only set the ceilings this sub-task needs raised; the conserved pool still fences.', properties: { - maxIterations: { type: 'number' }, - maxTokens: { type: 'number' }, - maxUsd: { type: 'number' }, - deadlineMs: { type: 'number' }, + maxIterations: { type: 'number', minimum: 0 }, + maxTokens: { type: 'number', minimum: 0 }, + maxUsd: { type: 'number', minimum: 0 }, + deadlineMs: { type: 'number', minimum: 0 }, }, }, }, @@ -921,6 +1282,7 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin // touches the pool. The conserved pool bounds TOTAL work; this bounds SIMULTANEOUS work. if ( !keyCompleted && + !usesTreeWideLimit() && maxLiveWorkers !== undefined && maxLiveWorkers > 0 && liveWorkerCount() >= maxLiveWorkers @@ -930,12 +1292,36 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin live: liveWorkerCount(), freeSlots: freeWorkerSlots(), }) - const agent = opts.makeWorkerAgent(a.profile) - const budget = - a.budget === undefined ? opts.perWorker : mergeBudget(opts.perWorker, a.budget) - const res = opts.scope.spawn(agent, a.task, { + const parsedProfile = agentProfileSchema.safeParse(a.profile) + if (!parsedProfile.success) { + return Promise.resolve({ + error: 'invalid-profile' as const, + issues: parsedProfile.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })), + }) + } + const profile = deepFreezeDetached(parsedProfile.data) + const task = deepFreezeDetached(a.task) + const label = typeof a.label === 'string' ? a.label : 'worker' + const budget = Object.freeze( + a.budget === undefined ? opts.perWorker : mergeBudget(opts.perWorker, a.budget), + ) + const assignmentId = + key !== undefined ? `key:${key}` : `ordinal:${unkeyedAssignmentOrdinal++}` + const context: WorkerSpawnContext = Object.freeze({ + assignmentId, + parentNodeId: opts.scope.view.root, budget, - label: typeof a.label === 'string' ? a.label : 'worker', + task, + label, + ...(key !== undefined ? { key } : {}), + }) + const res = opts.scope.spawn(() => opts.makeWorkerAgent(profile, context), task, { + budget, + label, + assignmentId, ...(key !== undefined ? { key } : {}), }) // A keyed spawn that resolved to committed work: NOTHING ran — return the finished result @@ -944,13 +1330,12 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin if (res.ok && res.prior?.state === 'completed') { const s = res.prior.settled if (key !== undefined) completedKeys.add(key) + const { id, status, resumed: _resumed, ...evidence } = projectSettled(s) return Promise.resolve({ - workerId: s.handle.id, + workerId: id, resumed: 'completed' as const, - status: 'done' as const, - score: s.verdict?.score ?? 0, - valid: s.verdict?.valid ?? false, - outRef: s.outRef, + status, + ...evidence, live: liveWorkerCount(), freeSlots: freeWorkerSlots(), }) @@ -979,6 +1364,14 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin res.ok ? { workerId: res.handle.id, + assignmentId: res.handle.assignmentId ?? assignmentId, + ...(res.handle.identity === undefined ? {} : { identity: res.handle.identity }), + ...(res.handle.materialization === undefined + ? {} + : { materialization: res.handle.materialization }), + ...(res.handle.executionBindings === undefined + ? {} + : { executionBindings: res.handle.executionBindings }), live: liveWorkerCount(), freeSlots: freeWorkerSlots(), ...priorHistory, @@ -1026,19 +1419,16 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin if (!resumed) return { error: `unknown workerId ${JSON.stringify(id)}` } const output = resumed.outRef ? await opts.blobs.get(resumed.outRef) : undefined return { - status: resumed.status, - spent: resumed.spent, + ...projectNodeEvidence(resumed, true), outRef: resumed.outRef ?? null, output: output ?? null, progress: null, - resumed: true, } } const output = node.outRef ? await opts.blobs.get(node.outRef) : undefined const progress = readProgress(id) return { - status: node.status, - spent: node.spent, + ...projectNodeEvidence(node), outRef: node.outRef ?? null, output: output ?? null, progress: progress ?? null, @@ -1072,18 +1462,20 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin const workerId = str(a.workerId, 'workerId') const instruction = str(a.instruction, 'instruction') const interrupt = a.interrupt === true - const delivered = opts.scope.send(workerId, { steer: instruction, interrupt }) - await sendDown('steer', { toWorker: workerId, instruction, delivered }) - if (delivered) return { delivered, progress: readProgress(workerId) ?? null } - // Say WHY nothing landed. A silent `delivered:false` is how steering became a no-op: - // the driver could not tell "already finished" from "this runtime has no inbox at all". - const progress = readProgress(workerId) - const reason = !progress - ? 'unknown-worker' - : !progress.live - ? 'already-settled' - : 'runtime-has-no-inbox' - return { delivered, reason, progress: progress ?? null } + const authorized = authorizeInstruction('steer', workerId, instruction, interrupt) + await recordInstruction(authorized) + const delivery = await attemptDelivery(authorized, { + steer: authorized.instruction, + interrupt, + }) + if (delivery.delivered) { + return { delivered: true, progress: readProgress(workerId) ?? null } + } + return { + delivered: false, + reason: delivery.outcome, + progress: readProgress(workerId) ?? null, + } }, }, { @@ -1163,24 +1555,45 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin const questionId = str(a.questionId, 'questionId') if (typeof a.answer === 'string' && a.answer.length > 0) { const answer = a.answer - const question = decideQuestion(questionId, { - kind: 'answer', - answer, - by: typeof a.by === 'string' && a.by.length > 0 ? a.by : 'user', - }) + const pendingQuestion = questions.find((question) => question.id === questionId) + if (pendingQuestion === undefined) { + throw new Error(`unknown questionId ${JSON.stringify(questionId)}`) + } // Route the answer DOWN to the worker that asked, unparking it, and record the down-leg. // A blocking question parked the worker, so deliver forcefully — it should resume on the // answer immediately, not wait for its next step boundary. - const interrupt = question.urgency === 'blocks-run' || question.urgency === 'blocks-step' - const delivered = opts.scope.send(question.from, { answer, questionId, interrupt }) - await sendDown( + const interrupt = + pendingQuestion.urgency === 'blocks-run' || pendingQuestion.urgency === 'blocks-step' + const authorized = authorizeInstruction( 'answer', - { toWorker: question.from, instruction: answer, delivered }, + pendingQuestion.from, + answer, + interrupt, questionId, ) + await recordInstruction(authorized) + const delivery = await attemptDelivery(authorized, { + answer: authorized.instruction, + questionId, + interrupt, + }) + // Authorization is evidence of allowed bytes, not proof the blocked worker received them. + // Resolve the question only after the durable delivery outcome says the live inbox accepted + // the answer. A refusal stays open both in this process and after replay. + const question = delivery.delivered + ? decideQuestion(questionId, { + kind: 'answer', + answer: authorized.instruction, + by: typeof a.by === 'string' && a.by.length > 0 ? a.by : 'user', + }) + : pendingQuestion // Surface `delivered` like steer_agent — the caller must see whether the answer actually // reached a live worker (false when it already settled or has no inbox). - return { question, delivered } + return { + question, + delivered: delivery.delivered, + ...(delivery.delivered ? {} : { reason: delivery.outcome }), + } } if (typeof a.deferReason === 'string' && a.deferReason.length > 0) { return Promise.resolve({ @@ -1344,18 +1757,35 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin handler: async (raw) => { const a = obj(raw) const id = str(a.workerId, 'workerId') - const node = opts.scope.view.nodes.find((n) => n.id === id) + const node = nodeForWorker(id) if (!node) return { error: `unknown workerId ${JSON.stringify(id)}` } - if (!node.outRef) + if (isLive(node.status)) { return { error: `worker ${JSON.stringify(id)} has not settled — no trace to analyze yet` } - const trace = await opts.blobs.get(node.outRef) - return { findings: await opts.analysts?.run(str(a.kind, 'kind'), trace) } + } + const trace = + ledger.find((worker) => worker.id === id)?.trace ?? + node.trace ?? + ({ + status: 'unavailable', + reason: 'legacy-settlement-without-trace-evidence', + } as const) + let store: TraceAnalysisStore + try { + store = await workerTraceAnalysisStore(trace, opts.blobs) + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + trace, + } + } + return { findings: await opts.analysts?.run(str(a.kind, 'kind'), store) } }, }) } return { tools, + ready, history: () => bus.history(), raiseFinding: (finding) => bus.publish({ type: 'finding', finding }).then(() => undefined), stats: () => bus.stats(), @@ -1367,3 +1797,37 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin drainResolved, } } + +function nextUnkeyedAssignmentOrdinal(scope: Scope): number { + let next = 0 + const views = [scope.resume?.view, scope.view] + for (const view of views) { + if (view === undefined) continue + for (const node of view.nodes) { + const match = /^ordinal:(\d+)$/.exec(node.assignmentId ?? '') + if (match === null) continue + const ordinal = Number(match[1]) + if (!Number.isSafeInteger(ordinal)) { + throw new Error( + `coordination: durable assignment id '${node.assignmentId}' exceeds the safe ordinal range`, + ) + } + next = Math.max(next, ordinal + 1) + } + } + if (!Number.isSafeInteger(next)) { + throw new Error('coordination: durable assignment ordinal space is exhausted') + } + return next +} + +function deepFreezeDetached(value: T): T { + return deepFreeze(structuredClone(value)) +} + +function deepFreeze(value: T, seen = new Set()): T { + if (value === null || typeof value !== 'object' || seen.has(value)) return value + seen.add(value) + for (const child of Object.values(value as Record)) deepFreeze(child, seen) + return Object.freeze(value) +} diff --git a/src/mcp/worktree-harness.ts b/src/mcp/worktree-harness.ts index 7cdfd860..b0df1731 100644 --- a/src/mcp/worktree-harness.ts +++ b/src/mcp/worktree-harness.ts @@ -25,6 +25,7 @@ import type { AgentProfile } from '@tangle-network/agent-interface' import { applyWorkspacePlan, type HarnessId, + hashWorkspacePlan, materializeProfile, type ResolveAgentProfileResourcesOptions, resolveAgentProfileResources, @@ -33,6 +34,11 @@ import { type WorkspacePlanConfigValue, type WorkspacePlanReceipt, } from '@tangle-network/agent-profile-materialize' +import { + assertProfileMaterialization, + profileMaterializationAxes, + worktreeCliProfileMaterialization, +} from '../agent/profile-materialization' import { type CodexExecutionPolicy, type CodexTokenUsage, @@ -82,6 +88,13 @@ export interface WorktreeProfileMaterializationReceipt { } } +/** Pure, pre-execution identity for the exact profile workspace plan this module later applies. */ +export interface WorktreeProfileExecutionPlan { + readonly workspacePlanDigest: string + readonly harness: HarnessId + readonly resourceInstructions: WorktreeProfileMaterializationReceipt['resourceInstructions'] +} + /** The canonical result of one worktree-harness run, projected by each port to its own shape. */ export interface WorktreeHarnessResult { /** The branch the worktree was cut on (`delegate/`). */ @@ -148,8 +161,8 @@ export interface RunWorktreeHarnessOptions { repoRoot: string /** * Supervisor-authored prompt/model plus structural resources materialized into the worktree. - * `model.default` selects the one-shot model; `small`, `provider`, and `metadata` remain hints. - * Resource failures are always fatal here, regardless of `resources.failOnError`. + * `model.default` selects the one-shot model. Routing-only model hints and + * `resources.failOnError` are rejected because this path cannot honor them. */ profile: AgentProfile /** Local harness for this run. This explicit choice overrides `profile.harness`. */ @@ -236,6 +249,20 @@ export async function runWorktreeHarness( const checkTimeoutMs = opts.checkTimeoutMs ?? opts.harnessTimeoutMs ?? 5 * 60 * 1000 const cap = opts.checkOutputCap ?? defaultCheckOutputCap + // Profile support is a pure admission decision. Resolve it before creating a branch/worktree so + // a refused candidate leaves no repository state behind and starts no external process. Remote + // (GitHub-backed) profile resources are fetched here too — also before any repository state — + // with the caller's abort signal linked into every fetch. + assertSupportedWorktreeProfile(opts.profile, opts.harness) + assertSafeProfileResourcePaths(opts.profile) + const profile = await resolveAgentProfileResources( + opts.profile, + resourceResolutionOptions(opts.resourceResolution, opts.signal), + ) + opts.signal?.throwIfAborted() + const prepared = prepareWorktreeProfile(profile, opts.harness) + const { plan, resourceInstructions } = prepared + const worktree = await createWorktree({ repoRoot: opts.repoRoot, runId: opts.runId, @@ -251,24 +278,6 @@ export async function runWorktreeHarness( }) try { - assertSupportedWorktreeProfile(opts.profile, opts.harness) - assertSafeProfileResourcePaths(opts.profile) - const profile = await resolveAgentProfileResources( - opts.profile, - resourceResolutionOptions(opts.resourceResolution, opts.signal), - ) - opts.signal?.throwIfAborted() - const resourceInstructions = resolveResourceInstructions(profile) - const workspaceProfile = materializationOnlyProfile(profile) - const plan = materializeProfile(workspaceProfile, materializerHarness(opts.harness)) - if (plan.unsupported.length > 0) { - throw new Error( - `runWorktreeHarness: profile cannot be materialized for ${opts.harness}: ${plan.unsupported - .map(({ dimension, reason }) => `${dimension}: ${reason}`) - .join('; ')}`, - ) - } - assertSafeMaterializedPaths(plan) const applied = applyWorkspacePlan(plan, worktree.path, { existingFiles: 'reject' }) if (applied.unsupported.length > 0) { throw new Error('runWorktreeHarness: applied profile unexpectedly retained unsupported rows') @@ -367,6 +376,42 @@ export async function runWorktreeHarness( } } +/** Compute the same plan identity `runWorktreeHarness` consumes, with no repository or process IO. */ +export function worktreeProfileExecutionPlan( + profile: AgentProfile, + harness: LocalHarness, +): WorktreeProfileExecutionPlan { + const { plan, resourceInstructions } = prepareWorktreeProfile(profile, harness) + return Object.freeze({ + workspacePlanDigest: hashWorkspacePlan(plan), + harness: plan.harness, + resourceInstructions: resourceInstructionReceipt(resourceInstructions), + }) +} + +function prepareWorktreeProfile( + profile: AgentProfile, + harness: LocalHarness, +): { + readonly plan: WorkspacePlan + readonly resourceInstructions: string | undefined +} { + const resourceInstructions = resolveResourceInstructions(profile) + const workspaceProfile = materializationOnlyProfile(profile) + assertSupportedWorktreeProfile(profile, harness) + assertSafeProfileResourcePaths(profile) + const plan = materializeProfile(workspaceProfile, materializerHarness(harness)) + if (plan.unsupported.length > 0) { + throw new Error( + `runWorktreeHarness: profile cannot be materialized for ${harness}: ${plan.unsupported + .map(({ dimension, reason }) => `${dimension}: ${reason}`) + .join('; ')}`, + ) + } + assertSafeMaterializedPaths(plan) + return { plan, resourceInstructions } +} + function resourceResolutionOptions( options: ResolveAgentProfileResourcesOptions | undefined, signal: AbortSignal | undefined, @@ -441,49 +486,18 @@ function materializationOnlyProfile(profile: AgentProfile): AgentProfile { } function assertSupportedWorktreeProfile(profile: AgentProfile, harness: LocalHarness): void { - // `profile.harness` is only a preference and the explicit run option wins. Model small/provider/ - // metadata fields are routing or descriptive hints; this fixed one-shot path only selects the - // concrete `model.default`. `resources.failOnError` never weakens this path's fail-closed policy. - const unsupportedAxes = [ - hasEntries(profile.tools) ? 'tools' : null, - hasEntries(profile.permissions) ? 'permissions' : null, - profile.connections && profile.connections.length > 0 ? 'connections' : null, - hasEntries(profile.confidential) ? 'confidential' : null, - hasEntries(profile.modes) ? 'modes' : null, - hasEntries(profile.extensions) ? 'extensions' : null, - ].filter((axis): axis is string => axis !== null) + assertProfileMaterialization({ + contract: worktreeCliProfileMaterialization, + changedAxes: profileMaterializationAxes(profile), + context: 'runWorktreeHarness', + }) + // `profile.harness` is only a preference and the explicit run option wins. The contract above + // rejects routing-only model hints and `resources.failOnError`; this harness-specific check + // handles values supported by only a subset of the three local CLIs. + const unsupportedAxes: string[] = [] if (profile.model?.reasoningEffort !== undefined && harness !== 'codex') { unsupportedAxes.push('model.reasoningEffort') } - for (const [name, server] of Object.entries(profile.mcp ?? {})) { - const path = `mcp[${JSON.stringify(name)}]` - if (server.enabled === false && harness !== 'opencode') { - unsupportedAxes.push(`${path}.enabled`) - } - if (hasEntries(server.headers) && harness === 'codex') { - unsupportedAxes.push(`${path}.headers`) - } - if (server.cwd !== undefined && harness === 'opencode') { - unsupportedAxes.push(`${path}.cwd`) - } - } - if (harness === 'claude') { - for (const [event, commands] of Object.entries(profile.hooks ?? {})) { - for (const [index, command] of commands.entries()) { - const path = `hooks[${JSON.stringify(event)}][${index}]` - if (hasEntries(command.env)) unsupportedAxes.push(`${path}.env`) - if (command.blocking !== undefined) unsupportedAxes.push(`${path}.blocking`) - } - } - } - for (const [name, subagent] of Object.entries(profile.subagents ?? {})) { - const path = `subagents[${JSON.stringify(name)}]` - if (hasEntries(subagent.permissions)) unsupportedAxes.push(`${path}.permissions`) - if (subagent.maxSteps !== undefined) unsupportedAxes.push(`${path}.maxSteps`) - if (hasEntries(subagent.tools) && harness !== 'claude') { - unsupportedAxes.push(`${path}.tools`) - } - } if (unsupportedAxes.length > 0) { throw new Error( `runWorktreeHarness: profile requests unsupported worktree behavior: ${unsupportedAxes.join(', ')}`, @@ -491,10 +505,6 @@ function assertSupportedWorktreeProfile(profile: AgentProfile, harness: LocalHar } } -function hasEntries(value: object | undefined): boolean { - return value !== undefined && Object.keys(value).length > 0 -} - function assertSafeMaterializedPaths(plan: WorkspacePlan): void { for (const file of plan.files) { if (file.relPath.split('/').some(isGitMetadataSegment)) { @@ -524,21 +534,27 @@ function profileMaterializationReceipt( applied: WorkspacePlanReceipt, resourceInstructions: string | undefined, ): WorktreeProfileMaterializationReceipt { - const instructionBytes = - resourceInstructions === undefined ? null : Buffer.from(resourceInstructions, 'utf8') return { workspacePlanDigest: applied.workspacePlanDigest, writtenPaths: [...applied.written], unsupported: [...applied.unsupported], environmentNames: Object.keys(applied.env).sort(), flags: applied.flags.map(publicPlanString), - resourceInstructions: instructionBytes - ? { - delivery: 'invocation-prompt', - sha256: `sha256:${createHash('sha256').update(instructionBytes).digest('hex')}`, - byteLength: instructionBytes.byteLength, - } - : { delivery: 'none', sha256: null, byteLength: 0 }, + resourceInstructions: resourceInstructionReceipt(resourceInstructions), + } +} + +function resourceInstructionReceipt( + resourceInstructions: string | undefined, +): WorktreeProfileMaterializationReceipt['resourceInstructions'] { + if (resourceInstructions === undefined) { + return { delivery: 'none', sha256: null, byteLength: 0 } + } + const instructionBytes = Buffer.from(resourceInstructions, 'utf8') + return { + delivery: 'invocation-prompt', + sha256: `sha256:${createHash('sha256').update(instructionBytes).digest('hex')}`, + byteLength: instructionBytes.byteLength, } } diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 5a6e75e3..073fac23 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -39,24 +39,38 @@ export { FileSpawnJournal, InMemoryResultBlobStore, InMemorySpawnJournal, + loadSpawnForest, materializeTreeView, // The waits a journaled tree shows as armed but never woken — what a resumed run re-arms with // the ORIGINAL deadline. Exported for the same reason the replay readers are: a durable wait a // consumer cannot read back is only a log line. pendingWaits, replaySpawnTree, + type SpawnForest, + type SpawnForestEvent, + type SpawnForestInDoubtNode, + type SpawnForestMissingTree, + type SpawnForestNode, + type SpawnForestTree, } from '../durable/spawn-journal' -// The typed coordination-bus event (up: settled/question/finding; down: steer/answer) — surfaced -// here so a host folding the bus onto its own timeline (the supervise-topology observability) can +// The typed coordination-bus event (up: settled/question/finding; authorized instruction receipt; +// down: steer/answer delivery outcome) — surfaced here so a host folding the bus onto its own timeline can // type its `onEvent` subscriber without reaching into the `/mcp` subpath. `MakeWorkerAgent` rides // alongside it: the worker-seam type `supervise`/`workerFromBackend` traffic in, so a host authoring // its own seam types it from the loop layer rather than the `/mcp` subpath. export type { AnalystFindingEvent, AnalystRegistry, + AuthorizeDownMessage, + AuthorizedDownMessage, + ContinuationInstruction, CoordinationEvent, + DownMessageAuthorizationInput, + DownMessageDeliveryAttempt, + DownMessageDeliveryOutcome, DownMessageEvent, MakeWorkerAgent, + WorkerSpawnContext, } from './../mcp/tools/coordination' export { DEFAULT_AWAIT_EVENT_TIMEOUT_MS } from './../mcp/tools/coordination' export type { WorktreeCheckRunner, WorktreeHarnessResult } from './../mcp/worktree-harness' @@ -510,6 +524,7 @@ export { } from './supervise/authoring' export { type BudgetPool, + type BudgetPoolRestore, type BudgetReadout, createBudgetPool, type ReservationRejection, @@ -520,18 +535,21 @@ export { // settlement `valid` reflects a deployable deliverable check (a test/judge), never self-report. export { type DeliverableSpec, gateOnDeliverable } from './supervise/completion-gate' // The CHEAP / offline driver: an in-process router-tools loop that drives the coordination -// verbs over the Scope (no box, no creds). The CAPABLE driver is a sandbox agent with the -// coordination verbs mounted as an MCP — this is the low-cost + offline-testable variant. +// verbs over the Scope (no box, no creds). The CAPABLE driver is an external harness with the +// coordination verbs mounted as an MCP: `supervise()` wires a local bridge automatically, while a +// remote sandbox requires an explicit reachable `driveHarness`. export { type DriverAgentOptions, driverAgent, finalizeBestDelivered, } from './supervise/coordination-driver' -// The durable coordination side-log a file-backed `RunContext` carries: the questions and analyst -// findings the spawn journal does not record, replayed into a resumed driver so a restarted -// coordinator keeps the coordination context its workers produced. +// The durable coordination side-log a file-backed `RunContext` carries: questions, findings, answer +// decisions, and authorized continuation receipts the spawn journal does not own. Receipts persist +// as evidence and are never auto-delivered to a replacement worker. export { + type CoordinationDeliveryEvidence, type CoordinationLog, + type CoordinationOwnerId, FileCoordinationLog, type PriorCoordination, } from './supervise/coordination-log' @@ -597,7 +615,7 @@ export { // drains it at the step boundary + before settle (queued) or aborts the turn (forceful interrupt). export { createInbox, type Inbox, type InboxMessage } from './supervise/inbox' // The fail-loud model-subset guard the front doors call: restrict a run to a chosen set of models. -export { assertModelAllowed } from './supervise/model-policy' +export { assertModelAllowed, assertProfileModelsAllowed } from './supervise/model-policy' // OPT-IN OTLP tracing for a supervised tree: a pure `RuntimeHooks` observer that turns the // lifecycle events `Scope` already emits into one span per node (opened at spawn, closed at settle, // parented to its parent node's span) plus an LLM child span per metered driver turn. A span with a @@ -737,26 +755,45 @@ export { // sensible defaults (blobs/perWorker/journal/executors). `workerFromBackend` derives the worker seam // from a backend config + an optional completion oracle (settled⟺delivered). export { + type AuthorizedSpawn, + type AuthorizedSpawnContext, + DEFAULT_AUTHORED_PROFILE_SECURITY_POLICY, + type DeliverableResolutionInput, type SuperviseOptions, type SuperviseRegistry, type SuperviseRegistryTable, supervise, workerFromBackend, } from './supervise/supervise' -export { createSupervisor } from './supervise/supervisor' +export { createRootHandle, createSupervisor } from './supervise/supervisor' // Build a supervisor FROM its profile: the brain is resolved from `profile.harness` like -// `createExecutor({backend})` resolves a worker — `null` → the in-process router tool-loop, +// `createExecutor({backend})` resolves a worker — omitted/`cli-base` → the in-process router tool-loop, // a coding-CLI harness → a sandboxed harness driving the coordination verbs. No hand-built brain. export { assertCoordinationBinding, type CoordinationBinding, type DriveHarness, + type DriveHarnessOwnerContext, + type ObserveSupervisorNodeEvent, + type ResolveDriveHarness, type ResolvedSupervisorProfile, + type ResolveSupervisorTools, resolveSupervisorProfile, type SupervisorAgentDeps, + type SupervisorNodeContext, + type SupervisorNodeContextSeed, type SupervisorProfile, + type SupervisorToolDescriptor, + type SupervisorToolInvocationContext, supervisorAgent, } from './supervise/supervisor-agent' +export { + captureWorkerTraceEvidence, + parseWorkerToolTraceArtifact, + WORKER_TOOL_TRACE_SCHEMA_VERSION, + type WorkerToolTraceArtifact, + workerTraceAnalysisStore, +} from './supervise/trace-evidence' // The substrate-agnostic trace source: a worker's tool calls as agent-eval `ToolSpan`s, from an // OWNED loop (push) OR a sandbox box session (message parts). The common currency for both analysts. export { @@ -773,23 +810,34 @@ export { export { analyzeTrace, type TrajectoryAnalysis } from './supervise/trajectory-recorder' export type { Agent, + AgentExecutionRef, AgentSpec, Budget, + ExecutionBindingReceipt, Executor, + ExecutorAccounting, ExecutorContext, + ExecutorExecutionBinding, ExecutorFactory, + ExecutorMaterialization, + ExecutorNodeContext, ExecutorRegistry, ExecutorResult, Handle, + MaterializedExecutionIdentity, + MaterializedModelIdentity, + NodeExecutionIdentity, NodeId, NodeSnapshot, NodeStatus, NoWinnerError, + ProfileMaterializationReceipt, Restart, ResultBlobStore, ResumedKeyState, ResumedWork, RootHandle, + RootMaterialization, RootSignal, Runtime, Scope, @@ -800,13 +848,17 @@ export type { SpawnPrior, SpawnRejection, Spend, + SteerableRootHandle, SupervisedResult, Supervisor, SupervisorOpts, TreeView, + UnknownMaterializationReason, UsageEvent, WaitOpts, WidenGate, + WorkerTraceEvidence, + WorkerTraceUnavailableReason, } from './supervise/types' // Untracked-artifact fidelity for cloned worker workspaces: `git clone` carries history only, and // real workspaces hold compiled build outputs as untracked files a worker's verify gate needs. diff --git a/src/runtime/personify/trajectory.ts b/src/runtime/personify/trajectory.ts index 2f64c731..292fe01e 100644 --- a/src/runtime/personify/trajectory.ts +++ b/src/runtime/personify/trajectory.ts @@ -69,7 +69,14 @@ export async function trajectoryReport( // they accumulate ONTO the settled child-work base regardless of seq order; closes are the // settlements/cancellations that set node status. const closes = events - .filter((ev) => ev.kind !== 'spawned' && ev.kind !== 'waiting' && ev.kind !== 'metered') + .filter( + (ev) => + ev.kind !== 'spawned' && + ev.kind !== 'waiting' && + ev.kind !== 'metered' && + ev.kind !== 'materialized' && + ev.kind !== 'execution-bound', + ) .sort(bySeq) const nodes = new Map() @@ -285,6 +292,7 @@ function addNodeSpend(a: Spend, b: Spend): Spend { return { iterations: a.iterations + b.iterations, tokens: { input: a.tokens.input + b.tokens.input, output: a.tokens.output + b.tokens.output }, + ...(a.tokensKnown === false || b.tokensKnown === false ? { tokensKnown: false } : {}), usd: a.usd + b.usd, ...(a.tokensKnown === false || b.tokensKnown === false ? { tokensKnown: false } : {}), ...(a.usdKnown === false || b.usdKnown === false ? { usdKnown: false } : {}), @@ -296,6 +304,7 @@ function cloneSpend(spend: Spend): Spend { return { iterations: spend.iterations, tokens: { input: spend.tokens.input, output: spend.tokens.output }, + ...(spend.tokensKnown === false ? { tokensKnown: false } : {}), usd: spend.usd, ...(spend.tokensKnown === false ? { tokensKnown: false } : {}), ...(spend.usdKnown === false ? { usdKnown: false } : {}), @@ -307,6 +316,7 @@ function cloneSpend(spend: Spend): Spend { function addSpend(acc: Spend, delta: Spend): void { acc.iterations += delta.iterations addTokenUsage(acc.tokens, delta.tokens) + if (delta.tokensKnown === false) acc.tokensKnown = false acc.usd += delta.usd if (delta.tokensKnown === false) acc.tokensKnown = false if (delta.usdKnown === false) acc.usdKnown = false diff --git a/src/runtime/router-client.ts b/src/runtime/router-client.ts index 0e203fcd..47257dd9 100644 --- a/src/runtime/router-client.ts +++ b/src/runtime/router-client.ts @@ -260,7 +260,7 @@ export async function routerChatWithTools( const body = toolCompletionBody(cfg, messages, tools, opts) // Injected transport short-circuits the network — the offline benchmark seam (see RouterConfig.complete). const raw = cfg.complete - ? await cfg.complete(body) + ? await cfg.complete(structuredClone(body)) : await (async () => { const res = await fetch(`${cfg.routerBaseUrl.replace(/\/$/, '')}/chat/completions`, { method: 'POST', diff --git a/src/runtime/strategy.ts b/src/runtime/strategy.ts index e9418eb8..f6690207 100644 --- a/src/runtime/strategy.ts +++ b/src/runtime/strategy.ts @@ -477,7 +477,7 @@ function shotExecutor(surface: AgenticSurface, opts: AgenticOptions): Executor string[] + traceSource: TraceSource } { let lastReport = '' + const trace = createPushTraceSource() const surface: AgenticSurface = { name: base.name, open: (t) => base.open(t), tools: (t, h) => base.tools(t, h), async call(h, name, args) { - const out = await base.call(h, name, args) - if (name === 'run_tests') lastReport = out - return out + const startedAt = Date.now() + const recordedArgs = structuredClone(args) + try { + const out = await base.call(h, name, args) + trace.record({ + toolName: name, + args: recordedArgs, + result: out, + status: out.startsWith('ERROR:') ? 'error' : 'ok', + startedAt, + endedAt: Date.now(), + }) + if (name === 'run_tests') lastReport = out + return out + } catch (error) { + trace.record({ + toolName: name, + args: recordedArgs, + result: `ERROR: ${error instanceof Error ? error.message : String(error)}`, + status: 'error', + startedAt, + endedAt: Date.now(), + }) + throw error + } }, score: (t, h) => base.score(t, h), close: (h) => base.close(h), } - const failing = () => { - const body = /FAILING:\s*(.+)/i.exec(lastReport)?.[1] - return body - ? body - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - : [] - } - return { surface, failing } + return { surface, failing: () => failingTestNames(lastReport), traceSource: trace.source } } /** The default self-improvement LENS — authored content, not a code path. On each settled worker it hands @@ -82,22 +100,87 @@ export function failuresAnalyst(): AnalystRegistry { area: 'progress', }, ], - run: async (_kindId: string, trace: unknown) => { - const w = (trace ?? {}) as Partial - if (!(typeof w === 'object' && w !== null && 'resolved' in w)) - return { summary: `worker produced: ${JSON.stringify(trace).slice(0, 300)}` } - if (w.resolved) return { summary: 'worker RESOLVED — every check passed; stop.' } - const failing = (w.failing ?? []) as readonly string[] - const head = `worker did NOT resolve — score ${(100 * (w.score ?? 0)).toFixed(0)}%, ${w.shots ?? '?'} shot(s)` + run: async (_kindId: string, trace: TraceAnalysisStore) => { + if (!isTraceAnalysisStore(trace)) return missingRunTestsEvidence() + const report = await latestRunTestsReport(trace) + if (report === undefined) return missingRunTestsEvidence() + const failing = failingTestNames(report) return { summary: failing.length - ? `${head}. STILL FAILING (${failing.length}): ${failing.slice(0, 12).join(', ')}. Spawn the next worker to fix exactly these; if a test keeps failing across workers, give it concrete guidance about that case.` - : `${head}. (no failing-test list available this round)`, + ? `Latest structured run_tests evidence reports STILL FAILING (${failing.length}): ${failing.join(', ')}. Spawn the next worker to fix exactly these; if a test keeps failing across workers, give it concrete guidance about that case.` + : allTestsPassed(report) + ? 'Latest structured run_tests evidence reports every test passed; stop.' + : `Latest structured run_tests evidence contains no parseable failing-test names. Refusing to infer them from worker prose. run_tests output: ${report.slice(0, 300)}`, } }, } } +async function latestRunTestsReport(store: TraceAnalysisStore): Promise { + const overview = await store.getOverview({ tool_names: ['run_tests'] }) + const candidates: Array<{ output: string; endedAt: string; ordinal: number }> = [] + let ordinal = 0 + for (const traceId of overview.sample_trace_ids) { + const view = await store.viewTrace({ trace_id: traceId, per_attribute_byte_cap: 16_384 }) + let spans = view.spans + if (spans === undefined) { + const matches = await store.searchTrace({ + trace_id: traceId, + regex_pattern: 'run_tests', + max_matches: 100, + }) + const spanIds = [ + ...new Set( + matches.hits.filter((hit) => hit.span_name === 'run_tests').map((hit) => hit.span_id), + ), + ] + spans = spanIds.length + ? ( + await store.viewSpans({ + trace_id: traceId, + span_ids: spanIds, + per_attribute_byte_cap: 16_384, + }) + ).spans + : [] + } + for (const span of spans) { + if (span.tool_name !== 'run_tests') continue + const output = span.attributes[OUTPUT_VALUE] + if (typeof output !== 'string') continue + candidates.push({ output, endedAt: span.end_time, ordinal: ordinal++ }) + } + } + candidates.sort( + (left, right) => + Date.parse(left.endedAt) - Date.parse(right.endedAt) || left.ordinal - right.ordinal, + ) + return candidates.at(-1)?.output +} + +function failingTestNames(report: string): string[] { + const body = /FAILING:\s*([^\n]+)/iu.exec(report)?.[1] + if (body === undefined) return [] + return body + .replace(/\.\s+COLLECTION-BLOCKED:.*$/iu, '') + .replace(/\s*\(\+\d+\s+more\)\s*$/iu, '') + .split(',') + .map((name) => name.trim()) + .filter(Boolean) +} + +function allTestsPassed(report: string): boolean { + const fraction = /(\d+)\s*\/\s*(\d+)\s+tests?\s+passed/iu.exec(report) + return fraction !== null && Number(fraction[1]) === Number(fraction[2]) +} + +function missingRunTestsEvidence(): { summary: string } { + return { + summary: + 'Missing structured run_tests span evidence. Refusing to infer failing-test names from worker prose.', + } +} + /** How a worker runs the surface task (its router substrate + per-attempt bounds). */ export interface SurfaceWorkerConfig { readonly routerBaseUrl: string @@ -119,6 +202,7 @@ function surfaceWorkerExecutor( strategy: Strategy, ): Executor { let artifact: ExecutorResult | undefined + const traced = traceSurfaceCalls(surface) return { runtime: 'surface-worker', async execute(brief: unknown): Promise> { @@ -129,9 +213,8 @@ function surfaceWorkerExecutor( systemPrompt: `${task.systemPrompt ?? ''}\n\n— Supervisor guidance for THIS attempt (incorporate it; do not just repeat a prior approach) —\n${guidance}`, } : task - const cap = captureFailures(surface) const r = await runAgentic({ - surface: cap.surface, + surface: traced.surface, task: attemptTask, strategy, budget: worker.budget ?? 1, @@ -146,7 +229,7 @@ function surfaceWorkerExecutor( score: r.score, shots: r.shots, summary: `${strategy.name} ${r.shots} shot(s) → ${(100 * r.score).toFixed(0)}% (${r.resolved ? 'resolved' : 'unresolved'})`, - failing: r.resolved ? [] : cap.failing(), + failing: r.resolved ? [] : traced.failing(), } const spent: Spend = { iterations: r.completions, tokens: r.tokens, usd: r.usd, ms: r.ms } artifact = { @@ -157,6 +240,7 @@ function surfaceWorkerExecutor( } return artifact }, + traceSource: () => traced.traceSource, teardown: () => Promise.resolve({ destroyed: true }), resultArtifact() { if (!artifact) throw new Error('surfaceWorkerExecutor: resultArtifact before execute') diff --git a/src/runtime/supervise/authoring.ts b/src/runtime/supervise/authoring.ts index 7dc952f2..4a50d157 100644 --- a/src/runtime/supervise/authoring.ts +++ b/src/runtime/supervise/authoring.ts @@ -15,30 +15,38 @@ */ import { type AnalystFinding, computeFindingId, makeFinding } from '@tangle-network/agent-eval' -import type { AgentProfile } from '@tangle-network/agent-interface' +import { + type AgentProfile, + type AgentProfilePrompt, + agentProfileSchema, +} from '@tangle-network/agent-interface' import { contentAddress } from '../../durable/spawn-journal' import { type RouterConfig, routerChatWithUsage } from '../router-client' import { type DeliverableSpec, gateOnDeliverable } from './completion-gate' +import { attestRuntimeOwnedExecutor, newExecutionAttemptId } from './materialization' import type { Agent, AgentSpec, Executor, ExecutorResult } from './types' -/** What the supervisor AUTHORS per sub-task — a worker recipe (a partial `AgentProfile`). */ -export interface AuthoredProfile { - name: string - /** The rich, task-specific instructions the supervisor wrote for THIS worker. */ - systemPrompt: string - /** The model the supervisor chose for this sub-task (falls back to the run default). */ - model?: string +/** What the supervisor AUTHORS per sub-task: one complete canonical profile whose name and + * task-specific system prompt are present. Every other `AgentProfile` axis is preserved exactly. */ +export type AuthoredProfile = AgentProfile & { + readonly name: string + readonly prompt: AgentProfilePrompt & { readonly systemPrompt: string } } /** Narrow an untyped `spawn_agent` profile argument to an `AuthoredProfile`, or null if the * supervisor failed to author one (empty/placeholder profile — a skill violation worth catching). */ export function asAuthoredProfile(raw: unknown): AuthoredProfile | null { - const p = raw as Partial | undefined - if (!p || typeof p.systemPrompt !== 'string' || p.systemPrompt.trim().length === 0) return null + const parsed = agentProfileSchema.safeParse(raw) + if (!parsed.success) return null + const systemPrompt = parsed.data.prompt?.systemPrompt + if (typeof systemPrompt !== 'string' || systemPrompt.trim().length === 0) return null return { - name: typeof p.name === 'string' && p.name.length > 0 ? p.name : 'worker', - systemPrompt: p.systemPrompt, - ...(typeof p.model === 'string' ? { model: p.model } : {}), + ...parsed.data, + name: + typeof parsed.data.name === 'string' && parsed.data.name.length > 0 + ? parsed.data.name + : 'worker', + prompt: { ...parsed.data.prompt, systemPrompt }, } } @@ -86,19 +94,21 @@ export function supervisorInstructions(opts?: { goal?: string }): string { 'For the task you are given:', '1. DECOMPOSE it into the smallest set of sub-tasks a single focused worker can each deliver.', '2. For EACH sub-task, AUTHOR a worker by calling spawn_agent with a COMPLETE `profile`:', - ' • name: a short id for the worker.', - ' • systemPrompt: rich, specific instructions for THIS sub-task — tell the worker exactly what to produce, how to use its tools fully, and what "done" means. Never a one-liner; write the prompt a power-user would write.', - ' • model: the model best suited to this sub-task (omit to use the default).', + ' • name and description: who this specialist is and why it exists.', + ' • prompt.systemPrompt: rich instructions for THIS sub-task — exact output, process, evidence, and what "done" means.', + ' • model.default, model.reasoningEffort, and harness: choose the execution system deliberately when the task benefits from it.', + ' • tools, mcp, resources.skills/files/instructions, hooks, subagents, permissions, and modes: grant or attach every capability the worker needs; omit an axis only when it is intentionally unnecessary.', + ' • metadata.role="driver" when this child should be a sub-supervisor that may author and drive its own children.', ' NEVER spawn a worker with an empty profile. The quality of the worker IS the quality of the profile you write.', "3. await_event (kinds:['settled']) to collect each worker. Its result says valid:true only if the deployable check passed.", - '4. If a worker did NOT deliver, AUTHOR A NEW worker whose systemPrompt names the SPECIFIC failure and how to fix it — never just retry the same prompt.', + '4. If a worker did NOT deliver, AUTHOR A NEW profile whose prompt.systemPrompt names the SPECIFIC failure and how to fix it — never just retry the same profile.', '5. Stop (reply with no tool call) once the work is delivered. You cannot declare done yourself — only a delivered (valid:true) worker counts.', ...(opts?.goal ? ['', `The goal: ${opts.goal}`] : []), ].join('\n') } -/** Build a worker AGENT from a profile the supervisor authored: the authored `systemPrompt` + - * `model` shape the worker's one model call; the deliverable gates settlement (valid ⟺ delivered). */ +/** Build a router-only worker from an authored profile. This helper executes the prompt/model axes; + * use `workerFromBackend` for full materialization of tools, MCP, resources, hooks, and subagents. */ export function authoredWorker( profile: AuthoredProfile, opts: { @@ -108,42 +118,66 @@ export function authoredWorker( temperature?: number }, ): Agent { - let artifact: ExecutorResult | undefined - const model = profile.model ?? opts.cfg.model - const inner: Executor = { - runtime: 'router', - async execute(_t, signal) { - const res = await routerChatWithUsage( - { ...opts.cfg, model }, - [ - { role: 'system', content: profile.systemPrompt }, - { role: 'user', content: opts.taskPrompt }, - ], - { temperature: opts.temperature ?? 0.4, ...(signal ? { signal } : {}) }, - ) - artifact = { - outRef: contentAddress(res.content), - out: res.content, - spent: { - iterations: 1, - tokens: res.usage ?? { input: 0, output: 0 }, - usd: res.costUsd ?? 0, - ms: 0, + const model = profile.model?.default ?? opts.cfg.model + const executorFactory: NonNullable = (spec, ctx) => { + let artifact: ExecutorResult | undefined + const executionId = ctx.node?.nodeId ?? `authored-router-${profile.name}` + const attemptId = ctx.node?.attemptId ?? newExecutionAttemptId(executionId) + const inner: Executor = attestRuntimeOwnedExecutor( + { + runtime: 'router', + async execute(_t, signal) { + const res = await routerChatWithUsage( + { ...opts.cfg, model }, + [ + { role: 'system', content: profile.prompt.systemPrompt }, + { role: 'user', content: opts.taskPrompt }, + ], + { temperature: opts.temperature ?? 0.4, ...(signal ? { signal } : {}) }, + ) + artifact = { + outRef: contentAddress(res.content), + out: res.content, + spent: { + iterations: 1, + tokens: res.usage ?? { input: 0, output: 0 }, + usd: res.costUsd ?? 0, + ms: 0, + }, + } + return artifact + }, + teardown: () => Promise.resolve({ destroyed: true }), + resultArtifact: () => { + if (!artifact) throw new Error('authoredWorker: resultArtifact read before execute') + return artifact + }, + }, + { + effectiveProfile: spec.profile, + backend: 'router', + model: { status: 'known', id: model }, + execution: { kind: 'request', id: executionId }, + materializer: 'authored-router-prompt', + plan: { + kind: 'authored-router-completion', + model, + temperature: opts.temperature ?? 0.4, + taskPrompt: opts.taskPrompt, }, - } - return artifact - }, - teardown: () => Promise.resolve({ destroyed: true }), - resultArtifact: () => { - if (!artifact) throw new Error('authoredWorker: resultArtifact read before execute') - return artifact - }, + }, + { + attemptId, + binding: { endpoint: opts.cfg.routerBaseUrl, executionId, model }, + descriptor: { kind: 'router-request', transport: 'http', backend: 'router' }, + }, + ) + return gateOnDeliverable(inner, opts.deliverable) } - const gated = gateOnDeliverable(inner, opts.deliverable) const spec: AgentSpec = { - profile: { name: profile.name } as AgentProfile, + profile, harness: null, - executor: gated, + executorFactory, } return { name: profile.name, act: async () => '', executorSpec: spec } as Agent< unknown, diff --git a/src/runtime/supervise/bridge-executor.test.ts b/src/runtime/supervise/bridge-executor.test.ts index 0f587ed3..f6b1b0f6 100644 --- a/src/runtime/supervise/bridge-executor.test.ts +++ b/src/runtime/supervise/bridge-executor.test.ts @@ -6,18 +6,75 @@ import { spendFromUsageEvents } from './budget' import { bridgeExecutor } from './runtime' import type { UsageEvent } from './types' +const TEST_RUN_DIGEST = `sha256:${'b'.repeat(64)}` + +function numberSseDataFrames(body: string): string { + let seq = 0 + return body.replace(/^data: (?!\[DONE\])/gmu, () => `id: ${++seq}\ndata: `) +} + +function durableRunHeaders(runId: string): Record { + return { + 'x-run-id': runId, + 'x-run-request-digest': TEST_RUN_DIGEST, + } +} + +function terminalCancelBody(runId: string): string { + return JSON.stringify({ + cancelled: true, + cancel_requested: true, + terminal: true, + run: { + id: runId, + requestDigest: TEST_RUN_DIGEST, + terminal: true, + status: 'cancelled', + state: 'terminal', + }, + }) +} + +function cancelledRunId(url: string | undefined): string | undefined { + const match = url?.match(/^\/v1\/runs\/([^/]+)\/cancel(?:\?|$)/u) + return match?.[1] ? decodeURIComponent(match[1]) : undefined +} + +function firstHeader(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value +} + /** Serve one canned cli-bridge response body per request (HTTP 200 unless told * otherwise) and hand back the bridge URL — the upstream-failure shapes under * test are byte-level wire artifacts, so the test speaks real HTTP. */ async function startBridgeStub( body: string, - opts: { status?: number; contentType?: string } = {}, + opts: { + status?: number + contentType?: string + onRequest?: (body: Record) => void + } = {}, ): Promise<{ url: string; server: Server }> { - const server = createServer((_req, res) => { + const server = createServer(async (req, res) => { + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(Buffer.from(chunk)) + const requestBody = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') as Record< + string, + unknown + > + if (opts.onRequest) { + opts.onRequest(requestBody) + } res.writeHead(opts.status ?? 200, { 'content-type': opts.contentType ?? 'text/event-stream', + 'x-run-id': String(requestBody.run_id), + 'x-run-request-digest': TEST_RUN_DIGEST, }) - res.end(body) + res.end( + (opts.contentType ?? 'text/event-stream') === 'text/event-stream' + ? numberSseDataFrames(body) + : body, + ) }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const { port } = server.address() as AddressInfo @@ -127,4 +184,413 @@ describe('bridgeExecutor upstream-error propagation', () => { expect(artifact.out).toMatchObject({ content: 'first second third' }) expect(normalized).toEqual({ ...artifact.spent, ms: 0 }) }) + + it('sends the complete canonical profile once and lets it select the harness and model', async () => { + let requestBody: Record | undefined + const stub = await startBridgeStub('data: [DONE]\n\n', { + onRequest: (body) => { + requestBody = body + }, + }) + server = stub.server + const profile: AgentProfile = { + name: 'research-leader', + description: 'Design and supervise discriminating experiments', + harness: 'codex', + prompt: { + systemPrompt: 'Lead the pursuit.', + instructions: ['Prefer falsifiable hypotheses.'], + }, + model: { default: 'gpt-5.6', reasoningEffort: 'high' }, + permissions: { shell: 'ask' }, + tools: { web: true }, + mcp: { + literature: { transport: 'http', url: 'https://papers.example.test/mcp' }, + }, + subagents: { + reviewer: { description: 'Challenge the evidence', prompt: 'Find confounds.' }, + }, + resources: { + skills: [{ kind: 'inline', name: 'hypothesis', content: '# Hypothesis\nTest mechanisms.' }], + failOnError: true, + }, + hooks: { afterTool: [{ command: './record-result', blocking: true }] }, + modes: { adversarial: { prompt: 'Try to falsify the leading claim.' } }, + metadata: { role: 'driver', source: 'test-fixture' }, + } + const executor = bridgeExecutor( + { profile, harness: null }, + { + signal: new AbortController().signal, + seams: { + bridge: { bridgeUrl: stub.url, bridgeBearer: 'test-bearer', model: 'kimi-code/k2' }, + }, + }, + ) + await drain( + executor.execute( + 'design the experiment', + new AbortController().signal, + ) as AsyncIterable, + ) + + expect(requestBody?.model).toBe('codex/gpt-5.6') + expect(requestBody?.agent_profile).toEqual(profile) + expect(requestBody?.messages).toEqual([{ role: 'user', content: 'design the experiment' }]) + }) + + it('marks dollar cost unknown when the bridge reports no price', async () => { + const stub = await startBridgeStub( + `data: ${JSON.stringify({ usage: { prompt_tokens: 3, completion_tokens: 2 } })}\n\ndata: [DONE]\n\n`, + ) + server = stub.server + const executor = makeExecutor(stub.url) + await drain( + executor.execute('do the task', new AbortController().signal) as AsyncIterable, + ) + expect(executor.resultArtifact().spent).toMatchObject({ + tokens: { input: 3, output: 2 }, + usd: 0, + usdKnown: false, + }) + }) + + it('keeps dollar cost unknown when a later completed turn omits price', async () => { + let requests = 0 + let deliver: (message: unknown) => void = () => {} + server = createServer(async (req, res) => { + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(Buffer.from(chunk)) + const requestBody = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record< + string, + unknown + > + const runId = String(requestBody.run_id) + requests += 1 + if (requests === 1) deliver({ steer: 'check the edge case too' }) + res.writeHead(200, { + 'content-type': 'text/event-stream', + ...durableRunHeaders(runId), + }) + const usage = + requests === 1 + ? { prompt_tokens: 3, completion_tokens: 2, cost: 0.01 } + : { prompt_tokens: 4, completion_tokens: 1 } + res.end( + numberSseDataFrames( + `data: ${JSON.stringify({ choices: [{ delta: { content: `turn-${requests}` } }], usage })}\n\ndata: [DONE]\n\n`, + ), + ) + }) + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + const executor = makeExecutor(`http://127.0.0.1:${port}`) + deliver = (message) => executor.deliver?.(message) + + await drain( + executor.execute('do the task', new AbortController().signal) as AsyncIterable, + ) + + expect(requests).toBe(2) + expect(executor.resultArtifact().spent).toMatchObject({ + iterations: 2, + tokens: { input: 7, output: 3 }, + usd: 0.01, + usdKnown: false, + }) + }) + + it.each([ + { defect: 'changed run id', expected: /run identity mismatch/u }, + { defect: 'changed request digest', expected: /request digest changed/u }, + { defect: 'skipped replay event', expected: /replay gap: expected event 2, received 3/u }, + ])('fails closed when a reconnect has a $defect', async ({ defect, expected }) => { + const requests: Array<{ + body: Record + lastEventId: string | undefined + }> = [] + server = createServer(async (req, res) => { + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(Buffer.from(chunk)) + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record + const runId = String(body.run_id) + requests.push({ body, lastEventId: firstHeader(req.headers['last-event-id']) }) + const responseRunId = + requests.length === 2 && defect === 'changed run id' ? `wrong-${runId}` : runId + const responseDigest = + requests.length === 2 && defect === 'changed request digest' + ? `sha256:${'c'.repeat(64)}` + : TEST_RUN_DIGEST + res.writeHead(200, { + 'content-type': 'text/event-stream', + 'x-run-id': responseRunId, + 'x-run-request-digest': responseDigest, + }) + if (requests.length === 1) { + res.end(`id: 1\ndata: ${JSON.stringify({ usage: { prompt_tokens: 1 } })}\n\n`) + return + } + const eventId = defect === 'skipped replay event' ? 3 : 2 + res.end( + `id: ${eventId}\ndata: ${JSON.stringify({ usage: { completion_tokens: 1 } })}\n\ndata: [DONE]\n\n`, + ) + }) + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + const executor = makeExecutor(`http://127.0.0.1:${port}`) + + await expect( + drain( + executor.execute('do the task', new AbortController().signal) as AsyncIterable, + ), + ).rejects.toThrow(expected) + expect(requests).toHaveLength(2) + expect(requests[1]?.body).toEqual(requests[0]?.body) + expect(requests[1]?.lastEventId).toBe('1') + }) + + it('reattaches one live execution after disconnect and cancels that exact run on teardown', async () => { + const chatRequests: Array<{ + body: Record + lastEventId: string | undefined + }> = [] + const liveRuns = new Set() + const cancelledRuns: string[] = [] + let executions = 0 + let reattached: () => void = () => {} + const reattachedPromise = new Promise((resolve) => { + reattached = resolve + }) + let cancelSeen: () => void = () => {} + const cancelSeenPromise = new Promise((resolve) => { + cancelSeen = resolve + }) + let acknowledgeTerminal: () => void = () => {} + const terminalAcknowledged = new Promise((resolve) => { + acknowledgeTerminal = resolve + }) + + server = createServer(async (req, res) => { + const cancelledId = cancelledRunId(req.url) + if (cancelledId) { + cancelledRuns.push(cancelledId) + cancelSeen() + await terminalAcknowledged + liveRuns.delete(cancelledId) + res.writeHead(200, { + 'content-type': 'application/json', + ...durableRunHeaders(cancelledId), + }) + res.end(terminalCancelBody(cancelledId)) + return + } + + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(Buffer.from(chunk)) + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record + const runId = String(body.run_id) + chatRequests.push({ body, lastEventId: firstHeader(req.headers['last-event-id']) }) + if (!liveRuns.has(runId)) { + liveRuns.add(runId) + executions += 1 + } + res.writeHead(200, { + 'content-type': 'text/event-stream', + ...durableRunHeaders(runId), + }) + if (chatRequests.length === 1) { + res.write( + `id: 1\ndata: ${JSON.stringify({ usage: { prompt_tokens: 5, completion_tokens: 2 } })}\n\n`, + ) + setTimeout(() => res.destroy(), 5) + return + } + reattached() + // Keep the second reader attached. Only the explicit cancel endpoint + // changes the logical run state; closing either socket does not. + res.write(': attached\n\n') + }) + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + const executor = makeExecutor(`http://127.0.0.1:${port}`) + const iterator = ( + executor.execute('do the task', new AbortController().signal) as AsyncIterable + )[Symbol.asyncIterator]() + + expect(await iterator.next()).toMatchObject({ + value: { kind: 'tokens', input: 5, output: 2 }, + done: false, + }) + const draining = drain({ [Symbol.asyncIterator]: () => iterator }).catch((error) => error) + await reattachedPromise + + expect(executions).toBe(1) + expect(liveRuns.size).toBe(1) + expect(chatRequests).toHaveLength(2) + expect(chatRequests[1]?.body).toEqual(chatRequests[0]?.body) + expect(chatRequests[1]?.lastEventId).toBe('1') + + let teardownSettled = false + const teardown = executor.teardown('infinity').then((receipt) => { + teardownSettled = true + return receipt + }) + await cancelSeenPromise + await Promise.resolve() + expect(teardownSettled).toBe(false) + expect(liveRuns.size).toBe(1) + acknowledgeTerminal() + await expect(teardown).resolves.toEqual({ destroyed: true }) + await draining + expect(cancelledRuns).toEqual([chatRequests[0]?.body.run_id]) + expect(liveRuns.size).toBe(0) + }) + + it('interrupts an active response body, accounts its partial usage, and resumes with the steer', async () => { + const requestBodies: Array> = [] + const liveRuns = new Set() + const cancelledRuns: string[] = [] + server = createServer(async (req, res) => { + const cancelledId = cancelledRunId(req.url) + if (cancelledId) { + cancelledRuns.push(cancelledId) + liveRuns.delete(cancelledId) + res.writeHead(200, { + 'content-type': 'application/json', + ...durableRunHeaders(cancelledId), + }) + res.end(terminalCancelBody(cancelledId)) + return + } + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(Buffer.from(chunk)) + const requestBody = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record< + string, + unknown + > + requestBodies.push(requestBody) + const runId = String(requestBody.run_id) + liveRuns.add(runId) + res.writeHead(200, { + 'content-type': 'text/event-stream', + ...durableRunHeaders(runId), + }) + if (requestBodies.length === 1) { + res.write( + `id: 1\ndata: ${JSON.stringify({ usage: { prompt_tokens: 5, completion_tokens: 2 } })}\n\n`, + ) + return + } + res.end( + numberSseDataFrames( + `data: ${JSON.stringify({ + choices: [{ delta: { content: 'corrected answer' } }], + usage: { prompt_tokens: 3, completion_tokens: 1, cost: 0.01 }, + })}\n\ndata: [DONE]\n\n`, + ), + ) + liveRuns.delete(runId) + }) + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + const executor = makeExecutor(`http://127.0.0.1:${port}`) + const iterator = ( + executor.execute('do the task', new AbortController().signal) as AsyncIterable + )[Symbol.asyncIterator]() + + expect(await iterator.next()).toMatchObject({ + value: { kind: 'tokens', input: 5, output: 2 }, + done: false, + }) + executor.deliver?.({ steer: 'stop and use the corrected method', interrupt: true }) + const remaining = await drain({ [Symbol.asyncIterator]: () => iterator }) + + expect(remaining).toContainEqual({ kind: 'tokens', input: 3, output: 1 }) + expect(requestBodies).toHaveLength(2) + expect(cancelledRuns).toEqual([requestBodies[0]?.run_id]) + expect(liveRuns.size).toBe(0) + expect(requestBodies[1]?.messages).toEqual([ + { + role: 'user', + content: expect.stringContaining('stop and use the corrected method'), + }, + ]) + expect(executor.resultArtifact()).toMatchObject({ + out: { content: 'corrected answer' }, + spent: { + iterations: 2, + tokens: { input: 8, output: 3 }, + usd: 0.01, + usdKnown: false, + }, + }) + }) + + it('marks pre-header interrupted bridge work unknown instead of treating it as free', async () => { + const requestBodies: Array> = [] + const cancelledRuns: string[] = [] + let firstRequestSeen: () => void = () => {} + const firstRequest = new Promise((resolve) => { + firstRequestSeen = resolve + }) + server = createServer(async (req, res) => { + const cancelledId = cancelledRunId(req.url) + if (cancelledId) { + cancelledRuns.push(cancelledId) + res.writeHead(200, { + 'content-type': 'application/json', + ...durableRunHeaders(cancelledId), + }) + res.end(terminalCancelBody(cancelledId)) + return + } + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(Buffer.from(chunk)) + const requestBody = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record< + string, + unknown + > + requestBodies.push(requestBody) + if (requestBodies.length === 1) { + firstRequestSeen() + return + } + const runId = String(requestBody.run_id) + res.writeHead(200, { + 'content-type': 'text/event-stream', + ...durableRunHeaders(runId), + }) + res.end( + numberSseDataFrames( + `data: ${JSON.stringify({ + choices: [{ delta: { content: 'resumed answer' } }], + usage: { prompt_tokens: 3, completion_tokens: 1, cost: 0.01 }, + })}\n\ndata: [DONE]\n\n`, + ), + ) + }) + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + const executor = makeExecutor(`http://127.0.0.1:${port}`) + const draining = drain( + executor.execute('do the task', new AbortController().signal) as AsyncIterable, + ) + + await firstRequest + executor.deliver?.({ steer: 'resume with the corrected plan', interrupt: true }) + await draining + + expect(requestBodies).toHaveLength(2) + expect(cancelledRuns).toEqual([requestBodies[0]?.run_id]) + expect(executor.resultArtifact()).toMatchObject({ + out: { content: 'resumed answer' }, + spent: { + iterations: 2, + tokens: { input: 3, output: 1 }, + tokensKnown: false, + usd: 0.01, + usdKnown: false, + }, + }) + }) }) diff --git a/src/runtime/supervise/budget.ts b/src/runtime/supervise/budget.ts index 660a34dd..a92d346c 100644 --- a/src/runtime/supervise/budget.ts +++ b/src/runtime/supervise/budget.ts @@ -28,6 +28,11 @@ * then a ceiling, not a measurement. It does not close admission the way the dollar channel does: * tokens are always capped, so one unreported turn must not end the run. * + * A pool can be RESTORED from a prior process's durable record (same-host restart): measured + * committed spend is debited exactly once, and each child journaled as started but never settled + * is charged at its full declared ceiling — restart never mints capacity, and the unknown flags + * stay visible in every later readout. The original absolute deadline never slides. + * * @experimental */ @@ -59,6 +64,7 @@ export interface ReservationTicket { /** Post-reservation pool readout — the shape `Scope.budget` exposes. `tokensLeft`, * `usdLeft`, and `reservedTokens` reflect committed-but-unsettled reservations; * `deadlineMs` is the ABSOLUTE wall-clock deadline (0 when the root set none). + * `iterationsLeft` is the remaining iteration capacity. * `usdCapped` distinguishes a real `usdLeft <= 0` exhaustion from an uncapped pool (which always * reads `usdLeft: 0`) — the in-loop guard needs it to bound a usd-capped driver. */ export type BudgetReadout = Readonly<{ @@ -69,9 +75,13 @@ export type BudgetReadout = Readonly<{ * consumption is at least the debited amount and possibly more. Reading `tokensLeft` without this * flag would present an under-count as an exact balance. */ - tokensKnown?: boolean + tokensKnown: boolean usdLeft: number usdCapped: boolean + /** False once any recorded work reported an unknown dollar cost; the dollar totals are then a + * lower bound on real spend, not a measurement. */ + usdKnown: boolean + iterationsLeft: number deadlineMs: number reservedTokens: number }> @@ -80,6 +90,54 @@ export type BudgetReadout = Readonly<{ * unsatisfiable at any amount and the fix is to budget the root, not to ask for less. */ export type ReservationRejection = 'budget-exhausted' | 'usd-unbudgeted' +/** State recovered from a prior process before new work is admitted. `committed` is measured spend + * already present in the durable journal. Each `uncertainReservation` is a child that was recorded + * as started but never recorded as settled: its full declared ceiling is charged conservatively, + * while the public readout remains explicitly unknown. */ +export interface BudgetPoolRestore { + readonly committed?: Spend + readonly uncertainReservations?: ReadonlyArray + /** Original absolute deadline from the first process. It may never slide on restart. */ + readonly absoluteDeadlineMs?: number +} + +/** Reject malformed ceilings before they can mint capacity through negative reservations. */ +export function assertValidBudget(budget: Budget, label = 'budget'): void { + const safeInteger = (value: number, field: string) => { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label}.${field} must be a non-negative safe integer`) + } + } + const finiteNonNegative = (value: number, field: string) => { + if (!Number.isFinite(value) || value < 0) { + throw new Error(`${label}.${field} must be a non-negative finite number`) + } + } + safeInteger(budget.maxIterations, 'maxIterations') + safeInteger(budget.maxTokens, 'maxTokens') + if (budget.maxUsd !== undefined) finiteNonNegative(budget.maxUsd, 'maxUsd') + if (budget.deadlineMs !== undefined) finiteNonNegative(budget.deadlineMs, 'deadlineMs') +} + +function assertValidSpend(spend: Spend, label: string): void { + if (!Number.isSafeInteger(spend.iterations) || spend.iterations < 0) { + throw new Error(`${label}.iterations must be a non-negative safe integer`) + } + for (const [field, value] of Object.entries(spend.tokens)) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label}.tokens.${field} must be a non-negative safe integer`) + } + } + for (const [field, value] of [ + ['usd', spend.usd], + ['ms', spend.ms], + ] as const) { + if (!Number.isFinite(value) || value < 0) { + throw new Error(`${label}.${field} must be a non-negative finite number`) + } + } +} + export interface BudgetPool { /** * Atomically reserve a child's full ceiling from the free balance. Fails closed @@ -177,10 +235,22 @@ function totalTokens(usage: LoopTokenUsage): number { /** * Create a conserved reservation pool from a root `Budget`. `now()` is injected so the * deadline readout is deterministic; defaults to `Date.now` for non-test callers. The - * absolute deadline is fixed at construction (`now() + budget.deadlineMs`) so the - * readout's `deadlineMs` is a stable wall-clock instant, not a shrinking remainder. + * absolute deadline for a fresh pool is fixed at construction (`now() + budget.deadlineMs`). A + * restored pool instead retains `restore.absoluteDeadlineMs`, so restart never slides the original + * wall-clock limit. The readout is an absolute instant, not a shrinking remainder. */ -export function createBudgetPool(root: Budget, now: () => number = Date.now): BudgetPool { +export function createBudgetPool( + root: Budget, + now: () => number = Date.now, + restore: BudgetPoolRestore = {}, +): BudgetPool { + assertValidBudget(root, 'root budget') + if ( + restore.absoluteDeadlineMs !== undefined && + (!Number.isFinite(restore.absoluteDeadlineMs) || restore.absoluteDeadlineMs < 0) + ) { + throw new Error('budget restore.absoluteDeadlineMs must be a non-negative finite number') + } // free + reserved + committed ≡ root totals, per channel, always. let freeTokens = root.maxTokens let reservedTokens = 0 @@ -196,13 +266,18 @@ export function createBudgetPool(root: Budget, now: () => number = Date.now): Bu let freeUsd = root.maxUsd ?? 0 let reservedUsd = 0 let committedUsd = 0 + // Closes dollar admission: set when unknown dollar cost lands under a dollar-capped root. let usdTainted = false + // Reporting only: set whenever ANY recorded work carried `usdKnown: false`, capped or not, so + // the readout never presents a lower bound as a measured dollar total. + let usdMeasured = true let freeIterations = root.maxIterations let reservedIterations = 0 let committedIterations = 0 - const absoluteDeadlineMs = root.deadlineMs !== undefined ? now() + root.deadlineMs : 0 + const absoluteDeadlineMs = + restore.absoluteDeadlineMs ?? (root.deadlineMs !== undefined ? now() + root.deadlineMs : 0) let nextTicketId = 0 const open = new Set() @@ -210,6 +285,7 @@ export function createBudgetPool(root: Budget, now: () => number = Date.now): Bu function reserve( b: Budget, ): { ok: true; ticket: ReservationTicket } | { ok: false; reason: ReservationRejection } { + assertValidBudget(b, 'reservation budget') const wantTokens = b.maxTokens const wantUsd = b.maxUsd ?? 0 const wantIterations = b.maxIterations @@ -255,6 +331,7 @@ export function createBudgetPool(root: Budget, now: () => number = Date.now): Bu if (!open.has(ticket.id)) { throw new Error(`budget pool: reconcile of unknown or already-settled ticket ${ticket.id}`) } + assertValidSpend(spent, `budget pool ticket ${ticket.id} spend`) const { tokens: rTokens, usd: rUsd, iterations: rIterations } = ticket.reserved const unknownUnderCap = usdCapped && spent.usdKnown === false const spentTokens = totalTokens(spent.tokens) @@ -298,6 +375,7 @@ export function createBudgetPool(root: Budget, now: () => number = Date.now): Bu // Release the whole reservation, then commit actual spend; the difference is the // refund that flows back to `free`. if (spent.tokensKnown === false) tokensTainted = true + if (spent.usdKnown === false) usdMeasured = false reservedTokens -= rTokens committedTokens += spentTokens freeTokens += rTokens - spentTokens @@ -327,6 +405,11 @@ export function createBudgetPool(root: Budget, now: () => number = Date.now): Bu } function observe(spend: Spend): void { + assertValidSpend(spend, 'observed spend') + // Unknown dollars under a dollar cap are REFUSED before any balance mutates: unlike a + // reconciled child (whose reservation must settle), an observation has no ticket to strand, + // so the honest reading is a fail-loud refusal the caller surfaces — `driver-failed` carrying + // this reason — rather than an invented figure or a silently consumed cap. if (usdCapped && spend.usdKnown === false) { throw new Error( 'budget pool: cannot observe unknown dollar cost under a dollar-capped budget', @@ -336,6 +419,7 @@ export function createBudgetPool(root: Budget, now: () => number = Date.now): Bu // marks the balance incomplete. The alternative — refusing to record it — is the "free turn" // the pool must never see. if (spend.tokensKnown === false) tokensTainted = true + if (spend.usdKnown === false) usdMeasured = false const tokens = totalTokens(spend.tokens) // Direct free → committed debit (no reservation ticket). `free` may go negative on overspend — // that is honest; the readout then reports exhaustion and the in-loop guard halts the driver. @@ -355,6 +439,8 @@ export function createBudgetPool(root: Budget, now: () => number = Date.now): Bu tokensKnown: !tokensTainted, usdLeft: usdCapped ? (usdTainted ? 0 : freeUsd) : 0, usdCapped, + usdKnown: usdMeasured && !usdTainted, + iterationsLeft: freeIterations, deadlineMs: absoluteDeadlineMs, reservedTokens, } @@ -368,6 +454,58 @@ export function createBudgetPool(root: Budget, now: () => number = Date.now): Bu } } + // Reconstruct the conserved balances before exposing the pool. Measured prior spend is debited + // exactly once. A started-without-terminal child is charged at its entire reservation: this + // never mints capacity on restart, but unlike treating unknown as zero it can leave unrelated + // capacity usable. The false known flags remain visible in every later report. Restore uses + // direct arithmetic rather than `observe`, because the prior process's spend ALREADY happened: + // refusing to record it would be the zero-cost restart the pool must never allow. + if (restore.committed !== undefined) { + assertValidSpend(restore.committed, 'budget restore committed') + const committed = restore.committed + if (committed.tokensKnown === false) tokensTainted = true + if (committed.usdKnown === false) usdMeasured = false + const tokens = totalTokens(committed.tokens) + freeTokens -= tokens + committedTokens += tokens + freeIterations -= committed.iterations + committedIterations += committed.iterations + committedUsd += committed.usd + if (usdCapped) { + freeUsd -= committed.usd + if (committed.usdKnown === false) { + // The prior process spent dollars it could not measure under a dollar cap: the remaining + // balance is not a sound bound, so consume it and close dollar admission. + usdTainted = true + if (freeUsd > 0) committedUsd += freeUsd + freeUsd = 0 + } + } + } + for (const [index, uncertain] of (restore.uncertainReservations ?? []).entries()) { + assertValidBudget(uncertain, `budget restore uncertainReservations[${index}]`) + // The child ran (or may have run) without a settle record: its telemetry is unknown, so the + // pool charges the full declared ceiling and reports the balance as a ceiling, never a + // measurement. + tokensTainted = true + usdMeasured = false + freeTokens -= uncertain.maxTokens + committedTokens += uncertain.maxTokens + freeIterations -= uncertain.maxIterations + committedIterations += uncertain.maxIterations + if (usdCapped) { + if (uncertain.maxUsd === undefined) { + // No child dollar ceiling existed, so the root's remaining dollar capacity is not safe. + usdTainted = true + committedUsd += freeUsd > 0 ? freeUsd : 0 + freeUsd = 0 + } else { + freeUsd -= uncertain.maxUsd + committedUsd += uncertain.maxUsd + } + } + } + return { reserve, reconcile, diff --git a/src/runtime/supervise/completion-gate.ts b/src/runtime/supervise/completion-gate.ts index 35a7e408..c785b2f1 100644 --- a/src/runtime/supervise/completion-gate.ts +++ b/src/runtime/supervise/completion-gate.ts @@ -21,6 +21,7 @@ * @experimental */ +import { inheritRuntimeOwnedExecutorAttestation } from './materialization' import type { DefaultVerdict, Executor, ExecutorResult, UsageEvent } from './types' /** @@ -58,7 +59,7 @@ export function gateOnDeliverable( return { valid: delivered, score: baseScore ?? (delivered ? 1 : 0) } } - return { + const wrapped: Executor = { runtime: inner.runtime, ...(inner.budgetExempt !== undefined ? { budgetExempt: inner.budgetExempt } : {}), ...(inner.deliver ? { deliver: (m: unknown) => inner.deliver?.(m) } : {}), @@ -97,6 +98,7 @@ export function gateOnDeliverable( return { ...art, verdict: gated ?? art.verdict } }, } + return inheritRuntimeOwnedExecutorAttestation(inner, wrapped) } function isAsyncIterable(v: unknown): v is AsyncIterable { diff --git a/src/runtime/supervise/coordination-driver.ts b/src/runtime/supervise/coordination-driver.ts index 14344903..a416b17f 100644 --- a/src/runtime/supervise/coordination-driver.ts +++ b/src/runtime/supervise/coordination-driver.ts @@ -30,6 +30,7 @@ import { RuntimeRunStateError, ValidationError } from '../../errors' import type { McpToolDescriptor } from '../../mcp/server' import { type AnalystRegistry, + type AuthorizeDownMessage, type CoordinationEvent, coordinationVerbNames, createCoordinationTools, @@ -46,6 +47,7 @@ import { } from '../tool-loop' import type { DeliverableSpec } from './completion-gate' import type { PriorCoordination } from './coordination-log' +import type { BusRecord } from './event-bus' import { bestDelivered, pickBestDelivered, @@ -53,6 +55,7 @@ import { runTree, type SupervisorFinalizer, } from './finalizer' +import { createInbox, type Inbox } from './inbox' import { createProgressTracker, type ProgressTracker, @@ -80,6 +83,7 @@ export interface DriverAgentOptions { readonly blobs: ResultBlobStore /** Resolve a spawned `profile` to a worker LEAF or a driver child (the recursion seam). */ readonly makeWorkerAgent: MakeWorkerAgent + readonly authorizeDownMessage?: AuthorizeDownMessage /** Per-child budget reserved from the conserved pool on each spawn. */ readonly perWorker: Budget /** Independent completion check for work the driver performs itself. When present, the driver @@ -105,6 +109,9 @@ export interface DriverAgentOptions { /** The driver's stance — a string, or built from the task (the worker-driver prompt / * the generator). INJECTED so the prompt is a pluggable, optimizable role. */ readonly systemPrompt: string | ((task: unknown) => string) + /** Product-selected tools already bound to this exact supervisor node. The same descriptors are + * served over MCP for external supervisors; this arm projects them into router ToolSpecs. */ + readonly nodeTools?: ReadonlyArray /** WORK tools the driver may call DIRECTLY (alongside the coordination verbs) — so the driver is * not a pure manager but a full agent that can ACT (do simple work itself) OR SPAWN (delegate). * Each is a router tool spec; their names must not collide with the coordination verbs. Pair with @@ -155,18 +162,27 @@ export interface DriverAgentOptions { * off (no behavior change). `distill` defaults to a self-summary authored by the brain combined * with the factual settled-worker roster; override to supply your own. */ readonly compaction?: ToolLoopCompactionOptions - /** Pass-through subscriber for every coordination bus event (settled / question / finding / - * steer / answer) — what a durable caller hooks its coordination log onto. Omit = no observer. */ - readonly onEvent?: (event: CoordinationEvent) => void | Promise - /** Questions + findings a durable coordination log replayed from a prior process of this run. - * Questions seed the ledger (`list_questions`, blocking-stop policy); both feed the resume - * brief. Omit = fresh (every run that is not a resume). */ + /** Pass-through subscriber for every coordination bus event: settled/question/finding, + * pre-delivery instruction receipts, and steer/answer delivery outcomes. A durable caller uses + * this to append the coordination log. Omit = no observer. */ + readonly onEvent?: ( + event: CoordinationEvent, + record: BusRecord, + ) => void | Promise + /** Re-publish resume-time settlements through the awaited observer before the first brain turn. */ + readonly replaySettlements?: boolean + /** Questions, findings, and authorized continuation receipts loaded from a prior process. + * Questions seed the ledger (`list_questions`, blocking-stop policy); all three feed the resume + * brief. Continuation receipts are evidence only and are never auto-delivered. Omit = fresh. */ readonly priorCoordination?: PriorCoordination /** How the settled-worker ledger becomes the run's output. Default `bestDelivered` — the single * highest-scoring DELIVERED child (the exact keep-best every existing caller had). Runs under * the delivered-only invariant (`runFinalizer`): whatever the finalizer, an undelivered or * invalid child's output stays unreachable. */ readonly finalizer?: SupervisorFinalizer + /** Optional shared manager inbox used by a wrapper that must accept messages before async node + * setup finishes. Ordinary callers omit it and the driver owns a fresh inbox. */ + readonly inbox?: Inbox } /** The default chapter-close prompt: the brain summarizes its OWN progress for its future self before @@ -213,10 +229,13 @@ const runawayTripwireTurns = 2000 * overspend usd up to the turn tripwire). */ function poolStarved(scope: Scope, perWorker: Budget): boolean { const b = scope.budget - if (b.reservedTokens > 0) return false // a child is in flight — await it, don't finalize early + if (scope.view.inFlight > 0 || scope.view.waiting > 0) return false const tokenStarved = b.tokensLeft < perWorker.maxTokens - const usdStarved = b.usdCapped && b.usdLeft <= 0 - return tokenStarved || usdStarved + const iterationStarved = b.iterationsLeft <= 0 + const usdStarved = + b.usdCapped && + (b.usdLeft <= 0 || (perWorker.maxUsd !== undefined && b.usdLeft < perWorker.maxUsd)) + return tokenStarved || iterationStarved || usdStarved } /** The absolute wall-clock deadline (when the root set one) has passed. */ @@ -282,12 +301,21 @@ export function driverAgent(opts: DriverAgentOptions): Agent { // Validate against the reserved verb set HERE (construction), so the conflict fails loud — not // buried inside act() where the supervisor would swallow the throw into a quiet no-winner. const reserved = new Set(coordinationVerbNames) + for (const tool of opts.nodeTools ?? []) { + if (reserved.has(tool.name)) { + throw new ValidationError( + `driverAgent: node tool "${tool.name}" collides with a coordination verb or another node tool`, + ) + } + reserved.add(tool.name) + } for (const t of opts.extraTools ?? []) { if (reserved.has(t.name)) { throw new ValidationError( - `driverAgent: extra work tool "${t.name}" collides with a coordination verb`, + `driverAgent: extra work tool "${t.name}" collides with a coordination verb or node tool`, ) } + reserved.add(t.name) } // Fail loud on a nonsensical cap: a negative maxTurns would silently run zero turns and // finalize an empty no-winner — a silent zero the house rules forbid. @@ -302,14 +330,19 @@ export function driverAgent(opts: DriverAgentOptions): Agent { // pure anti-runaway guard, NOT the intended limit. const maxTurns = opts.maxTurns === 0 ? runawayTripwireTurns : (opts.maxTurns ?? 16) const now = opts.now ?? Date.now + const inbox = opts.inbox ?? createInbox() return { name: opts.name, + deliver(message): boolean { + return inbox.deliver(message) + }, async act(task, scope: Scope): Promise { const coord = createCoordinationTools({ scope, blobs: opts.blobs, makeWorkerAgent: opts.makeWorkerAgent, + ...(opts.authorizeDownMessage ? { authorizeDownMessage: opts.authorizeDownMessage } : {}), perWorker: opts.perWorker, ...(opts.deliverable ? { deliverable: opts.deliverable } : {}), ...(opts.maxLiveWorkers !== undefined ? { maxLiveWorkers: opts.maxLiveWorkers } : {}), @@ -318,10 +351,12 @@ export function driverAgent(opts: DriverAgentOptions): Agent { ...(opts.watchWorkers ? { watchWorkers: opts.watchWorkers } : {}), ...(opts.stallAfterMs !== undefined ? { stallAfterMs: opts.stallAfterMs } : {}), ...(opts.onEvent ? { onEvent: opts.onEvent } : {}), + ...(opts.replaySettlements ? { replaySettlements: true } : {}), ...(opts.priorCoordination?.questions.length ? { priorQuestions: opts.priorCoordination.questions } : {}), }) + await coord.ready() // Resume-first: re-establish the prior process's supervision state BEFORE the first brain // turn — its armed-but-never-woken waits become live again on their ORIGINAL deadlines // (they settle through the same cursor `await_event` drains). Fail loud on a wait that @@ -334,12 +369,18 @@ export function driverAgent(opts: DriverAgentOptions): Agent { ) } } - const byName = new Map(coord.tools.map((t) => [t.name, t])) + const byName = new Map( + [...coord.tools, ...(opts.nodeTools ?? [])].map((t) => [t.name, t]), + ) const toolSpecs: ToolSpec[] = [ ...coord.tools.map((t) => ({ type: 'function' as const, function: { name: t.name, description: t.description, parameters: t.inputSchema }, })), + ...(opts.nodeTools ?? []).map((t) => ({ + type: 'function' as const, + function: { name: t.name, description: t.description, parameters: t.inputSchema }, + })), // Work tools the driver calls DIRECTLY — so it can ACT, not only delegate. ...(opts.extraTools ?? []).map((t) => ({ type: 'function' as const, @@ -486,13 +527,26 @@ export function driverAgent(opts: DriverAgentOptions): Agent { // work instead of re-planning (and re-paying) from scratch. ...(scope.resume ? [{ role: 'user', content: resumeBrief(scope.resume, opts.priorCoordination) }] - : []), + : hasPriorCoordination(opts.priorCoordination) + ? [ + { + role: 'user', + content: priorCoordinationBrief(opts.priorCoordination as PriorCoordination), + }, + ] + : []), ], maxTurns, // The conserved-pool + deadline + external-stop bound (what maxTurns=0 relies on): a driver // that can no longer spawn (pool starved) or has run past the deadline stops here instead of // burning turns. Checked before each inference turn. hooks: { + beforeTurn: (_turn, messages) => { + const pending = inbox.drain() + if (pending.length > 0) { + messages.push({ role: 'user', content: inbox.fold(pending) }) + } + }, stopBefore: () => { // HARD CEILINGS FIRST, and independently — a progress rule may never keep a run alive // past one, so they are not folded into the same expression. @@ -546,8 +600,9 @@ export function driverAgent(opts: DriverAgentOptions): Agent { /** * The factual context a resumed driver starts from — everything the durable stores prove about * the prior process(es): committed settlements, per-key states (completed / lost / failed), - * re-armed waits, carried-over questions and findings, and the spend already paid. Injected as - * the brain's first user-context on a resumed run so it continues from the unresolved work. + * re-armed waits, carried-over questions/findings/continuation receipts, and spend already paid. + * Injected as the brain's first user-context on a resumed run so it continues from unresolved work; + * old continuation receipts are evidence and are never auto-delivered. */ function resumeBrief(resume: ResumedWork, prior?: PriorCoordination): string { const lines: string[] = [ @@ -600,6 +655,38 @@ function resumeBrief(resume: ResumedWork, prior?: PriorCoordination): s ...resume.waits.map((w) => `- ${w.label} (${w.spec.kind})`), ) } + appendPriorCoordination(lines, prior) + const spent = resume.priorSpend + lines.push( + '', + 'Budget the run ALREADY spent before this process (it counts toward the run total):', + `- child work: tokens in=${spent.childWork.tokens.input} out=${spent.childWork.tokens.output}, usd=${spent.childWork.usd}, iterations=${spent.childWork.iterations}`, + `- driver inference: tokens in=${spent.driverInference.tokens.input} out=${spent.driverInference.tokens.output}, usd=${spent.driverInference.usd}`, + ) + return lines.join('\n') +} + +function hasPriorCoordination(prior?: PriorCoordination): boolean { + return ( + prior !== undefined && + (prior.questions.length > 0 || + prior.findings.length > 0 || + prior.continuations.length > 0 || + prior.deliveryEvidence.length > 0) + ) +} + +function priorCoordinationBrief(prior: PriorCoordination): string { + const lines = [ + 'PRIOR COORDINATION EVIDENCE: this logical supervisor ran in an earlier process.', + 'Use the evidence below as context. Never auto-deliver an old continuation; issue a new', + 'authorized instruction only when current live state still warrants it.', + ] + appendPriorCoordination(lines, prior) + return lines.join('\n') +} + +function appendPriorCoordination(lines: string[], prior?: PriorCoordination): void { const openQuestions = (prior?.questions ?? []).filter( (q) => q.status === 'open' || q.status === 'escalated', ) @@ -621,14 +708,31 @@ function resumeBrief(resume: ResumedWork, prior?: PriorCoordination): s ), ) } - const spent = resume.priorSpend - lines.push( - '', - 'Budget the run ALREADY spent before this process (it counts toward the run total):', - `- child work: tokens in=${spent.childWork.tokens.input} out=${spent.childWork.tokens.output}, usd=${spent.childWork.usd}, iterations=${spent.childWork.iterations}`, - `- driver inference: tokens in=${spent.driverInference.tokens.input} out=${spent.driverInference.tokens.output}, usd=${spent.driverInference.usd}`, - ) - return lines.join('\n') + if ((prior?.continuations.length ?? 0) > 0) { + const attempts = new Set( + (prior?.deliveryEvidence ?? []) + .filter((event) => event.type === 'delivery-attempt') + .map((event) => event.attempt.receiptId), + ) + const outcomes = new Map( + (prior?.deliveryEvidence ?? []) + .filter((event) => event.type === 'steer' || event.type === 'answer') + .map((event) => [event.down.receiptId, event.down.outcome] as const), + ) + lines.push( + '', + 'Authorized continuations committed by the prior process (evidence only; never replayed automatically):', + ...(prior?.continuations ?? []).map((continuation) => { + const outcome = outcomes.get(continuation.receiptId) + const delivery = + outcome ?? + (attempts.has(continuation.receiptId) + ? 'unknown-after-crash' + : 'not-attempted-before-crash') + return `- receipt=${continuation.receiptId}, ${continuation.kind} → ${continuation.toWorker}, instruction=${continuation.instructionDigest}, delivery=${delivery}` + }), + ) + } } /** Run a work tool. A throw is data to the driver (it can recover next turn), not a crash — fold diff --git a/src/runtime/supervise/coordination-log.ts b/src/runtime/supervise/coordination-log.ts index 61859ba3..867b7e00 100644 --- a/src/runtime/supervise/coordination-log.ts +++ b/src/runtime/supervise/coordination-log.ts @@ -1,14 +1,14 @@ /** - * Durable side-log for the coordination bus: questions and analyst findings, the two UP-leg - * message kinds the spawn journal does NOT record (it owns spawns/settlements/waits/spend). A - * durable run (`supervise({ runDir })`) appends them as they publish and replays them on resume, - * so a restarted coordinator still sees the questions its workers raised and the findings its - * analysts produced — instead of losing both with the process. + * Durable side-log for coordination evidence the spawn journal does not own: questions, analyst + * findings, answer decisions, authorized continuation receipts, delivery-attempt markers, and + * delivery outcomes. A durable run + * (`supervise({ runDir })`) appends them as they publish and loads them on resume, so a restarted + * coordinator retains the exact evidence produced by prior processes. * - * Answer down-events are logged too, ONLY to fold status on load: a question answered before the - * crash reloads as `answered`, not as a re-blocking `open`. Settled events are skipped (the spawn - * journal is their ledger); steer events are skipped (a delivered steer to a dead worker has no - * meaning to a new process). + * Answer down-events also fold status on load: a question answered before the crash reloads as + * `answered`, not as a re-blocking `open`. Settled events are skipped (the spawn journal is their + * ledger). A receipt followed by an attempt but no outcome proves the process died in the delivery + * window; that outcome remains unknown and no prior instruction is auto-delivered. * * JSONL, one fsynced record per event, keyed by `runId` — several runs may share one log file * exactly as they share one spawn-journal file. @@ -16,93 +16,201 @@ * @experimental */ +import { + parseCommittedJsonLines, + prepareJsonlAppend, + writeAllBytes, +} from '../../durable/jsonl-file' import type { AnalystFindingEvent, + ContinuationInstruction, CoordinationEvent, QuestionRecord, } from '../../mcp/tools/coordination' +import type { BusRecord } from './event-bus' -/** What a prior process's coordination log replays into a resumed driver. */ +/** Stable identity of the supervisor that owns one coordination stream. High-level supervision + * derives it from the exact root/child execution identity plus its parent assignment. */ +export type CoordinationOwnerId = string + +/** Durable delivery evidence retained in commit order. An attempt without a later event carrying + * the same `receiptId` has an unknown outcome after a crash and is never replayed. */ +export type CoordinationDeliveryEvidence = Extract< + CoordinationEvent, + { readonly type: 'delivery-attempt' | 'steer' | 'answer' } +> + +/** Coordination evidence loaded from prior processes of one durable supervised run. */ export interface PriorCoordination { + /** The owner filter used for this replay. Omitted only for the compatibility all-owner read. */ + readonly ownerId?: CoordinationOwnerId /** Every question the prior process raised, with answer-status folded in, raise order. */ readonly questions: ReadonlyArray /** Every analyst finding the prior process published, publish order. */ readonly findings: ReadonlyArray + /** Every authorized continuation, in commit order. These are evidence, never replayed to a new + * worker automatically. */ + readonly continuations: ReadonlyArray + /** Delivery intent and result records in commit order, linked to receipts by `receiptId`. */ + readonly deliveryEvidence: ReadonlyArray + /** Exact source-bus stamps in durable append order. Bus `seq` restarts with each process; append + * order remains the cross-process replay order. */ + readonly records: ReadonlyArray> } /** The durable coordination side-log seam. `append` records one bus event (kinds it does not * persist are ignored); `load` replays a run's prior records folded into `PriorCoordination`. */ export interface CoordinationLog { - append(runId: string, event: CoordinationEvent, at: string): Promise - load(runId: string): Promise + append( + runId: string, + record: BusRecord, + ownerId?: CoordinationOwnerId, + ): Promise + load(runId: string, ownerId?: CoordinationOwnerId): Promise } type CoordinationLogRecord = { readonly runId: string + readonly ownerId?: CoordinationOwnerId + readonly seq: number + readonly at: number + readonly priority: number + readonly event: CoordinationEvent +} + +type LegacyCoordinationLogRecord = { + readonly runId: string + readonly ownerId?: CoordinationOwnerId readonly at: string readonly event: CoordinationEvent } -/** Should this bus event be persisted? Questions and findings ARE the prior context a resumed - * driver needs; answers fold their status; everything else has a better ledger or none. */ +/** Persist prior context plus exact continuation authorization, attempt, and result evidence. + * Settlements have their own journal. */ function persisted(event: CoordinationEvent): boolean { - return event.type === 'question' || event.type === 'finding' || event.type === 'answer' + return event.type !== 'settled' } /** FS-backed `CoordinationLog`: append-only JSONL, fsynced per record. */ export class FileCoordinationLog implements CoordinationLog { + private appendTail: Promise = Promise.resolve() + constructor(private readonly path: string) {} - async append(runId: string, event: CoordinationEvent, at: string): Promise { - if (!persisted(event)) return + async append( + runId: string, + record: BusRecord, + ownerId?: CoordinationOwnerId, + ): Promise { + if (!persisted(record.event)) return + const append = this.appendTail.then(() => this.appendRecord(runId, record, ownerId)) + this.appendTail = append.catch(() => undefined) + return append + } + + private async appendRecord( + runId: string, + busRecord: BusRecord, + ownerId?: CoordinationOwnerId, + ): Promise { const fs = await import('node:fs/promises') const path = await import('node:path') await fs.mkdir(path.dirname(this.path), { recursive: true }) - const record: CoordinationLogRecord = { runId, at, event } + const record: CoordinationLogRecord = { + runId, + ...(ownerId !== undefined ? { ownerId } : {}), + ...busRecord, + } + const needsSeparator = await prepareJsonlAppend(this.path) const fh = await fs.open(this.path, 'a') try { - await fh.write(`${JSON.stringify(record)}\n`) + await writeAllBytes(fh, `${needsSeparator ? '\n' : ''}${JSON.stringify(record)}\n`) await fh.sync() } finally { await fh.close() } } - async load(runId: string): Promise { + async load(runId: string, ownerId?: CoordinationOwnerId): Promise { const fs = await import('node:fs/promises') let text: string try { text = await fs.readFile(this.path, 'utf8') } catch (err) { - if (isNoEntError(err)) return { questions: [], findings: [] } + if (isNoEntError(err)) return emptyPriorCoordination(ownerId) throw err } const byId = new Map() const findings: AnalystFindingEvent[] = [] - for (const line of text.split('\n')) { - if (line.length === 0) continue - const record = JSON.parse(line) as CoordinationLogRecord - if (record.runId !== runId) continue + const continuations: ContinuationInstruction[] = [] + const deliveryEvidence: CoordinationDeliveryEvidence[] = [] + const records: BusRecord[] = [] + let legacySeq = 0 + for (const stored of parseCommittedJsonLines< + CoordinationLogRecord | LegacyCoordinationLogRecord + >(text, this.path)) { + if (stored.runId !== runId) continue + // Omitting ownerId preserves the historical all-run read for direct consumers. Runtime always + // supplies one, so root and nested supervisors never receive one another's evidence. + if (ownerId !== undefined && stored.ownerId !== ownerId) continue + const record: BusRecord = + 'seq' in stored + ? { + seq: stored.seq, + at: stored.at, + priority: stored.priority, + event: stored.event, + } + : { + seq: legacySeq++, + at: Date.parse(stored.at), + priority: 0, + event: stored.event, + } + records.push(record) const ev = record.event + if (ev.type === 'delivery-attempt' || ev.type === 'steer' || ev.type === 'answer') { + deliveryEvidence.push(ev) + } if (ev.type === 'question') { byId.set(ev.question.id, ev.question) } else if (ev.type === 'finding') { findings.push(ev.finding) } else if (ev.type === 'answer') { - // Fold the answer into the question it decided, so an answered blocking question does - // not reload as open and re-block the resumed run's stop. `by` is not on the wire — - // the honest attribution is the prior run itself. + // Only accepted delivery resolves a blocking question. Authorization and an attempted + // refusal remain evidence, but cannot turn "worker never received the answer" into answered + // state on replay. `by` is not on the wire; prior-run is the honest attribution. const prior = byId.get(ev.questionId) - if (prior) { + if (prior && ev.down.delivered) { byId.set(ev.questionId, { ...prior, status: 'answered', decision: { kind: 'answer', answer: ev.down.instruction, by: 'prior-run' }, }) } + } else if (ev.type === 'instruction') { + continuations.push(ev.instruction) } } - return { questions: [...byId.values()], findings } + return { + ...(ownerId !== undefined ? { ownerId } : {}), + questions: [...byId.values()], + findings, + continuations, + deliveryEvidence, + records, + } + } +} + +function emptyPriorCoordination(ownerId?: CoordinationOwnerId): PriorCoordination { + return { + ...(ownerId !== undefined ? { ownerId } : {}), + questions: [], + findings: [], + continuations: [], + deliveryEvidence: [], + records: [], } } diff --git a/src/runtime/supervise/coordination-mcp.ts b/src/runtime/supervise/coordination-mcp.ts index 88150294..066e4755 100644 --- a/src/runtime/supervise/coordination-mcp.ts +++ b/src/runtime/supervise/coordination-mcp.ts @@ -21,9 +21,10 @@ import { createServer, type Server } from 'node:http' import { ConfigError } from '../../errors' -import { createMcpServer } from '../../mcp/server' +import { createMcpServer, type McpToolDescriptor } from '../../mcp/server' import { type AnalystRegistry, + type AuthorizeDownMessage, type CoordinationEvent, type CoordinationTools, createCoordinationTools, @@ -35,6 +36,7 @@ import { type WorkerWatchOptions, } from '../../mcp/tools/coordination' import type { DeliverableSpec } from './completion-gate' +import type { BusRecord } from './event-bus' import type { Budget, ResultBlobStore, Scope } from './types' export interface CoordinationMcpHandle { @@ -49,7 +51,7 @@ export interface CoordinationMcpHandle { * `settled()` for a finalize, so a delivered child the harness never awaited is not lost. */ drainResolved: CoordinationTools['drainResolved'] isStopped(): boolean - /** The full ordered bus-event log — observability audit + replay trail. */ + /** The full ordered bus-event log for current-process observability and audit evidence. */ history: CoordinationTools['history'] /** Bus throughput counters for live dashboards. */ stats: CoordinationTools['stats'] @@ -77,6 +79,7 @@ export async function serveCoordinationMcp(opts: { scope: Scope blobs: ResultBlobStore makeWorkerAgent: MakeWorkerAgent + authorizeDownMessage?: AuthorizeDownMessage perWorker: Budget /** Independent completion check exposed to the driver as `submit_result`. */ deliverable?: DeliverableSpec @@ -103,11 +106,17 @@ export async function serveCoordinationMcp(opts: { watchWorkers?: WorkerWatchOptions /** Idle time after which `observe_agent` reports a worker as stalled. */ stallAfterMs?: number - /** Pass-through subscriber for every bus event (settled / question / finding). */ - onEvent?: (event: CoordinationEvent) => void | Promise + /** Pass-through subscriber for every bus event, including pre-delivery instruction receipts and + * steer/answer delivery outcomes. */ + onEvent?: (event: CoordinationEvent, record: BusRecord) => void | Promise + /** Re-publish resume-time settlements through the awaited observer before this server listens. */ + replaySettlements?: boolean questionPolicy?: QuestionPolicy /** Questions replayed from a prior process of this run — seeds the question ledger. */ priorQuestions?: ReadonlyArray + /** Product-selected tools already bound to this exact supervisor node. They share this server + * with the coordination verbs, so the existing MCP duplicate-name guard applies before listen. */ + nodeTools?: ReadonlyArray }): Promise { const host = opts.host ?? '127.0.0.1' // Fail closed on a non-loopback bind HERE, in the primitive, not only at the composition sites @@ -130,6 +139,7 @@ export async function serveCoordinationMcp(opts: { scope: opts.scope, blobs: opts.blobs, makeWorkerAgent: opts.makeWorkerAgent, + ...(opts.authorizeDownMessage ? { authorizeDownMessage: opts.authorizeDownMessage } : {}), perWorker: opts.perWorker, ...(opts.deliverable ? { deliverable: opts.deliverable } : {}), ...(opts.maxLiveWorkers !== undefined ? { maxLiveWorkers: opts.maxLiveWorkers } : {}), @@ -139,10 +149,15 @@ export async function serveCoordinationMcp(opts: { ...(opts.watchWorkers ? { watchWorkers: opts.watchWorkers } : {}), ...(opts.stallAfterMs !== undefined ? { stallAfterMs: opts.stallAfterMs } : {}), ...(opts.onEvent ? { onEvent: opts.onEvent } : {}), + ...(opts.replaySettlements ? { replaySettlements: true } : {}), ...(opts.questionPolicy ? { questionPolicy: opts.questionPolicy } : {}), ...(opts.priorQuestions?.length ? { priorQuestions: opts.priorQuestions } : {}), }) - const mcp = createMcpServer({ extraTools: coord.tools, serverName: 'coordination' }) + await coord.ready() + const mcp = createMcpServer({ + extraTools: [...coord.tools, ...(opts.nodeTools ?? [])], + serverName: 'coordination', + }) const server: Server = createServer((req, res) => { if (req.method !== 'POST') { diff --git a/src/runtime/supervise/deadline.ts b/src/runtime/supervise/deadline.ts new file mode 100644 index 00000000..cddf869a --- /dev/null +++ b/src/runtime/supervise/deadline.ts @@ -0,0 +1,110 @@ +import { ValidationError } from '../../errors' +import type { Executor } from './types' + +// Node clamps larger delays to 1 ms instead of waiting, so long budgets must be armed in chunks. +const MAX_TIMER_DELAY_MS = 2_147_483_647 + +export const DEFAULT_SUCCESSFUL_SHUTDOWN_MS = 5_000 +const TEARDOWN_ACKNOWLEDGEMENT_MS = 250 + +/** Arm one wall-clock deadline without keeping the Node.js process alive. */ +export function armDeadlineTimer(delayMs: number, onDeadline: () => void): () => void { + const deadlineAtMs = Date.now() + Math.max(0, delayMs) + let cleared = false + let timer: ReturnType | undefined + + const arm = (): void => { + if (cleared) return + const remainingMs = Math.max(0, deadlineAtMs - Date.now()) + timer = setTimeout( + () => { + if (cleared) return + if (Date.now() >= deadlineAtMs) onDeadline() + else arm() + }, + Math.min(remainingMs, MAX_TIMER_DELAY_MS), + ) + if (typeof timer.unref === 'function') timer.unref() + } + + arm() + return () => { + cleared = true + if (timer !== undefined) clearTimeout(timer) + } +} + +/** + * Resolve a spawned child's duration into one absolute cutoff. An omitted child deadline inherits + * the parent cutoff; an explicit child duration may shorten, but never extend, that cutoff. + */ +export function boundedChildDeadlineAt( + parentDeadlineAtMs: number, + childDeadlineMs: number | undefined, + nowMs: number, +): number | undefined { + const parent = parentDeadlineAtMs > 0 ? parentDeadlineAtMs : undefined + const child = childDeadlineMs === undefined ? undefined : nowMs + childDeadlineMs + if (parent === undefined) return child + if (child === undefined) return parent + return Math.min(parent, child) +} + +/** Invoke teardown once and bound how long the runtime waits for its acknowledgement. A hard + * execution deadline always wins; without one, numeric grace gets a short acknowledgement + * allowance and a brutal kill gets only that allowance. Explicit `infinity` remains unbounded + * only when no execution deadline exists. The losing promise stays observed. */ +export async function teardownExecutor( + executor: Executor, + grace: number | 'brutalKill' | 'infinity', + deadlineAtMs: number | undefined, + now: () => number, +): Promise { + const work = Promise.resolve(executor.teardown(grace)) + + const requestedWaitMs = + grace === 'infinity' + ? undefined + : grace === 'brutalKill' + ? TEARDOWN_ACKNOWLEDGEMENT_MS + : grace + TEARDOWN_ACKNOWLEDGEMENT_MS + // The execution cutoff stops new work; cleanup still gets a small, bounded acknowledgement + // window after that cutoff. Otherwise an already-resolved brutal kill invoked exactly at the + // deadline would be mislabeled as a cleanup failure without receiving one microtask. + const deadlineWaitMs = + deadlineAtMs === undefined + ? undefined + : Math.max(0, deadlineAtMs - now()) + TEARDOWN_ACKNOWLEDGEMENT_MS + const waitMs = + requestedWaitMs === undefined + ? deadlineWaitMs + : deadlineWaitMs === undefined + ? requestedWaitMs + : Math.min(requestedWaitMs, deadlineWaitMs) + + let receipt: { destroyed: boolean } + if (waitMs === undefined) { + receipt = await work + } else if (waitMs <= 0) { + void work.catch(() => undefined) + throw new ValidationError('executor teardown did not acknowledge before its deadline') + } else { + let timer: ReturnType | undefined + const timedOut = new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject(new ValidationError(`executor teardown did not acknowledge within ${waitMs}ms`)), + waitMs, + ) + }) + try { + receipt = await Promise.race([work, timedOut]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } + } + + if (!receipt.destroyed) { + throw new ValidationError('executor teardown reported destroyed=false') + } +} diff --git a/src/runtime/supervise/delegate.ts b/src/runtime/supervise/delegate.ts index f03a17d4..e9932149 100644 --- a/src/runtime/supervise/delegate.ts +++ b/src/runtime/supervise/delegate.ts @@ -58,24 +58,27 @@ export interface DelegateOptions { readonly brain?: ToolLoopChat /** Override the default authoring-supervisor profile (name / extra system-prompt stance). The * default already carries the authoring skill; override only to add a goal or rename. */ - readonly supervisor?: Partial> + readonly supervisor?: { + readonly name?: string + readonly systemPrompt?: string + } /** Restrict the run to this subset of models (forwarded to `supervise()`). */ readonly allowedModels?: readonly string[] readonly runId?: string } -/** Build the DEFAULT authoring supervisor profile: a router-brained supervisor (`harness: null`) +/** Build the DEFAULT authoring supervisor profile: a router-brained supervisor (`harness: cli-base`) * whose standing instruction IS the authoring-agent-profiles skill, so it decomposes the intent and * AUTHORS a worker profile per sub-task. No worker profile is baked in here. */ function authoringSupervisorProfile( model: string | undefined, - override?: Partial>, + override?: { readonly name?: string; readonly systemPrompt?: string }, ): SupervisorProfile { return { name: override?.name ?? 'delegate-supervisor', - harness: null, - ...(model ? { model } : {}), - systemPrompt: override?.systemPrompt ?? supervisorInstructions(), + harness: 'cli-base', + ...(model ? { model: { default: model } } : {}), + prompt: { systemPrompt: override?.systemPrompt ?? supervisorInstructions() }, } } diff --git a/src/runtime/supervise/dispatch.ts b/src/runtime/supervise/dispatch.ts index 3e98d1c4..6a1222d5 100644 --- a/src/runtime/supervise/dispatch.ts +++ b/src/runtime/supervise/dispatch.ts @@ -20,9 +20,11 @@ * Three unrelated caps bound "how much runs at once" in this stack, at three different layers. * They are NOT aware of each other, and the smallest one silently wins: * - * 1. `CoordinationToolsOptions.maxLiveWorkers` (`src/mcp/tools/coordination.ts`) — supervisor - * level. How many workers may be spawned-but-not-settled at once; `spawn_agent` fails closed - * with `error: 'max-live-workers'` past it. Unset by default ⇒ NO cap at this layer. + * 1. `SuperviseOptions.maxLiveWorkers` (`src/runtime/supervise/supervise.ts`) — supervised-tree + * level. One shared Scope counter bounds every spawned manager and leaf in the recursive tree; + * `spawn_agent` fails closed with `error: 'max-live-workers'` past it. Unset ⇒ NO tree cap. + * `CoordinationToolsOptions.maxLiveWorkers` remains the local form for a toolbox mounted on a + * caller-owned Scope that has no tree limit. * 2. `SandboxLineage`'s `maxConcurrency` / `DEFAULT_FORK_CONCURRENCY = 4` * (`src/runtime/sandbox-lineage.ts`) — kernel level. How many BOXES one `runAgentRounds` fork wave * provisions at once. It bounds a single leaf's fanout, not the supervisor's worker count. diff --git a/src/runtime/supervise/driver-executor.ts b/src/runtime/supervise/driver-executor.ts index 01e5d53f..d3e1d365 100644 --- a/src/runtime/supervise/driver-executor.ts +++ b/src/runtime/supervise/driver-executor.ts @@ -6,8 +6,8 @@ * A spawned child resolves through the open registry to an `Executor`; the built-in * executors (router/inline, sandbox, cli) are LEAVES — `execute(task, signal)` runs the * work and settles. This executor is the recursive case: on `execute`, it mounts a NESTED - * `Scope` (the scope hands it the mount via the `nested-scope` seam) over the SAME - * conserved pool + shared journal/blobs + the same open registry, one `depth` deeper, then + * `Scope` (the scope hands it the mount via the `nested-scope` seam) over a child allocation + * carved from its parent reservation, plus shared journal/blobs and the same open registry, then * runs the wrapped driver `Agent.act(task, nestedScope)`. The driver spawns its own * children into that nested scope; each resolves to EITHER a leaf executor (a worker child) * OR this same driver-executor (a driver child) — recursively. So a driver spawns a driver @@ -15,18 +15,16 @@ * * Why this preserves every keystone invariant (the scope owns the sharing; this executor * only runs the driver over what the scope mounts): - * - Conserved budget: the nested scope reserves from the SAME `BudgetPool` the root owns - * (the scope mounts it over `args.pool`), so `Σk` is conserved ACROSS depth by - * construction — a deep tree cannot overspend the root ceiling (reserve-on-spawn fails - * closed at any depth). + * - Conserved budget: the parent reserves the driver's whole allocation once. The nested scope + * partitions only that allocation, and its actual child work + driver inference reconcile the + * parent ticket once at settle. A deep tree cannot overspend or double-charge the root total. * - Journal: the nested scope writes to its OWN tree key (`${journalRoot}/${nodeId}`) so * its cursor `seq`s never collide with the parent's in the per-tree uniqueness guard, * while every nested tree shares the one `SpawnJournal` — the whole recursion is one * journal, queryable tree by tree. - * - Settlement bubbling: the driver child settles into its PARENT scope with the conserved - * spend summed off its nested tree's settled events, so the parent's pool reconcile + - * the supervisor's `spentTotal` see the whole sub-tree's spend rolled up — settlements - * bubble to the root. + * - Settlement bubbling: the driver child settles into its PARENT scope with child work and + * driver inference kept separate in the journal, while their sum reconciles the one parent + * reservation. The supervisor sees the whole sub-tree exactly once. * - Depth ceiling: the nested scope runs at `depth+1`, so the supervisor's `maxDepth` * (paired with the conserved pool per R3) fails a spawn closed once the recursion is too * deep — exactly as it does for a flat tree. @@ -38,12 +36,25 @@ * @experimental */ +import type { AgentProfile } from '@tangle-network/agent-interface' import { ValidationError } from '../../errors' -import { type NestedScopeSeam, nestedScopeSeamKey } from './scope' +import { + attestRuntimeOwnedDeferredExecutor, + runtimeOwnedScopeOwnerRuntime, +} from './materialization' +import { + finalizeScopeOwnerMaterialization, + type NestedScopeSeam, + nestedScopeSeamKey, +} from './scope' +import { attestNestedDriverTreeOwner, driverRuntime, nestedDriverTreeRoot } from './tree-key' import type { Agent, + AgentExecutionRef, AgentSpec, DefaultVerdict, + Executor, + ExecutorAccounting, ExecutorContext, ExecutorFactory, ExecutorRegistry, @@ -55,13 +66,14 @@ import type { } from './types' /** The runtime tag the registry maps a driver child to. */ -export const driverRuntime = 'driver' as const +export { driverRuntime } from './tree-key' /** The metadata marker on a driver child's spec the recursive registry routes on. */ const driverRole = 'driver' /** A driver child's spec carries the `Agent` to run inside the nested scope. */ interface DriverSpec extends AgentSpec { + readonly driverRuntime: typeof driverRuntime readonly driver: Agent /** The shared journal the nested tree is one tree key inside (so the executor can * begin its nested tree + sum its spend off the same record). */ @@ -76,19 +88,35 @@ interface DriverSpec extends AgentSpec { * called directly — a driver child runs THROUGH its nested-scope executor, never as a root. */ export function driverChild( - name: string, + profileOrName: AgentProfile | string, driver: Agent, journal: SpawnJournal, + execution?: AgentExecutionRef, ): Agent { + const profile: AgentProfile = + typeof profileOrName === 'string' + ? { name: profileOrName, metadata: { role: driverRole } } + : profileOrName + const name = profile.name ?? driver.name const spec: DriverSpec = { - profile: { name, metadata: { role: driverRole } } as AgentSpec['profile'], + profile, harness: null, + ...(execution ? { execution } : {}), + driverRuntime, driver: driver as Agent, journal, } + const deliver = driver.deliver?.bind(driver) return { name, executorSpec: spec, + ...(deliver + ? { + deliver(message: unknown): boolean { + return deliver(message) !== false + }, + } + : {}), act(): Promise { throw new ValidationError( `driverChild: "${name}" was run directly; a driver child runs through its nested-scope executor`, @@ -99,8 +127,7 @@ export function driverChild( /** True when a spec is a driver child (carries the role marker + a driver Agent). */ export function isDriverSpec(spec: AgentSpec): spec is DriverSpec { - const role = (spec.profile.metadata as { role?: unknown } | undefined)?.role - if (role !== driverRole) return false + if ((spec as { driverRuntime?: unknown }).driverRuntime !== driverRuntime) return false const driver = (spec as { driver?: unknown }).driver if (!isAgent(driver)) { throw new ValidationError( @@ -114,7 +141,7 @@ export function isDriverSpec(spec: AgentSpec): spec is DriverSpec { * The recursive driver-executor factory. `withDriverExecutor` routes a child marked * `role: 'driver'` here; any other child resolves to a leaf built-in. On `execute`, it * reads the `nested-scope` seam the SCOPE seeded, mounts a nested `Scope` one `depth` - * deeper over the shared pool/journal/blobs/registry, runs the driver + * deeper over the driver's reserved child pool plus shared journal/blobs/registry, runs the driver * `Agent.act(task, nestedScope)`, and reports the conserved spend summed off the nested * tree's settled events — so the parent scope's reconcile rolls the whole sub-tree's spend * into the conserved total. @@ -130,6 +157,7 @@ export const driverExecutorFactory: ExecutorFactory = (spec, ctx) => { ) } const driver = spec.driver + const deliver = driver.deliver?.bind(driver) const journal = spec.journal const seam = readNestedScopeSeam(ctx) @@ -138,60 +166,102 @@ export const driverExecutorFactory: ExecutorFactory = (spec, ctx) => { // on BOTH the success AND crash paths (metered events are durable in the nested tree regardless), // so a sub-driver that crashes mid-run still re-homes its partial inference — pool + journal agree. let meteredSpend: Spend | undefined + let accounting: ExecutorAccounting | undefined + let active: + | { + readonly controller: AbortController + readonly scope: Scope + close?: Promise + } + | undefined - return { + const closeActive = (reason: string): Promise => { + if (active === undefined) return Promise.resolve() + active.close ??= closeNestedScope(active.scope, active.controller, reason) + return active.close + } + + const executor: Executor = { runtime: driverRuntime, + ...(deliver + ? { + deliver(message: unknown): boolean { + return deliver(message) !== false + }, + } + : {}), async execute(task, signal): Promise> { // The nested tree key namespaces this driver's children inside the ONE shared // journal, so its cursor seqs never collide with the parent's per-tree guard. - const nestedRoot = nestedTreeKey(seam, journal) + const nestedRoot = nestedDriverTreeRoot(seam.journalRoot, seam.nodeId) await journal.beginTree(nestedRoot, new Date(0).toISOString()) - const nestedScope: Scope = seam.mount(nestedRoot, signal) + const controller = new AbortController() + const onParentAbort = () => controller.abort(signal.reason) + if (signal.aborted) controller.abort(signal.reason) + else signal.addEventListener('abort', onParentAbort, { once: true }) + const nestedScope: Scope = seam.mount(nestedRoot, controller.signal) + active = { controller, scope: nestedScope } try { // Run the driver. Its `act` spawns children into the nested scope and reacts via // `scope.next()`; a thrown `act` propagates so the PARENT scope types it into a down. - const out = await driver.act(task, nestedScope) + const out = await driverActAbortable(() => driver.act(task, nestedScope), controller.signal) + await finalizeScopeOwnerMaterialization(nestedScope) - // Read the nested tree's events ONCE. Two roll-ups, kept separate so the conserved invariant - // is not double-charged: - // - `spent` = settled child WORK → reconciled against THIS driver's reservation (as before). - // - `metered` = the nested subtree's driver INFERENCE → re-homed by the parent scope as a - // `metered` event, NOT reconciled (already pool-debited live via `observe`). + // A driver may choose its answer while descendants still run. Its allocation is not + // refundable until every descendant has stopped and reached a terminal journal record. + await closeActive('driver completed') + + // Read the nested tree's events ONCE. Keep two journal categories while reconciling their + // sum against this driver's one parent reservation: + // - `spent` = settled child WORK, written on this driver's settlement. + // - `metered` = nested driver INFERENCE, re-homed as a separate metered event. const events = await loadTreeEvents(journal, nestedRoot) const settled = events.filter(isSettled) + const childWork = sumSpend(settled) meteredSpend = nonZeroOrUndef(sumMetered(events)) - // Completion-oracle propagation: a driver "delivered" iff at least one of its DIRECT - // children settled `valid` (the child its keep-best finalize returns). Deriving the - // driver child's verdict this way composes delivery UP the recursion — a sub-driver is - // `valid` only when it itself selected a delivered child — so a node never settles - // "done = delivered" on a sub-tree that delivered nothing (Foreman's 0/18 lesson). - const verdict = deriveDeliveryVerdict(settled) + accounting = { + reported: childWork, + reservation: addSpend(childWork, meteredSpend ?? zeroSpend()), + } + // Completion propagation follows both facts: the subtree delivered at least one valid + // child, and this manager's finalizer actually accepted an output. A custom finalizer may + // refuse an otherwise valid child because product state is incomplete; that refusal must + // remain invalid when the nested manager settles into its parent. + const verdict = deriveDeliveryVerdict(settled, out) artifact = { outRef: `${driverRuntime}:${nestedRoot}`, out, - spent: sumSpend(settled), + spent: childWork, ...(verdict ? { verdict } : {}), } return artifact } catch (err) { + await finalizeScopeOwnerMaterialization(active?.scope ?? nestedScope).catch(() => undefined) + await closeActive('driver stopped') // Crash mid-run: the nested tree still holds the durable `metered` events the sub-driver // already wrote (pool already debited them). Cache them so the parent's down-path re-home // lands the partial inference and the two ledgers stay in agreement. A missing tree must // not mask the original error. - meteredSpend = await safeSumMetered(journal, nestedRoot) + const partial = await safeRollup(journal, nestedRoot) + meteredSpend = partial?.metered + accounting = partial?.accounting throw err + } finally { + signal.removeEventListener('abort', onParentAbort) + active = undefined } }, + accounting(): ExecutorAccounting | undefined { + return accounting + }, metered(): Spend | undefined { return meteredSpend }, - teardown(): Promise<{ destroyed: boolean }> { - // The nested scope's live children are torn down by the driver's own `act` discipline - // (it drains to settlement) and by the parent's abort cascade through `signal`; there - // is no separate box/process to reap here. - return Promise.resolve({ destroyed: true }) + async teardown(): Promise<{ destroyed: boolean }> { + await closeActive('driver executor teardown') + return { destroyed: true } }, resultArtifact(): ExecutorResult { if (!artifact) { @@ -200,6 +270,11 @@ export const driverExecutorFactory: ExecutorFactory = (spec, ctx) => { return artifact }, } + const treeOwner = attestNestedDriverTreeOwner(executor) + const ownerRuntime = runtimeOwnedScopeOwnerRuntime(driver) + return ownerRuntime === undefined + ? treeOwner + : attestRuntimeOwnedDeferredExecutor(treeOwner, ownerRuntime) } /** @@ -213,8 +288,7 @@ export function withDriverExecutor(base: ExecutorRegistry): ExecutorRegistry { return { register: base.register.bind(base), resolve(spec: AgentSpec) { - const role = (spec.profile.metadata as { role?: unknown } | undefined)?.role - if (role === driverRole && !spec.executor) { + if ((spec as { driverRuntime?: unknown }).driverRuntime === driverRuntime && !spec.executor) { return { succeeded: true as const, value: driverExecutorFactory as ExecutorFactory } } return base.resolve(spec) @@ -224,23 +298,70 @@ export function withDriverExecutor(base: ExecutorRegistry): ExecutorRegistry { // ── Helpers ────────────────────────────────────────────────────────────────────── -/** Mint a unique nested-tree key under the parent's journal root. Uses the parent's - * `journalRoot` + a per-journal monotonic ordinal so two sibling driver trees never - * collide their keys (each driver child mints exactly one nested tree). */ -function nestedTreeKey(seam: NestedScopeSeam, journal: SpawnJournal): string { - return `${seam.journalRoot}/d${nextNestOrdinal(journal)}` +async function closeNestedScope( + scope: Scope, + controller: AbortController, + reason: string, +): Promise { + if (!controller.signal.aborted) controller.abort(reason) + for (;;) { + const settled = await scope.next() + if (settled === null) break + } + const view = scope.view + if (view.inFlight > 0 || view.waiting > 0) { + throw new ValidationError( + `driverExecutor: nested cleanup left ${view.inFlight} running and ${view.waiting} waiting nodes`, + ) + } } -/** Per-journal monotonic nest counter — keyed on the journal instance so a single run's - * nested-tree keys are unique without a shared module global. */ -const nestCounters = new WeakMap() -function nextNestOrdinal(journal: SpawnJournal): number { - let c = nestCounters.get(journal) - if (!c) { - c = { n: 0 } - nestCounters.set(journal, c) - } - return c.n++ +async function driverActAbortable(act: () => Promise, signal: AbortSignal): Promise { + if (signal.aborted) throw driverAbortError(signal) + return await new Promise((resolve, reject) => { + let settled = false + const cleanup = () => signal.removeEventListener('abort', onAbort) + const onAbort = () => { + queueMicrotask(() => { + if (settled) return + settled = true + cleanup() + reject(driverAbortError(signal)) + }) + } + signal.addEventListener('abort', onAbort, { once: true }) + let work: Promise + try { + work = Promise.resolve(act()) + } catch (error) { + cleanup() + reject(error) + return + } + work.then( + (value) => { + if (settled) return + settled = true + cleanup() + resolve(value) + }, + (error) => { + if (settled) return + settled = true + cleanup() + reject(error) + }, + ) + }) +} + +function driverAbortError(signal: AbortSignal): Error { + const reason = signal.reason + const error = new Error( + typeof reason === 'string' && reason.length > 0 ? reason : 'driver aborted', + ) + error.name = 'AbortError' + return error } /** The nested tree's full event list — the one evidence the spend, verdict, AND driver-inference @@ -267,6 +388,7 @@ function sumSpend(settled: ReadonlyArray<{ spent: Spend }>): Spend { total.iterations += ev.spent.iterations total.tokens.input += ev.spent.tokens.input total.tokens.output += ev.spent.tokens.output + if (ev.spent.tokensKnown === false) total.tokensKnown = false total.usd += ev.spent.usd if (ev.spent.tokensKnown === false) total.tokensKnown = false if (ev.spent.usdKnown === false) total.usdKnown = false @@ -285,6 +407,7 @@ function sumMetered(events: ReadonlyArray): Spend { total.iterations += ev.spend.iterations total.tokens.input += ev.spend.tokens.input total.tokens.output += ev.spend.tokens.output + if (ev.spend.tokensKnown === false) total.tokensKnown = false total.usd += ev.spend.usd if (ev.spend.tokensKnown === false) total.tokensKnown = false if (ev.spend.usdKnown === false) total.usdKnown = false @@ -293,6 +416,24 @@ function sumMetered(events: ReadonlyArray): Spend { return total } +function zeroSpend(): Spend { + return { iterations: 0, tokens: { input: 0, output: 0 }, usd: 0, ms: 0 } +} + +function addSpend(a: Spend, b: Spend): Spend { + return { + iterations: a.iterations + b.iterations, + tokens: { + input: a.tokens.input + b.tokens.input, + output: a.tokens.output + b.tokens.output, + }, + ...(a.tokensKnown === false || b.tokensKnown === false ? { tokensKnown: false } : {}), + usd: a.usd + b.usd, + ...(a.usdKnown === false || b.usdKnown === false ? { usdKnown: false } : {}), + ms: a.ms + b.ms, + } +} + /** An all-zero spend that carries an UNKNOWN marker counts as non-zero: a sub-driver whose turns * went unmeasured did real work, and dropping its `metered` event here would re-hide upstream the * very turn the driver refused to skip. */ @@ -316,24 +457,37 @@ function nonZeroOrUndef(s: Spend): Spend | undefined { /** Sum the nested tree's metered events, tolerating a missing tree (a crash before `beginTree` * landed) — never throw here, or it would mask the original `act` error on the crash path. */ -async function safeSumMetered( +async function safeRollup( journal: SpawnJournal, nestedRoot: string, -): Promise { +): Promise< + { readonly metered: Spend | undefined; readonly accounting: ExecutorAccounting } | undefined +> { try { - return nonZeroOrUndef(sumMetered(await loadTreeEvents(journal, nestedRoot))) + const events = await loadTreeEvents(journal, nestedRoot) + const childWork = sumSpend(events.filter(isSettled)) + const metered = nonZeroOrUndef(sumMetered(events)) + return { + metered, + accounting: { + reported: childWork, + reservation: addSpend(childWork, metered ?? zeroSpend()), + }, + } } catch { return undefined } } -/** Derive the driver child's delivery verdict from its DIRECT children's settlements: - * `valid` iff any direct child settled `done` AND `valid` (the keep-best finalize's pick); +/** Derive the driver child's delivery verdict from its DIRECT children's settlements and the + * manager's actual finalized output: `valid` iff a direct child delivered AND the finalizer + * returned a defined output; * `score` = the best delivered score. Returns `undefined` when no child settled at all (the * driver itself produced nothing to bubble a verdict from). Fail-closed: a child whose verdict * carried no `valid` counts as not-delivered. */ function deriveDeliveryVerdict( settled: ReadonlyArray<{ status: 'done' | 'down'; verdict?: DefaultVerdict }>, + finalizedOutput: unknown, ): DefaultVerdict | undefined { let sawChild = false let anyValid = false @@ -354,9 +508,10 @@ function deriveDeliveryVerdict( } } if (!sawChild) return undefined + const accepted = anyValid && finalizedOutput !== undefined return { - valid: anyValid, - score: anyValid ? (bestValidScore ?? 1) : (bestDoneScore ?? 0), + valid: accepted, + score: accepted ? (bestValidScore ?? 1) : (bestDoneScore ?? 0), } } diff --git a/src/runtime/supervise/event-bus.ts b/src/runtime/supervise/event-bus.ts index 5a7be78c..cc81c620 100644 --- a/src/runtime/supervise/event-bus.ts +++ b/src/runtime/supervise/event-bus.ts @@ -12,8 +12,8 @@ * queued settles/findings so the driver sees it first; ties resolve FIFO by publish order. * * Observability is first-class (A++): every event is stamped with a monotonic `seq` and wall-clock - * `at`, the full ordered `history()` is retained as an audit/replay trail, and `stats()` exposes - * published/pulled counts by kind. Subscribers receive the stamped record, not a bare event. + * `at`, the full ordered `history()` is retained as current-process audit evidence, and `stats()` + * exposes published/pulled counts by kind. Subscribers receive the stamped record, not a bare event. * * The interface is transport-agnostic on purpose. Same box → this in-process queue. Cross box → * the SAME publish/pull/subscribe surface backed by a durable mailbox on the parent's box (children @@ -55,8 +55,9 @@ export interface BusStats { } export interface EventBus { - /** Stamp + queue the event, then deliver the stamped record to every subscriber in order. - * Returns the stamped record. */ + /** Stamp the event, await every subscriber in order, then make it pull-visible. A subscriber + * failure leaves the event invisible and retrying the SAME event object reuses the exact stamp. + * This lets an awaited product observer commit its record before a supervisor can consume it. */ publish(event: E, opts?: PublishOptions): Promise> /** Remove and return the highest-priority QUEUED event whose type is in `kinds` (any if omitted), * ties broken FIFO by `seq`; `undefined` when nothing matches. */ @@ -66,7 +67,7 @@ export interface EventBus { subscribe(handler: (record: BusRecord) => void | Promise): () => void /** Count of queued, not-yet-pulled events (filtered by `kinds` when given). */ pending(kinds?: ReadonlyArray): number - /** The full ordered log of every event ever published (the audit/replay trail). */ + /** The full ordered log of every event published in this process (audit evidence, not replay). */ history(): ReadonlyArray> /** Throughput counters for observability dashboards. */ stats(): BusStats @@ -78,7 +79,12 @@ export function createEventBus(now: () => number = Date.now) const log: BusRecord[] = [] const subscribers: Array<(record: BusRecord) => void | Promise> = [] const byKind: Record = {} + // A failed publication is staged, not published. Coordination retains and retries the same event + // object, so the exact BusRecord survives a lost acknowledgement and downstream idempotency keys + // do not change between attempts. + const staged = new WeakMap>() let seq = 0 + let published = 0 let pulled = 0 const matches = (r: BusRecord, kinds?: ReadonlyArray) => @@ -102,14 +108,19 @@ export function createEventBus(now: () => number = Date.now) return { async publish(event, opts) { - const record: BusRecord = { seq: seq++, at: now(), priority: opts?.priority ?? 0, event } + const record = + staged.get(event) ?? + ({ seq: seq++, at: now(), priority: opts?.priority ?? 0, event } satisfies BusRecord) + staged.set(event, record) + // Sequential, not Promise.all: transaction observers must see causal order. Nothing enters + // the pull queue or successful history until all observers acknowledge the record. + for (const handler of subscribers) await handler(record) + staged.delete(event) // Record-only events (the down-leg) skip the pull queue but still hit the log + subscribers. if (opts?.queue !== false) queue.push(record) log.push(record) + published += 1 byKind[event.type] = (byKind[event.type] ?? 0) + 1 - // Sequential, not Promise.all: a subscriber that steers off this event must observe a - // consistent order, and a throwing subscriber must not silently drop siblings' delivery. - for (const handler of subscribers) await handler(record) return record }, pull(kinds) { @@ -132,7 +143,7 @@ export function createEventBus(now: () => number = Date.now) return log }, stats() { - return { published: seq, pulled, byKind: { ...byKind } } + return { published, pulled, byKind: { ...byKind } } }, } } diff --git a/src/runtime/supervise/inbox.ts b/src/runtime/supervise/inbox.ts index 0de13105..adf161a6 100644 --- a/src/runtime/supervise/inbox.ts +++ b/src/runtime/supervise/inbox.ts @@ -11,7 +11,8 @@ * in-flight turn immediately, then re-plan with the message folded in — breaking the worker out * of a wrong path mid-task instead of waiting for it to finish the step. * - * `deliver` never throws — a malformed message is ignored, per the `Executor.deliver` contract. + * `deliver` never throws — a malformed message is ignored and returns `false`, so no caller can + * report delivery for bytes this inbox discarded. * * @experimental */ @@ -26,8 +27,9 @@ export interface InboxMessage { } export interface Inbox { - /** The `Executor.deliver` implementation — accept a raw down-message from `Scope.send`. */ - deliver(msg: unknown): void + /** The `Executor.deliver` implementation. Returns false when the raw message is malformed and + * therefore was not queued; callers must not acknowledge a message this inbox discarded. */ + deliver(msg: unknown): boolean /** Remove and return all pending messages (the flush). */ drain(): InboxMessage[] pending(): number @@ -60,10 +62,11 @@ export function createInbox(): Inbox { return { deliver(msg) { const m = parseDown(msg) - if (!m) return + if (!m) return false pending.push(m) // A forceful message aborts the turn currently in flight (if any). if (m.interrupt && live && !live.signal.aborted) live.abort() + return true }, drain() { return pending.splice(0, pending.length) diff --git a/src/runtime/supervise/materialization.ts b/src/runtime/supervise/materialization.ts new file mode 100644 index 00000000..508f6a3f --- /dev/null +++ b/src/runtime/supervise/materialization.ts @@ -0,0 +1,361 @@ +import { randomUUID } from 'node:crypto' +import { + agentProfileSchema, + canonicalCandidateDigest, + type Sha256Digest, +} from '@tangle-network/agent-interface' +import { ValidationError } from '../../errors' +import { detachedSnapshot } from './snapshot' +import type { + ExecutionBindingReceipt, + Executor, + ExecutorExecutionBinding, + ExecutorMaterialization, + ProfileMaterializationReceipt, + Runtime, + UnknownMaterializationReason, +} from './types' + +type RuntimeOwnedExecutorMaterialization = + | { + readonly kind: 'known' + readonly declaration: ExecutorMaterialization + readonly binding?: ExecutorExecutionBinding + } + | { readonly kind: 'deferred'; readonly runtime: Runtime } + +const runtimeOwnedExecutorMaterializations = new WeakMap< + object, + RuntimeOwnedExecutorMaterialization +>() +const runtimeOwnedScopeOwners = new WeakMap() + +interface KnownReceiptInput { + readonly authoredProfileDigest: Sha256Digest + readonly runtime: Runtime + readonly declaration: ExecutorMaterialization +} + +interface UnknownReceiptInput { + readonly authoredProfileDigest?: Sha256Digest + readonly runtime: Runtime + readonly reason: UnknownMaterializationReason +} + +const declarationKeys = new Set([ + 'effectiveProfile', + 'backend', + 'model', + 'execution', + 'materializer', + 'plan', + 'platformAttachments', +]) + +/** + * Bind a declaration to a runtime-owned executor without putting an attestable method on the + * public Executor interface. This module is not a package export: arbitrary caller executors stay + * unknown even if they add a lookalike `materialization()` property. + */ +export function attestRuntimeOwnedExecutor( + executor: Executor, + declaration: ExecutorMaterialization, + binding?: ExecutorExecutionBinding, +): Executor { + runtimeOwnedExecutorMaterializations.set( + executor as object, + Object.freeze({ + kind: 'known', + declaration: detachedSnapshot(declaration, 'runtime-owned executor materialization'), + ...(binding === undefined + ? {} + : { + binding: detachedSnapshot(binding, 'runtime-owned executor execution binding'), + }), + }), + ) + return executor +} + +/** Mark a runtime-owned orchestration executor whose exact leaf declaration is published through + * its private nested Scope before the first backend inference. Arbitrary executors cannot opt in. */ +export function attestRuntimeOwnedDeferredExecutor( + executor: Executor, + runtime: Runtime, +): Executor { + runtimeOwnedExecutorMaterializations.set( + executor as object, + Object.freeze({ kind: 'deferred', runtime }), + ) + return executor +} + +/** Preserve the runtime-owned attestation when a trusted wrapper changes result semantics only. */ +export function inheritRuntimeOwnedExecutorAttestation( + source: Executor, + wrapper: Executor, +): Executor { + const attestation = runtimeOwnedExecutorMaterializations.get(source as object) + if (attestation !== undefined) { + runtimeOwnedExecutorMaterializations.set(wrapper as object, attestation) + } + return wrapper +} + +/** Read a declaration only when Runtime itself branded this exact executor object. */ +export function runtimeOwnedExecutorMaterialization( + executor: Executor, +): ExecutorMaterialization | undefined { + const attestation = runtimeOwnedExecutorMaterializations.get(executor as object) + return attestation?.kind === 'known' ? attestation.declaration : undefined +} + +/** Read one attempt binding only from the private brand on this exact executor object. */ +export function runtimeOwnedExecutorExecutionBinding( + executor: Executor, +): ExecutorExecutionBinding | undefined { + const attestation = runtimeOwnedExecutorMaterializations.get(executor as object) + return attestation?.kind === 'known' ? attestation.binding : undefined +} + +/** Read the expected leaf runtime only for a privately branded deferred orchestration executor. */ +export function runtimeOwnedDeferredExecutorRuntime( + executor: Executor, +): Runtime | undefined { + const attestation = runtimeOwnedExecutorMaterializations.get(executor as object) + return attestation?.kind === 'deferred' ? attestation.runtime : undefined +} + +/** Propagate trust from a built-in driver adapter to the Agent and recursive executor it owns. */ +export function attestRuntimeOwnedScopeOwner(owner: T, runtime: Runtime): T { + runtimeOwnedScopeOwners.set(owner, runtime) + return owner +} + +/** Return a built-in scope owner's expected concrete leaf runtime; caller-owned functions/Agents + * deliberately have no entry even if they add lookalike fields. */ +export function runtimeOwnedScopeOwnerRuntime(owner: object): Runtime | undefined { + return runtimeOwnedScopeOwners.get(owner) +} + +/** Build one kernel-owned known receipt from a data-only executor declaration. */ +export function knownMaterializationReceipt( + input: KnownReceiptInput, +): ProfileMaterializationReceipt { + const declaration = detachedSnapshot(input.declaration, 'executor materialization') + assertExactDeclaration(declaration) + const effectiveProfile = agentProfileSchema.safeParse(declaration.effectiveProfile) + if (!effectiveProfile.success) { + throw new ValidationError('executor materialization: effectiveProfile must be an AgentProfile') + } + assertNonEmpty(declaration.backend, 'backend') + assertNonEmpty(declaration.materializer, 'materializer') + assertNonEmpty(declaration.execution?.kind, 'execution.kind') + assertNonEmpty(declaration.execution?.id, 'execution.id') + assertModel(declaration.model) + + let effectiveProfileDigest: Sha256Digest + let materializationPlanDigest: Sha256Digest + let platformAttachmentsDigest: Sha256Digest | undefined + try { + // AgentProfile parsing represents omitted optional fields as `undefined`, while the backend's + // JSON request omits them. Commit the exact JSON document that crosses that boundary. + effectiveProfileDigest = canonicalCandidateDigest(jsonWireSnapshot(effectiveProfile.data)) + materializationPlanDigest = canonicalCandidateDigest(jsonWireSnapshot(declaration.plan)) + platformAttachmentsDigest = + declaration.platformAttachments === undefined + ? undefined + : canonicalCandidateDigest(jsonWireSnapshot(declaration.platformAttachments)) + } catch (error) { + throw new ValidationError( + 'executor materialization: profile, plan, and attachments must be finite RFC 8785 JSON', + { cause: error }, + ) + } + + return Object.freeze({ + status: 'known' as const, + authoredProfileDigest: input.authoredProfileDigest, + effectiveProfileDigest, + materializationPlanDigest, + ...(platformAttachmentsDigest === undefined ? {} : { platformAttachmentsDigest }), + runtime: input.runtime, + backend: declaration.backend, + model: declaration.model, + execution: declaration.execution, + materializer: declaration.materializer, + }) +} + +/** Build an explicit unknown receipt without fabricating any executor-owned identity. */ +export function unknownMaterializationReceipt( + input: UnknownReceiptInput, +): ProfileMaterializationReceipt { + return Object.freeze({ + status: 'unknown' as const, + ...(input.authoredProfileDigest === undefined + ? {} + : { authoredProfileDigest: input.authoredProfileDigest }), + runtime: input.runtime, + reason: input.reason, + }) +} + +/** Mint a process-unique attempt id before executor construction. */ +export function newExecutionAttemptId(nodeId: string): string { + return `${nodeId}:attempt:${randomUUID()}` +} + +/** Build the immutable safe receipt for a full, transient execution binding. */ +export function knownExecutionBindingReceipt( + materialization: ProfileMaterializationReceipt, + bindingInput: ExecutorExecutionBinding, +): ExecutionBindingReceipt { + const binding = detachedSnapshot(bindingInput, 'executor execution binding') + assertNonEmpty(binding.attemptId, 'binding.attemptId') + assertSafeDescriptor(binding.descriptor) + let materializationReceiptDigest: Sha256Digest + let bindingDigest: Sha256Digest + try { + materializationReceiptDigest = canonicalCandidateDigest(materialization) + // Bind the exact JSON document a backend receives. Optional AgentProfile fields are represented + // as `undefined` in memory but omitted by JSON transport; normalize that one distinction while + // rejecting every non-finite/non-data value instead of silently coercing it. + bindingDigest = canonicalCandidateDigest(jsonWireSnapshot(binding.binding)) + } catch (error) { + throw new ValidationError('executor binding must be finite RFC 8785 JSON', { cause: error }) + } + return Object.freeze({ + status: 'known' as const, + attemptId: binding.attemptId, + materializationReceiptDigest, + bindingDigest, + descriptor: binding.descriptor, + }) +} + +/** Preserve an attempt boundary even when its caller-owned transport cannot be attested. */ +export function unknownExecutionBindingReceipt( + materialization: ProfileMaterializationReceipt, + attemptId: string, + reason: UnknownMaterializationReason, +): ExecutionBindingReceipt { + assertNonEmpty(attemptId, 'binding.attemptId') + return Object.freeze({ + status: 'unknown' as const, + attemptId, + materializationReceiptDigest: canonicalCandidateDigest(materialization), + reason, + }) +} + +/** Derive an authored-profile digest without turning non-canonical input into a fake identity. */ +export function authoredProfileDigest(profile: unknown): Sha256Digest | undefined { + try { + return canonicalCandidateDigest(profile) + } catch { + return undefined + } +} + +function assertExactDeclaration(value: ExecutorMaterialization): void { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new ValidationError('executor materialization: declaration must be an object') + } + const unknown = Object.keys(value).filter((key) => !declarationKeys.has(key)) + if (unknown.length > 0) { + throw new ValidationError( + `executor materialization: unknown declaration fields: ${unknown.sort().join(', ')}`, + ) + } + if (!Object.hasOwn(value, 'plan')) { + throw new ValidationError('executor materialization: plan is required') + } +} + +function assertModel(model: ExecutorMaterialization['model']): void { + if (typeof model !== 'object' || model === null || Array.isArray(model)) { + throw new ValidationError('executor materialization: model must be a known/unknown identity') + } + const keys = Object.keys(model).sort() + if (model.status === 'known') { + if (keys.join(',') !== 'id,status') { + throw new ValidationError('executor materialization: known model accepts only status and id') + } + assertNonEmpty(model.id, 'model.id') + return + } + if (model.status === 'unknown') { + if (keys.join(',') !== 'reason,status') { + throw new ValidationError( + 'executor materialization: unknown model accepts only status and reason', + ) + } + assertNonEmpty(model.reason, 'model.reason') + return + } + throw new ValidationError('executor materialization: model.status must be known or unknown') +} + +function assertNonEmpty(value: unknown, field: string): asserts value is string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new ValidationError(`executor materialization: ${field} must be a non-empty string`) + } +} + +function assertSafeDescriptor(descriptor: ExecutorExecutionBinding['descriptor']): void { + if (typeof descriptor !== 'object' || descriptor === null || Array.isArray(descriptor)) { + throw new ValidationError('executor binding: descriptor must be an object') + } + for (const [key, value] of Object.entries(descriptor)) { + if (/(url|uri|token|key|secret|bearer|credential|authorization)/i.test(key)) { + throw new ValidationError(`executor binding: unsafe descriptor key ${JSON.stringify(key)}`) + } + if ( + value !== null && + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' + ) { + throw new ValidationError( + `executor binding: descriptor ${JSON.stringify(key)} must be scalar`, + ) + } + if (typeof value === 'string' && (value.includes('://') || value.includes('@'))) { + throw new ValidationError( + `executor binding: descriptor ${JSON.stringify(key)} contains transport or credential text`, + ) + } + } +} + +function jsonWireSnapshot(value: unknown, path = '$'): unknown { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new ValidationError(`executor binding: ${path} must be finite`) + } + return value + } + if (Array.isArray(value)) { + return value.map((item, index) => { + if (item === undefined) { + throw new ValidationError(`executor binding: ${path}[${index}] cannot be undefined`) + } + return jsonWireSnapshot(item, `${path}[${index}]`) + }) + } + if (typeof value !== 'object' || value === null) { + throw new ValidationError(`executor binding: ${path} must be JSON data`) + } + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) { + throw new ValidationError(`executor binding: ${path} must be a plain object`) + } + const out: Record = {} + for (const [key, item] of Object.entries(value as Record)) { + if (item === undefined) continue + out[key] = jsonWireSnapshot(item, `${path}.${key}`) + } + return out +} diff --git a/src/runtime/supervise/model-policy.ts b/src/runtime/supervise/model-policy.ts index 150d4b4e..4bb705bb 100644 --- a/src/runtime/supervise/model-policy.ts +++ b/src/runtime/supervise/model-policy.ts @@ -4,6 +4,7 @@ * model at resolve time, so a run that names a model outside the allowed set throws before * any compute is spent — never silently swapped or silently allowed. */ +import type { AgentProfile } from '@tangle-network/agent-interface' import { ConfigError } from '../../errors' /** @@ -22,3 +23,19 @@ export function assertModelAllowed( ) } } + +/** Check every canonical model-bearing field in a complete profile, including the models a + * backend may select for cheap work, named subagents, or modes. */ +export function assertProfileModelsAllowed( + profile: AgentProfile, + allowed: readonly string[] | undefined, +): void { + assertModelAllowed(profile.model?.default, allowed) + assertModelAllowed(profile.model?.small, allowed) + for (const subagent of Object.values(profile.subagents ?? {})) { + assertModelAllowed(subagent.model, allowed) + } + for (const mode of Object.values(profile.modes ?? {})) { + assertModelAllowed(mode.model, allowed) + } +} diff --git a/src/runtime/supervise/pi-executor.ts b/src/runtime/supervise/pi-executor.ts index d779c1c1..1fcf0553 100644 --- a/src/runtime/supervise/pi-executor.ts +++ b/src/runtime/supervise/pi-executor.ts @@ -42,8 +42,8 @@ * | `profile.prompt.systemPrompt` | honored — prepended to the task text (pi RPC takes no separate system-prompt channel) | * | `profile.mcp` | honored — written to this execution's own file and passed as `--mcp-config` for `pi-mcp-adapter`; see `pi-mcp.ts` | * | `profile.extensions.pi.load` | honored — lowered to `--no-extensions` + `--extension ` | - * | `profile.prompt.instructions` | DROPPED — fold into `systemPrompt` before calling | - * | `profile.model` | DROPPED — the seam's `model` is the only model channel; a profile that disagrees with the seam is silently overridden by the seam | + * | `profile.prompt.instructions` | honored — appended to the system prompt, one per line | + * | `profile.model.default` | honored — overrides the seam's `model`; the seam is the fallback for profiles that select none | * | `profile.model.reasoningEffort` | DROPPED — no `--thinking` flag is emitted, so pi's configured `defaultThinkingLevel` applies | * | `profile.tools` | DROPPED — no `--no-tools` / allow-deny mapping; pi runs its full builtin tool set | * | `profile.permissions` | DROPPED | @@ -60,10 +60,12 @@ */ import { type ChildProcess, spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' import type { AgentProfile } from '@tangle-network/agent-interface' import { ValidationError } from '../../errors' import { abortError, throwIfAborted } from '../util' import { createInbox, type Inbox, type InboxMessage } from './inbox' +import { attestRuntimeOwnedExecutor, newExecutionAttemptId } from './materialization' import { PI_MCP_ADAPTER, type PiMcpReceipt, preparePiMcp } from './pi-mcp' import { type ActivityLog, createActivityLog, type ExecutorProgress } from './progress' import { createPushTraceSource, type ToolStepInput, type TraceSource } from './trace-source' @@ -127,7 +129,13 @@ interface PiAssistantOutcome { /** Build the `Executor` for one pi worker. Registered as runtime `'pi'`. */ export const piExecutor: ExecutorFactory = (spec, ctx) => { - const seam = readPiSeam(ctx) + const configured = readPiSeam(ctx) + const seam: PiSeam = { + ...configured, + // The backend model is a fallback for profiles that do not select one. AgentProfile is the + // experiment-owned knob, so an ambient/default seam must never override the authored arm. + ...(spec.profile.model?.default ? { model: spec.profile.model.default } : {}), + } // `TRACE_ID` / `PARENT_SPAN_ID` for this worker when the run records spans; `{}` otherwise, which // leaves the spawn environment byte-identical to the untraced path. const traceEnv = workerTraceEnv(ctx) @@ -137,6 +145,8 @@ export const piExecutor: ExecutorFactory = (spec, ctx) => { // private MCP config directory, and two workers built from one seam must not share either. const runId = `pi-${spec.profile.name ?? 'worker'}-${Date.now()}` const trace = createPushTraceSource({ runId }) + const executionId = ctx.node?.nodeId ?? `pi-run-${randomUUID()}` + const attemptId = ctx.node?.attemptId ?? newExecutionAttemptId(executionId) // What this executor changed about what the caller declared. Unlike `recentActivity` this is // never evicted, so a run that fails after the change still reports it. const derived: string[] = [] @@ -154,7 +164,7 @@ export const piExecutor: ExecutorFactory = (spec, ctx) => { artifact: undefined as ExecutorResult | undefined, } - return { + const executor: ReturnType> = { runtime: PI_RUNTIME, // pi owns the queue; `deliver` only routes through its state-safe `prompt` command. Its // streaming behavior chooses steer versus follow-up atomically in pi, rather than trusting @@ -202,6 +212,40 @@ export const piExecutor: ExecutorFactory = (spec, ctx) => { return state.artifact }, } + // Attestation binds the KERNEL-minted attempt id. An executor built outside a Scope (no + // `ctx.node`) has no kernel identity to bind, so it makes no attestation claim and a later + // scope spawn records honest unknown receipts instead of a mismatched binding. + if (ctx.node === undefined) return executor + return attestRuntimeOwnedExecutor( + executor, + { + effectiveProfile: spec.profile, + backend: 'pi', + model: seam.model + ? { status: 'known', id: seam.model } + : { status: 'unknown', reason: 'pi selected its configured default model' }, + execution: { kind: 'run', id: executionId }, + materializer: 'pi-rpc-agent-profile', + plan: { + kind: 'pi-rpc-session', + bin: seam.bin ?? 'pi', + args: seam.args ?? [], + cwd: seam.cwd ?? null, + model: seam.model ?? null, + turnTimeoutMs: seam.turnTimeoutMs ?? null, + }, + }, + { + attemptId, + binding: { + executionId, + bin: seam.bin ?? 'pi', + cwd: seam.cwd ?? null, + model: seam.model ?? null, + }, + descriptor: { kind: 'pi-rpc-run', transport: 'process', backend: 'pi' }, + }, + ) } /** What one pi run reports about the terminal assistant turn, plus any derived MCP mount. */ @@ -249,6 +293,7 @@ async function* streamPiSession(args: StreamPiArgs): AsyncIterable { const tokens = { input: 0, output: 0 } let usd = 0 let usdKnown = true + let tokensKnown = true throwIfAborted(args.signal) throwIfAborted(args.controller.signal) @@ -338,7 +383,12 @@ async function* streamPiSession(args: StreamPiArgs): AsyncIterable { args.signal.addEventListener('abort', abortAll, { once: true }) args.controller.signal.addEventListener('abort', abortAll, { once: true }) - const system = args.spec.profile.prompt?.systemPrompt + const system = [ + args.spec.profile.prompt?.systemPrompt, + ...(args.spec.profile.prompt?.instructions ?? []), + ] + .filter((line): line is string => typeof line === 'string' && line.trim().length > 0) + .join('\n') const opening = system ? `${system}\n\n${taskText(args.task)}` : taskText(args.task) const deadline = seam.turnTimeoutMs ? Date.now() + seam.turnTimeoutMs : undefined const sendPrompt = (message: string, streamingBehavior?: 'steer' | 'followUp'): void => { @@ -389,6 +439,7 @@ async function* streamPiSession(args: StreamPiArgs): AsyncIterable { } const projected = projectPiEvent(ev, args, tokens, pendingTools) if (projected.assistant) lastAssistant = projected.assistant + if (projected.tokensUnknown) tokensKnown = false for (const usage of projected.events) { if (usage.kind === 'cost') { usd += usage.usd @@ -469,6 +520,7 @@ async function* streamPiSession(args: StreamPiArgs): AsyncIterable { const spent: Spend = { iterations: state.turns, tokens, + ...(tokensKnown ? {} : { tokensKnown: false }), usd, ...(usdKnown ? {} : { usdKnown: false }), ms: Date.now() - started, @@ -517,7 +569,7 @@ function projectPiEvent( args: StreamPiArgs, tokens: { input: number; output: number }, pendingTools: Map, -): { events: UsageEvent[]; assistant?: PiAssistantOutcome } { +): { events: UsageEvent[]; assistant?: PiAssistantOutcome; tokensUnknown?: true } { const out: UsageEvent[] = [] const at = Date.now() if (ev.type === 'tool_execution_start' && typeof ev.toolName === 'string') { @@ -566,7 +618,14 @@ function projectPiEvent( args.activity.push({ at, kind: 'turn', label: `turn ${args.state.turns}` }) out.push({ kind: 'iteration' }) const assistant = readAssistantOutcome(ev.message) - return { events: out, ...(assistant ? { assistant } : {}) } + // A turn whose receipt named no token field at all did real work with an unreported count. + // The terminal artifact must carry that as `tokensKnown: false`, never as a silent zero. + const tokensUnknown = !usage || usage.tokensKnown === false + return { + events: out, + ...(assistant ? { assistant } : {}), + ...(tokensUnknown ? { tokensUnknown: true as const } : {}), + } } return { events: out } } @@ -574,21 +633,25 @@ function projectPiEvent( /** Pi's fresh input excludes cache reads and writes. Runtime's input channel includes all model * input, so combine them once at the assistant receipt. A missing or zero price is unknown because * subscription-backed providers report zero even when compute was not free. */ -function readUsage(message: unknown): { input: number; output: number; usd?: number } | undefined { +function readUsage( + message: unknown, +): { input: number; output: number; usd?: number; tokensKnown: boolean } | undefined { if (!message || typeof message !== 'object') return undefined const usage = (message as { usage?: unknown }).usage if (!usage || typeof usage !== 'object') return undefined const u = usage as Record const promptTokens = num(u.prompt_tokens) + const freshInput = num(u.input) ?? num(u.inputTokens) + const outputRaw = num(u.output) ?? num(u.outputTokens) ?? num(u.completion_tokens) const input = promptTokens ?? - (num(u.input) ?? num(u.inputTokens) ?? 0) + + (freshInput ?? 0) + (num(u.cacheRead) ?? num(u.cache_read_input_tokens) ?? num(u.cacheReadInputTokens) ?? 0) + (num(u.cacheWrite) ?? num(u.cache_creation_input_tokens) ?? num(u.cacheCreationInputTokens) ?? 0) - const output = num(u.output) ?? num(u.outputTokens) ?? num(u.completion_tokens) ?? 0 + const output = outputRaw ?? 0 const costRaw = u.cost const reportedUsd = num(costRaw) ?? @@ -600,6 +663,8 @@ function readUsage(message: unknown): { input: number; output: number; usd?: num input, output, ...(reportedUsd !== undefined && reportedUsd > 0 ? { usd: reportedUsd } : {}), + // A usage object that named NO token field is a receipt without a count, not a zero. + tokensKnown: promptTokens !== undefined || freshInput !== undefined || outputRaw !== undefined, } } diff --git a/src/runtime/supervise/run-context.ts b/src/runtime/supervise/run-context.ts index e46c3da3..e86ea1ec 100644 --- a/src/runtime/supervise/run-context.ts +++ b/src/runtime/supervise/run-context.ts @@ -61,10 +61,11 @@ export interface InMemoryRunContext { */ readonly resume?: boolean /** - * Present only on a DURABLE context: the coordination side-log (questions + analyst findings — - * the bus messages the spawn journal does not record). `supervise({ runDir })` appends to it as - * they publish and replays it on resume, so a restarted coordinator keeps them. In-memory - * contexts have none: nothing outlives the process to replay into. + * Present only on a DURABLE context: the coordination side-log stores questions, analyst + * findings, answer decisions, and authorized continuation receipts that the spawn journal does + * not own. `supervise({ runDir })` appends them as they publish and loads them on resume. + * Continuation receipts are evidence and are never auto-delivered to a replacement worker. + * In-memory contexts have none: nothing outlives the process. */ readonly coordinationLog?: CoordinationLog } @@ -94,8 +95,9 @@ export function createInMemoryRunContext(opts: InMemoryRunContextOptions = {}): * back on `Scope.resume` (rehydrated by `replaySpawnTree`) instead of being re-executed. * * Layout: `${dir}/spawn-journal.jsonl` (one JSONL record per event), `${dir}/blobs/` (one - * content-addressed JSON file per settled result), and `${dir}/coordination-log.jsonl` (questions - * + findings, replayed into a resumed driver). The directory is created on first write. + * content-addressed JSON file per settled result), and `${dir}/coordination-log.jsonl` + * (questions, findings, answer decisions, and authorized continuation receipts retained as + * evidence). The directory is created on first write. * * Opt-in by construction — `createInMemoryRunContext()` is unchanged and stays the default, so no * existing consumer writes to disk or resumes unless it asks for this. diff --git a/src/runtime/supervise/runtime.ts b/src/runtime/supervise/runtime.ts index 9de742e9..8c4532f9 100644 --- a/src/runtime/supervise/runtime.ts +++ b/src/runtime/supervise/runtime.ts @@ -30,6 +30,11 @@ import { request as httpRequest } from 'node:http' import { request as httpsRequest } from 'node:https' import { Readable } from 'node:stream' import { estimateCost, isModelPriced } from '@tangle-network/agent-eval' +import { + type AgentProfile, + agentProfileSchema, + mergeAgentProfiles, +} from '@tangle-network/agent-interface' import type { BackendType, SandboxEvent } from '@tangle-network/sandbox' import { ValidationError } from '../../errors' import type { LocalHarness } from '../../mcp/local-harness' @@ -66,9 +71,11 @@ import type { } from '../types' import { zeroTokenUsage } from '../util' import { createInbox, type Inbox } from './inbox' +import { attestRuntimeOwnedExecutor, newExecutionAttemptId } from './materialization' import { PI_RUNTIME, type PiSeam, piExecutor } from './pi-executor' import type { ExecutorProgress } from './progress' import { createSteerableSandboxSession, type SandboxSteeringOptions } from './sandbox-session' +import { detachedSnapshot } from './snapshot' import type { TraceSource } from './trace-source' import type { AgentSpec, @@ -141,7 +148,9 @@ export interface CliSeam { /** * cli-worktree seam. A supervisor-authored `AgentProfile` driving a local coding-harness CLI * (claude / codex / opencode) on its own git worktree — the leaf `createWorktreeCliExecutor` - * named as data. `harness` + `repoRoot` + `taskPrompt` are required; the authored + * named as data. `harness` + `repoRoot` are required; the task comes from `Executor.execute`. + * `taskPrompt` remains an optional direct-call fallback for callers that execute with `undefined`. + * The authored * `profile.prompt.systemPrompt` + `profile.model.default` reach the harness via the §1.5 * `harnessInvocation` mapper. Everything else mirrors `WorktreeCliExecutorOptions`. */ @@ -149,7 +158,7 @@ export interface CliWorktreeSeam { repoRoot: string /** Local CLI harness transport. Omit when `bridge` is set. */ harness?: LocalHarness - taskPrompt: string + taskPrompt?: string runId?: string baseRef?: string harnessTimeoutMs?: number @@ -176,7 +185,8 @@ export interface CliWorktreeBridgeSeam { bridgeBearer: string /** Bridge model/harness id. Defaults to the profile's model hint when omitted. */ model?: string - agentProfile?: Record + /** Canonical profile overlay merged over the spawned profile. */ + agentProfile?: AgentProfile timeoutMs?: number /** Stable cli-bridge session id. Defaults to `bridge-worktree-${runId}`. */ sessionId?: string @@ -191,19 +201,20 @@ export interface CliWorktreeBridgeSeam { * forwarded verbatim per request — how an arm disables native tools or injects * a provider search MCP. * - * The executor opens a RESUMABLE cli-bridge session — structurally identical to the - * sandbox executor's persistent box, just local. `sessionId` is the stable - * caller-owned id cli-bridge maps to the harness's internal conversation id; a - * follow-up steer/resume on the SAME id continues the SAME harness session (opencode - * `-s`, claude `--resume`, …). Omit it and the executor mints a stable one per spawn. + * The executor opens a resumable cli-bridge session. `sessionId` identifies the + * harness conversation across turns; each turn also receives its own durable run id. + * A dropped HTTP reader reattaches to that exact run and explicit cancel is the only + * operation allowed to stop it. Omit `sessionId` and the executor mints one per spawn. */ export interface BridgeSeam { bridgeUrl: string bridgeBearer: string - model: string + /** Fallback bridge wire id. A spawned profile may select its own harness and model. */ + model?: string /** Optional working directory forwarded to cli-bridge and persisted with the session. */ cwd?: string - agentProfile?: Record + /** Canonical profile overlay merged over the spawned profile. */ + agentProfile?: AgentProfile timeoutMs?: number /** Stable, caller-owned cli-bridge session id for harness-side resume. Defaults * to a freshly minted per-spawn id so each worker is its own resumable session. */ @@ -274,7 +285,7 @@ function zeroSpend(): Spend { */ export const routerInlineExecutor: ExecutorFactory = (spec, ctx) => { const seam = readSeam(ctx, routerSeamKey, 'router/inline') - const model = seam.model ?? spec.profile.model?.default + const model = spec.profile.model?.default ?? seam.model if (!model) { throw new ValidationError( 'routerInlineExecutor: no model — set RouterSeam.model or AgentProfile.model.default', @@ -292,39 +303,65 @@ export const routerInlineExecutor: ExecutorFactory = (spec, ctx) => { if (!ctx.signal.aborted) ctx.signal.addEventListener('abort', abortIfSignalled, { once: true }) let artifact: ExecutorResult | undefined - - return { - runtime: 'router' as Runtime, - async execute(task, signal): Promise> { - const messages = taskToMessages(task, spec) - const started = Date.now() - const linked = linkSignals(signal, controller.signal) - const r = await routerChatWithUsage( - { routerBaseUrl: seam.routerBaseUrl, routerKey: seam.routerKey, model }, - messages, - linked ? { signal: linked } : {}, - ) - const spent: Spend = { - iterations: 1, - tokens: r.usage ? { input: r.usage.input, output: r.usage.output } : zeroTokenUsage(), - usd: r.costUsd ?? 0, - ms: Date.now() - started, - } - const out = { content: r.content } as unknown - artifact = { outRef: contentRef('router', { model, content: r.content }), out, spent } - return artifact + const executionId = ctx.node?.nodeId ?? `router-request-${randomUUID()}` + const attemptId = ctx.node?.attemptId ?? newExecutionAttemptId(executionId) + + return attestRuntimeOwnedExecutor( + { + runtime: 'router' as Runtime, + async execute(task, signal): Promise> { + const messages = taskToMessages(task, spec) + const started = Date.now() + const linked = linkSignals(signal, controller.signal) + const r = await routerChatWithUsage( + { routerBaseUrl: seam.routerBaseUrl, routerKey: seam.routerKey, model }, + messages, + linked ? { signal: linked } : {}, + ) + const spent: Spend = { + iterations: 1, + tokens: r.usage ? { input: r.usage.input, output: r.usage.output } : zeroTokenUsage(), + usd: r.costUsd ?? 0, + ...(r.usage ? {} : { tokensKnown: false }), + ...(r.costUsd === undefined ? { usdKnown: false } : {}), + ms: Date.now() - started, + } + const out = { content: r.content } as unknown + artifact = { outRef: contentRef('router', { model, content: r.content }), out, spent } + return artifact + }, + teardown(_grace): Promise<{ destroyed: boolean }> { + controller.abort() + return Promise.resolve({ destroyed: true }) + }, + resultArtifact() { + if (!artifact) { + throw new ValidationError('routerInlineExecutor: resultArtifact() read before execute()') + } + return { ...artifact, spent: artifact.spent } + }, }, - teardown(_grace): Promise<{ destroyed: boolean }> { - controller.abort() - return Promise.resolve({ destroyed: true }) + { + effectiveProfile: spec.profile, + backend: 'router', + model: { status: 'known', id: model }, + execution: { + kind: 'request', + id: executionId, + }, + materializer: 'router-prompt-model', + plan: { kind: 'openai-chat-completion', model }, }, - resultArtifact() { - if (!artifact) { - throw new ValidationError('routerInlineExecutor: resultArtifact() read before execute()') - } - return { ...artifact, spent: artifact.spent } + { + attemptId, + binding: { + endpoint: seam.routerBaseUrl, + executionId, + model, + }, + descriptor: { kind: 'router-request', transport: 'http', backend: 'router' }, }, - } + ) } export type { ToolSpec } @@ -383,7 +420,7 @@ interface RouterToolsResponse { */ export const routerToolsInlineExecutor: ExecutorFactory = (spec, ctx) => { const seam = readSeam(ctx, routerToolsSeamKey, 'router-tools') - const model = seam.model ?? spec.profile.model?.default + const model = spec.profile.model?.default ?? seam.model if (!model) { throw new ValidationError( 'routerToolsInlineExecutor: no model — set RouterToolsSeam.model or AgentProfile.model.default', @@ -407,174 +444,212 @@ export const routerToolsInlineExecutor: ExecutorFactory = (spec, ctx) = const inbox = createInbox() let artifact: ExecutorResult | undefined + const executionId = ctx.node?.nodeId ?? `router-tools-run-${randomUUID()}` + const attemptId = ctx.node?.attemptId ?? newExecutionAttemptId(executionId) - return { - runtime: 'router' as Runtime, - deliver: (m) => inbox.deliver(m), - async execute(task, signal): Promise> { - const started = Date.now() - const messages: Array> = [ - ...(taskToMessages(task, spec) as Array>), - ] - const tokens = zeroTokenUsage() - let turns = 0 - let lastText = '' - // Fold any queued down-messages into the conversation as one operator turn (the boundary flush). - const flush = () => { - const pending = inbox.drain() - if (pending.length) messages.push({ role: 'user', content: inbox.fold(pending) }) - return pending.length > 0 - } - - // The external abort sources (caller signal + executor teardown), merged ONCE — so we don't - // re-register listeners on these long-lived signals every turn. - const external = mergeAbortSignals(signal, controller.signal) - - for (let t = 0; t < maxTurns; t += 1) { - // QUEUED messages flush at the step boundary, before this turn's inference. - flush() - // A forceful (interrupt) message aborts THIS turn so the worker re-plans immediately. The - // per-turn controller fires on `external` OR a fresh interrupt; its listener on `external` is - // removed after the turn (`cleanup`) so nothing accumulates across turns. - const interruptSig = inbox.freshInterrupt() - const turnController = new AbortController() - const abortTurn = () => turnController.abort() - if (external.aborted) turnController.abort() - else external.addEventListener('abort', abortTurn) - interruptSig.addEventListener('abort', abortTurn, { once: true }) - const cleanup = () => external.removeEventListener('abort', abortTurn) - let res: Response - try { - res = await fetch(`${seam.routerBaseUrl.replace(/\/$/, '')}/chat/completions`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${seam.routerKey}`, - }, - body: JSON.stringify({ - model, - messages, - tools: seam.tools, - tool_choice: 'auto', - temperature: 0.2, - }), - signal: turnController.signal, - }) - } catch (e) { - cleanup() - // Re-plan ONLY when a forceful inbox message aborted this turn (a real AbortError, with the - // interrupt — not the external teardown/budget signal). The re-planned turn still consumes a - // loop slot (so interrupt spam is bounded by maxTurns, not a hang) but does not bill a turn. - // Any other error — incl. a network fault coincident with an interrupt — is fatal: rethrow. - const interruptAbort = - e instanceof DOMException && - e.name === 'AbortError' && - interruptSig.aborted && - !signal.aborted && - !controller.signal.aborted - if (interruptAbort) continue - throw e - } - cleanup() - // The inference completed — count the turn now (an interrupted, re-planned turn doesn't bill). - turns += 1 - if (!res.ok) { - throw new ValidationError( - `routerToolsInlineExecutor: router ${res.status}: ${(await res.text()).slice(0, 200)}`, - ) - } - const data = (await res.json()) as RouterToolsResponse - const u = data.usage - if (u && typeof u.prompt_tokens === 'number' && typeof u.completion_tokens === 'number') { - tokens.input += u.prompt_tokens - tokens.output += u.completion_tokens - } - const msg = data.choices?.[0]?.message - if (msg?.content) lastText = msg.content - const toolCalls = msg?.tool_calls ?? [] - if (toolCalls.length === 0) { - // Before settling, flush once more — a worker may not finish while a steer/answer it never - // read is still pending. If anything flushed, keep going; otherwise it is truly done. - if (flush()) continue - break + return attestRuntimeOwnedExecutor( + { + runtime: 'router' as Runtime, + deliver: (m) => inbox.deliver(m), + async execute(task, signal): Promise> { + const started = Date.now() + const messages: Array> = [ + ...(taskToMessages(task, spec) as Array>), + ] + const tokens = zeroTokenUsage() + let tokensKnown = true + let turns = 0 + let lastText = '' + // Fold any queued down-messages into the conversation as one operator turn (the boundary flush). + const flush = () => { + const pending = inbox.drain() + if (pending.length) messages.push({ role: 'user', content: inbox.fold(pending) }) + return pending.length > 0 } - // Record the assistant turn verbatim, then run each call on the host and - // fold the result back as a `tool` message for the next turn. - messages.push({ - role: 'assistant', - content: msg?.content ?? '', - tool_calls: toolCalls.map((tc, i) => ({ - id: tc.id ?? `call_${i}`, - type: 'function', - function: { name: tc.function?.name ?? '', arguments: tc.function?.arguments ?? '{}' }, - })), - }) - for (let i = 0; i < toolCalls.length; i += 1) { - const tc = toolCalls[i] - const id = tc?.id ?? `call_${i}` - let args: Record = {} + // The external abort sources (caller signal + executor teardown), merged ONCE — so we don't + // re-register listeners on these long-lived signals every turn. + const external = mergeAbortSignals(signal, controller.signal) + + for (let t = 0; t < maxTurns; t += 1) { + // QUEUED messages flush at the step boundary, before this turn's inference. + flush() + // A forceful (interrupt) message aborts THIS turn so the worker re-plans immediately. The + // per-turn controller fires on `external` OR a fresh interrupt; its listener on `external` is + // removed after the turn (`cleanup`) so nothing accumulates across turns. + const interruptSig = inbox.freshInterrupt() + const turnController = new AbortController() + const abortTurn = () => turnController.abort() + if (external.aborted) turnController.abort() + else external.addEventListener('abort', abortTurn) + interruptSig.addEventListener('abort', abortTurn, { once: true }) + const cleanup = () => external.removeEventListener('abort', abortTurn) + let res: Response try { - args = JSON.parse(tc?.function?.arguments ?? '{}') as Record - } catch { - // Malformed args are a real outcome, not an infra fault — feed the error - // back so the model can correct, rather than aborting the whole loop. - messages.push({ - role: 'tool', - tool_call_id: id, - content: 'error: tool arguments were not valid JSON', + res = await fetch(`${seam.routerBaseUrl.replace(/\/$/, '')}/chat/completions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${seam.routerKey}`, + }, + body: JSON.stringify({ + model, + messages, + tools: seam.tools, + tool_choice: 'auto', + temperature: 0.2, + }), + signal: turnController.signal, }) - continue - } - const toolName = tc?.function?.name ?? '' - let result: string - let status: 'ok' | 'error' = 'ok' - const toolStartedAt = Date.now() - try { - result = await seam.executeToolCall(toolName, args, task) } catch (e) { - status = 'error' - result = `error: ${e instanceof Error ? e.message : String(e)}` + cleanup() + // Re-plan ONLY when a forceful inbox message aborted this turn (a real AbortError, with the + // interrupt — not the external teardown/budget signal). The re-planned turn still consumes a + // loop slot (so interrupt spam is bounded by maxTurns, not a hang) but does not bill a turn. + // Any other error — incl. a network fault coincident with an interrupt — is fatal: rethrow. + const interruptAbort = + e instanceof DOMException && + e.name === 'AbortError' && + interruptSig.aborted && + !signal.aborted && + !controller.signal.aborted + if (interruptAbort) continue + throw e } - const toolEndedAt = Date.now() - messages.push({ role: 'tool', tool_call_id: id, content: result }) - // Feed the online detector pipe (stuck-loop / error-streak) — a worker repeating the same - // call or hammering errors is caught mid-run, not only at settle. This is an observability - // side-channel: a throwing monitor must never crash the production inference loop. - try { - seam.onToolStep?.({ - toolName, - args, - status, - startedAt: toolStartedAt, - endedAt: toolEndedAt, - durationMs: toolEndedAt - toolStartedAt, - }) - } catch { - // ignore — monitoring must not break the worker + cleanup() + // The inference completed — count the turn now (an interrupted, re-planned turn doesn't bill). + turns += 1 + if (!res.ok) { + throw new ValidationError( + `routerToolsInlineExecutor: router ${res.status}: ${(await res.text()).slice(0, 200)}`, + ) + } + const data = (await res.json()) as RouterToolsResponse + const u = data.usage + if (u && typeof u.prompt_tokens === 'number' && typeof u.completion_tokens === 'number') { + tokens.input += u.prompt_tokens + tokens.output += u.completion_tokens + } else { + tokensKnown = false + } + const msg = data.choices?.[0]?.message + if (msg?.content) lastText = msg.content + const toolCalls = msg?.tool_calls ?? [] + if (toolCalls.length === 0) { + // Before settling, flush once more — a worker may not finish while a steer/answer it never + // read is still pending. If anything flushed, keep going; otherwise it is truly done. + if (flush()) continue + break + } + + // Record the assistant turn verbatim, then run each call on the host and + // fold the result back as a `tool` message for the next turn. + messages.push({ + role: 'assistant', + content: msg?.content ?? '', + tool_calls: toolCalls.map((tc, i) => ({ + id: tc.id ?? `call_${i}`, + type: 'function', + function: { + name: tc.function?.name ?? '', + arguments: tc.function?.arguments ?? '{}', + }, + })), + }) + for (let i = 0; i < toolCalls.length; i += 1) { + const tc = toolCalls[i] + const id = tc?.id ?? `call_${i}` + let args: Record = {} + try { + args = JSON.parse(tc?.function?.arguments ?? '{}') as Record + } catch { + // Malformed args are a real outcome, not an infra fault — feed the error + // back so the model can correct, rather than aborting the whole loop. + messages.push({ + role: 'tool', + tool_call_id: id, + content: 'error: tool arguments were not valid JSON', + }) + continue + } + const toolName = tc?.function?.name ?? '' + let result: string + let status: 'ok' | 'error' = 'ok' + const toolStartedAt = Date.now() + try { + result = await seam.executeToolCall(toolName, args, task) + } catch (e) { + status = 'error' + result = `error: ${e instanceof Error ? e.message : String(e)}` + } + const toolEndedAt = Date.now() + messages.push({ role: 'tool', tool_call_id: id, content: result }) + // Feed the online detector pipe (stuck-loop / error-streak) — a worker repeating the same + // call or hammering errors is caught mid-run, not only at settle. This is an observability + // side-channel: a throwing monitor must never crash the production inference loop. + try { + seam.onToolStep?.({ + toolName, + args, + status, + startedAt: toolStartedAt, + endedAt: toolEndedAt, + durationMs: toolEndedAt - toolStartedAt, + }) + } catch { + // ignore — monitoring must not break the worker + } } } - } - const usd = isModelPriced(model) ? estimateCost(tokens.input, tokens.output, model) : 0 - const spent: Spend = { iterations: turns, tokens, usd, ms: Date.now() - started } - const out = { content: lastText } as unknown - artifact = { outRef: contentRef('router-tools', { model, content: lastText }), out, spent } - return artifact + const priced = isModelPriced(model) + const usd = priced ? estimateCost(tokens.input, tokens.output, model) : 0 + const spent: Spend = { + iterations: turns, + tokens, + ...(tokensKnown ? {} : { tokensKnown: false }), + usd, + ...(!priced || !tokensKnown ? { usdKnown: false } : {}), + ms: Date.now() - started, + } + const out = { content: lastText } as unknown + artifact = { outRef: contentRef('router-tools', { model, content: lastText }), out, spent } + return artifact + }, + teardown(_grace): Promise<{ destroyed: boolean }> { + controller.abort() + return Promise.resolve({ destroyed: true }) + }, + resultArtifact() { + if (!artifact) { + throw new ValidationError( + 'routerToolsInlineExecutor: resultArtifact() read before execute()', + ) + } + return { ...artifact, spent: artifact.spent } + }, }, - teardown(_grace): Promise<{ destroyed: boolean }> { - controller.abort() - return Promise.resolve({ destroyed: true }) + { + effectiveProfile: spec.profile, + backend: 'router-tools', + model: { status: 'known', id: model }, + execution: { + kind: 'run', + id: executionId, + }, + materializer: 'router-tools-prompt-model', + plan: { kind: 'openai-tool-loop', model, maxTurns, tools: seam.tools }, }, - resultArtifact() { - if (!artifact) { - throw new ValidationError( - 'routerToolsInlineExecutor: resultArtifact() read before execute()', - ) - } - return { ...artifact, spent: artifact.spent } + { + attemptId, + binding: { + endpoint: seam.routerBaseUrl, + executionId, + model, + }, + descriptor: { kind: 'router-tool-loop', transport: 'http', backend: 'router-tools' }, }, - } + ) } // ── sandbox executor (harness is a BackendType) ──────────────────────────────── @@ -618,6 +693,35 @@ export const sandboxExecutor: ExecutorFactory = (spec, ctx) => { if (!ctx.signal.aborted) ctx.signal.addEventListener('abort', abortIfSignalled, { once: true }) let artifact: ExecutorResult | undefined + const executionId = ctx.node?.nodeId ?? `sandbox-run-${randomUUID()}` + const attemptId = ctx.node?.attemptId ?? newExecutionAttemptId(executionId) + const sandboxMaterialization = { + effectiveProfile: spec.profile, + backend: harness, + model: spec.profile.model?.default + ? ({ status: 'known', id: spec.profile.model.default } as const) + : ({ status: 'unknown', reason: 'sandbox harness selected its default model' } as const), + execution: { + kind: 'run', + id: executionId, + }, + materializer: 'sandbox-agent-profile', + plan: { + kind: 'sandbox-agent-rounds', + harness, + maxIterations, + steering: seam.steering !== undefined, + }, + } + const sandboxBinding = { + attemptId, + binding: { + executionId, + harness, + model: spec.profile.model?.default ?? null, + }, + descriptor: { kind: 'sandbox-run', transport: 'sandbox', backend: harness }, + } // STEERABLE mode (opt-in): the worker becomes a multi-turn session on one box, with a real // inbox, so a driver's steer has a turn boundary to be folded into. This is the path that @@ -636,29 +740,33 @@ export const sandboxExecutor: ExecutorFactory = (spec, ctx) => { ...(Object.keys(traceEnv).length > 0 ? { traceEnv } : {}), contentRef, }) - return { - runtime: 'sandbox' as Runtime, - deliver: (m) => inbox.deliver(m), - progress: (): ExecutorProgress => session.progress(), - traceSource: (): TraceSource => session.traceSource(), - execute(task, signal): AsyncIterable { - return session.stream(task, signal) - }, - async teardown(_grace): Promise<{ destroyed: boolean }> { - controller.abort() - await session.teardown() - return { destroyed: true } - }, - resultArtifact() { - const a = session.artifact() - if (!a) { - throw new ValidationError( - 'sandboxExecutor(steering): resultArtifact() read before stream drained', - ) - } - return a + return attestRuntimeOwnedExecutor( + { + runtime: 'sandbox' as Runtime, + deliver: (m) => inbox.deliver(m), + progress: (): ExecutorProgress => session.progress(), + traceSource: (): TraceSource => session.traceSource(), + execute(task, signal): AsyncIterable { + return session.stream(task, signal) + }, + async teardown(_grace): Promise<{ destroyed: boolean }> { + controller.abort() + await session.teardown() + return { destroyed: true } + }, + resultArtifact() { + const a = session.artifact() + if (!a) { + throw new ValidationError( + 'sandboxExecutor(steering): resultArtifact() read before stream drained', + ) + } + return a + }, }, - } + sandboxMaterialization, + sandboxBinding, + ) } // The leaf runs an opaque, self-parallelizing coding harness; the loop just @@ -670,39 +778,43 @@ export const sandboxExecutor: ExecutorFactory = (spec, ctx) => { } const driver = singleShotDriver(maxIterations) - return { - runtime: 'sandbox' as Runtime, - execute(task, signal): AsyncIterable { - return streamSandboxLeaf({ - task, - signal, - harness, - spec, - seam, - output, - driver, - maxIterations, - controller, - loopCtx: seam.loopCtx, - traceEnv, - onArtifact: (a) => { - artifact = a - }, - }) - }, - teardown(_grace): Promise<{ destroyed: boolean }> { - // The composed runAgentRounds owns its box teardown (finally{allSettled(destroy)}); - // aborting the loop's signal cascades into that barrier. - controller.abort() - return Promise.resolve({ destroyed: true }) - }, - resultArtifact() { - if (!artifact) { - throw new ValidationError('sandboxExecutor: resultArtifact() read before stream drained') - } - return artifact + return attestRuntimeOwnedExecutor( + { + runtime: 'sandbox' as Runtime, + execute(task, signal): AsyncIterable { + return streamSandboxLeaf({ + task, + signal, + harness, + spec, + seam, + output, + driver, + maxIterations, + controller, + loopCtx: seam.loopCtx, + traceEnv, + onArtifact: (a) => { + artifact = a + }, + }) + }, + teardown(_grace): Promise<{ destroyed: boolean }> { + // The composed runAgentRounds owns its box teardown (finally{allSettled(destroy)}); + // aborting the loop's signal cascades into that barrier. + controller.abort() + return Promise.resolve({ destroyed: true }) + }, + resultArtifact() { + if (!artifact) { + throw new ValidationError('sandboxExecutor: resultArtifact() read before stream drained') + } + return artifact + }, }, - } + sandboxMaterialization, + sandboxBinding, + ) } interface SandboxLeafOut { @@ -829,9 +941,8 @@ function leafVerdict(result: { winner?: { output?: unknown } }): DefaultVerdict /** * Spawns a subprocess (`bin` + `args`). It cannot account tokens, so it is - * `budgetExempt: true`: its spend is NOT metered against the conserved pool and - * its iterations are EXCLUDED from the equal-k arms by construction (the - * resolver/equal-k path checks `budgetExempt`). teardown is SIGTERM → SIGKILL + * `budgetExempt: true`: it remains usable as a direct executor, while budgeted supervision + * refuses it before process execution because the CLI exposes no usage receipt. teardown is SIGTERM → SIGKILL * with a grace window. Streaming: yields one `iteration` event on clean exit. */ export const cliExecutor: ExecutorFactory = (_spec, ctx) => { @@ -849,37 +960,65 @@ export const cliExecutor: ExecutorFactory = (_spec, ctx) => { let proc: ReturnType | undefined let artifact: ExecutorResult | undefined + const executionId = ctx.node?.nodeId ?? `cli-process-${randomUUID()}` + const attemptId = ctx.node?.attemptId ?? newExecutionAttemptId(executionId) - return { - runtime: 'cli' as Runtime, - budgetExempt: true, - execute(task, signal): AsyncIterable { - return streamCliLeaf({ - task, - signal, - traceEnv, - seam, - controller, - onProc: (p) => { - proc = p - }, - onArtifact: (a) => { - artifact = a - }, - }) + return attestRuntimeOwnedExecutor( + { + runtime: 'cli' as Runtime, + budgetExempt: true, + execute(task, signal): AsyncIterable { + return streamCliLeaf({ + task, + signal, + traceEnv, + seam, + controller, + onProc: (p) => { + proc = p + }, + onArtifact: (a) => { + artifact = a + }, + }) + }, + async teardown(grace): Promise<{ destroyed: boolean }> { + controller.abort() + if (!proc || proc.exitCode !== null || proc.killed) return { destroyed: true } + return killWithGrace(proc, grace) + }, + resultArtifact() { + if (!artifact) { + throw new ValidationError('cliExecutor: resultArtifact() read before stream drained') + } + return artifact + }, }, - async teardown(grace): Promise<{ destroyed: boolean }> { - controller.abort() - if (!proc || proc.exitCode !== null || proc.killed) return { destroyed: true } - return killWithGrace(proc, grace) + { + effectiveProfile: _spec.profile, + backend: 'cli', + model: { status: 'unknown', reason: 'raw subprocess has no model identity contract' }, + execution: { kind: 'process-attempt', id: executionId }, + materializer: 'raw-cli-stdin', + plan: { + kind: 'raw-cli-process', + bin: seam.bin, + args: seam.args ?? [], + cwd: seam.cwd ?? null, + envOverrides: seam.env ?? {}, + ambientEnvironment: 'inherited', + }, }, - resultArtifact() { - if (!artifact) { - throw new ValidationError('cliExecutor: resultArtifact() read before stream drained') - } - return artifact + { + attemptId, + binding: { + executionId, + bin: seam.bin, + cwd: seam.cwd ?? null, + }, + descriptor: { kind: 'cli-process', transport: 'process', backend: 'cli' }, }, - } + ) } interface StreamCliArgs { @@ -988,26 +1127,29 @@ function killWithGrace( * - STEERABLE: the down-leg `inbox` is drained at each turn boundary; a queued * steer becomes the next turn's prompt on the same session, and the worker can't * settle while a steer it never read is pending (the sandbox/router contract). - * - ABORT: the caller signal + teardown fold into the per-turn fetch signal; a - * forceful (`interrupt`) steer aborts the in-flight turn so the worker re-plans. + * - ABORT: reader abort only detaches HTTP. Interrupt/teardown then call the + * bridge's explicit cancel operation and wait for the owned run to terminate. * * Reports REAL usage when the bridge surfaces it, never a fabricated cost. */ -/** Resolve the bridge wire model for this spawn: a per-create `backend` override - * (harness + model) wins over the seam default, encoded as `${harness}/${model}`. - * Absent an override the seam `model` is used verbatim. */ -function bridgeCellModel(seamModel: string, ctx: ExecutorContext): string { +/** Resolve the bridge wire model for this spawn. Per-create matrix settings win, then the + * canonical profile's harness/model preferences, then the bridge's configured fallback. */ +function bridgeCellModel( + seamModel: string | undefined, + ctx: ExecutorContext, + profile: AgentProfile, +): string | undefined { const create = ctx.seams.createOptions as | { backend?: { type?: string; model?: { model?: string } } } | undefined const backend = create?.backend - const harness = backend?.type - const model = backend?.model?.model + const profileHarness = profile.harness === 'cli-base' ? undefined : profile.harness + const harness = backend?.type ?? profileHarness + const model = backend?.model?.model ?? profile.model?.default if (!harness && !model) return seamModel - const h = harness ?? '' - const m = model ?? seamModel - if (!h) return m - return m.startsWith(`${h}/`) ? m : `${h}/${m}` + if (!harness) return model + if (model) return model.startsWith(`${harness}/`) ? model : `${harness}/${model}` + return seamModel?.startsWith(`${harness}/`) ? seamModel : undefined } export const bridgeExecutor: ExecutorFactory = (spec, ctx) => { @@ -1018,16 +1160,20 @@ export const bridgeExecutor: ExecutorFactory = (spec, ctx) => { // the wire id is `${harness}/${model}` (an already-`${harness}/`-prefixed model // passes through). This is how ONE bridge `SandboxClient` drives every // harness×model cell of a matrix — the seam `model` is the fixed default. - const seam = { ...base, model: bridgeCellModel(base.model, ctx) } + const effectiveProfile = agentProfileSchema.parse( + mergeAgentProfiles(spec.profile, base.agentProfile) ?? spec.profile, + ) + const seam = { ...base, model: bridgeCellModel(base.model, ctx, effectiveProfile) } if (!seam.bridgeUrl || !seam.bridgeBearer || !seam.model) { throw new ValidationError( - 'bridgeExecutor: BridgeSeam.bridgeUrl + bridgeBearer + model required', + 'bridgeExecutor: bridgeUrl + bridgeBearer and a profile or bridge model are required', ) } const maxTurns = seam.maxTurns ?? 200 // A stable per-spawn session id (caller can pin one) — cli-bridge keys harness // resume off this exactly as a box id keys a sandbox session. const sessionId = seam.sessionId ?? `bridge-${spec.profile.name ?? 'worker'}-${randomUUID()}` + const attemptId = ctx.node?.attemptId ?? newExecutionAttemptId(sessionId) const controller = new AbortController() const abortIfSignalled = () => { @@ -1039,50 +1185,96 @@ export const bridgeExecutor: ExecutorFactory = (spec, ctx) => { // The down-leg receive end: the driver's steer/answer/resume land here via `Scope.send`. const inbox = createInbox() let artifact: ExecutorResult | undefined - - return { - runtime: 'cli' as Runtime, - deliver: (m) => inbox.deliver(m), - execute(task, signal): AsyncIterable { - return streamBridgeSession({ - task, - signal, - spec, - seam, - sessionId, - maxTurns, - inbox, - controller, - onArtifact: (a) => { - artifact = a - }, - }) + // One spawn owns one resumable session and, at most, one live durable run per + // turn. Keep the server-owned run identities until the bridge proves each job + // terminal; closing a response body is deliberately not terminal evidence. + const activeRuns = new Map() + + return attestRuntimeOwnedExecutor( + { + runtime: 'cli' as Runtime, + deliver: (m) => inbox.deliver(m), + execute(task, signal): AsyncIterable { + return streamBridgeSession({ + task, + signal, + profile: effectiveProfile, + seam, + sessionId, + maxTurns, + inbox, + controller, + activeRuns, + onArtifact: (a) => { + artifact = a + }, + }) + }, + async teardown(grace): Promise<{ destroyed: boolean }> { + controller.abort() + const remaining = [...activeRuns.values()].filter((run) => !run.terminal) + if (remaining.length === 0) return { destroyed: true } + const terminal = await Promise.all( + remaining.map((run) => cancelBridgeRunToTerminal(seam, run, grace)), + ) + return { destroyed: terminal.every(Boolean) } + }, + resultArtifact() { + if (!artifact) { + throw new ValidationError('bridgeExecutor: resultArtifact() read before stream drained') + } + return { ...artifact, spent: artifact.spent } + }, }, - teardown(_grace): Promise<{ destroyed: boolean }> { - controller.abort() - return Promise.resolve({ destroyed: true }) + { + effectiveProfile, + backend: 'bridge', + model: { status: 'known', id: seam.model }, + execution: { kind: 'session', id: sessionId }, + materializer: 'cli-bridge-agent-profile', + plan: { + kind: 'cli-bridge-session', + cwd: seam.cwd ?? null, + maxTurns, + timeoutMs: seam.timeoutMs ?? null, + streaming: true, + }, }, - resultArtifact() { - if (!artifact) { - throw new ValidationError('bridgeExecutor: resultArtifact() read before stream drained') - } - return { ...artifact, spent: artifact.spent } + { + attemptId, + binding: { + bridgeUrl: seam.bridgeUrl, + cwd: seam.cwd ?? null, + effectiveProfile, + model: seam.model, + sessionId, + }, + descriptor: { kind: 'bridge-session', transport: 'http', backend: 'bridge' }, }, - } + ) } interface StreamBridgeArgs { task: unknown signal: AbortSignal - spec: AgentSpec + profile: AgentProfile seam: BridgeSeam sessionId: string maxTurns: number inbox: Inbox controller: AbortController + activeRuns: Map onArtifact: (a: ExecutorResult) => void } +interface ActiveBridgeRun { + readonly id: string + requestDigest?: string + lastEventId: number + terminal: boolean + cancelInFlight?: Promise +} + /** * One resumable cli-bridge session, run as a streamed turn loop. Turn 0 sends the * task; each subsequent turn fires ONLY when the inbox has a steer/answer to fold — @@ -1095,7 +1287,9 @@ async function* streamBridgeSession(args: StreamBridgeArgs): AsyncIterable 0 they ARE the prompt (resume content). const pending = inbox.drain() @@ -1114,13 +1306,10 @@ async function* streamBridgeSession(args: StreamBridgeArgs): AsyncIterable = [] - if (t === 0 && typeof system === 'string' && system.length > 0) { - messages.push({ role: 'system', content: system }) - } messages.push({ role: 'user', content: nextPrompt }) nextPrompt = undefined @@ -1131,74 +1320,103 @@ async function* streamBridgeSession(args: StreamBridgeArgs): AsyncIterable { + timedOut = true + abortTurn() + }, seam.timeoutMs) + : undefined const cleanup = () => { external.removeEventListener('abort', abortTurn) if (timer) clearTimeout(timer) } - let res: BridgeResponse - try { - res = await bridgeStreamPost(seam.bridgeUrl, { - bearer: seam.bridgeBearer, - sessionId: args.sessionId, - body: { - model: seam.model, - stream: true, - session_id: args.sessionId, - ...(seam.cwd ? { cwd: seam.cwd } : {}), - ...(seam.agentProfile ? { agent_profile: seam.agentProfile } : {}), - messages, - }, - signal: turnController.signal, - }) - } catch (e) { - cleanup() - // Re-plan ONLY when a forceful steer (not external teardown) aborted the turn — - // the steer is already queued, so loop back and fold it. Anything else is fatal. - const interruptAbort = - e instanceof DOMException && - e.name === 'AbortError' && - interruptSig.aborted && - !args.signal.aborted && - !args.controller.signal.aborted - if (interruptAbort) continue - throw e - } - if (!res.ok) { - cleanup() - throw new ValidationError( - `bridgeExecutor: bridge ${res.status}: ${(await res.text()).slice(0, 300)}`, - ) + const activeRun: ActiveBridgeRun = { + id: `bridge-run-${randomUUID()}`, + lastEventId: 0, + terminal: false, } - if (!res.body) { - cleanup() - throw new ValidationError('bridgeExecutor: bridge response had no body to stream') + args.activeRuns.set(activeRun.id, activeRun) + const requestBody = { + model: seam.model, + stream: true, + run_id: activeRun.id, + session_id: args.sessionId, + ...(seam.cwd ? { cwd: seam.cwd } : {}), + agent_profile: args.profile, + messages, } let turnText = '' + let turnTokensKnown = false + let turnUsdKnown = false + let interrupted = false try { - for await (const chunk of parseSseChatStream(res.body)) { + for await (const chunk of streamDurableBridgeRun({ + seam, + sessionId: args.sessionId, + body: requestBody, + signal: turnController.signal, + run: activeRun, + })) { if (chunk.content) { turnText += chunk.content } if (chunk.toolCall) toolCalls.push(chunk.toolCall) if (chunk.usage) { + turnTokensKnown = true tokens.input += chunk.usage.input tokens.output += chunk.usage.output yield { kind: 'tokens', input: chunk.usage.input, output: chunk.usage.output } } - if (typeof chunk.cost === 'number' && chunk.cost > 0) { - usd += chunk.cost - yield { kind: 'cost', usd: chunk.cost } + if (typeof chunk.cost === 'number') { + turnUsdKnown = true + if (chunk.cost > 0) { + usd += chunk.cost + yield { kind: 'cost', usd: chunk.cost } + } + } + } + } catch (error) { + // A forceful steer first detaches this HTTP reader, then explicitly cancels + // the durable run and waits for terminal proof. Starting the resume turn + // before that acknowledgement would race two harness processes against one + // resumable session. + const interruptAbort = + interruptSig.aborted && !args.signal.aborted && !args.controller.signal.aborted + if (interruptAbort) { + const terminal = await cancelBridgeRunToTerminal(seam, activeRun, 'infinity', external) + if (!terminal) { + throw new ValidationError( + `bridgeExecutor: interrupted run ${activeRun.id} did not reach terminal state`, + ) + } + interrupted = true + } else { + // A per-turn timeout is owned here, not by the HTTP socket. Request + // explicit cancellation before surfacing it; external scope teardown + // performs the same operation under its own grace budget. + if (timedOut && !activeRun.terminal) { + await requestBridgeRunCancellation(seam, activeRun, 0) } + throw error } } finally { cleanup() } + // Some transports can finish a buffered body normally after their signal fires. The forceful + // steer still wins and must become a new turn rather than letting this response settle. + if (interruptSig.aborted && !args.signal.aborted && !args.controller.signal.aborted) { + interrupted = true + } turns += 1 + if (!turnTokensKnown) tokensKnown = false + if (!turnUsdKnown) usdKnown = false yield { kind: 'iteration' } - if (turnText) lastText = turnText + if (!interrupted && turnText) lastText = turnText + + if (interrupted) continue // Before settling, drain once more — the worker can't finish while a steer it // never read is pending (the sandbox/router settle contract). A pending steer @@ -1209,7 +1427,9 @@ async function* streamBridgeSession(args: StreamBridgeArgs): AsyncIterable { + let reconnects = 0 + let pendingUpstreamError: ValidationError | undefined + + for (;;) { + let res: BridgeResponse + try { + res = await bridgeStreamPost(args.seam.bridgeUrl, { + bearer: args.seam.bridgeBearer, + sessionId: args.sessionId, + runId: args.run.id, + afterEventId: args.run.lastEventId, + body: args.body, + signal: args.signal, + }) + } catch (error) { + if (args.signal.aborted) throw error + if (reconnects >= BRIDGE_MAX_RECONNECTS) { + throw new ValidationError( + `bridgeExecutor: run ${args.run.id} disconnected before terminal acknowledgement after ${reconnects + 1} attempts: ${errorMessage(error)}`, + ) + } + reconnects += 1 + continue + } + + if (!res.ok) { + throw new ValidationError( + `bridgeExecutor: bridge ${res.status}: ${(await res.text()).slice(0, 300)}`, + ) + } + if (!res.body) { + throw new ValidationError('bridgeExecutor: bridge response had no body to stream') + } + assertBridgeResponseIdentity(res, args.run) + + let sawDone = false + try { + for await (const event of parseSseChatStream(res.body)) { + if (event.kind === 'done') { + sawDone = true + break + } + const expected = args.run.lastEventId + 1 + if (event.id !== expected) { + throw new ValidationError( + `bridgeExecutor: run ${args.run.id} replay gap: expected event ${expected}, received ${event.id}`, + ) + } + args.run.lastEventId = event.id + if (event.error) pendingUpstreamError = event.error + if (event.chunk) yield event.chunk + } + } catch (error) { + if (args.signal.aborted) throw error + if (error instanceof ValidationError) throw error + if (reconnects >= BRIDGE_MAX_RECONNECTS) { + throw new ValidationError( + `bridgeExecutor: run ${args.run.id} stream disconnected before terminal acknowledgement after ${reconnects + 1} attempts: ${errorMessage(error)}`, + ) + } + reconnects += 1 + continue + } + + if (sawDone) { + args.run.terminal = true + if (pendingUpstreamError) throw pendingUpstreamError + return + } + // Preserve the provider's actual diagnostic even when a non-conforming + // bridge drops the final [DONE]. The run remains nonterminal in our local + // state, so teardown still has to cancel and obtain real terminal proof. + if (pendingUpstreamError) throw pendingUpstreamError + if (args.signal.aborted) { + throw new DOMException('bridgeExecutor: turn aborted', 'AbortError') + } + if (reconnects >= BRIDGE_MAX_RECONNECTS) { + throw new ValidationError( + `bridgeExecutor: run ${args.run.id} ended without terminal acknowledgement after ${reconnects + 1} attempts`, + ) + } + reconnects += 1 + } +} + /** The subset of `Response` `streamBridgeSession` consumes: status gate, an error * body reader, and a web `ReadableStream` the SSE parser drains. */ interface BridgeResponse { ok: boolean status: number + headers: Readonly> text: () => Promise body: ReadableStream | null } @@ -1232,6 +1559,8 @@ interface BridgeResponse { interface BridgeStreamPostArgs { bearer: string sessionId: string + runId: string + afterEventId: number body: unknown signal: AbortSignal } @@ -1265,6 +1594,8 @@ function bridgeStreamPost(url: string, args: BridgeStreamPostArgs): Promise 0 ? { 'last-event-id': String(args.afterEventId) } : {}), 'content-length': Buffer.byteLength(payload), }, // No header/body idle timeout: a slow bridge is a live bridge; the abort @@ -1272,12 +1603,15 @@ function bridgeStreamPost(url: string, args: BridgeStreamPostArgs): Promise { + response = res + res.once('close', () => args.signal.removeEventListener('abort', onAbort)) const status = res.statusCode ?? 0 const ok = status >= 200 && status < 300 const body = Readable.toWeb(res) as ReadableStream resolve({ ok, status, + headers: res.headers, body, text: async () => { const chunks: Buffer[] = [] @@ -1287,8 +1621,12 @@ function bridgeStreamPost(url: string, args: BridgeStreamPostArgs): Promise[0] | undefined const onAbort = (): void => { req.destroy(new DOMException('bridgeExecutor: turn aborted', 'AbortError')) + if (response && 'destroy' in response && typeof response.destroy === 'function') { + response.destroy(new DOMException('bridgeExecutor: turn aborted', 'AbortError')) + } } if (args.signal.aborted) onAbort() else args.signal.addEventListener('abort', onAbort, { once: true }) @@ -1296,12 +1634,187 @@ function bridgeStreamPost(url: string, args: BridgeStreamPostArgs): Promise args.signal.removeEventListener('abort', onAbort)) + req.on('close', () => { + if (!response) args.signal.removeEventListener('abort', onAbort) + }) req.write(payload) req.end() }) } +interface BridgeBufferedResponse { + status: number + headers: Readonly> + text: string +} + +function bridgeHeader( + headers: Readonly>, + name: string, +): string | undefined { + const raw = headers[name.toLowerCase()] + if (Array.isArray(raw)) return raw.length === 1 ? raw[0] : undefined + return raw +} + +function assertBridgeResponseIdentity(response: BridgeResponse, run: ActiveBridgeRun): void { + assertBridgeIdentityHeaders(response.headers, run) +} + +function assertBridgeIdentityHeaders( + headers: Readonly>, + run: ActiveBridgeRun, +): void { + const responseRunId = bridgeHeader(headers, 'x-run-id') + if (responseRunId !== run.id) { + throw new ValidationError( + `bridgeExecutor: bridge run identity mismatch: expected ${run.id}, received ${responseRunId ?? 'missing'}`, + ) + } + const digest = bridgeHeader(headers, 'x-run-request-digest') + if (!digest || !/^sha256:[a-f0-9]{64}$/u.test(digest)) { + throw new ValidationError('bridgeExecutor: bridge response omitted a valid request digest') + } + if (run.requestDigest !== undefined && run.requestDigest !== digest) { + throw new ValidationError( + `bridgeExecutor: bridge request digest changed for run ${run.id}: expected ${run.requestDigest}, received ${digest}`, + ) + } + run.requestDigest = digest +} + +/** Explicitly cancel one server-owned run and long-poll for its terminal snapshot. */ +function bridgeCancelPost( + seam: BridgeSeam, + run: ActiveBridgeRun, + waitMs: number, +): Promise { + const target = new URL( + `${seam.bridgeUrl.replace(/\/$/, '')}/v1/runs/${encodeURIComponent(run.id)}/cancel`, + ) + target.searchParams.set('wait_ms', String(waitMs)) + const requestFn = target.protocol === 'https:' ? httpsRequest : httpRequest + return new Promise((resolve, reject) => { + const req = requestFn( + target, + { + method: 'POST', + headers: { + authorization: `Bearer ${seam.bridgeBearer}`, + 'x-run-id': run.id, + 'content-length': '0', + }, + timeout: 0, + }, + (res) => { + void (async () => { + const chunks: Buffer[] = [] + for await (const chunk of res) chunks.push(Buffer.from(chunk)) + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + text: Buffer.concat(chunks).toString('utf8'), + }) + })().catch(reject) + }, + ) + req.on('error', reject) + req.end() + }) +} + +async function requestBridgeRunCancellation( + seam: BridgeSeam, + run: ActiveBridgeRun, + waitMs: number, +): Promise { + if (run.terminal) return true + if (run.cancelInFlight) return run.cancelInFlight + const work = (async (): Promise => { + const response = await bridgeCancelPost(seam, run, waitMs) + if (response.status === 404) { + throw new ValidationError( + `bridgeExecutor: bridge no longer knows run ${run.id}; terminal state is unproven`, + ) + } + if (response.status !== 200 && response.status !== 202) { + throw new ValidationError( + `bridgeExecutor: cancel ${run.id} returned ${response.status}: ${response.text.slice(0, 300)}`, + ) + } + assertBridgeIdentityHeaders(response.headers, run) + let parsed: { + terminal?: unknown + run?: { id?: unknown; requestDigest?: unknown; terminal?: unknown } + } + try { + parsed = JSON.parse(response.text) as typeof parsed + } catch { + throw new ValidationError(`bridgeExecutor: cancel ${run.id} returned invalid JSON`) + } + if ( + parsed.run?.id !== run.id || + parsed.run.requestDigest !== run.requestDigest || + typeof parsed.terminal !== 'boolean' || + typeof parsed.run.terminal !== 'boolean' || + parsed.terminal !== parsed.run.terminal + ) { + throw new ValidationError( + `bridgeExecutor: cancel ${run.id} returned an inconsistent terminal snapshot`, + ) + } + if (response.status === 200 && parsed.terminal === true) { + run.terminal = true + return true + } + if (response.status === 202 && parsed.terminal === false) return false + throw new ValidationError( + `bridgeExecutor: cancel ${run.id} status ${response.status} disagreed with terminal=${String(parsed.terminal)}`, + ) + })() + run.cancelInFlight = work + try { + return await work + } finally { + if (run.cancelInFlight === work) run.cancelInFlight = undefined + } +} + +async function cancelBridgeRunToTerminal( + seam: BridgeSeam, + run: ActiveBridgeRun, + grace: number | 'brutalKill' | 'infinity', + stopSignal?: AbortSignal, +): Promise { + if (run.terminal) return true + const deadline = + grace === 'infinity' + ? undefined + : Date.now() + (grace === 'brutalKill' ? BRIDGE_BRUTAL_KILL_WAIT_MS : Math.max(0, grace)) + let first = true + for (;;) { + const remaining = deadline === undefined ? BRIDGE_CANCEL_LONG_POLL_MS : deadline - Date.now() + if (!first && remaining <= 0) return false + if (!first && stopSignal?.aborted) return false + const waitMs = Math.max( + 0, + Math.min( + stopSignal ? 1_000 : BRIDGE_CANCEL_LONG_POLL_MS, + deadline === undefined ? remaining : Math.max(0, remaining), + ), + ) + const terminal = await requestBridgeRunCancellation(seam, run, waitMs) + if (terminal) return true + first = false + if (deadline !== undefined && Date.now() >= deadline) return false + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + interface BridgeStreamChunk { content?: string toolCall?: string @@ -1309,15 +1822,19 @@ interface BridgeStreamChunk { cost?: number } +type BridgeSseEvent = + | { kind: 'event'; id: number; chunk?: BridgeStreamChunk; error?: ValidationError } + | { kind: 'done' } + /** * Parse cli-bridge's OpenAI-compatible SSE stream into normalized chunks. Each - * `data:` line is an OpenAI chat-completion chunk (`choices[].delta`); `[DONE]` - * and SSE comments (`:` keepalives) terminate/skip. Mirrors how `streamSandboxLeaf` - * folds a box's event stream — same `UsageEvent` currency, different wire shape. + * `data:` line is an OpenAI chat-completion chunk (`choices[].delta`). Every + * run-owned frame, including an id-only comment, is returned so the caller can + * prove a contiguous replay sequence. Transport keepalives have no id and are ignored. */ async function* parseSseChatStream( body: ReadableStream, -): AsyncIterable { +): AsyncIterable { const reader = body.getReader() const decoder = new TextDecoder() let buf = '' @@ -1327,16 +1844,16 @@ async function* parseSseChatStream( if (done) break buf += decoder.decode(value, { stream: true }) // SSE frames are separated by a blank line; split on it and keep the tail. - let sep = buf.indexOf('\n\n') - while (sep !== -1) { - const frame = buf.slice(0, sep) - buf = buf.slice(sep + 2) - const chunk = parseSseFrame(frame) - if (chunk === 'done') return - if (chunk) yield chunk - sep = buf.indexOf('\n\n') + let separator = /\r?\n\r?\n/u.exec(buf) + while (separator) { + const frame = buf.slice(0, separator.index) + buf = buf.slice(separator.index + separator[0].length) + const event = parseSseFrame(frame) + if (event) yield event + separator = /\r?\n\r?\n/u.exec(buf) } } + buf += decoder.decode() // Upstream failures routinely arrive UNTERMINATED: a final `data:` frame // with no trailing blank line, or a bare JSON error body with no SSE // framing at all (kimi's access_terminated_error). Dropping the tail here @@ -1344,7 +1861,7 @@ async function* parseSseChatStream( // fails the run, but the diagnostic dies with the buffer. Parse the tail so // the upstream error message rides the thrown event instead. const tail = parseSseStreamTail(buf) - if (tail !== undefined && tail !== 'done') yield tail + if (tail !== undefined) yield tail } finally { reader.releaseLock() } @@ -1354,7 +1871,7 @@ async function* parseSseChatStream( * blank line, or a bare (non-SSE) JSON body — the shape bridge upstreams use * for terminal failures. Throws `ValidationError` on an error payload; returns * `undefined` for keepalive noise or non-JSON leftovers. */ -function parseSseStreamTail(buf: string): BridgeStreamChunk | 'done' | undefined { +function parseSseStreamTail(buf: string): BridgeSseEvent | undefined { const tail = buf.trim() if (!tail) return undefined const framed = parseSseFrame(tail) @@ -1373,18 +1890,32 @@ function parseSseStreamTail(buf: string): BridgeStreamChunk | 'done' | undefined return undefined } -/** Parse one SSE frame (possibly multi-line `data:`/comment) into a chunk, `'done'`, - * or undefined (comment/keepalive/empty). */ -function parseSseFrame(frame: string): BridgeStreamChunk | 'done' | undefined { +/** Parse one SSE frame into a numbered run event, terminal marker, or unnumbered keepalive. */ +function parseSseFrame(frame: string): BridgeSseEvent | undefined { const dataLines: string[] = [] + let id: number | undefined for (const rawLine of frame.split('\n')) { const line = rawLine.replace(/\r$/, '') - if (!line || line.startsWith(':')) continue // comment / keepalive + if (!line || line.startsWith(':')) continue + if (line.startsWith('id:')) { + const rawId = line.slice('id:'.length).trim() + if (!/^[1-9][0-9]*$/u.test(rawId)) { + throw new ValidationError(`bridgeExecutor: invalid SSE event id ${JSON.stringify(rawId)}`) + } + const parsedId = Number(rawId) + if (!Number.isSafeInteger(parsedId)) { + throw new ValidationError(`bridgeExecutor: SSE event id exceeds safe integer range`) + } + id = parsedId + continue + } if (line.startsWith('data:')) dataLines.push(line.slice('data:'.length).trimStart()) } - if (dataLines.length === 0) return undefined + if (dataLines.length === 0) { + return id === undefined ? undefined : { kind: 'event', id } + } const data = dataLines.join('\n') - if (data === '[DONE]') return 'done' + if (data === '[DONE]') return { kind: 'done' } let parsed: { choices?: Array<{ delta?: { @@ -1399,14 +1930,21 @@ function parseSseFrame(frame: string): BridgeStreamChunk | 'done' | undefined { try { parsed = JSON.parse(data) } catch { - return undefined + throw new ValidationError('bridgeExecutor: bridge emitted a non-JSON SSE data frame') + } + if (id === undefined) { + throw new ValidationError('bridgeExecutor: bridge emitted an unnumbered run event') } if (parsed.error) { // `type` is the upstream's error class (e.g. kimi's access_terminated_error) // — carry it when the payload has no message, never collapse to 'unknown'. - throw new ValidationError( - `bridgeExecutor: bridge stream error: ${parsed.error.message ?? parsed.error.type ?? 'unknown'}`, - ) + return { + kind: 'event', + id, + error: new ValidationError( + `bridgeExecutor: bridge stream error: ${parsed.error.message ?? parsed.error.type ?? 'unknown'}`, + ), + } } const out: BridgeStreamChunk = {} const choice = parsed.choices?.[0] @@ -1419,7 +1957,11 @@ function parseSseFrame(frame: string): BridgeStreamChunk | 'done' | undefined { out.usage = { input: u.prompt_tokens ?? 0, output: u.completion_tokens ?? 0 } } if (typeof u?.cost === 'number') out.cost = u.cost - return Object.keys(out).length > 0 ? out : undefined + return { + kind: 'event', + id, + ...(Object.keys(out).length > 0 ? { chunk: out } : {}), + } } function bridgeWorktreeExecutor( @@ -1439,6 +1981,11 @@ function bridgeWorktreeExecutor( const runId = seam.runId ?? randomUUID() const sessionId = bridge.sessionId ?? `bridge-worktree-${runId}` + const attemptId = ctx.node?.attemptId ?? newExecutionAttemptId(runId) + const effectiveProfile = agentProfileSchema.parse( + mergeAgentProfiles(spec.profile, bridge.agentProfile) ?? spec.profile, + ) + const model = bridgeCellModel(bridge.model, ctx, effectiveProfile) const controller = new AbortController() const pending: unknown[] = [] let inner: Executor | undefined @@ -1466,129 +2013,157 @@ function bridgeWorktreeExecutor( pending.push(msg) } - return { - runtime: 'cli' as Runtime, - budgetExempt: seam.budgetExempt ?? false, - deliver, - execute(_task, signal): AsyncIterable { - return (async function* bridgeWorktreeStream() { - const started = Date.now() - const linked = mergeAbortSignals(signal, controller.signal) - let bridgeArtifact: ExecutorResult | undefined - - try { - worktree = await createWorktree({ - repoRoot: seam.repoRoot, - runId, - ...(seam.baseRef ? { baseRef: seam.baseRef } : {}), - ...(seam.runGit ? { runGit: seam.runGit } : {}), - }) - removed = false - - const bridgeSeam: BridgeSeam = { - bridgeUrl: bridge.bridgeUrl, - bridgeBearer: bridge.bridgeBearer, - model: resolveBridgeWorktreeModel(spec, bridge), - cwd: worktree.path, - sessionId, - ...(bridge.agentProfile - ? { agentProfile: bridge.agentProfile } - : { agentProfile: spec.profile as unknown as Record }), - ...(bridge.timeoutMs !== undefined ? { timeoutMs: bridge.timeoutMs } : {}), - ...(bridge.maxTurns !== undefined ? { maxTurns: bridge.maxTurns } : {}), - } - const bridgeCtx: ExecutorContext = { - ...ctx, - signal: linked, - seams: { ...ctx.seams, [bridgeSeamKey]: bridgeSeam }, - } - inner = bridgeExecutor(spec, bridgeCtx) - for (const msg of pending.splice(0)) inner.deliver?.(msg) - - const run = inner.execute(seam.taskPrompt, linked) - if (isAsyncIterable(run)) { - for await (const event of run) yield event - bridgeArtifact = inner.resultArtifact() - } else { - bridgeArtifact = await run - } + return attestRuntimeOwnedExecutor( + { + runtime: 'cli' as Runtime, + budgetExempt: seam.budgetExempt ?? false, + deliver, + execute(task, signal): AsyncIterable { + return (async function* bridgeWorktreeStream() { + const started = Date.now() + const linked = mergeAbortSignals(signal, controller.signal) + let bridgeArtifact: ExecutorResult | undefined - const diff = await captureWorktreeDiff({ - worktree, - ...(seam.runGit ? { runGit: seam.runGit } : {}), - }) - const checks = await runWorktreeChecks({ - worktreePath: worktree.path, - ...(seam.testCmd !== undefined ? { testCmd: seam.testCmd } : {}), - ...(seam.typecheckCmd !== undefined ? { typecheckCmd: seam.typecheckCmd } : {}), - timeoutMs: - seam.checkTimeoutMs ?? seam.harnessTimeoutMs ?? bridge.timeoutMs ?? 5 * 60 * 1000, - cap: seam.checkOutputCap ?? 16_000, - ...(seam.runCommand ? { runCommand: seam.runCommand } : {}), - signal: linked, - }) + try { + worktree = await createWorktree({ + repoRoot: seam.repoRoot, + runId, + ...(seam.baseRef ? { baseRef: seam.baseRef } : {}), + ...(seam.runGit ? { runGit: seam.runGit } : {}), + }) + removed = false + + const bridgeSeam: BridgeSeam = { + bridgeUrl: bridge.bridgeUrl, + bridgeBearer: bridge.bridgeBearer, + cwd: worktree.path, + sessionId, + ...(bridge.model ? { model: bridge.model } : {}), + ...(bridge.agentProfile ? { agentProfile: bridge.agentProfile } : {}), + ...(bridge.timeoutMs !== undefined ? { timeoutMs: bridge.timeoutMs } : {}), + ...(bridge.maxTurns !== undefined ? { maxTurns: bridge.maxTurns } : {}), + } + const bridgeCtx: ExecutorContext = { + ...ctx, + signal: linked, + seams: { ...ctx.seams, [bridgeSeamKey]: bridgeSeam }, + } + inner = bridgeExecutor(spec, bridgeCtx) + for (const msg of pending.splice(0)) inner.deliver?.(msg) + + const run = inner.execute(task, linked) + if (isAsyncIterable(run)) { + for await (const event of run) yield event + bridgeArtifact = inner.resultArtifact() + } else { + bridgeArtifact = await run + } + + const diff = await captureWorktreeDiff({ + worktree, + ...(seam.runGit ? { runGit: seam.runGit } : {}), + }) + const checks = await runWorktreeChecks({ + worktreePath: worktree.path, + ...(seam.testCmd !== undefined ? { testCmd: seam.testCmd } : {}), + ...(seam.typecheckCmd !== undefined ? { typecheckCmd: seam.typecheckCmd } : {}), + timeoutMs: + seam.checkTimeoutMs ?? seam.harnessTimeoutMs ?? bridge.timeoutMs ?? 5 * 60 * 1000, + cap: seam.checkOutputCap ?? 16_000, + ...(seam.runCommand ? { runCommand: seam.runCommand } : {}), + signal: linked, + }) - const result: WorktreeHarnessResult = { - branch: worktree.branch, - patch: diff.patch, - stats: diff.stats, - harness: { - name: 'bridge', - exitCode: null, - timedOut: false, - killedBySignal: null, - durationMs: bridgeArtifact.spent.ms || Date.now() - started, - stdout: bridgeOutputText(bridgeArtifact.out), - stderr: '', - }, - ...(checks ? { checks } : {}), + const result: WorktreeHarnessResult = { + branch: worktree.branch, + patch: diff.patch, + stats: diff.stats, + harness: { + name: 'bridge', + exitCode: null, + timedOut: false, + killedBySignal: null, + durationMs: bridgeArtifact.spent.ms || Date.now() - started, + stdout: bridgeOutputText(bridgeArtifact.out), + stderr: '', + }, + ...(checks ? { checks } : {}), + } + const spent: Spend = { + ...bridgeArtifact.spent, + ms: bridgeArtifact.spent.ms || Date.now() - started, + } + artifact = { + outRef: contentRef('bridge-worktree', { sessionId, result }), + out: result, + spent, + } + } catch (err) { + controller.abort() + await inner?.teardown('brutalKill').catch(() => undefined) + await cleanupWorktree() + throw err } - const spent: Spend = { - ...bridgeArtifact.spent, - ms: bridgeArtifact.spent.ms || Date.now() - started, - } - artifact = { - outRef: contentRef('bridge-worktree', { sessionId, result }), - out: result, - spent, + })() + }, + async teardown(grace): Promise<{ destroyed: boolean }> { + controller.abort() + let destroyed = true + try { + if (inner) { + destroyed = (await inner.teardown(grace)).destroyed } - } catch (err) { - controller.abort() - await inner?.teardown('brutalKill').catch(() => undefined) + } finally { await cleanupWorktree() - throw err } - })() - }, - async teardown(grace): Promise<{ destroyed: boolean }> { - controller.abort() - let destroyed = true - try { - if (inner) { - destroyed = (await inner.teardown(grace)).destroyed + return { destroyed } + }, + resultArtifact() { + if (!artifact) { + throw new ValidationError( + 'cliWorktreeExecutor: bridge resultArtifact() read before stream drained', + ) } - } finally { - await cleanupWorktree() - } - return { destroyed } + return artifact + }, }, - resultArtifact() { - if (!artifact) { - throw new ValidationError( - 'cliWorktreeExecutor: bridge resultArtifact() read before stream drained', - ) - } - return artifact + { + effectiveProfile, + backend: 'bridge-worktree', + model: model + ? { status: 'known', id: model } + : { status: 'unknown', reason: 'bridge worktree profile did not select a model' }, + execution: { kind: 'worktree-session', id: `${runId}:${sessionId}` }, + materializer: 'bridge-worktree-agent-profile', + plan: { + kind: 'bridge-worktree-session', + runId, + sessionId, + baseRef: seam.baseRef ?? 'HEAD', + model: model ?? null, + testCmd: seam.testCmd ?? null, + typecheckCmd: seam.typecheckCmd ?? null, + checkTimeoutMs: + seam.checkTimeoutMs ?? seam.harnessTimeoutMs ?? bridge.timeoutMs ?? 5 * 60 * 1000, + checkOutputCap: seam.checkOutputCap ?? 16_000, + }, + }, + { + attemptId, + binding: { + bridgeUrl: bridge.bridgeUrl, + effectiveProfile, + model: model ?? null, + repoRoot: seam.repoRoot, + runId, + sessionId, + }, + descriptor: { + kind: 'bridge-worktree-session', + transport: 'http', + backend: 'bridge-worktree', + }, }, - } -} - -function resolveBridgeWorktreeModel(spec: AgentSpec, bridge: CliWorktreeBridgeSeam): string { - if (bridge.model) return bridge.model - const model = spec.profile.model?.default - if (typeof model === 'string' && model.length > 0) return model - throw new ValidationError( - 'cliWorktreeExecutor: bridge.model or AgentProfile.model.default required', ) } @@ -1622,8 +2197,8 @@ function isAsyncIterable(value: unknown): value is AsyncIterable { */ export const cliWorktreeExecutor: ExecutorFactory = (spec, ctx) => { const seam = readSeam(ctx, cliWorktreeSeamKey, 'cli-worktree') - if (!seam.repoRoot || !seam.taskPrompt) { - throw new ValidationError('cliWorktreeExecutor: CliWorktreeSeam.repoRoot + taskPrompt required') + if (!seam.repoRoot) { + throw new ValidationError('cliWorktreeExecutor: CliWorktreeSeam.repoRoot required') } if (seam.bridge) return bridgeWorktreeExecutor(spec, ctx, seam) if (!seam.harness) { @@ -1635,7 +2210,7 @@ export const cliWorktreeExecutor: ExecutorFactory = (spec, ctx) => { repoRoot: seam.repoRoot, profile: spec.profile, harness: seam.harness, - taskPrompt: seam.taskPrompt, + ...(seam.taskPrompt !== undefined ? { taskPrompt: seam.taskPrompt } : {}), ...(seam.runId ? { runId: seam.runId } : {}), ...(seam.baseRef ? { baseRef: seam.baseRef } : {}), ...(seam.harnessTimeoutMs !== undefined ? { harnessTimeoutMs: seam.harnessTimeoutMs } : {}), @@ -1648,6 +2223,7 @@ export const cliWorktreeExecutor: ExecutorFactory = (spec, ctx) => { ...(seam.runGit ? { runGit: seam.runGit } : {}), ...(seam.runCommand ? { runCommand: seam.runCommand } : {}), ...(seam.budgetExempt !== undefined ? { budgetExempt: seam.budgetExempt } : {}), + ...(ctx.node?.attemptId !== undefined ? { executionAttemptId: ctx.node.attemptId } : {}), }) as Executor } @@ -1668,6 +2244,139 @@ export type ExecutorConfig = | ({ backend: 'pi' } & PiSeam) | ({ backend: 'sandbox'; harness?: BackendType } & SandboxSeam) +/** Capture one public executor configuration at its call boundary. All data that selects policy, + * model, process, limits, profile overlays, or backend behavior is detached and deeply frozen. + * Explicit service/function fields remain live by reference because they are executable ports, + * not portable configuration. */ +export function snapshotExecutorConfig(config: ExecutorConfig): ExecutorConfig { + switch (config.backend) { + case 'router-tools': { + const { executeToolCall, onToolStep, ...decisionData } = config + const snapshot = detachedSnapshot(decisionData, 'createExecutor router-tools config') + return Object.freeze({ + ...snapshot, + executeToolCall, + ...(onToolStep === undefined ? {} : { onToolStep }), + }) + } + case 'cli-worktree': { + const { runGit, runCommand, ...decisionData } = config + const snapshot = detachedSnapshot(decisionData, 'createExecutor cli-worktree config') + return Object.freeze({ + ...snapshot, + ...(runGit === undefined ? {} : { runGit }), + ...(runCommand === undefined ? {} : { runCommand }), + }) + } + case 'provider': { + const { provider, registry, taskToTurn, ...decisionData } = config + const snapshot = detachedSnapshot(decisionData, 'createExecutor provider config') + // A registry is a live service. Resolve its mutable name mapping exactly once at intake and + // retain the resulting provider instance, never the registry lookup for later execution. + const resolvedProvider = resolveAgentEnvironmentProvider(provider, registry) + return Object.freeze({ + ...snapshot, + provider: resolvedProvider, + ...(taskToTurn === undefined ? {} : { taskToTurn }), + }) + } + case 'sandbox': { + const { sandboxClient, loopCtx, ...decisionData } = config + if (loopCtx === undefined) { + const snapshot = detachedSnapshot(decisionData, 'createExecutor sandbox config') + return Object.freeze({ ...snapshot, sandboxClient }) + } + const { hooks, traceEmitter, onSandboxEvent, runHandle, ...loopDecisionData } = loopCtx + const snapshot = detachedSnapshot( + { ...decisionData, loopCtx: loopDecisionData }, + 'createExecutor sandbox config', + ) + const loopSnapshot = snapshot.loopCtx + return Object.freeze({ + ...snapshot, + sandboxClient, + loopCtx: Object.freeze({ + ...loopSnapshot, + ...(hooks === undefined ? {} : { hooks }), + ...(traceEmitter === undefined ? {} : { traceEmitter }), + ...(onSandboxEvent === undefined ? {} : { onSandboxEvent }), + ...(runHandle === undefined ? {} : { runHandle }), + }), + }) + } + case 'router': + case 'bridge': + case 'cli': + case 'pi': + return detachedSnapshot(config, `createExecutor ${config.backend} config`) + } +} + +/** A backend config reused for multiple workers/managers cannot pin execution identity or carry a + * profile overlay applied after Scope hashed the authored profile. Direct single-execution + * `createExecutor` calls may still use those fields. */ +export function captureReusableExecutorConfig( + config: ExecutorConfig, + context: string, +): ExecutorConfig { + const captured = snapshotExecutorConfig(config) + const profileOverlay = + captured.backend === 'bridge' + ? captured.agentProfile + : captured.backend === 'cli-worktree' + ? captured.bridge?.agentProfile + : undefined + if (profileOverlay !== undefined) { + throw new ValidationError( + `${context}: backend agentProfile overlays are not allowed because they change the effective profile after spawn identity is fixed`, + ) + } + const fixedIdentity = + captured.backend === 'bridge' && captured.sessionId !== undefined + ? 'sessionId' + : captured.backend === 'cli-worktree' && captured.runId !== undefined + ? 'runId' + : captured.backend === 'cli-worktree' && captured.bridge?.sessionId !== undefined + ? 'bridge.sessionId' + : undefined + if (fixedIdentity !== undefined) { + throw new ValidationError( + `${context}: fixed ${fixedIdentity} is not allowed on a reusable backend; let each execution derive an isolated id`, + ) + } + return captured +} + +/** Bind one already-captured reusable backend to the durable identity of the execution that will + * use it. Stateful bridge backends need an explicit external id: a random default isolates two + * siblings but cannot reconnect a replacement process to the same harness session. Non-stateful + * backends carry no external execution id and are returned unchanged. */ +export function bindReusableExecutorExecutionId( + captured: ExecutorConfig, + executionId: string, +): ExecutorConfig { + if (typeof executionId !== 'string' || executionId.length === 0) { + throw new ValidationError( + 'bindReusableExecutorExecutionId: executionId must be a non-empty string', + ) + } + switch (captured.backend) { + case 'bridge': + return Object.freeze({ ...captured, sessionId: executionId }) + case 'cli-worktree': + // The bridged worktree derives `bridge-worktree-${runId}` when no inner session id is set, + // so this one durable value binds both the worktree and its resumed harness conversation. + return Object.freeze({ ...captured, runId: executionId }) + case 'router': + case 'router-tools': + case 'cli': + case 'provider': + case 'pi': + case 'sandbox': + return captured + } +} + /** * The single built-in executor factory. Picks a leaf backend by data (`config.backend`), * injects the matching seam, and delegates to that backend's built-in implementation. @@ -1677,10 +2386,12 @@ export type ExecutorConfig = * `UsageEvent` reporting channel. */ export function createExecutor(config: ExecutorConfig): ExecutorFactory { + const captured = snapshotExecutorConfig(config) return (spec, ctx) => { - const { backend, ...seam } = config as ExecutorConfig & Record + const { backend, ...seamData } = captured as ExecutorConfig & Record + const seam = Object.freeze(seamData) const seamed: ExecutorContext = { ...ctx, seams: { ...ctx.seams, [backend]: seam } } - switch (config.backend) { + switch (captured.backend) { case 'router': return routerInlineExecutor(spec, seamed) case 'router-tools': @@ -1740,7 +2451,7 @@ export function createExecutor(config: ExecutorConfig): ExecutorFactory case 'sandbox': { // The sandbox executor requires a concrete harness; a spec-level harness // wins, else the config names it (fail-loud inside if both are absent). - const harness = spec.harness ?? config.harness ?? null + const harness = spec.harness ?? captured.harness ?? null return sandboxExecutor({ ...spec, harness }, seamed) } } @@ -1776,8 +2487,8 @@ function requiredProviderProfileHarness(spec: AgentSpec, seam: ProviderSeam): Ba * without touching the registry at all. NOT a closed switch; registration + BYO * ARE the extension points. * - * `resolve` precedence (frozen in `ExecutorRegistry`): a BYO `spec.executor` → - * `harness === null` → the `'router'` factory; else a registered factory for the + * `resolve` precedence (frozen in `ExecutorRegistry`): a BYO `spec.executorFactory` → + * `spec.executor` → `harness === null` → the `'router'` factory; else a registered factory for the * harness-derived runtime (`'sandbox'` for any `BackendType`); else fail loud. */ export function createExecutorRegistry(): ExecutorRegistry { @@ -1801,6 +2512,10 @@ export function createExecutorRegistry(): ExecutorRegistry { resolve( spec: AgentSpec, ): { succeeded: true; value: ExecutorFactory } | { succeeded: false; error: string } { + // BYO factory: constructed only after Scope admission with the real signal/context. + if (spec.executorFactory) { + return { succeeded: true, value: spec.executorFactory as ExecutorFactory } + } // BYO: a caller-supplied executor wins, wrapped in a trivial per-spawn factory. if (spec.executor) { const byo = spec.executor @@ -1851,11 +2566,13 @@ function taskToPrompt(task: unknown): string { return JSON.stringify(task) } -/** Router messages from the opaque task + the profile's system prompt, when set. */ +/** Router messages from the opaque task + every portable profile prompt instruction. */ function taskToMessages(task: unknown, spec: AgentSpec): Array<{ role: string; content: string }> { const messages: Array<{ role: string; content: string }> = [] - const system = spec.profile.prompt?.systemPrompt - if (typeof system === 'string' && system.length > 0) { + const system = [spec.profile.prompt?.systemPrompt, ...(spec.profile.prompt?.instructions ?? [])] + .filter((line): line is string => typeof line === 'string' && line.trim().length > 0) + .join('\n') + if (system.length > 0) { messages.push({ role: 'system', content: system }) } messages.push({ role: 'user', content: taskToPrompt(task) }) diff --git a/src/runtime/supervise/scope.ts b/src/runtime/supervise/scope.ts index 6c524e6c..d52b150b 100644 --- a/src/runtime/supervise/scope.ts +++ b/src/runtime/supervise/scope.ts @@ -25,31 +25,62 @@ * @experimental */ +import { + canonicalCandidateDigest, + type Sha256Digest, + sha256DigestSchema, +} from '@tangle-network/agent-interface' import { contentAddress } from '../../durable/spawn-journal' import { ValidationError } from '../../errors' import { notifyRuntimeHookEvent, type RuntimeHooks } from '../../runtime-hooks' import type { Iteration } from '../types' -import type { BudgetPool, ReservationTicket } from './budget' +import { type BudgetPool, createBudgetPool, type ReservationTicket } from './budget' +import { + armDeadlineTimer, + boundedChildDeadlineAt, + DEFAULT_SUCCESSFUL_SHUTDOWN_MS, + teardownExecutor, +} from './deadline' +import { freeSlots } from './dispatch' +import { + authoredProfileDigest, + knownExecutionBindingReceipt, + knownMaterializationReceipt, + newExecutionAttemptId, + runtimeOwnedDeferredExecutorRuntime, + runtimeOwnedExecutorExecutionBinding, + runtimeOwnedExecutorMaterialization, + unknownExecutionBindingReceipt, + unknownMaterializationReceipt, +} from './materialization' import { DEFAULT_STALL_AFTER_MS, type ExecutorProgress, readWorkerProgress, type WorkerProgress, } from './progress' +import { detachedSnapshot } from './snapshot' +import { captureWorkerTraceEvidence } from './trace-evidence' import type { TraceSource } from './trace-source' +import { runtimeOwnedNestedDriverTreeRoot } from './tree-key' import type { Agent, AgentSpec, Budget, DefaultVerdict, + ExecutionBindingReceipt, Executor, ExecutorContext, + ExecutorExecutionBinding, + ExecutorNodeContext, ExecutorRegistry, ExecutorResult, Handle, + NodeExecutionIdentity, NodeId, NodeSnapshot, NodeStatus, + ProfileMaterializationReceipt, ResultBlobStore, ResumedKeyState, ResumedWork, @@ -63,6 +94,7 @@ import type { TreeView, UsageEvent, WaitOpts, + WorkerTraceEvidence, } from './types' import { assertWaitWithinDeadline, @@ -84,7 +116,7 @@ export interface ScopeArgs { readonly parentId: NodeId /** Journal/blob root key the supervisor `beginTree`'d. */ readonly root: NodeId - /** The shared conserved reservation pool (one per supervised run). */ + /** The reservation pool for this scope: the root total or one nested allocated partition. */ readonly pool: BudgetPool /** Append-only spawn journal; this scope writes `spawned` + `settled` records. */ readonly journal: SpawnJournal @@ -103,6 +135,11 @@ export interface ScopeArgs { readonly depth: number /** Runtime recursion-depth ceiling — a spawn past it fails closed `depth-exceeded`. */ readonly maxDepth?: number + /** Root-owned limit on live spawned workers across this scope and every nested scope. */ + readonly maxLiveWorkers?: number + /** @internal Shared counter inherited by nested scopes. Callers set `maxLiveWorkers`; the root + * scope creates this state once and passes the same object through its recursion seam. */ + readonly liveWorkerCapacity?: LiveWorkerCapacityState /** Abort signal for this scope; an abort cascades into every live child's executor. */ readonly signal: AbortSignal /** Injected clock — keeps the journal `at` timestamp deterministic in tests. */ @@ -118,6 +155,20 @@ export interface ScopeArgs { * ⇒ no seam is seeded and no worker environment is touched. */ readonly workerTrace?: WorkerTraceResolver + /** @internal Trusted root-adapter publication channel. It is never exposed as a Scope method. */ + readonly ownerMaterialization?: { + readonly runtime: NodeSnapshot['runtime'] + readonly authoredProfile?: unknown + readonly attemptId: string + readonly prior?: ProfileMaterializationReceipt + readonly journalRoot?: NodeId + readonly nodeId?: NodeId + readonly requiredKnown?: boolean + readonly onReceipt?: ( + materialization: ProfileMaterializationReceipt, + binding: ExecutionBindingReceipt, + ) => void + } /** * Resume seam — set ONLY by the supervisor when `SupervisorOpts.resume` is on AND a non-empty * journal tree exists for this root. It carries the replayed committed work (so `scope.resume` @@ -143,6 +194,12 @@ export interface ScopeArgs { } } +/** Mutable only inside Scope admission/release. Every nested scope receives this exact object. */ +export interface LiveWorkerCapacityState { + readonly max: number | undefined + live: number +} + /** * Internal live-set entry. `settled` resolves once the child's executor has fully drained, * its reservation reconciled, and its result blob persisted; `next()` awaits these to drive @@ -154,13 +211,20 @@ interface LiveChild { readonly id: NodeId status: NodeStatus runtime: NodeSnapshot['runtime'] + readonly ownedTreeRoot?: NodeId readonly budget: Budget readonly label: string + readonly assignmentId?: string + readonly identity?: NodeExecutionIdentity /** The semantic spawn key, when this child was spawned with one — the settle path folds the * terminal state back into the scope's key registry under it. */ readonly key?: string spent: Spend outRef?: string + /** Durable structured tool evidence once this executor is terminal. */ + trace?: WorkerTraceEvidence + /** Exact terminal timestamp committed to the journal. */ + settledAt?: number /** Resolves with the terminal settlement WITHOUT a `seq` — `next()` stamps the seq. */ readonly settled: Promise /** Synchronous mirror of `settled`'s value once it has resolved (else `undefined`). */ @@ -169,14 +233,21 @@ interface LiveChild { * on `settled` resolves without further executor progress (only persistence/teardown), so a * non-blocking drain may await it without waiting on live work. */ executorDone: boolean + /** True only after executor teardown returned `{ destroyed: true }`. A failed/unknown cleanup + * retains the shared capacity slot so replacement work cannot exceed the physical live count. */ + cleanupConfirmed: boolean /** True once `next()` has yielded this child's settlement. */ delivered: boolean /** The executor's out-of-band inbox, captured at spawn — backs `scope.send`. */ - readonly deliver?: (msg: unknown) => void + readonly deliver?: (msg: unknown) => boolean /** The executor's optional live progress read, captured at spawn — backs `scope.progress`. */ readonly readProgress?: () => ExecutorProgress | undefined /** The executor's optional live tool trace, captured at spawn — backs `scope.traceSource`. */ readonly readTraceSource?: () => TraceSource | undefined + /** Kernel-owned declaration of the exact execution plan, durable before `execute` starts. */ + materialization?: ProfileMaterializationReceipt + /** One immutable record per concrete execution attempt. */ + executionBindings: ExecutionBindingReceipt[] /** Wall-clock of the spawn, and of the last metered usage event this child produced. Both are * stamped by the scope from the stream the conserved pool already meters, so EVERY executor — * including one that implements no progress read at all — has an observable liveness signal. */ @@ -184,7 +255,12 @@ interface LiveChild { lastActivityAt: number /** Present ONLY on a wait-state node. Its presence is what routes the settle path to the * `woken` journal event instead of `settled`, and what keeps a wait out of `inFlight`. */ - readonly wait?: { readonly spec: WaitSpec; readonly armedAt: number; readonly label: string } + readonly wait?: { + readonly spec: WaitSpec + readonly armedAt: number + readonly label: string + armCommitted: boolean + } } /** A child's terminal settlement before the cursor stamps the monotonic `seq`. A wait-state's @@ -197,6 +273,7 @@ type PreSeqSettled = outRef: string verdict?: DefaultVerdict spent: Spend + trace: WorkerTraceEvidence /** A driver child's OWN-inference subtree total (from `Executor.metered()`) — journaled as a * `metered` event for this node, NOT reconciled (already debited live via `observe`). */ metered?: Spend @@ -206,6 +283,7 @@ type PreSeqSettled = reason: string infra: boolean restartCount: number + trace: WorkerTraceEvidence /** A CRASHED driver child's partial OWN-inference subtree total — re-homed on the down path * too, so the journal matches the pool (which already debited it via `observe`). */ metered?: Spend @@ -214,7 +292,7 @@ type PreSeqSettled = /** * The recursion seam key. A `Scope` seeds a value of this on each child's * `ExecutorContext.seams` so a child whose executor is a DRIVER can mount a NESTED `Scope` - * over the SAME conserved pool at `depth+1`. A leaf executor never reads it. Single-sourced + * over the driver's reserved child allocation at `depth+1`. A leaf executor never reads it. Single-sourced * here so the scope and the driver-executor agree on the seam without a circular import. */ export const nestedScopeSeamKey = 'nested-scope' @@ -224,11 +302,13 @@ export const nestedScopeSeamKey = 'nested-scope' * driver child's own node id (so its children get `${nodeId}:s${ordinal}` ids and its * nested journal tree is namespaced under it); `root` is the journal tree key for the * nested tree (distinct from the parent's so cursor seqs never collide in the per-tree - * guard). `depth` is `parent.depth + 1`. The nested scope shares the parent's `pool` - * (conserved budget across depth), `journal`/`blobs` (one record), and `executors` (a - * nested child resolves to leaf-or-driver through the same open registry). + * guard). `depth` is `parent.depth + 1`. The nested scope spends from a child pool backed by + * the driver's already-reserved allocation; it shares the parent's `journal`/`blobs` and + * `executors` (a nested child resolves to leaf-or-driver through the same open registry). */ export interface NestedScopeSeam { + /** Durable id of the driver child that owns the nested tree. */ + readonly nodeId: NodeId /** This scope's recursion depth — a nested scope runs at `depth + 1`. */ readonly depth: number /** The runtime recursion-depth ceiling, paired with the conserved pool (R3). */ @@ -239,16 +319,35 @@ export interface NestedScopeSeam { mount(nestedRoot: NodeId, signal: AbortSignal): Scope } -function makeNestedScopeSeam(args: ScopeArgs, childNodeId: NodeId): NestedScopeSeam { +interface DeferredOwnerSlot { + ownerMaterialization?: NonNullable +} + +function makeNestedScopeSeam( + args: ScopeArgs, + liveWorkerCapacity: LiveWorkerCapacityState, + childNodeId: NodeId, + childBudget: Budget, + childDeadlineAtMs: number | undefined, + deferredOwner: DeferredOwnerSlot, +): NestedScopeSeam { + const now = args.now ?? Date.now return { + nodeId: childNodeId, depth: args.depth, ...(args.maxDepth !== undefined ? { maxDepth: args.maxDepth } : {}), journalRoot: args.root, mount(nestedRoot: NodeId, signal: AbortSignal): Scope { + const deadlineMs = + childDeadlineAtMs === undefined ? undefined : Math.max(0, childDeadlineAtMs - now()) + const nestedBudget = { + ...childBudget, + ...(deadlineMs !== undefined ? { deadlineMs } : {}), + } return createScope({ parentId: childNodeId, root: nestedRoot, - pool: args.pool, + pool: createBudgetPool(nestedBudget, now), journal: args.journal, blobs: args.blobs, executors: args.executors, @@ -257,12 +356,16 @@ function makeNestedScopeSeam(args: ScopeArgs, childNodeId: NodeId): NestedScopeS seams: args.seams, depth: args.depth + 1, ...(args.maxDepth !== undefined ? { maxDepth: args.maxDepth } : {}), + liveWorkerCapacity, signal, ...(args.now ? { now: args.now } : {}), ...(args.hooks ? { hooks: args.hooks } : {}), // The nested scope resolves the trace context against ITS OWN `parentId` (this driver // child), so a grandchild worker joins under the middle node's span, not the run root's. ...(args.workerTrace ? { workerTrace: args.workerTrace } : {}), + ...(deferredOwner.ownerMaterialization === undefined + ? {} + : { ownerMaterialization: deferredOwner.ownerMaterialization }), }) }, } @@ -271,6 +374,10 @@ function makeNestedScopeSeam(args: ScopeArgs, childNodeId: NodeId): NestedScopeS /** Create the reactive `Scope` a driver's `Agent.act` runs inside: spawn children on an atomically reserved conserved budget, settle via the `next()` cursor, journal for replay. */ export function createScope(args: ScopeArgs): Scope { const children = new Map() + const liveWorkerCapacity: LiveWorkerCapacityState = args.liveWorkerCapacity ?? { + max: normalizeLiveWorkerLimit(args.maxLiveWorkers), + live: 0, + } // Two distinct monotonic counters in two namespaces: // - `spawnOrdinal` is the spawn order (0,1,2,…); it mints the deterministic node id // `${parent}:s${ordinal}` and stamps the `spawned` event's `seq`. Known at spawn. @@ -301,51 +408,101 @@ export function createScope(args: ScopeArgs): Scope { // committed result, `live` refuses a concurrent duplicate, `down`/`in-doubt` spawn fresh but // say so explicitly. type KeyState = - | { readonly state: 'live'; readonly id: NodeId } + | { readonly state: 'live'; readonly id: NodeId; readonly identity: NodeExecutionIdentity } | { readonly state: 'done' readonly id: NodeId + readonly identity: NodeExecutionIdentity readonly settled: Settled & { kind: 'done' } } - | { readonly state: 'down'; readonly id: NodeId; readonly reason: string } - | { readonly state: 'in-doubt'; readonly id: NodeId } + | { + readonly state: 'down' + readonly id: NodeId + readonly identity: NodeExecutionIdentity + readonly reason: string + } + | { readonly state: 'in-doubt'; readonly id: NodeId; readonly identity: NodeExecutionIdentity } const keyed = new Map() for (const [key, prior] of args.resumeFrom?.keys ?? []) { if (prior.state === 'completed' && prior.settled?.kind === 'done') { + if (prior.identity === undefined) continue keyed.set(key, { state: 'done', id: prior.id, + identity: prior.identity, settled: prior.settled as Settled & { kind: 'done' }, }) } else if (prior.state === 'down' && prior.settled?.kind === 'down') { - keyed.set(key, { state: 'down', id: prior.id, reason: prior.settled.reason }) - } else { - keyed.set(key, { state: 'in-doubt', id: prior.id }) + if (prior.identity === undefined) continue + keyed.set(key, { + state: 'down', + id: prior.id, + identity: prior.identity, + reason: prior.settled.reason, + }) + } else if (prior.identity !== undefined) { + keyed.set(key, { state: 'in-doubt', id: prior.id, identity: prior.identity }) } } /** Fold a keyed child's terminal settlement back into the registry, so a later spawn with the * same key resolves to it (done → committed result; down → explicit retry). */ const recordKeyedSettlement = (key: string, settled: Settled): void => { + const identity = settled.handle.identity + if (identity === undefined) { + throw new ValidationError(`scope: keyed settlement '${key}' lost its execution identity`) + } if (settled.kind === 'done') { - keyed.set(key, { state: 'done', id: settled.handle.id, settled }) + keyed.set(key, { state: 'done', id: settled.handle.id, identity, settled }) } else { - keyed.set(key, { state: 'down', id: settled.handle.id, reason: settled.reason }) + keyed.set(key, { state: 'down', id: settled.handle.id, identity, reason: settled.reason }) } } function spawn( - agent: Agent, - task: unknown, - opts: SpawnOpts, + agentOrFactory: Agent | (() => Agent), + rawTask: unknown, + rawOpts: SpawnOpts, ): | { ok: true; handle: Handle; prior?: SpawnPrior } | { ok: false; reason: SpawnRejection } { - // Resolve the semantic key FIRST — a committed key spends nothing (no reservation, no - // executor, no journal record: the prior settlement is already the durable record), and a - // still-live duplicate is refused before any resource is touched. + if (args.signal.aborted) return { ok: false, reason: 'scope-aborted' } + const task = detachedSnapshot(rawTask, 'scope.spawn task') + const opts = detachedSnapshot(rawOpts, 'scope.spawn options') + + // A key is an identity claim, not merely a cache label. On every reuse, prepare the requested + // agent far enough to derive the authorized profile/task identity, then compare it with the + // journal before returning an old result or retrying old work. No executor is resolved, + // constructed, reserved, or run on the completed path. let prior: SpawnPrior | undefined + let prepared: + | { + readonly agent: Agent + readonly spec: AgentSpec + readonly identity: NodeExecutionIdentity | undefined + } + | undefined + const prepare = () => { + const agent = typeof agentOrFactory === 'function' ? agentOrFactory() : agentOrFactory + const rawSpec = (agent as unknown as { executorSpec?: unknown }).executorSpec + if (!isAgentSpec(rawSpec)) { + throw new ValidationError( + `scope.spawn: agent "${agent.name}" exposes no \`executorSpec\` (AgentSpec) to resolve a Executor`, + ) + } + const spec = snapshotAgentSpec(rawSpec) + return { agent, spec, identity: deriveNodeExecutionIdentity(spec, task) } + } if (opts.key !== undefined) { const existing = keyed.get(opts.key) + if (existing !== undefined) { + prepared = prepare() + if (!isCompleteIdentity(prepared.identity)) { + return { ok: false, reason: 'invalid-identity' } + } + if (!sameNodeExecutionIdentity(existing.identity, prepared.identity)) { + return { ok: false, reason: 'key-conflict' } + } + } if (existing?.state === 'live') return { ok: false, reason: 'duplicate-key' } if (existing?.state === 'done') { return { @@ -365,70 +522,134 @@ export function createScope(args: ScopeArgs): Scope { return { ok: false, reason: 'depth-exceeded' } } - // Resolve the leaf executor through the OPEN registry FIRST (no reservation to unwind - // if the agent is misconfigured). An agent carries its executor mapping as the - // `executorSpec` (an `AgentSpec`); resolution precedence (BYO → router/inline → harness - // factory) lives in the registry, not in a call-site switch. - const spec = (agent as unknown as { executorSpec?: unknown }).executorSpec - if (!isAgentSpec(spec)) { - throw new ValidationError( - `scope.spawn: agent "${agent.name}" exposes no \`executorSpec\` (AgentSpec) to resolve a Executor`, - ) - } - const resolved = args.executors.resolve(spec) - if (!resolved.succeeded) throw new ValidationError(`scope.spawn: ${resolved.error}`) + // ONE admission counter is shared by the root scope and every recursive scope it mounts. + // Acquire before calling a lazy worker factory, resolving/constructing its executor, or + // reserving budget. A completed keyed assignment returned above never touches the counter. + const permit = acquireLiveWorker(liveWorkerCapacity) + if (!permit.ok) return { ok: false, reason: 'max-live-workers' } // Reserve the child's whole ceiling atomically; fail CLOSED when the pool can't cover - // it (never read-then-spawn overcommit, so Σk is conserved by construction). - const reservation = args.pool.reserve(opts.budget) - if (!reservation.ok) return { ok: false, reason: reservation.reason } + // it (never read-then-spawn overcommit, so Σk is conserved by construction). This happens + // before a fresh lazy agent factory is called: refused work constructs nothing. + let reservation: ReturnType + try { + reservation = args.pool.reserve(opts.budget) + } catch (error) { + permit.release() + throw error + } + if (!reservation.ok) { + permit.release() + return { ok: false, reason: reservation.reason } + } + + // Resolve the leaf executor through the open registry after both worker and budget admission. + // If preparation fails, refund the reservation here because runChild never receives it. + let spec: AgentSpec + let resolved: { succeeded: true; value: (spec: AgentSpec, ctx: ExecutorContext) => Executor } + let identity: NodeExecutionIdentity | undefined + try { + prepared ??= prepare() + spec = prepared.spec + identity = prepared.identity + if (opts.key !== undefined && !isCompleteIdentity(identity)) { + args.pool.reconcile(reservation.ticket, zeroSpend()) + permit.release() + return { ok: false, reason: 'invalid-identity' } + } + const outcome = args.executors.resolve(spec) + if (!outcome.succeeded) throw new ValidationError(`scope.spawn: ${outcome.error}`) + resolved = outcome + } catch (error) { + args.pool.reconcile(reservation.ticket, zeroSpend()) + permit.release() + throw error + } // Everything between reserve and runChild's hand-off owns the reservation. A SYNCHRONOUS // throw here (most likely the executor factory `resolved.value(spec, ctx)`) would otherwise // leak the reservation — runChild, which reconciles the ticket, is never reached. Release it // with zero spend on throw, then rethrow, so `total ≡ free + reserved + committed` holds. // (runChild is the last statement and never sync-throws, so there is no double-reconcile.) + let cascadeAbort: (() => void) | undefined + let clearChildDeadline: (() => void) | undefined try { const ordinal = spawnOrdinal++ const id: NodeId = `${args.parentId}:s${ordinal}` + const attemptId = newExecutionAttemptId(id) + const startedAt = now() + const childDeadlineAtMs = boundedChildDeadlineAt( + args.pool.readout().deadlineMs, + opts.budget.deadlineMs, + startedAt, + ) // The child's abort chains off this scope's signal (a scope abort reaps every child) - // AND off its own handle.abort(). Aborting mid-acquire cascades through the executor's - // signal into its acquireSandbox find-by-name reap, so an acquiring node never leaks. - const childAbort = new AbortController() - const cascadeAbort = () => childAbort.abort() - if (args.signal.aborted) childAbort.abort() + // AND off its own handle.abort() or bounded deadline. Aborting mid-acquire cascades through + // the executor's signal into its acquireSandbox find-by-name reap, so an acquiring node + // never leaks. + const controller = new AbortController() + cascadeAbort = () => controller.abort(args.signal.reason) + if (args.signal.aborted) controller.abort(args.signal.reason) else args.signal.addEventListener('abort', cascadeAbort, { once: true }) + if (childDeadlineAtMs !== undefined) { + clearChildDeadline = armDeadlineTimer(Math.max(0, childDeadlineAtMs - now()), () => + controller.abort('child deadline exceeded'), + ) + } // Seed THIS scope's own keystone deps into the child's `ExecutorContext.seams`, so a - // child whose executor is a DRIVER can mount a nested `Scope` at `depth+1` over the - // SAME conserved pool + shared journal/blobs/registry (the recursion seam). A leaf - // executor ignores it; the parent's sandbox/router seams still pass through for leaves. - // The mounted nested scope re-seeds the SAME bag for ITS children, so the recursion - // composes — a driver child of a driver child mounts one level deeper still. + // child whose executor is a DRIVER can mount a nested `Scope` at `depth+1` over a child + // pool backed by THIS reservation. A leaf executor ignores it; the parent's sandbox/router + // seams still pass through for leaves. Each nested driver repeats the same partitioning. + const deferredOwner: DeferredOwnerSlot = {} // Resolved per spawn, not once per scope: the span of the spawning node is opened by the // `agent.spawn` hook, so reading it lazily needs no assumption about hook ordering. Absent // resolver (the untraced default) ⇒ no seam, and the child's `ExecutorContext` is exactly // the object it was before worker trace propagation existed. const workerTrace = args.workerTrace?.(args.parentId) const ctx: ExecutorContext = { - signal: childAbort.signal, + signal: controller.signal, + node: { + rootId: args.root, + parentId: args.parentId, + nodeId: id, + attemptId, + ...(identity ? { identity } : {}), + }, seams: { ...args.seams, - [nestedScopeSeamKey]: makeNestedScopeSeam(args, id), + [nestedScopeSeamKey]: makeNestedScopeSeam( + args, + liveWorkerCapacity, + id, + opts.budget, + childDeadlineAtMs, + deferredOwner, + ), ...(workerTrace ? { [workerTraceSeamKey]: workerTrace } : {}), }, } const executor = resolved.value(spec, ctx) as Executor + const ownedTreeRoot = runtimeOwnedNestedDriverTreeRoot(executor, args.root, id) const handle: Handle = { id, label: opts.label, + ...(opts.assignmentId === undefined ? {} : { assignmentId: opts.assignmentId }), + ...(identity ? { identity } : {}), get status(): NodeStatus { return children.get(id)?.status ?? 'cancelled' }, + get materialization(): ProfileMaterializationReceipt | undefined { + return children.get(id)?.materialization + }, + get executionBindings(): ReadonlyArray | undefined { + const bindings = children.get(id)?.executionBindings + return bindings && bindings.length > 0 ? Object.freeze([...bindings]) : undefined + }, abort(reason?: string): void { - childAbort.abort(reason) + controller.abort(reason) }, } @@ -436,33 +657,117 @@ export function createScope(args: ScopeArgs): Scope { id, status: 'acquiring', runtime: executor.runtime, + ...(ownedTreeRoot === undefined ? {} : { ownedTreeRoot }), + ...(identity ? { identity } : {}), budget: opts.budget, label: opts.label, + ...(opts.assignmentId === undefined ? {} : { assignmentId: opts.assignmentId }), ...(opts.key !== undefined ? { key: opts.key } : {}), spent: zeroSpend(), settled: undefined as unknown as Promise, delivered: false, executorDone: false, - startedAt: now(), - lastActivityAt: now(), - ...(executor.deliver ? { deliver: executor.deliver.bind(executor) } : {}), + cleanupConfirmed: false, + executionBindings: [], + startedAt, + lastActivityAt: startedAt, + ...(executor.deliver + ? { deliver: (message: unknown): boolean => executor.deliver?.(message) !== false } + : {}), ...(executor.progress ? { readProgress: executor.progress.bind(executor) } : {}), ...(executor.traceSource ? { readTraceSource: executor.traceSource.bind(executor) } : {}), } children.set(id, live) - if (opts.key !== undefined) keyed.set(opts.key, { state: 'live', id }) + if (opts.key !== undefined) { + keyed.set(opts.key, { + state: 'live', + id, + identity: identity as NodeExecutionIdentity, + }) + } - void args.journal.appendEvent(args.root, { + const spawnCommitted = args.journal.appendEvent(args.root, { kind: 'spawned', id, parent: args.parentId, label: opts.label, ...(opts.key !== undefined ? { key: opts.key } : {}), + ...(opts.assignmentId === undefined ? {} : { assignmentId: opts.assignmentId }), budget: opts.budget, runtime: executor.runtime, + ...(ownedTreeRoot === undefined ? {} : { ownedTreeRoot }), + ...(identity ? { identity } : {}), seq: ordinal, at: new Date(now()).toISOString(), }) + const materializationCommitted = spawnCommitted.then(async () => { + const profileDigest = identity?.profileDigest ?? authoredProfileDigest(spec.profile) + let receipt: ProfileMaterializationReceipt + let binding: ExecutionBindingReceipt + const declaration = runtimeOwnedExecutorMaterialization(executor) + const deferredRuntime = runtimeOwnedDeferredExecutorRuntime(executor) + if (deferredRuntime !== undefined) { + deferredOwner.ownerMaterialization = { + runtime: deferredRuntime, + authoredProfile: spec.profile, + attemptId, + journalRoot: args.root, + nodeId: id, + requiredKnown: true, + onReceipt(materialization, executionBinding) { + live.runtime = materialization.runtime + live.materialization = materialization + live.executionBindings.push(executionBinding) + }, + } + return + } + if (declaration === undefined) { + receipt = unknownMaterializationReceipt({ + ...(profileDigest === undefined ? {} : { authoredProfileDigest: profileDigest }), + runtime: executor.runtime, + reason: 'executor-did-not-report', + }) + binding = unknownExecutionBindingReceipt(receipt, attemptId, 'executor-did-not-report') + } else { + try { + if (profileDigest === undefined) { + throw new ValidationError( + 'scope.spawn: a known materialization requires a canonical authored profile', + ) + } + receipt = knownMaterializationReceipt({ + authoredProfileDigest: profileDigest, + runtime: executor.runtime, + declaration, + }) + const reportedBinding = runtimeOwnedExecutorExecutionBinding(executor) + if (reportedBinding === undefined || reportedBinding.attemptId !== attemptId) { + throw new ValidationError( + 'scope.spawn: trusted executor did not bind the kernel-minted attempt id', + ) + } + binding = knownExecutionBindingReceipt(receipt, reportedBinding) + } catch (error) { + receipt = unknownMaterializationReceipt({ + ...(profileDigest === undefined ? {} : { authoredProfileDigest: profileDigest }), + runtime: executor.runtime, + reason: 'invalid-executor-report', + }) + binding = unknownExecutionBindingReceipt(receipt, attemptId, 'invalid-executor-report') + await appendNodeMaterialization(args, id, ordinal, receipt, binding, now) + live.materialization = receipt + live.executionBindings.push(binding) + throw new ValidationError( + `scope.spawn: executor ${JSON.stringify(executor.runtime)} returned invalid materialization evidence`, + { cause: error }, + ) + } + } + await appendNodeMaterialization(args, id, ordinal, receipt, binding, now) + live.materialization = receipt + live.executionBindings.push(binding) + }) notifyRuntimeHookEvent( args.hooks, @@ -477,7 +782,9 @@ export function createScope(args: ScopeArgs): Scope { payload: { childId: id, label: opts.label, + ...(opts.assignmentId === undefined ? {} : { assignmentId: opts.assignmentId }), runtime: executor.runtime, + ...(identity ? { identity } : {}), budget: opts.budget, depth: args.depth, }, @@ -491,25 +798,32 @@ export function createScope(args: ScopeArgs): Scope { const settled = runChild( live, executor, - childAbort, + controller, task, opts, args.pool, reservation.ticket, args.blobs, now, + materializationCommitted, + childDeadlineAtMs, ) .then((s) => { live.resolved = s return s }) .finally(() => { - args.signal.removeEventListener('abort', cascadeAbort) + if (live.cleanupConfirmed) permit.release() + clearChildDeadline?.() + if (cascadeAbort) args.signal.removeEventListener('abort', cascadeAbort) }) ;(live as { settled: Promise }).settled = settled return { ok: true, handle, ...(prior ? { prior } : {}) } } catch (err) { + permit.release() + clearChildDeadline?.() + if (cascadeAbort) args.signal.removeEventListener('abort', cascadeAbort) args.pool.reconcile(reservation.ticket, zeroSpend()) throw err } @@ -563,11 +877,13 @@ export function createScope(args: ScopeArgs): Scope { } function send(nodeId: NodeId, msg: unknown): boolean { + if (args.signal.aborted) return false const child = children.get(nodeId) // Deliver only to a child that is still LIVE (not yet yielded by the cursor) and whose executor // accepts an inbox. A settled/unknown child, or a leaf with no `deliver`, cannot be steered. if (!child || child.delivered || !child.deliver) return false - child.deliver(msg) + const accepted = child.deliver(msg) !== false + if (!accepted) return false // A delivered steer IS activity: it resets the idle clock so a worker that was about to read // as stalled is not immediately re-steered before it can act on the message it just got. child.lastActivityAt = now() @@ -588,6 +904,7 @@ export function createScope(args: ScopeArgs): Scope { spec: WaitSpec, opts: WaitOpts, ): { ok: true; handle: Handle } | { ok: false; reason: WaitRejection } { + if (args.signal.aborted) return { ok: false, reason: 'deadline-exceeded' } if (validateWaitSpec(spec) !== null) return { ok: false, reason: 'invalid-spec' } if (spec.kind === 'poll' && args.probes?.resolve(spec.probe) === undefined) { return { ok: false, reason: 'unknown-probe' } @@ -637,72 +954,102 @@ export function createScope(args: ScopeArgs): Scope { settled: undefined as unknown as Promise, delivered: false, executorDone: false, + cleanupConfirmed: true, + executionBindings: [], startedAt: armedAt, lastActivityAt: now(), - wait: { spec: effectiveSpec, armedAt, label: opts.label }, + wait: { + spec: effectiveSpec, + armedAt, + label: opts.label, + armCommitted: adopted !== undefined, + }, } children.set(id, live) // Only a FRESH arm journals `waiting`; an adopted one already has its record (re-writing it - // would duplicate the wait ordinal in the journal's per-tree guard). - if (!adopted) { - void args.journal.appendEvent(args.root, { - kind: 'waiting', - id, - parent: args.parentId, - label: opts.label, - spec: effectiveSpec, - armedAt, - seq: ordinal, - at: new Date(now()).toISOString(), - }) - } + // would duplicate the wait ordinal in the journal's per-tree guard). A fresh wait may not + // start racing its timer/probe until this identity record is durable: otherwise a zero-delay + // wait can journal `woken` before `waiting`, or disappear entirely if this append fails. + const armCommitted = adopted + ? Promise.resolve() + : args.journal.appendEvent(args.root, { + kind: 'waiting', + id, + parent: args.parentId, + label: opts.label, + spec: effectiveSpec, + armedAt, + seq: ordinal, + at: new Date(now()).toISOString(), + }) - notifyRuntimeHookEvent( - args.hooks, - { - id: `${id}:waiting`, - runId: args.root, - target: 'agent.spawn', - phase: 'after', - timestamp: now(), - stepIndex: ordinal, - parentId: args.parentId, - payload: { - childId: id, + const settled = armCommitted + .then(() => { + if (live.wait) live.wait.armCommitted = true + notifyRuntimeHookEvent( + args.hooks, + { + id: `${id}:waiting`, + runId: args.root, + target: 'agent.spawn', + phase: 'after', + timestamp: now(), + stepIndex: ordinal, + parentId: args.parentId, + payload: { + childId: id, + label: opts.label, + runtime: 'wait', + wait: effectiveSpec, + armedAt, + resumed: adopted !== undefined, + }, + }, + { signal: args.signal }, + ) + return runWait({ + spec: effectiveSpec, label: opts.label, - runtime: 'wait', - wait: effectiveSpec, armedAt, resumed: adopted !== undefined, - }, - }, - { signal: args.signal }, - ) - - const settled = runWait({ - spec: effectiveSpec, - label: opts.label, - armedAt, - resumed: adopted !== undefined, - signal: waitAbort.signal, - ...(args.probes ? { probes: args.probes } : {}), - now, - ...(args.waitSleep ? { sleep: args.waitSleep } : {}), - }) + signal: waitAbort.signal, + ...(args.probes ? { probes: args.probes } : {}), + now, + ...(args.waitSleep ? { sleep: args.waitSleep } : {}), + }) + }) .then(async (resolution): Promise => { live.executorDone = true live.lastActivityAt = now() if (resolution.kind === 'cancelled') { - return { kind: 'down', reason: resolution.reason, infra: false, restartCount: 0 } + return { + kind: 'down', + reason: resolution.reason, + infra: false, + restartCount: 0, + trace: { status: 'unavailable', reason: 'not-an-executor' }, + } } const outRef = contentAddress(resolution.outcome) await args.blobs.put(outRef, resolution.outcome) - return { kind: 'done', out: resolution.outcome, outRef, spent: zeroSpend() } + return { + kind: 'done', + out: resolution.outcome, + outRef, + spent: zeroSpend(), + trace: { status: 'unavailable', reason: 'not-an-executor' }, + } }) .catch((err): PreSeqSettled => { live.executorDone = true - return { kind: 'down', reason: errMessage(err), infra: true, restartCount: 0 } + return { + kind: 'down', + reason: errMessage(err), + infra: true, + restartCount: 0, + trace: { status: 'unavailable', reason: 'not-an-executor' }, + } }) .then((s) => { live.resolved = s @@ -760,10 +1107,21 @@ export function createScope(args: ScopeArgs): Scope { } async function meter(spend: Spend, detail?: Record): Promise { + if (args.signal.aborted) { + throw new ValidationError('scope.meter: cannot record new driver work after scope abort') + } const seq = meterSeq++ // Debit the driver's own inference against the shared conserved pool (free → committed), so // equal-k counts it live and `budget.tokensLeft` reflects it for the in-loop guard. - args.pool.observe(spend) + // An invalid observation (currently: unknown dollar cost under a dollar ceiling) still + // describes compute that already happened. Preserve it in the durable record before + // returning the refusal; otherwise the terminal result would falsely report that cost as $0. + let observeError: unknown + try { + args.pool.observe(spend) + } catch (error) { + observeError = error + } // Journal it as a `metered` event — the durable TWIN of the pool debit (as `settled` is the // twin of `reconcile`), so every journal-based cost reader sums driver inference automatically. // Awaited like the settled append (cost-critical), so it has landed before the supervisor's @@ -790,6 +1148,7 @@ export function createScope(args: ScopeArgs): Scope { }, { signal: args.signal }, ) + if (observeError !== undefined) throw observeError } // The replayed committed work, frozen once at construction — a resume-aware `act` reads it @@ -804,7 +1163,7 @@ export function createScope(args: ScopeArgs): Scope { } : undefined - return { + const scope: Scope = { spawn, next, nextResolved, @@ -821,7 +1180,277 @@ export function createScope(args: ScopeArgs): Scope { get budget() { return args.pool.readout() }, + get workerCapacity() { + return { + live: liveWorkerCapacity.live, + freeSlots: freeSlots(liveWorkerCapacity.live, liveWorkerCapacity.max), + } + }, + } + if (args.ownerMaterialization !== undefined) { + const authoredProfile = + args.ownerMaterialization.authoredProfile === undefined + ? undefined + : detachedSnapshot( + args.ownerMaterialization.authoredProfile, + 'scope owner authored profile', + ) + ownerMaterializationStates.set(scope as Scope, { + journal: args.journal, + root: args.ownerMaterialization.journalRoot ?? args.root, + nodeId: args.ownerMaterialization.nodeId ?? args.parentId, + runtime: args.ownerMaterialization.runtime, + attemptId: args.ownerMaterialization.attemptId, + ...(authoredProfile === undefined ? {} : { authoredProfile }), + ...(authoredProfile === undefined + ? {} + : { authoredProfileDigest: authoredProfileDigest(authoredProfile) }), + ...(args.ownerMaterialization.prior === undefined + ? {} + : { prior: args.ownerMaterialization.prior }), + requiredKnown: args.ownerMaterialization.requiredKnown === true, + ...(args.ownerMaterialization.onReceipt === undefined + ? {} + : { onReceipt: args.ownerMaterialization.onReceipt }), + now, + receipt: args.ownerMaterialization.prior, + bindingPublished: false, + publishedThisProcess: false, + }) + } + return scope +} + +interface OwnerMaterializationState { + readonly journal: SpawnJournal + readonly root: NodeId + readonly nodeId: NodeId + readonly runtime: NodeSnapshot['runtime'] + readonly attemptId: string + readonly authoredProfile?: unknown + readonly authoredProfileDigest?: Sha256Digest + readonly prior?: ProfileMaterializationReceipt + readonly requiredKnown: boolean + readonly onReceipt?: ( + materialization: ProfileMaterializationReceipt, + binding: ExecutionBindingReceipt, + ) => void + readonly now: () => number + receipt?: ProfileMaterializationReceipt + bindingPublished: boolean + publishedThisProcess: boolean +} + +const ownerMaterializationStates = new WeakMap, OwnerMaterializationState>() + +/** + * @internal Publish exact root-manager materialization from a runtime-owned adapter after dynamic + * attachments exist and before its executor starts. This is deliberately a module function backed + * by a private WeakMap, not a Scope method an Agent can call. + */ +export async function recordScopeOwnerMaterialization( + scope: Scope, + runtime: NodeSnapshot['runtime'], + declaration: import('./types').ExecutorMaterialization, + bindingInput: ExecutorExecutionBinding, +): Promise { + const state = ownerMaterializationState(scope) + if (runtime !== state.runtime) { + await rejectOwnerMaterialization(state) + throw new ValidationError( + `scope owner materialization runtime ${JSON.stringify(runtime)} does not match ${JSON.stringify(state.runtime)}`, + ) + } + if (state.authoredProfileDigest === undefined) { + await rejectOwnerMaterialization(state) + throw new ValidationError('scope owner materialization requires an exact authored profile') + } + let receipt: ProfileMaterializationReceipt + let binding: ExecutionBindingReceipt + try { + if (bindingInput.attemptId !== state.attemptId) { + throw new ValidationError( + 'scope owner execution binding does not use the kernel-minted attempt id', + ) + } + if (canonicalCandidateDigest(declaration.effectiveProfile) !== state.authoredProfileDigest) { + throw new ValidationError( + 'scope owner stable effective profile conflicts with its admitted authored profile', + ) + } + receipt = knownMaterializationReceipt({ + authoredProfileDigest: state.authoredProfileDigest, + runtime, + declaration, + }) + binding = knownExecutionBindingReceipt(receipt, bindingInput) + } catch (error) { + await rejectOwnerMaterialization(state) + throw new ValidationError('scope owner returned invalid materialization evidence', { + cause: error, + }) + } + if (state.prior !== undefined) { + if (canonicalCandidateDigest(state.prior) !== canonicalCandidateDigest(receipt)) { + await rejectOwnerMaterialization(state) + throw new ValidationError( + 'scope owner materialization changed across resume; backend, model, execution identity, and plan must match', + ) + } + state.receipt = state.prior + await appendOwnerBinding(state, binding) + state.onReceipt?.(state.prior, binding) + state.publishedThisProcess = true + return } + if (state.receipt !== undefined) { + throw new ValidationError('scope owner materialization was already recorded') + } + await appendOwnerMaterialization(state, receipt, binding) + state.onReceipt?.(receipt, binding) + state.publishedThisProcess = true +} + +/** @internal Kernel identity for constructing the exact deferred owner executor. */ +export function scopeOwnerExecutorNodeContext(scope: Scope): ExecutorNodeContext { + const state = ownerMaterializationState(scope) + return Object.freeze({ + rootId: state.root, + parentId: state.nodeId, + nodeId: state.nodeId, + attemptId: state.attemptId, + }) +} + +/** @internal Ensure a deferred root that never published evidence remains visibly unknown. */ +export async function finalizeScopeOwnerMaterialization(scope: Scope): Promise { + const state = ownerMaterializationStates.get(scope) + if (state === undefined || state.publishedThisProcess) return + if (state.prior !== undefined) { + await appendUnknownOwnerBinding(state, state.prior, 'root-agent-did-not-report') + throw new ValidationError( + 'resumed scope owner did not re-attest its prior materialization before execution', + ) + } + if (state.receipt !== undefined) return + const receipt = unknownMaterializationReceipt({ + ...(state.authoredProfileDigest === undefined + ? {} + : { authoredProfileDigest: state.authoredProfileDigest }), + runtime: state.runtime, + reason: 'root-agent-did-not-report', + }) + const binding = unknownExecutionBindingReceipt( + receipt, + state.attemptId, + 'root-agent-did-not-report', + ) + await appendOwnerMaterialization(state, receipt, binding) + state.onReceipt?.(receipt, binding) + if (state.requiredKnown) { + throw new ValidationError( + 'runtime-owned scope owner did not publish materialization before completing', + ) + } +} + +function ownerMaterializationState(scope: Scope): OwnerMaterializationState { + const state = ownerMaterializationStates.get(scope) + if (state === undefined) { + throw new ValidationError('scope has no deferred runtime-owned root materialization channel') + } + return state +} + +async function rejectOwnerMaterialization(state: OwnerMaterializationState): Promise { + if (state.bindingPublished) return + if (state.prior !== undefined) { + await appendUnknownOwnerBinding(state, state.prior, 'invalid-executor-report') + return + } + if (state.receipt !== undefined) { + await appendUnknownOwnerBinding(state, state.receipt, 'invalid-executor-report') + return + } + const receipt = unknownMaterializationReceipt({ + ...(state.authoredProfileDigest === undefined + ? {} + : { authoredProfileDigest: state.authoredProfileDigest }), + runtime: state.runtime, + reason: 'invalid-executor-report', + }) + const binding = unknownExecutionBindingReceipt( + receipt, + state.attemptId, + 'invalid-executor-report', + ) + await appendOwnerMaterialization(state, receipt, binding) + state.onReceipt?.(receipt, binding) +} + +async function appendOwnerMaterialization( + state: OwnerMaterializationState, + receipt: ProfileMaterializationReceipt, + binding: ExecutionBindingReceipt, +): Promise { + await state.journal.appendEvent(state.root, { + kind: 'materialized', + id: state.nodeId, + receipt, + seq: 0, + at: new Date(state.now()).toISOString(), + }) + state.receipt = receipt + await appendOwnerBinding(state, binding) +} + +async function appendOwnerBinding( + state: OwnerMaterializationState, + binding: ExecutionBindingReceipt, +): Promise { + await state.journal.appendEvent(state.root, { + kind: 'execution-bound', + id: state.nodeId, + binding, + seq: 0, + at: new Date(state.now()).toISOString(), + }) + state.bindingPublished = true +} + +async function appendUnknownOwnerBinding( + state: OwnerMaterializationState, + receipt: ProfileMaterializationReceipt, + reason: import('./types').UnknownMaterializationReason, +): Promise { + const binding = unknownExecutionBindingReceipt(receipt, state.attemptId, reason) + await appendOwnerBinding(state, binding) + state.onReceipt?.(receipt, binding) +} + +async function appendNodeMaterialization( + args: Pick, + id: NodeId, + seq: number, + receipt: ProfileMaterializationReceipt, + binding: ExecutionBindingReceipt, + now: () => number, +): Promise { + const at = new Date(now()).toISOString() + await args.journal.appendEvent(args.root, { + kind: 'materialized', + id, + receipt, + seq, + at, + }) + await args.journal.appendEvent(args.root, { + kind: 'execution-bound', + id, + binding, + seq, + at, + }) } /** Await whichever pending child settles first, returning the child (its `resolved` is set @@ -845,16 +1474,22 @@ async function finalizeSettlement( // journal reader can separate zero-cost waiting from paid work without inspecting payloads // (`spentFromJournal` therefore sums waits as the zero they are, with no special case). if (child.wait) return finalizeWait(child, settlement, seq, args, now, handle) + const settledAt = now() + child.settledAt = settledAt + const at = new Date(settledAt).toISOString() if (settlement.kind === 'down') { child.status = 'failed' + child.trace = settlement.trace await args.journal.appendEvent(args.root, { kind: 'settled', id: child.id, status: 'down', spent: child.spent, infra: settlement.infra, + reason: settlement.reason, + trace: settlement.trace, seq, - at: new Date(now()).toISOString(), + at, }) // Re-home a crashed driver child's partial inference too (the pool already debited it via // `observe`) — so spentTotal/trajectory never undercount a sub-driver that died mid-run. @@ -864,7 +1499,7 @@ async function finalizeSettlement( id: child.id, spend: settlement.metered, seq, - at: new Date(now()).toISOString(), + at, }) } notifyRuntimeHookEvent( @@ -874,7 +1509,7 @@ async function finalizeSettlement( runId: args.root, target: 'agent.child', phase: 'after', - timestamp: now(), + timestamp: settledAt, stepIndex: seq, parentId: args.parentId, payload: { @@ -893,6 +1528,8 @@ async function finalizeSettlement( reason: settlement.reason, infra: settlement.infra, restartCount: settlement.restartCount, + trace: settlement.trace, + settledAt, seq, } } @@ -900,6 +1537,7 @@ async function finalizeSettlement( child.status = 'done' child.outRef = settlement.outRef child.spent = settlement.spent + child.trace = settlement.trace await args.journal.appendEvent(args.root, { kind: 'settled', id: child.id, @@ -907,8 +1545,9 @@ async function finalizeSettlement( outRef: settlement.outRef, ...(settlement.verdict ? { verdict: settlement.verdict } : {}), spent: settlement.spent, + trace: settlement.trace, seq, - at: new Date(now()).toISOString(), + at, }) // Re-home a driver child's OWN-inference subtree total up to THIS (parent) tree as a `metered` // event for the child node — mirroring how `settled.spent` rolls child WORK up. So summing any @@ -919,7 +1558,7 @@ async function finalizeSettlement( id: child.id, spend: settlement.metered, seq, - at: new Date(now()).toISOString(), + at, }) } notifyRuntimeHookEvent( @@ -929,7 +1568,7 @@ async function finalizeSettlement( runId: args.root, target: 'agent.child', phase: 'after', - timestamp: now(), + timestamp: settledAt, stepIndex: seq, parentId: args.parentId, payload: { @@ -950,6 +1589,8 @@ async function finalizeSettlement( outRef: settlement.outRef, ...(settlement.verdict ? { verdict: settlement.verdict } : {}), spent: settlement.spent, + trace: settlement.trace, + settledAt, seq, } } @@ -965,22 +1606,30 @@ async function finalizeWait( now: () => number, handle: Handle, ): Promise> { - const at = new Date(now()).toISOString() + const settledAt = now() + child.settledAt = settledAt + const at = new Date(settledAt).toISOString() if (settlement.kind === 'down') { child.status = 'cancelled' - await args.journal.appendEvent(args.root, { - kind: 'woken', - id: child.id, - by: 'cancelled', - seq, - at, - }) + // A failed fresh `waiting` append means this node never existed durably. Do not leave a + // terminal `woken` record with no arm record for replay to attach it to. + if (child.wait?.armCommitted) { + await args.journal.appendEvent(args.root, { + kind: 'woken', + id: child.id, + by: 'cancelled', + seq, + at, + }) + } return { kind: 'down', handle, reason: settlement.reason, infra: settlement.infra, restartCount: settlement.restartCount, + trace: settlement.trace, + settledAt, seq, } } @@ -1002,7 +1651,7 @@ async function finalizeWait( runId: args.root, target: 'agent.child', phase: 'after', - timestamp: now(), + timestamp: settledAt, stepIndex: seq, parentId: args.parentId, payload: { childId: child.id, status: 'done', wait: out }, @@ -1015,6 +1664,8 @@ async function finalizeWait( out: settlement.out as Out, outRef: settlement.outRef, spent: settlement.spent, + trace: settlement.trace, + settledAt, seq, } } @@ -1040,17 +1691,48 @@ async function runChild( ticket: ReservationTicket, blobs: ResultBlobStore, now: () => number, + executionReady: Promise, + deadlineAtMs: number | undefined, ): Promise { let reconciled = false - const reconcileOnce = (spend: Spend) => { - if (reconciled) return + let started = false + let terminalTelemetryCaptured = false + let teardownStarted = false + let traceEvidence: WorkerTraceEvidence | undefined + const captureTraceOnce = async (): Promise => { + traceEvidence ??= await captureWorkerTraceEvidence(live.readTraceSource, blobs, started) + return traceEvidence + } + const teardownOnce = async (grace: number | 'brutalKill' | 'infinity'): Promise => { + if (teardownStarted) return + teardownStarted = true + await teardownExecutor(executor, grace, deadlineAtMs, now) + live.cleanupConfirmed = true + } + const reconcileOnce = (spend: Spend): unknown | undefined => { + if (reconciled) return undefined reconciled = true - // A budgetExempt executor reports zero spend by contract; the reconcile refunds its - // whole reservation, keeping it out of the conserved Σk by construction. - pool.reconcile(ticket, clampSpend(spend, opts.budget)) + // A refused pre-execution path (including an unmetered executor) reconciles zero and refunds + // its whole reservation. Every path that actually executes reports measured or unknown spend. + try { + pool.reconcile(ticket, spend) + return undefined + } catch (error) { + return error + } } try { + // Identity and kernel-owned materialization evidence must be durable before execution can + // begin. A failed append or invalid executor declaration produces a typed-down child and + // refunds its reservation; the executor observes zero calls. + await executionReady + if (childAbort.signal.aborted) throw abortError(childAbort.signal) + // A budgetExempt WORKER (e.g. the raw `cli` printer) reports zero spend by contract; its + // reconcile refunds the whole reservation, keeping it out of the conserved Σk by construction. + // Only the DRIVER path refuses budget-exempt runtimes (`driveHarnessFromBackend`), because a + // driver's own inference must be metered. live.status = 'running' + started = true const ran = executor.execute(task, childAbort.signal) let artifact: ExecutorResult if (isAsyncIterable(ran)) { @@ -1058,18 +1740,32 @@ async function runChild( // authority), then read the terminal artifact after the stream drains. Each event also // republishes the running total + a fresh activity stamp onto the live child, so a // concurrent `scope.progress(id)` sees a worker mid-flight rather than a zeroed row. - const spend = await foldStream(ran, (running) => { - live.spent = running - live.lastActivityAt = now() - }) + const spend = await foldStream( + ran, + (running) => { + live.spent = running + live.lastActivityAt = now() + }, + childAbort.signal, + ) live.spent = spend artifact = executor.resultArtifact() as ExecutorResult - reconcileOnce(spend) + const accounting = executor.accounting?.() + const terminalSpend = preserveUnknownTelemetry(spend, artifact.spent) + live.spent = accounting?.reported ?? terminalSpend + terminalTelemetryCaptured = true + live.executorDone = true + const reconcileError = reconcileOnce(accounting?.reservation ?? terminalSpend) + if (reconcileError !== undefined) throw reconcileError } else { - const terminal = await ran - live.spent = terminal.spent + const terminal = await awaitAbortable(Promise.resolve(ran), childAbort.signal) + const accounting = executor.accounting?.() + live.spent = accounting?.reported ?? terminal.spent artifact = terminal - reconcileOnce(terminal.spent) + terminalTelemetryCaptured = true + live.executorDone = true + const reconcileError = reconcileOnce(accounting?.reservation ?? terminal.spent) + if (reconcileError !== undefined) throw reconcileError } // Executor work is complete; everything below is persistence/teardown. From here `settled` // resolves without further executor progress — the non-blocking drain keys on this. @@ -1078,10 +1774,11 @@ async function runChild( // A driver child's OWN-inference subtree total — re-homed by the parent on EVERY settle exit // (done, aborted, crash) so the journal always matches what the pool already debited. const ownMetered = executor.metered?.() + const trace = await captureTraceOnce() if (childAbort.signal.aborted) { - await teardownSafe(executor, opts.shutdown ?? 'brutalKill') - return downRecord('aborted before settle', true, ownMetered) + await teardownOnce(opts.shutdown ?? 'brutalKill') + return downRecord('aborted before settle', true, trace, ownMetered) } // The durable record is keyed by the canonical content address of the output — the @@ -1092,25 +1789,54 @@ async function runChild( // so a crash never leaves a journaled ref pointing at a missing blob. const outRef = contentAddress(artifact.out) await blobs.put(outRef, artifact.out) - await teardownSafe(executor, opts.shutdown ?? 'infinity') + await teardownOnce(opts.shutdown ?? DEFAULT_SUCCESSFUL_SHUTDOWN_MS) return { kind: 'done', out: artifact.out, outRef, ...(artifact.verdict ? { verdict: artifact.verdict } : {}), spent: live.spent, + trace, ...(ownMetered ? { metered: ownMetered } : {}), } } catch (err) { // A thrown executor has also finished its own work — only the down-record persistence // remains, so the non-blocking drain may await this child too. live.executorDone = true - // Reconcile the (likely partial) spend so the reservation is refunded even on a throw. - reconcileOnce(live.spent) - await teardownSafe(executor, 'brutalKill') + // A recursive executor can still report the nested work committed before it threw. + // Reconcile that whole partial subtree while journaling its child-work component separately. + // A box-backed trace must be collected before teardown destroys the session that owns it. + const trace = await captureTraceOnce() + let teardownError: unknown + try { + await teardownOnce('brutalKill') + } catch (error) { + teardownError = error + } + const accounting = executor.accounting?.() + if (accounting) live.spent = accounting.reported const aborted = childAbort.signal.aborted || isAbortError(err) + if (started && !terminalTelemetryCaptured && accounting === undefined) { + // The provider never delivered a terminal usage receipt. This is true for an ordinary + // network/provider crash just as it is for an abort. Preserve observed partial counts as a + // lower bound, but never reinterpret the unreported remainder as zero under either root + // ceiling. A recursive executor's explicit accounting remains authoritative on its throw + // path; a persistence/teardown failure after a terminal artifact does too. + live.spent = { ...live.spent, tokensKnown: false, usdKnown: false } + } + const reconcileError = reconcileOnce(accounting?.reservation ?? live.spent) // A crashed driver child still re-homes the partial inference it durably metered. - return downRecord(errMessage(err), aborted || isInfraError(err), executor.metered?.()) + return downRecord( + // The operation that failed is the causal error. Cleanup and accounting can independently + // fail while handling it, but must never replace it with a secondary diagnostic (for + // example, missing terminal usage after a provider crash). Their presence still marks the + // settlement as infrastructure-related, while the unknown spend flags retain the accounting + // failure itself in the durable record. + errMessage(err), + teardownError !== undefined || reconcileError !== undefined || aborted || isInfraError(err), + trace, + executor.metered?.(), + ) } } @@ -1147,6 +1873,35 @@ export function settledToIteration(settled: Settled): Iteration void } | { ok: false } { + if (capacity.max !== undefined && capacity.live >= capacity.max) return { ok: false } + capacity.live += 1 + let released = false + return { + ok: true, + release(): void { + if (released) return + released = true + capacity.live -= 1 + if (capacity.live < 0) { + throw new ValidationError('scope: live-worker capacity released more than once') + } + }, + } +} + function makeTreeView(root: NodeId, children: Map): TreeView { const nodes: NodeSnapshot[] = [...children.values()].map((c) => ({ id: c.id, @@ -1155,8 +1910,17 @@ function makeTreeView(root: NodeId, children: Map): TreeView status: c.status, runtime: c.runtime, budget: c.budget, + ...(c.ownedTreeRoot === undefined ? {} : { ownedTreeRoot: c.ownedTreeRoot }), + ...(c.assignmentId === undefined ? {} : { assignmentId: c.assignmentId }), + ...(c.identity ? { identity: c.identity } : {}), + ...(c.materialization ? { materialization: c.materialization } : {}), + ...(c.executionBindings.length > 0 + ? { executionBindings: Object.freeze([...c.executionBindings]) } + : {}), spent: c.spent, + ...(c.settledAt === undefined ? {} : { settledAt: c.settledAt }), ...(c.outRef ? { outRef: c.outRef } : {}), + ...(c.trace ? { trace: c.trace } : {}), })) return { root, @@ -1171,12 +1935,78 @@ function frozenHandle(child: LiveChild): Handle { id: child.id, label: child.label, status: child.status, + ...(child.assignmentId === undefined ? {} : { assignmentId: child.assignmentId }), + ...(child.identity ? { identity: child.identity } : {}), + ...(child.materialization ? { materialization: child.materialization } : {}), + ...(child.executionBindings.length > 0 + ? { executionBindings: Object.freeze([...child.executionBindings]) } + : {}), abort(): void { // A settled child is terminal; abort is a no-op (its executor already tore down). }, } } +/** Derive the portable identity of the exact profile and task a node will execute. Caller-owned + * candidate/correlation fields are checked at this boundary before they enter the durable log. */ +export function deriveNodeExecutionIdentity( + spec: Pick, + task: unknown, +): NodeExecutionIdentity | undefined { + const digest = (value: unknown) => { + try { + return canonicalCandidateDigest(value) + } catch { + return undefined + } + } + const profileDigest = digest(spec.profile) + const taskDigest = digest(task) + const candidateDigest = spec.execution?.candidateDigest + if (candidateDigest !== undefined && !sha256DigestSchema.safeParse(candidateDigest).success) { + throw new ValidationError('scope.spawn: execution.candidateDigest must be a sha256 digest') + } + const correlation = freezeCorrelation(spec.execution?.correlation) + if (!profileDigest && !taskDigest && !candidateDigest && !correlation) return undefined + return Object.freeze({ + ...(profileDigest ? { profileDigest } : {}), + ...(taskDigest ? { taskDigest } : {}), + ...(candidateDigest ? { candidateDigest } : {}), + ...(correlation ? { correlation } : {}), + }) +} + +function isCompleteIdentity( + identity: NodeExecutionIdentity | undefined, +): identity is NodeExecutionIdentity & { + readonly profileDigest: string + readonly taskDigest: string +} { + return identity?.profileDigest !== undefined && identity.taskDigest !== undefined +} + +function sameNodeExecutionIdentity(a: NodeExecutionIdentity, b: NodeExecutionIdentity): boolean { + return canonicalCandidateDigest(a) === canonicalCandidateDigest(b) +} + +function freezeCorrelation( + value: Readonly> | undefined, +): Readonly> | undefined { + if (value === undefined) return undefined + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new ValidationError('scope.spawn: execution.correlation must be a string record') + } + const entries = Object.entries(value) + for (const [key, item] of entries) { + if (key.length === 0 || typeof item !== 'string' || item.length === 0) { + throw new ValidationError( + 'scope.spawn: execution.correlation keys and values must be non-empty strings', + ) + } + } + return Object.freeze(Object.fromEntries(entries)) +} + /** * Fold a streaming executor's normalized usage into the conserved `Spend`, publishing the * running total after EVERY event via `onProgress`. @@ -1190,28 +2020,42 @@ function frozenHandle(child: LiveChild): Handle { async function foldStream( stream: AsyncIterable, onProgress?: (running: Spend) => void, + signal?: AbortSignal, ): Promise { const tokens = { input: 0, output: 0 } let usd = 0 let usdKnown = true let iterations = 0 - for await (const ev of stream) { - if (ev.kind === 'tokens') { - tokens.input += ev.input - tokens.output += ev.output - } else if (ev.kind === 'cost') { - usd += ev.usd - if (ev.usdKnown === false) usdKnown = false - } else { - iterations += 1 + const iterator = stream[Symbol.asyncIterator]() + try { + for (;;) { + const next = signal + ? await awaitAbortable(Promise.resolve(iterator.next()), signal) + : await iterator.next() + if (next.done) break + const ev = next.value + if (ev.kind === 'tokens') { + tokens.input += ev.input + tokens.output += ev.output + } else if (ev.kind === 'cost') { + usd += ev.usd + if (ev.usdKnown === false) usdKnown = false + } else { + iterations += 1 + } + onProgress?.({ + iterations, + tokens: { ...tokens }, + usd, + ...(usdKnown ? {} : { usdKnown: false }), + ms: 0, + }) } - onProgress?.({ - iterations, - tokens: { ...tokens }, - usd, - ...(usdKnown ? {} : { usdKnown: false }), - ms: 0, - }) + } catch (error) { + // Ask a cooperative async generator to close, but never let a broken `return()` hide the + // deadline that already won. Its promise is observed so a late rejection is not unhandled. + void Promise.resolve(iterator.return?.()).catch(() => undefined) + throw error } return { iterations, @@ -1222,46 +2066,73 @@ async function foldStream( } } -/** Clamp a child's reported spend to its reservation so the pool's fail-loud over-spend - * guard never trips on a benign overshoot from an external usage report; the difference - * refunds to the pool as if the child stopped at its ceiling. */ -function clampSpend(spend: Spend, budget: Budget): Spend { - const totalTokens = spend.tokens.input + spend.tokens.output - const tokensOk = totalTokens <= budget.maxTokens - const itersOk = spend.iterations <= budget.maxIterations - const usdOk = budget.maxUsd === undefined || spend.usd <= budget.maxUsd - if (tokensOk && itersOk && usdOk) return spend - const ratio = !tokensOk && totalTokens > 0 ? budget.maxTokens / totalTokens : 1 +/** Usage events carry measured increments; the terminal artifact carries whether a provider omitted + * a whole accounting channel. Preserve those unknowns on the common streaming path. */ +function preserveUnknownTelemetry(streamed: Spend, terminal: Spend): Spend { return { - iterations: Math.min(spend.iterations, budget.maxIterations), - tokens: - ratio < 1 - ? { - input: Math.floor(spend.tokens.input * ratio), - output: Math.floor(spend.tokens.output * ratio), - } - : spend.tokens, - usd: budget.maxUsd === undefined ? spend.usd : Math.min(spend.usd, budget.maxUsd), - ...(spend.tokensKnown === false ? { tokensKnown: false } : {}), - ...(spend.usdKnown === false ? { usdKnown: false } : {}), - ms: spend.ms, + ...streamed, + ...(terminal.tokensKnown === false ? { tokensKnown: false } : {}), + ...(terminal.usdKnown === false ? { usdKnown: false } : {}), + ms: terminal.ms, } } -async function teardownSafe( - executor: Executor, - grace: number | 'brutalKill' | 'infinity', -): Promise { - try { - await executor.teardown(grace) - } catch { - // Teardown failure is observable through the node staying live; swallow so it never - // masks the settlement itself. The supervisor's join barrier reaps on its own grace. +async function awaitAbortable(work: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + // `execute()` may have returned a promise that rejects from the same abort. Observe it before + // returning the already-winning cancellation so a fast abort cannot become an unhandled + // provider rejection. + void work.catch(() => undefined) + throw abortError(signal) } + return await new Promise((resolve, reject) => { + let settled = false + const onAbort = () => { + // A cooperative executor often resolves its terminal usage receipt from an abort listener. + // Give that already-triggered resolution one microtask to win; an ignoring executor still + // loses immediately afterward. + queueMicrotask(() => { + if (settled) return + settled = true + cleanup() + reject(abortError(signal)) + }) + } + const cleanup = () => signal.removeEventListener('abort', onAbort) + signal.addEventListener('abort', onAbort, { once: true }) + work.then( + (value) => { + if (settled) return + settled = true + cleanup() + resolve(value) + }, + (error) => { + if (settled) return + settled = true + cleanup() + reject(error) + }, + ) + }) +} + +function abortError(signal: AbortSignal): Error { + const reason = signal.reason + const error = new Error( + typeof reason === 'string' && reason.length > 0 ? reason : 'execution aborted', + ) + error.name = 'AbortError' + return error } -function downRecord(reason: string, infra: boolean, metered?: Spend): PreSeqSettled { - return { kind: 'down', reason, infra, restartCount: 0, ...(metered ? { metered } : {}) } +function downRecord( + reason: string, + infra: boolean, + trace: WorkerTraceEvidence, + metered?: Spend, +): PreSeqSettled { + return { kind: 'down', reason, infra, restartCount: 0, trace, ...(metered ? { metered } : {}) } } function zeroSpend(): Spend { @@ -1284,6 +2155,27 @@ function isAgentSpec(value: unknown): value is AgentSpec { return 'profile' in v && 'harness' in v } +/** Snapshot every data field whose bytes affect identity, admission, or materialization while + * preserving the executable callbacks by reference. The executor implementation is trusted code; + * its profile and attribution inputs are not. */ +function snapshotAgentSpec(raw: AgentSpec): AgentSpec { + const { + profile: rawProfile, + harness, + execution: rawExecution, + ...runtimeExtensions + } = raw as AgentSpec & Readonly> + const profile = detachedSnapshot(rawProfile, 'scope.spawn profile') + const execution = + rawExecution === undefined ? undefined : detachedSnapshot(rawExecution, 'scope.spawn execution') + return Object.freeze({ + ...runtimeExtensions, + profile, + harness, + ...(execution === undefined ? {} : { execution }), + }) as AgentSpec +} + function isAbortError(err: unknown): boolean { return ( typeof err === 'object' && diff --git a/src/runtime/supervise/snapshot.ts b/src/runtime/supervise/snapshot.ts new file mode 100644 index 00000000..2735f601 --- /dev/null +++ b/src/runtime/supervise/snapshot.ts @@ -0,0 +1,18 @@ +import { ValidationError } from '../../errors' + +/** Deeply detach and freeze untrusted data at a runtime decision boundary. The clone prevents the + * caller from mutating it later; the freeze prevents downstream code from mutating the snapshot. */ +export function detachedSnapshot(value: T, context: string): T { + try { + return deepFreeze(structuredClone(value)) + } catch (error) { + throw new ValidationError(`${context}: input must be structured-cloneable`, { cause: error }) + } +} + +function deepFreeze(value: T, seen = new Set()): T { + if (value === null || typeof value !== 'object' || seen.has(value)) return value + seen.add(value) + for (const child of Object.values(value as Record)) deepFreeze(child, seen) + return Object.freeze(value) +} diff --git a/src/runtime/supervise/supervise.ts b/src/runtime/supervise/supervise.ts index b47f549c..3d239fca 100644 --- a/src/runtime/supervise/supervise.ts +++ b/src/runtime/supervise/supervise.ts @@ -7,46 +7,102 @@ * `workerFromBackend` derives the worker seam (`makeWorkerAgent`) from a backend config + an optional * completion oracle — so "where the workers run" is one data choice, not a hand-rolled factory. */ +import { randomUUID } from 'node:crypto' +import { resolve } from 'node:path' +import { + type AgentProfile, + type AgentProfileSecurityPolicy, + agentProfileSchema, + canonicalCandidateDigest, + type Sha256Digest, + validateAgentProfileSecurity, +} from '@tangle-network/agent-interface' +import type { BackendType } from '@tangle-network/sandbox' +import { + assertProfileMaterialization, + controlProfileMaterialization, + defineProfileMaterializationContract, + fullProfileMaterialization, + type ProfileMaterializationContract, + profileMaterializationAxes, + promptControlProfileMaterialization, + promptModelProfileMaterialization, + worktreeCliProfileMaterialization, +} from '../../agent/profile-materialization' import { ConfigError, ValidationError } from '../../errors' import type { AnalystRegistry, + AuthorizeDownMessage, + AuthorizedDownMessage, + CoordinationEvent, + DownMessageAuthorizationInput, MakeWorkerAgent, + WorkerSpawnContext, WorkerWatchOptions, } from '../../mcp/tools/coordination' import { composeRuntimeHooks, type RuntimeHooks } from '../../runtime-hooks' import type { RouterConfig } from '../router-client' import type { ToolLoopChat, ToolLoopCompactionOptions } from '../tool-loop' import { canonicalizeAuthoredProfile } from './authoring' +import { assertValidBudget, spendFromUsageEvents } from './budget' import { type DeliverableSpec, gateOnDeliverable } from './completion-gate' +import { DEFAULT_SUCCESSFUL_SHUTDOWN_MS, teardownExecutor } from './deadline' +import { driverChild } from './driver-executor' +import type { BusRecord } from './event-bus' import type { SupervisorFinalizer } from './finalizer' -import { assertModelAllowed } from './model-policy' +import { + attestRuntimeOwnedScopeOwner, + runtimeOwnedExecutorExecutionBinding, + runtimeOwnedExecutorMaterialization, + runtimeOwnedScopeOwnerRuntime, +} from './materialization' +import { assertModelAllowed, assertProfileModelsAllowed } from './model-policy' import { createSupervisorSpanRecorder, type SupervisorSpanOptions, type SupervisorSpanRecorder, } from './otel-spans' import { createFileRunContext, createInMemoryRunContext } from './run-context' -import { createExecutor, type ExecutorConfig } from './runtime' +import { + bindReusableExecutorExecutionId, + captureReusableExecutorConfig, + createExecutor, + type ExecutorConfig, + snapshotExecutorConfig, +} from './runtime' +import { + deriveNodeExecutionIdentity, + recordScopeOwnerMaterialization, + scopeOwnerExecutorNodeContext, +} from './scope' +import { detachedSnapshot } from './snapshot' import type { StopRule } from './stop-rules' import { createSupervisor } from './supervisor' import { assertCoordinationBinding, type CoordinationBinding, type DriveHarness, - resolveSupervisorModelId, + type DriveHarnessOwnerContext, + type ResolveDriveHarness, + type ResolveSupervisorTools, + type SupervisorNodeContext, type SupervisorProfile, supervisorAgent, } from './supervisor-agent' import type { Agent, + AgentExecutionRef, AgentSpec, Budget, + Executor, ExecutorContext, + NodeExecutionIdentity, ResultBlobStore, + RootHandle, SpawnJournal, + UsageEvent, } from './types' import type { WaitProbeRegistry } from './wait' -import { workerTraceSeamKey } from './worker-trace' /** * Build the worker seam from a backend (WHERE workers run) + an optional completion oracle (the @@ -66,24 +122,396 @@ export function workerFromBackend( deliverable?: DeliverableSpec, seams?: () => Readonly>, ): MakeWorkerAgent { - return (rawProfile) => { - const p = (rawProfile ?? {}) as { name?: unknown } - const name = typeof p.name === 'string' && p.name.length > 0 ? p.name : 'worker' + const capturedBackend = captureReusableExecutorConfig(backend, 'workerFromBackend') + const unscopedNamespace = randomUUID() + let unscopedOrdinal = 0 + return (rawProfile, spawnContext) => { // The supervisor authors in the skill's flat vocabulary; every leaf reads the canonical // profile. Lift it HERE — the one place a backend becomes a spawnable worker — so no leaf - // has to guess which shape it was handed. - const profile = canonicalizeAuthoredProfile(rawProfile) - // harness:null — createExecutor(backend) carries the harness in its config (the sandbox case-arm - // reads config.harness when the spec leaves it null); the BYO executor below resolves the leaf. - const spec: AgentSpec = { profile, harness: null } - const ctx: ExecutorContext = { signal: new AbortController().signal, seams: seams?.() ?? {} } - const built = createExecutor(backend)(spec, ctx) - const executor = deliverable ? gateOnDeliverable(built, deliverable) : built - return { name, act: async () => '', executorSpec: { ...spec, executor } } as Agent< - unknown, - unknown - > & { executorSpec: AgentSpec } + // has to guess which shape it was handed, then hold the LIFTED form to the canonical schema. + const parsed = agentProfileSchema.safeParse(canonicalizeAuthoredProfile(rawProfile)) + if (!parsed.success) { + throw new ValidationError(`workerFromBackend: invalid AgentProfile: ${parsed.error.message}`) + } + const profile = parsed.data + assertBackendProfileMaterialization(profile, capturedBackend, 'workerFromBackend') + const name = profile.name ?? 'worker' + // A Scope assignment is stable across reconstruction. Direct callers that omit that context + // still get isolation, but only Scope-backed calls claim durable external-session recovery. + const assignmentId = + spawnContext?.assignmentId ?? `unscoped:${unscopedNamespace}:${unscopedOrdinal++}` + const boundBackend = bindReusableExecutorExecutionId( + capturedBackend, + externalExecutionId('supervised-worker', { assignmentId }), + ) + const baseFactory = createExecutor(boundBackend) + // Carry the configured factory into Scope. It is built only AFTER reservation with the real + // child signal/context, so a rejected or already-completed keyed spawn creates no executor. + const executorFactory = (spec: AgentSpec, ctx: ExecutorContext) => { + // Caller-supplied seams sit UNDER the per-child seams the Scope seeds, so the scope's + // recursion and trace context always win on a key collision. + const extraSeams = seams?.() + const built = baseFactory( + spec, + extraSeams === undefined ? ctx : { ...ctx, seams: { ...extraSeams, ...ctx.seams } }, + ) + return deliverable ? gateOnDeliverable(built, deliverable) : built + } + const spec: AgentSpec = { + profile, + harness: null, + executorFactory, + ...(spawnContext?.execution ? { execution: spawnContext.execution } : {}), + } + return { name, act: async () => '', executorSpec: spec } as Agent & { + executorSpec: AgentSpec + } + } +} + +function externalExecutionId(kind: string, identity: unknown): string { + const digest = canonicalCandidateDigest({ kind, identity }) + return `${kind}-${digest.slice('sha256:'.length)}` +} + +function backendProfileMaterialization(backend: ExecutorConfig): ProfileMaterializationContract { + switch (backend.backend) { + case 'bridge': + case 'sandbox': + case 'provider': + return fullProfileMaterialization + case 'cli-worktree': + return backend.bridge ? fullProfileMaterialization : worktreeCliProfileMaterialization + case 'router': + case 'router-tools': + case 'pi': + return promptModelProfileMaterialization + case 'cli': + return controlProfileMaterialization + } +} + +function assertProfileContract( + profile: AgentProfile, + contract: ProfileMaterializationContract, + context: string, +): void { + assertProfileMaterialization({ + contract, + changedAxes: profileMaterializationAxes(profile), + context, + }) +} + +function assertBackendProfileMaterialization( + profile: AgentProfile, + backend: ExecutorConfig, + context: string, +): void { + assertProfileContract(profile, backendProfileMaterialization(backend), context) +} + +/** + * The ROOT router-brained supervisor's materialization claim. The router arm consumes the + * identity fields, the resolved system prompt (`systemPrompt` + `prompt.instructions` + + * `resources.instructions`), and the resolved model id (`model.default`); the remaining model + * HINTS (`small`, `provider`, `reasoningEffort`, `metadata`) are accepted as documented-unhonored + * router-arm material (`supervisorAgent`'s contract table states each one), so a canonical + * profile carrying ordinary hints is not refused. Every behavioral axis — tools, permissions, + * MCP, hooks, modes, subagents, file resources — still fails loud before any compute. + */ +const routerSupervisorProfileMaterialization = defineProfileMaterializationContract({ + name: 'router-supervisor-execution', + axes: [ + 'name', + 'description', + 'version', + 'tags', + 'systemPrompt', + 'instructions', + 'resourceInstructions', + 'modelDefault', + 'modelSmall', + 'modelProvider', + 'modelReasoningEffort', + 'modelMetadata', + 'harness', + 'metadata', + ], +}) + +const coordinationMcpAlias = 'agent-runtime-coordination' +const defaultAllowedMcpHosts: string[] = [] +Object.freeze(defaultAllowedMcpHosts) + +/** Manager-authored profiles are untrusted until product policy says otherwise. Remote MCP and + * ambient connection grants therefore fail closed by default, in addition to local MCP and hooks. */ +export const DEFAULT_AUTHORED_PROFILE_SECURITY_POLICY: AgentProfileSecurityPolicy = Object.freeze({ + allowLocalMcp: false, + allowHooks: false, + allowedMcpHosts: defaultAllowedMcpHosts, + allowConnections: false, +}) + +function isExternalSupervisor(profile: AgentProfile): boolean { + return profile.harness !== undefined && profile.harness !== 'cli-base' +} + +function automaticDriverBackendSupported(backend: ExecutorConfig): boolean { + // The built-in coordination server binds host loopback. A local bridge can reach it; a remote + // sandbox cannot until the caller supplies an explicit relay/tunnel through `driveHarness`. + return backend.backend === 'bridge' +} + +function backendProfileOverlays(backend: ExecutorConfig | undefined): AgentProfile[] { + if (!backend) return [] + if (backend.backend === 'bridge' && backend.agentProfile) return [backend.agentProfile] + if (backend.backend === 'cli-worktree' && backend.bridge?.agentProfile) { + return [backend.bridge.agentProfile] + } + return [] +} + +/** Run a harness-brained manager through the same executor factory as its children. The manager's + * full profile is preserved, the live coordination server is added under one reserved alias, and + * every streamed turn is charged to the manager's scope before it may continue. */ +function driveHarnessFromBackend( + backend: ExecutorConfig, + executionId: string, + now: () => number = Date.now, +): DriveHarness { + const capturedBackend = captureReusableExecutorConfig(backend, 'driveHarnessFromBackend') + const boundBackend = bindReusableExecutorExecutionId(capturedBackend, executionId) + const baseFactory = createExecutor(boundBackend) + let activeExecutor: Executor | undefined + const drive: DriveHarness = async ({ + profile, + task, + scope, + coordinationMcpUrl, + coordinationTools, + }) => { + const initialBudget = scope.budget + const hasLiveCoordination = scope.view.inFlight > 0 || scope.view.waiting > 0 + if ( + !hasLiveCoordination && + (initialBudget.tokensLeft <= 0 || + initialBudget.iterationsLeft <= 0 || + (initialBudget.usdCapped && initialBudget.usdLeft <= 0) || + (initialBudget.deadlineMs > 0 && now() >= initialBudget.deadlineMs)) + ) { + throw new ValidationError('driveHarnessFromBackend: supervisor budget exhausted') + } + // `supervise` only builds this drive path for canonical, schema-parsed AgentProfiles; parsing + // again here keeps that invariant local and gives the compound `mcp` field a real type. + const canonicalDriverProfile = agentProfileSchema.parse(profile) + if (canonicalDriverProfile.mcp?.[coordinationMcpAlias] !== undefined) { + throw new ValidationError( + `driveHarnessFromBackend: profile MCP alias ${JSON.stringify(coordinationMcpAlias)} is reserved`, + ) + } + const effectiveProfile = agentProfileSchema.parse({ + ...canonicalDriverProfile, + mcp: { + ...canonicalDriverProfile.mcp, + [coordinationMcpAlias]: { transport: 'http', url: coordinationMcpUrl }, + }, + }) + const stableCoordinationTools = detachedSnapshot( + coordinationTools, + 'driveHarnessFromBackend coordination tools', + ) + const spec: AgentSpec = { + profile: effectiveProfile, + harness: + boundBackend.backend === 'sandbox' + ? ((effectiveProfile.harness ?? boundBackend.harness ?? null) as BackendType | null) + : null, + } + const executor = baseFactory(spec, { + signal: scope.signal, + node: scopeOwnerExecutorNodeContext(scope), + seams: {}, + }) + activeExecutor = executor + let completed = false + let started = false + let terminalAccountingCaptured = false + let pendingUsage: UsageEvent[] = [] + let teardownStarted = false + const deadlineAtMs = scope.budget.deadlineMs || undefined + const teardownOnce = async (grace: number | 'brutalKill' | 'infinity') => { + if (teardownStarted) return + teardownStarted = true + await teardownExecutor(executor, grace, deadlineAtMs, now) + } + const meterPending = async () => { + if (pendingUsage.length === 0) return + const batch = pendingUsage + pendingUsage = [] + await scope.meter(spendFromUsageEvents(batch), { + role: 'driver', + runtime: executor.runtime, + }) + const budget = scope.budget + if ( + budget.tokensLeft <= 0 || + (budget.usdCapped && budget.usdLeft <= 0) || + (budget.deadlineMs > 0 && now() >= budget.deadlineMs) + ) { + throw new ValidationError('driveHarnessFromBackend: supervisor budget exhausted') + } + } + + let failed = false + let failure: unknown + try { + // Construction transfers cleanup ownership immediately. Even a rejected receipt or an + // unmetered runtime reaches the single bounded teardown path below. + const declaration = runtimeOwnedExecutorMaterialization(executor) + const executionBinding = runtimeOwnedExecutorExecutionBinding(executor) + if (declaration === undefined || executionBinding === undefined) { + throw new ValidationError( + `driveHarnessFromBackend: built-in runtime ${JSON.stringify(executor.runtime)} has no trusted materialization declaration or execution binding`, + ) + } + await recordScopeOwnerMaterialization( + scope, + executor.runtime, + { + ...declaration, + // The endpoint is one attempt's transport binding, not AgentProfile identity. Keep the + // admitted profile stable and commit the logical coordination capability separately. + effectiveProfile: canonicalDriverProfile, + platformAttachments: { + [coordinationMcpAlias]: { + kind: 'coordination-mcp', + transport: 'http', + tools: stableCoordinationTools, + }, + }, + }, + { + ...executionBinding, + binding: { + stableBinding: executionBinding.binding, + platformAttachments: { + [coordinationMcpAlias]: { + transport: 'http', + url: coordinationMcpUrl, + }, + }, + }, + descriptor: { + ...executionBinding.descriptor, + coordination: true, + }, + }, + ) + if (executor.budgetExempt) { + throw new ValidationError( + `driveHarnessFromBackend: runtime ${JSON.stringify(executor.runtime)} does not report usage and cannot drive a budgeted supervisor`, + ) + } + + started = true + const run = executor.execute(task, scope.signal) + if (isAsyncIterable(run)) { + for await (const event of run) { + if (event.kind === 'iteration') { + await meterPending() + } else { + pendingUsage.push(event) + } + } + await meterPending() + const artifact = executor.resultArtifact() + terminalAccountingCaptured = true + // A stream carries increments, while its terminal artifact says whether either accounting + // channel was omitted. Preserve unknowns in the shared pool instead of treating them as 0. + if (artifact.spent.tokensKnown === false || artifact.spent.usdKnown === false) { + await scope.meter( + { + iterations: 0, + tokens: { input: 0, output: 0 }, + ...(artifact.spent.tokensKnown === false ? { tokensKnown: false } : {}), + usd: 0, + ...(artifact.spent.usdKnown === false ? { usdKnown: false } : {}), + ms: 0, + }, + { role: 'driver', runtime: executor.runtime, telemetry: 'unknown' }, + ) + } + } else { + const artifact = await run + terminalAccountingCaptured = true + await scope.meter( + { ...artifact.spent, iterations: 0 }, + { role: 'driver', runtime: executor.runtime }, + ) + } + completed = true + } catch (error) { + failed = true + failure = error + } finally { + try { + await meterPending() + } catch (error) { + if (!failed) { + failed = true + failure = error + } + } + if (failed && started && !terminalAccountingCaptured) { + try { + await scope.meter( + { + iterations: 0, + tokens: { input: 0, output: 0 }, + tokensKnown: false, + usd: 0, + usdKnown: false, + ms: 0, + }, + { role: 'driver', runtime: executor.runtime, telemetry: 'unknown-after-failure' }, + ) + } catch (error) { + // The budget pool intentionally throws after durably recording unknown capped usage and + // closing that capacity. Only replace the original failure if the marker did not land. + const budget = scope.budget + if (budget.tokensKnown !== false || (budget.usdCapped && budget.usdKnown !== false)) { + failure = error + } + } + } + try { + await teardownOnce(completed ? DEFAULT_SUCCESSFUL_SHUTDOWN_MS : 'brutalKill') + } catch (error) { + if (!failed) { + failed = true + failure = error + } + } + if (activeExecutor === executor) activeExecutor = undefined + } + if (failed) throw failure } + drive.deliver = (message): boolean => { + const deliver = activeExecutor?.deliver + if (!deliver) return false + return deliver.call(activeExecutor, message) !== false + } + return attestRuntimeOwnedScopeOwner(drive, 'cli') +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return ( + value !== null && + typeof value === 'object' && + Symbol.asyncIterator in value && + typeof (value as AsyncIterable)[Symbol.asyncIterator] === 'function' + ) } /** A name→value table, in this package's resolver-port shape (the same one `WaitProbeRegistry` @@ -150,6 +578,15 @@ function resolveNamed + /** Caller-owned cancellation for the complete recursive run. Aborting it cascades through the + * root scope and every live child, including acquisition and backend execution. */ + readonly signal?: AbortSignal + /** Trusted candidate and pursuit attribution for the root. The runtime derives profile/task + * digests itself from the exact detached values it executes. */ + readonly execution?: AgentExecutionRef /** WHERE workers run — derives the worker seam. Provide this OR an explicit `makeWorkerAgent`. */ readonly backend?: ExecutorConfig /** The independent completion check for backend-derived workers and direct supervisor @@ -157,6 +594,12 @@ export interface SuperviseOptions { * backend-derived workers fall back to their own validity signal. A `string` names an entry in * `registry.deliverables`. */ readonly deliverable?: DeliverableSpec | string + /** Resolve the completion check for one exact authorized backend-derived leaf. The callback runs + * after spawn authorization and driver classification, receives a detached immutable context, + * and may return `undefined` to use the run-wide `deliverable`. Driver profiles never call it. */ + readonly resolveDeliverable?: ( + input: DeliverableResolutionInput, + ) => DeliverableSpec | undefined /** Name→value tables for the four code-valued options, so a recorded run configuration can name * them instead of carrying closures. See {@link SuperviseRegistry}. */ readonly registry?: SuperviseRegistry @@ -164,17 +607,82 @@ export interface SuperviseOptions { * port on `127.0.0.1`, which an off-host root cannot reach. A non-loopback host is refused * unless `allowUnauthenticatedRemote` acknowledges that the verbs are unauthenticated. */ readonly coordination?: CoordinationBinding - /** Override the worker seam directly (tests / advanced) instead of deriving it from `backend`. */ + /** Override the worker seam directly (tests / advanced) instead of deriving it from `backend`. + * This is caller-owned execution: profile security, spawn authorization, and recursive-driver + * selection below apply only to the backend-derived worker path. `authorizeMessage` still + * governs continuations sent through Runtime's coordination tools. */ readonly makeWorkerAgent?: MakeWorkerAgent - /** The supervisor's router substrate (`harness` null). The profile's model wins. */ + /** Run harness-brained supervisors here. Automatic execution supports a local `bridge`; a remote + * sandbox requires an explicit `driveHarness` with a reachable coordination relay or tunnel. + * Defaults to `backend`; separate it when managers and workers use different services. */ + readonly driverBackend?: ExecutorConfig + /** Security policy applied to every manager-authored child profile before budget reservation. + * The default blocks local and remote MCP, hooks, and connection grants. Pass an explicit + * allowlist to grant remote MCP hosts or other author-controlled capabilities. */ + readonly profileSecurity?: AgentProfileSecurityPolicy + /** Product authority over one complete manager-authored spawn. The callback sees the detached, + * immutable profile, task, budget, label, and key together, so approving a profile cannot + * authorize a different task. Return the exact allowed profile (which may be narrowed) plus + * trusted candidate/pursuit attribution, or throw to refuse the whole spawn before reservation. */ + readonly authorizeSpawn?: (input: { + readonly profile: AgentProfile + readonly parent: AgentProfile + /** Trusted identity of the manager authorizing this exact child. */ + readonly parentIdentity: NodeExecutionIdentity + /** Concrete manager node; never accepted from model-authored tool arguments. */ + readonly parentNodeId: string + /** Stable manager-scoped assignment, including deterministic unkeyed siblings. */ + readonly assignmentId: string + readonly task: unknown + readonly budget: Budget + readonly label: string + readonly key?: string + readonly depth: number + }) => AuthorizedSpawn + /** Product authority over every continuation sent to a live child. When spawn authorization is + * enabled, omitting this refuses steer/answer instructions instead of silently extending the + * authorized task. The exact worker identity and detached bytes are recorded before delivery. */ + readonly authorizeMessage?: ( + input: DownMessageAuthorizationInput & { + readonly parent: AgentProfile + readonly depth: number + }, + ) => AuthorizedDownMessage + /** Decide whether an authorized child becomes another supervisor. By default only + * `metadata.role === 'driver'` does. Products receive the same frozen post-authorization + * context as `resolveDeliverable`, so trusted execution/assignment authority can override + * model-authored metadata without a side channel. */ + readonly isDriverProfile?: (input: AuthorizedSpawnContext) => boolean + /** The supervisor's router substrate (`profile.harness` omitted or `cli-base`). The profile's + * model wins. */ readonly router?: RouterConfig /** Inject the supervisor brain directly (tests / advanced). */ readonly brain?: ToolLoopChat - /** Run a sandboxed-harness supervisor (`harness` set). */ + /** Run an external-harness supervisor explicitly. Required for a remote sandbox; optional as a + * caller-owned override for a local bridge. */ readonly driveHarness?: DriveHarness + /** Resolve one custom external-harness session per trusted manager identity. Use this instead of + * `driveHarness` when recursive managers must be independently steerable. */ + readonly resolveDriveHarness?: ResolveDriveHarness + /** Required with a custom `driveHarness` or `resolveDriveHarness`: declares which complete + * AgentProfile axes that path really applies. Built-in bridge driving supplies its own + * full-profile contract. */ + readonly driveHarnessMaterialization?: ProfileMaterializationContract + /** Resolve product-owned tools from the exact trusted manager context. The same descriptors and + * handlers are bound to router and external-harness managers; resolution happens once per node. + * Each handler receives that manager scope's live cancellation signal in its trusted invocation + * context, including recursive parent and root cascades. */ + readonly resolveSupervisorTools?: ResolveSupervisorTools + /** Awaited product transaction hook for every coordination record. `eventId` is stable across a + * lost acknowledgement and durable restart; the record is not pull-visible until this commits. */ + readonly onCoordinationEvent?: ( + context: SupervisorNodeContext, + eventId: Sha256Digest, + record: BusRecord, + ) => void | Promise /** WORK tools the supervisor may call DIRECTLY — so a recursive atom can ACT (do simple work * itself) OR SPAWN (delegate when it needs parallelism), not be a pure manager. Pair with - * `executeExtraTool`. Router arm only (`harness` null). */ + * `executeExtraTool`. Router arm only (`profile.harness` omitted or `cli-base`). */ readonly extraTools?: ReadonlyArray<{ readonly name: string readonly description?: string @@ -187,9 +695,9 @@ export interface SuperviseOptions { ) => Promise /** Per-child budget reserved on each spawn. Defaults to a quarter of the pool's tokens. */ readonly perWorker?: Budget - /** Hard cap on simultaneously-LIVE workers — `spawn_agent` fails closed once this many are in - * flight. The conserved pool bounds TOTAL work; this bounds SIMULTANEOUS work (live boxes/ - * sandboxes a real fleet runs at once). Omit/`<= 0` = no cap (the pool stays the only fence). */ + /** Hard cap on simultaneously executing spawned workers across the WHOLE recursive tree. The + * root is excluded; nested drivers and leaves share one allocation, so recursion cannot multiply + * the cap. Omit/`<= 0` = no cap (the conserved pool stays the only bound). */ readonly maxLiveWorkers?: number /** Analyst lenses available to the driver. Required for `analyzeOnSettle`. Unset → status quo * (the driver receives settled worker outputs, no analyst findings). A `string` names an entry in @@ -218,17 +726,23 @@ export interface SuperviseOptions { /** * Make the run DURABLE: journal + result blobs + the coordination side-log are file-backed under * this directory (`createFileRunContext`), fsynced per write, and the supervisor reads the prior - * tree first. Re-running with the same `runDir` AND the same `runId` resumes, and the built-in - * driver is resume-AWARE out of the box: the children that already settled are replayed onto + * tree first. Re-running with the same `runDir` AND the same `runId` resumes only when the exact + * root profile/task identity and declared budget match. The original absolute deadline and prior + * measured spend are restored before new admission. The built-in driver is resume-aware: children + * that already settled, including their exact execution identities, are replayed onto * `Scope.resume` (and into the driver's settled ledger + its first context), keyed assignments * (`spawn_agent`'s `key`) resolve to their committed results instead of re-running, pending - * waits re-arm on their original deadlines, prior questions/findings replay from the - * coordination log, and the finalize spans both processes' work. Unset = in-memory, fresh - * every call. + * waits re-arm on their original deadlines, and the coordination log loads prior questions, + * findings, and instruction receipts. The router arm receives all three in its resume brief; the + * external arm seeds prior questions while findings and receipts remain in the durable log. + * Instruction receipts are evidence and are never delivered automatically to a replacement + * worker. The final result spans both processes' work. Unset = in-memory, fresh every call. * * The boundary that remains: work that was IN FLIGHT when the process died is not recovered — - * the built-in executors cannot re-attach to a dead process's executions, so those assignments - * resume as explicitly lost/in-doubt and re-run (reported, never silent). + * the built-in executors cannot re-attach to a dead process's executions. Each such assignment + * resumes as explicitly lost/in-doubt, its full declared reservation is charged conservatively, + * and its token/dollar telemetry remains unknown. A retry is admitted only from safely remaining + * capacity, so restart cannot mint a fresh budget or slide the original absolute deadline. * * `runId` matters here: it defaults to the constant `'supervise'`, which is fine for a single * resumable run per directory but collides across concurrent runs sharing one `runDir`. @@ -294,26 +808,393 @@ export interface SuperviseOptions { readonly otel?: Omit } -/** A quarter of the token pool per worker → ~4 workers fit before `poolStarved` halts spawning. */ +/** The product-authorized result for one complete spawn request. Attribution is never accepted + * from the manager itself; it enters only through this trusted callback. */ +export interface AuthorizedSpawn { + readonly profile: AgentProfile + readonly execution?: AgentExecutionRef +} + +/** Exact trusted context after a manager-authored spawn has passed product authorization. */ +export interface AuthorizedSpawnContext { + readonly profile: AgentProfile + readonly parent: AgentProfile + readonly parentIdentity: NodeExecutionIdentity + readonly execution: NodeExecutionIdentity + readonly parentNodeId: string + readonly assignmentId: string + readonly task: unknown + readonly budget: Budget + readonly label: string + readonly key?: string + readonly depth: number +} + +/** Exact trusted context for selecting one backend-derived leaf's completion check. */ +export type DeliverableResolutionInput = AuthorizedSpawnContext + +function captureDeliverable( + deliverable: DeliverableSpec, + context: string, +): DeliverableSpec { + if (typeof deliverable !== 'object' || deliverable === null || Array.isArray(deliverable)) { + throw new ValidationError(`${context}: deliverable must be an object`) + } + if (typeof deliverable.check !== 'function') { + throw new ValidationError(`${context}: deliverable.check must be a function`) + } + return Object.freeze({ + ...detachedSnapshot({ describe: deliverable.describe }, `${context} configuration`), + check: deliverable.check, + }) +} + +/** Capture the public one-call configuration before any asynchronous work starts. Decision data is + * detached and frozen; executable ports are copied as the exact references selected at intake. + * Service internals intentionally remain live, while replacing a callback/service on the caller's + * mutable options object can no longer change an in-flight run. */ +function captureSuperviseOptions(opts: SuperviseOptions): SuperviseOptions { + const { + backend, + driverBackend, + deliverable, + resolveDeliverable, + router, + compaction, + watchWorkers, + analysts, + makeWorkerAgent, + blobs, + journal, + probes, + registry, + hooks, + otel, + authorizeSpawn, + authorizeMessage, + isDriverProfile, + brain, + driveHarness, + resolveDriveHarness, + resolveSupervisorTools, + onCoordinationEvent, + executeExtraTool, + stopRule, + onProgressStop, + finalizer, + now, + signal, + rootHandle, + ...decisionData + } = opts + const capturedData = detachedSnapshot(decisionData, 'supervise options') + const capturedBackend = backend === undefined ? undefined : snapshotExecutorConfig(backend) + const capturedDriverBackend = + driverBackend === undefined ? undefined : snapshotExecutorConfig(driverBackend) + // A string names a registry entry; it is resolved (and the resolved spec validated) by + // `resolveNamed` before anything is built or spent. + const capturedDeliverable = + deliverable === undefined || typeof deliverable === 'string' + ? deliverable + : captureDeliverable(deliverable, 'supervise deliverable') + const capturedRouter = + router === undefined + ? undefined + : (() => { + const { complete, ...routerData } = router + return Object.freeze({ + ...detachedSnapshot(routerData, 'supervise router configuration'), + ...(complete === undefined ? {} : { complete }), + }) + })() + const capturedCompaction = + compaction === undefined + ? undefined + : (() => { + const { distill, estimateTokens, onCompact, ...compactionData } = compaction + return Object.freeze({ + ...detachedSnapshot(compactionData, 'supervise compaction configuration'), + ...(distill === undefined ? {} : { distill }), + ...(estimateTokens === undefined ? {} : { estimateTokens }), + ...(onCompact === undefined ? {} : { onCompact }), + }) + })() + const capturedWatchWorkers = + watchWorkers === undefined + ? undefined + : Object.freeze({ + ...detachedSnapshot( + { maxFindingsPerWorker: watchWorkers.maxFindingsPerWorker }, + 'supervise worker-watch configuration', + ), + ...(watchWorkers.detectors === undefined + ? {} + : { detectors: Object.freeze([...watchWorkers.detectors]) }), + }) + const capturedAnalysts = + analysts === undefined || typeof analysts === 'string' + ? analysts + : Object.freeze({ + kinds: detachedSnapshot(analysts.kinds, 'supervise analyst kinds'), + run: analysts.run, + }) + + return Object.freeze({ + ...capturedData, + ...(capturedBackend === undefined ? {} : { backend: capturedBackend }), + ...(capturedDriverBackend === undefined ? {} : { driverBackend: capturedDriverBackend }), + ...(capturedDeliverable === undefined ? {} : { deliverable: capturedDeliverable }), + ...(resolveDeliverable === undefined ? {} : { resolveDeliverable }), + ...(capturedRouter === undefined ? {} : { router: capturedRouter }), + ...(capturedCompaction === undefined ? {} : { compaction: capturedCompaction }), + ...(capturedWatchWorkers === undefined ? {} : { watchWorkers: capturedWatchWorkers }), + ...(capturedAnalysts === undefined ? {} : { analysts: capturedAnalysts }), + ...(makeWorkerAgent === undefined ? {} : { makeWorkerAgent }), + ...(blobs === undefined ? {} : { blobs }), + ...(journal === undefined ? {} : { journal }), + ...(probes === undefined ? {} : { probes }), + ...(authorizeSpawn === undefined ? {} : { authorizeSpawn }), + ...(authorizeMessage === undefined ? {} : { authorizeMessage }), + ...(isDriverProfile === undefined ? {} : { isDriverProfile }), + ...(brain === undefined ? {} : { brain }), + ...(driveHarness === undefined ? {} : { driveHarness }), + ...(resolveDriveHarness === undefined ? {} : { resolveDriveHarness }), + ...(resolveSupervisorTools === undefined ? {} : { resolveSupervisorTools }), + ...(onCoordinationEvent === undefined ? {} : { onCoordinationEvent }), + ...(executeExtraTool === undefined ? {} : { executeExtraTool }), + ...(stopRule === undefined ? {} : { stopRule }), + ...(onProgressStop === undefined ? {} : { onProgressStop }), + ...(finalizer === undefined ? {} : { finalizer }), + ...(now === undefined ? {} : { now }), + ...(signal === undefined ? {} : { signal }), + ...(rootHandle === undefined ? {} : { rootHandle }), + // Live collaborators: registries resolve lazily, hooks and otel exporters are process objects. + // They are captured as the exact references selected at intake, never deep-snapshot. + ...(registry === undefined ? {} : { registry }), + ...(hooks === undefined ? {} : { hooks }), + ...(otel === undefined ? {} : { otel }), + }) +} + +/** A quarter of token and optional dollar capacity per worker; nested managers partition again. */ function defaultPerWorker(budget: Budget): Budget { return { - maxIterations: budget.maxIterations, + maxIterations: Math.max(1, Math.floor(budget.maxIterations / 4)), maxTokens: Math.max(1, Math.floor(budget.maxTokens / 4)), + ...(budget.maxUsd !== undefined ? { maxUsd: budget.maxUsd / 4 } : {}), } } +function freezeDetached(value: T): T { + return detachedSnapshot(value, 'supervise') +} + +function freezeDetachedProfile(value: unknown): AgentProfile { + return freezeDetached(agentProfileSchema.parse(value)) +} + +/** + * Map the two loose `SupervisorProfile` spellings onto their canonical `AgentProfile` form before + * the strict schema parse, so both documented spellings run the SAME canonical pipeline and share + * one identity digest: + * - a string `model` IS `model.default`; + * - a top-level `systemPrompt` IS `prompt.systemPrompt` (two disagreeing values are a fault); + * - `harness: null` selects the router brain, which canonically is an ABSENT harness. + * A canonical profile passes through byte-identical; every other field is left for the schema to + * accept or refuse. + */ +function canonicalSupervisorProfileInput(profile: SupervisorProfile): unknown { + if (typeof profile !== 'object' || profile === null) return profile + const { harness, model, systemPrompt, prompt, ...rest } = profile as SupervisorProfile & + Record + const promptSystem = prompt?.systemPrompt + if (systemPrompt !== undefined && promptSystem !== undefined && systemPrompt !== promptSystem) { + throw new ValidationError( + 'supervise: profile.prompt.systemPrompt and profile.systemPrompt are both set and differ — ' + + 'they are the same standing instruction, so keep exactly one', + ) + } + const canonicalPrompt = + systemPrompt !== undefined ? { ...prompt, systemPrompt } : (prompt as unknown) + return { + ...rest, + ...(harness === null || harness === undefined ? {} : { harness }), + ...(model === undefined + ? {} + : { model: typeof model === 'string' ? { default: model } : model }), + ...(canonicalPrompt === undefined ? {} : { prompt: canonicalPrompt }), + } +} + +function canonicalExecution( + profile: AgentProfile, + task: unknown, + rawExecution: AgentExecutionRef | undefined, + context: string, +): { readonly identity: NodeExecutionIdentity; readonly ref?: AgentExecutionRef } { + const execution = rawExecution === undefined ? undefined : freezeDetached(rawExecution) + if (execution !== undefined) { + if (typeof execution !== 'object' || execution === null || Array.isArray(execution)) { + throw new ValidationError(`${context}: execution must be an object`) + } + const unknown = Object.keys(execution).filter( + (key) => key !== 'candidateDigest' && key !== 'correlation', + ) + if (unknown.length > 0) { + throw new ValidationError(`${context}: unknown execution fields: ${unknown.join(', ')}`) + } + } + const identity = deriveNodeExecutionIdentity({ profile, execution }, task) + if (!identity?.profileDigest || !identity.taskDigest) { + throw new ValidationError( + `${context}: profile and task must be finite, acyclic canonical JSON for durable identity`, + ) + } + const ref: AgentExecutionRef | undefined = + identity.candidateDigest || identity.correlation + ? Object.freeze({ + ...(identity.candidateDigest ? { candidateDigest: identity.candidateDigest } : {}), + ...(identity.correlation ? { correlation: identity.correlation } : {}), + }) + : undefined + return { identity, ...(ref ? { ref } : {}) } +} + +function rootCoordinationOwner(identity: NodeExecutionIdentity): string { + return canonicalCandidateDigest({ kind: 'supervisor-root', identity }) +} + +function childCoordinationOwner( + parentOwnerId: string, + identity: NodeExecutionIdentity, + context: WorkerSpawnContext, + depth: number, +): string { + return canonicalCandidateDigest({ + kind: 'supervisor-child', + parentOwnerId, + identity, + assignment: { + id: context.assignmentId, + label: context.label, + key: context.key ?? null, + depth, + }, + }) +} + +function supervisionRunNamespace(runDir: string | undefined, runId: string): string { + return canonicalCandidateDigest( + runDir === undefined + ? { kind: 'supervise-ephemeral-run', runId, nonce: randomUUID() } + : { kind: 'supervise-durable-run', runId, runDir: resolve(runDir) }, + ) +} + +function workerAssignmentNamespace( + runNamespace: string, + parentOwnerId: string, + assignmentId: string, +): string { + return canonicalCandidateDigest({ + kind: 'supervise-worker-assignment', + runNamespace, + parentOwnerId, + assignmentId, + }) +} + +/** Hash only durable coordination meaning. Bus sequence/timestamp are delivery metadata and a + * resumed projection's marker describes the reader, not the original settlement. */ +function coordinationEventId( + context: SupervisorNodeContext, + event: CoordinationEvent, +): Sha256Digest { + const durableEvent = + event.type === 'settled' && event.worker.resumed === true + ? (() => { + const { resumed: _resumed, ...worker } = event.worker + return { type: 'settled' as const, worker } + })() + : event + return canonicalCandidateDigest({ + kind: 'supervise-coordination-event', + runNamespace: context.runNamespace, + ownerId: context.ownerId, + event: detachedSnapshot(durableEvent, 'supervise coordination event identity'), + }) +} + /** One-call supervisor: build + run a supervisor from its profile with sensible defaults; the raw `supervisorAgent` + `createSupervisor().run` seams stay available for power use. */ export function supervise(profile: SupervisorProfile, task: unknown, opts: SuperviseOptions) { + const options = captureSuperviseOptions(opts) + assertValidBudget(options.budget, 'supervise budget') // Fail loud before any compute: every configured model must be in the allowed subset (no-op - // when allowedModels is unset). The backend seam carries its own model on most backends. The - // profile's model is checked as the RESOLVED id, so a canonical AgentProfile's `model.default` - // is subject to the same policy a plain string model is. - const backendModel = (opts.backend as { model?: unknown } | undefined)?.model - assertModelAllowed(opts.router?.model, opts.allowedModels) - assertModelAllowed(resolveSupervisorModelId(profile), opts.allowedModels) + // when allowedModels is unset). The backend seam carries its own model on most backends. + const parsedProfile = agentProfileSchema.safeParse(canonicalSupervisorProfileInput(profile)) + if (!parsedProfile.success) { + throw new ValidationError(`supervise: invalid AgentProfile: ${parsedProfile.error.message}`) + } + const canonicalProfile = freezeDetachedProfile(parsedProfile.data) + const canonicalTask = freezeDetached(task) + if (options.makeWorkerAgent && options.authorizeSpawn) { + throw new ValidationError( + 'supervise: authorizeSpawn cannot be combined with caller-owned makeWorkerAgent; wrap and authorize the custom factory explicitly or use backend-derived workers', + ) + } + if (options.makeWorkerAgent && options.resolveDeliverable) { + throw new ValidationError( + 'supervise: resolveDeliverable applies only to backend-derived workers; wrap a caller-owned makeWorkerAgent with its completion checks explicitly', + ) + } + const authorizeDownFor = ( + parent: AgentProfile, + depth: number, + ): AuthorizeDownMessage | undefined => { + if (!options.authorizeSpawn && !options.authorizeMessage) return undefined + return (input) => { + if (!options.authorizeMessage) { + throw new ValidationError( + 'supervise: authorizeMessage is required before steer_agent or answer_question when authorizeSpawn is enabled', + ) + } + return freezeDetached( + options.authorizeMessage( + freezeDetached({ + ...input, + parent, + depth, + }), + ), + ) + } + } + const rootExecution = canonicalExecution( + canonicalProfile, + canonicalTask, + options.execution, + 'supervise root', + ) + const backendModel = (options.backend as { model?: unknown } | undefined)?.model + const driverBackendModel = (options.driverBackend as { model?: unknown } | undefined)?.model + const overlays = [ + ...backendProfileOverlays(options.backend), + ...backendProfileOverlays(options.driverBackend), + ] + if (overlays.length > 0) { + throw new ValidationError( + 'supervise: backend agentProfile overlays are not allowed because they run after spawn authorization; merge the overlay into the exact profile before calling supervise', + ) + } + assertModelAllowed(options.router?.model, options.allowedModels) + assertProfileModelsAllowed(canonicalProfile, options.allowedModels) assertModelAllowed( typeof backendModel === 'string' ? backendModel : undefined, - opts.allowedModels, + options.allowedModels, + ) + assertModelAllowed( + typeof driverBackendModel === 'string' ? driverBackendModel : undefined, + options.allowedModels, ) // Named options become values before anything is built or spent — an unknown name is a @@ -321,31 +1202,151 @@ export function supervise(profile: SupervisorProfile, task: unknown, opts: Super const deliverable = resolveNamed( 'deliverable', 'deliverables', - opts.deliverable, - opts.registry?.deliverables, + options.deliverable, + options.registry?.deliverables, ) const finalizer = resolveNamed( 'finalizer', 'finalizers', - opts.finalizer, - opts.registry?.finalizers, + options.finalizer, + options.registry?.finalizers, ) - const analysts = resolveNamed('analysts', 'analysts', opts.analysts, opts.registry?.analysts) - const probes = resolveNamed('probes', 'probes', opts.probes, opts.registry?.probes) - assertCoordinationBinding(opts.coordination) + const analysts = resolveNamed( + 'analysts', + 'analysts', + options.analysts, + options.registry?.analysts, + ) + const probes = resolveNamed('probes', 'probes', options.probes, options.registry?.probes) + assertCoordinationBinding(options.coordination) // `withDriver: true` is the wiring invariant either way (a `role: 'driver'` child must resolve // to the nested-scope executor); `runDir` only changes WHERE the journal and blobs live. const ctx = - opts.runDir !== undefined - ? createFileRunContext(opts.runDir, { withDriver: true }) + options.runDir !== undefined + ? createFileRunContext(options.runDir, { withDriver: true }) : createInMemoryRunContext({ withDriver: true }) - const blobs = opts.blobs ?? ctx.blobs - const perWorker = opts.perWorker ?? defaultPerWorker(opts.budget) - - const runId = opts.runId ?? 'supervise' + const blobs = options.blobs ?? ctx.blobs + const perWorker = options.perWorker ?? defaultPerWorker(options.budget) + assertValidBudget(perWorker, 'supervise perWorker') + const journal = options.journal ?? ctx.journal + const runId = options.runId ?? 'supervise' + const runNamespace = supervisionRunNamespace(options.runDir, runId) const log = ctx.coordinationLog - const now = opts.now ?? Date.now + const rootOwnerId = rootCoordinationOwner(rootExecution.identity) + const observeNodeEvent = options.onCoordinationEvent + ? async ( + context: SupervisorNodeContext, + event: CoordinationEvent, + record: BusRecord, + ) => { + await options.onCoordinationEvent?.(context, coordinationEventId(context, event), record) + } + : undefined + const managerBackend = options.driverBackend ?? options.backend + if (options.driveHarness && options.resolveDriveHarness) { + throw new ValidationError('supervise: provide driveHarness or resolveDriveHarness, not both') + } + const hasCustomDriveHarness = Boolean(options.driveHarness || options.resolveDriveHarness) + // A custom harness receives the WHOLE profile by contract (`DriveHarness.profile` is the + // caller's object, never rewritten), so an undeclared materialization defaults to the full + // canonical leaf set — responsibility for every axis transfers to the harness the caller owns. + // Declaring `driveHarnessMaterialization` narrows that claim and turns dropped axes into + // pre-spawn faults. + const driverMaterialization = hasCustomDriveHarness + ? (options.driveHarnessMaterialization ?? fullProfileMaterialization) + : managerBackend && automaticDriverBackendSupported(managerBackend) + ? backendProfileMaterialization(managerBackend) + : undefined + if ( + isExternalSupervisor(canonicalProfile) && + !options.driveHarness && + !options.resolveDriveHarness && + (!managerBackend || !automaticDriverBackendSupported(managerBackend)) + ) { + throw new ValidationError( + `supervise: external supervisor profile.harness=${JSON.stringify(canonicalProfile.harness)} requires a local bridge driverBackend, an explicit driveHarness, or resolveDriveHarness with reachable coordination transport`, + ) + } + const harnessClaims = new WeakMap< + DriveHarness, + { readonly owners: Set; steerable: boolean } + >() + const claimDriveHarness = (rawHarness: unknown, ownerId: string): DriveHarness => { + if (typeof rawHarness !== 'function') { + throw new ValidationError( + 'supervise: resolveDriveHarness must return a DriveHarness function', + ) + } + const harness = rawHarness as DriveHarness + const deliver: unknown = harness.deliver + if (deliver !== undefined && typeof deliver !== 'function') { + throw new ValidationError('supervise: driveHarness.deliver must be a function when provided') + } + const claim = harnessClaims.get(harness) + const conflictingOwner = claim + ? [...claim.owners].find((claimedOwner) => claimedOwner !== ownerId) + : undefined + const steerable = typeof deliver === 'function' + if (conflictingOwner !== undefined && (steerable || claim?.steerable === true)) { + throw new ValidationError( + `supervise: steerable driveHarness is already bound to manager owner ${JSON.stringify(conflictingOwner)}; resolveDriveHarness must return a distinct steerable instance for owner ${JSON.stringify(ownerId)}`, + ) + } + if (claim) { + claim.owners.add(ownerId) + claim.steerable ||= steerable + } else { + harnessClaims.set(harness, { owners: new Set([ownerId]), steerable }) + } + return harness + } + const driveHarnessForOwner = (context: DriveHarnessOwnerContext): DriveHarness | undefined => { + if (options.resolveDriveHarness) { + return claimDriveHarness(options.resolveDriveHarness(context), context.ownerId) + } + if (options.driveHarness) { + return claimDriveHarness(options.driveHarness, context.ownerId) + } + return managerBackend && automaticDriverBackendSupported(managerBackend) + ? driveHarnessFromBackend( + managerBackend, + externalExecutionId('supervised-manager', { + runNamespace, + ownerId: context.ownerId, + }), + options.now ?? Date.now, + ) + : undefined + } + const rootDriveHarness = isExternalSupervisor(canonicalProfile) + ? driveHarnessForOwner( + freezeDetached({ + runId, + runNamespace, + ownerId: rootOwnerId, + depth: 0, + identity: rootExecution.identity, + profile: canonicalProfile, + task: canonicalTask, + }), + ) + : undefined + const rootOwnerRuntime = + !isExternalSupervisor(canonicalProfile) || rootDriveHarness === undefined + ? undefined + : runtimeOwnedScopeOwnerRuntime(rootDriveHarness) + assertProfileContract( + canonicalProfile, + isExternalSupervisor(canonicalProfile) + ? (driverMaterialization as ProfileMaterializationContract) + : options.brain + ? promptControlProfileMaterialization + : routerSupervisorProfileMaterialization, + 'supervise root', + ) + + const now = options.now ?? Date.now // The span recorder for this run, built inside `start()` so a configuration fault below still // throws without leaving an exporter's flush timer behind. The worker seam reads it LAZILY (it is @@ -353,19 +1354,202 @@ export function supervise(profile: SupervisorProfile, task: unknown, opts: Super // seam be built here while the recorder is built there. let spans: SupervisorSpanRecorder | undefined - let makeWorkerAgent = opts.makeWorkerAgent + let makeWorkerAgent = options.makeWorkerAgent if (!makeWorkerAgent) { - if (!opts.backend) { + if (!options.backend) { throw new ValidationError( 'supervise: provide opts.backend (where workers run) or opts.makeWorkerAgent', ) } - // A front-door worker is spawned by the ROOT scope (the supervisor brain drives that scope - // directly), so the span that parents it is the run's root span — which is exactly what - // `workerTrace(runId)` resolves to. A caller nesting sub-drivers behind its own recursive - // `makeWorkerAgent` owns the same wiring one level down, for ITS spawning node. - makeWorkerAgent = workerFromBackend(opts.backend, deliverable, () => - spans ? { [workerTraceSeamKey]: spans.workerTrace(runId) } : {}, + const makeLeaf = workerFromBackend(options.backend, deliverable) + const securityPolicy = options.profileSecurity ?? DEFAULT_AUTHORED_PROFILE_SECURITY_POLICY + + const makeRecursiveWorkerFor = ( + parent: AgentProfile, + parentIdentity: NodeExecutionIdentity, + depth: number, + parentOwnerId: string, + ): MakeWorkerAgent => { + const makeRecursiveWorker: MakeWorkerAgent = (authoredProfile, spawnContext) => { + if (!spawnContext) { + throw new ValidationError('supervise: backend-derived workers require spawn context') + } + const input = freezeDetachedProfile(authoredProfile) + const authorizationInput = Object.freeze({ + profile: input, + parent, + parentIdentity, + parentNodeId: spawnContext.parentNodeId, + assignmentId: spawnContext.assignmentId, + task: spawnContext.task, + budget: spawnContext.budget, + label: spawnContext.label, + ...(spawnContext.key !== undefined ? { key: spawnContext.key } : {}), + depth, + }) + const decision = options.authorizeSpawn + ? freezeDetached(options.authorizeSpawn(authorizationInput)) + : Object.freeze({ + profile: input, + ...(spawnContext.execution ? { execution: spawnContext.execution } : {}), + }) + if (typeof decision !== 'object' || decision === null || Array.isArray(decision)) { + throw new ValidationError('supervise: authorizeSpawn must return an AuthorizedSpawn') + } + const authorized = freezeDetachedProfile(decision.profile) + const childExecution = canonicalExecution( + authorized, + spawnContext.task, + decision.execution, + `supervise spawn ${JSON.stringify(spawnContext.label)}`, + ) + const authorizedContext = Object.freeze({ + ...spawnContext, + ...(childExecution.ref ? { execution: childExecution.ref } : {}), + }) + const postAuthorizationContext: AuthorizedSpawnContext = freezeDetached({ + profile: authorized, + parent, + parentIdentity, + execution: childExecution.identity, + parentNodeId: spawnContext.parentNodeId, + assignmentId: spawnContext.assignmentId, + task: spawnContext.task, + budget: spawnContext.budget, + label: spawnContext.label, + ...(spawnContext.key !== undefined ? { key: spawnContext.key } : {}), + depth, + }) + const security = validateAgentProfileSecurity(authorized, securityPolicy) + if (!security.ok) { + const details = security.issues + .filter((issue) => issue.level === 'error') + .map((issue) => `${issue.code}${issue.path ? ` at ${issue.path}` : ''}`) + .join(', ') + throw new ValidationError(`supervise: spawned AgentProfile refused: ${details}`) + } + assertProfileModelsAllowed(authorized, options.allowedModels) + let isDriver: boolean + if (options.isDriverProfile) { + const driverDecision: unknown = options.isDriverProfile(postAuthorizationContext) + if (typeof driverDecision !== 'boolean') { + throw new ValidationError('supervise: isDriverProfile must return a boolean') + } + isDriver = driverDecision + } else { + isDriver = authorized.metadata?.role === 'driver' + } + if (!isDriver) { + const selectedDeliverable = options.resolveDeliverable?.(postAuthorizationContext) + const leafDeliverable = + selectedDeliverable === undefined + ? deliverable + : captureDeliverable( + selectedDeliverable, + `supervise deliverable for ${JSON.stringify(spawnContext.label)}`, + ) + const makeSelectedLeaf = + leafDeliverable === deliverable + ? makeLeaf + : workerFromBackend(options.backend as ExecutorConfig, leafDeliverable) + return makeSelectedLeaf( + authorized, + Object.freeze({ + ...authorizedContext, + assignmentId: workerAssignmentNamespace( + runNamespace, + parentOwnerId, + spawnContext.assignmentId, + ), + }), + ) + } + const ownerId = childCoordinationOwner( + parentOwnerId, + childExecution.identity, + spawnContext, + depth, + ) + const nestedDriveHarness = isExternalSupervisor(authorized) + ? driveHarnessForOwner( + freezeDetached({ + runId, + runNamespace, + ownerId, + depth, + identity: childExecution.identity, + assignmentId: spawnContext.assignmentId, + profile: authorized, + task: spawnContext.task, + }), + ) + : undefined + if (isExternalSupervisor(authorized) && !nestedDriveHarness) { + throw new ValidationError( + `supervise: authored external supervisor profile.harness=${JSON.stringify(authorized.harness)} requires a local bridge driverBackend, an explicit driveHarness, or resolveDriveHarness with reachable coordination transport`, + ) + } + assertProfileContract( + authorized, + isExternalSupervisor(authorized) + ? (driverMaterialization as ProfileMaterializationContract) + : promptModelProfileMaterialization, + `supervise driver ${JSON.stringify(spawnContext.label)}`, + ) + + const childFactory = makeRecursiveWorkerFor( + authorized, + childExecution.identity, + depth + 1, + ownerId, + ) + const nestedPerWorker = defaultPerWorker(spawnContext.budget) + const authorizeNestedMessage = authorizeDownFor(authorized, depth + 1) + const nested = supervisorAgent(authorized, { + blobs, + makeWorkerAgent: childFactory, + ...(authorizeNestedMessage ? { authorizeDownMessage: authorizeNestedMessage } : {}), + perWorker: nestedPerWorker, + ...(options.router ? { router: options.router } : {}), + ...(nestedDriveHarness ? { driveHarness: nestedDriveHarness } : {}), + nodeContext: { + runId, + runNamespace, + ownerId, + depth, + identity: childExecution.identity, + assignmentId: spawnContext.assignmentId, + }, + ...(options.resolveSupervisorTools + ? { resolveSupervisorTools: options.resolveSupervisorTools } + : {}), + ...(observeNodeEvent ? { observeNodeEvent, replaySettlements: true } : {}), + ...(analysts ? { analysts } : {}), + ...(options.analyzeOnSettle ? { analyzeOnSettle: options.analyzeOnSettle } : {}), + ...(options.watchWorkers ? { watchWorkers: options.watchWorkers } : {}), + ...(options.stallAfterMs !== undefined ? { stallAfterMs: options.stallAfterMs } : {}), + ...(options.stopRule ? { stopRule: options.stopRule } : {}), + ...(options.onProgressStop ? { onProgressStop: options.onProgressStop } : {}), + ...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}), + ...(options.compaction ? { compaction: options.compaction } : {}), + ...(log + ? { + onEvent: (_event, record) => log.append(runId, record, ownerId), + loadPriorCoordination: () => log.load(runId, ownerId), + } + : {}), + ...(finalizer ? { finalizer } : {}), + }) + return driverChild(authorized, nested, journal, childExecution.ref) + } + return makeRecursiveWorker + } + + makeWorkerAgent = makeRecursiveWorkerFor( + canonicalProfile, + rootExecution.identity, + 1, + rootOwnerId, ) } const workerFactory = makeWorkerAgent @@ -374,55 +1558,90 @@ export function supervise(profile: SupervisorProfile, task: unknown, opts: Super // `expect(() => supervise(...)).toThrow` still sees the throw, and no compute starts. Only the // durable coordination replay needs to await, so the run begins inside this closure. const start = async () => { - // The durable coordination side-log (file contexts only): replay the prior process's questions - // and findings into the driver, and append this process's as they publish — so a resumed run - // keeps the coordination context the spawn journal does not record. - const priorCoordination = log ? await log.load(runId) : undefined + // The durable coordination side-log (file contexts only) loads prior questions, findings, and + // authorized instruction receipts, then appends this process's evidence as it publishes. The + // router arm receives all three in its resume brief; the external arm seeds prior questions and + // leaves the other evidence in the log. No prior instruction is auto-delivered. + const priorCoordination = log ? await log.load(runId, rootOwnerId) : undefined - const agent = supervisorAgent(profile, { + const authorizeRootMessage = authorizeDownFor(canonicalProfile, 1) + const agent = supervisorAgent(canonicalProfile, { blobs, makeWorkerAgent: workerFactory, + ...(authorizeRootMessage ? { authorizeDownMessage: authorizeRootMessage } : {}), perWorker, + ...(log + ? { + onEvent: (_event, record) => log.append(runId, record, rootOwnerId), + } + : {}), ...(deliverable ? { deliverable } : {}), - ...(log ? { onEvent: (ev) => log.append(runId, ev, new Date(now()).toISOString()) } : {}), ...(priorCoordination && - (priorCoordination.questions.length > 0 || priorCoordination.findings.length > 0) + (priorCoordination.questions.length > 0 || + priorCoordination.findings.length > 0 || + priorCoordination.continuations.length > 0 || + priorCoordination.deliveryEvidence.length > 0) ? { priorCoordination } : {}), ...(finalizer ? { finalizer } : {}), - ...(opts.coordination ? { coordination: opts.coordination } : {}), - ...(opts.maxLiveWorkers !== undefined ? { maxLiveWorkers: opts.maxLiveWorkers } : {}), - ...(opts.router ? { router: opts.router } : {}), - ...(opts.brain ? { brain: opts.brain } : {}), - ...(opts.driveHarness ? { driveHarness: opts.driveHarness } : {}), - ...(opts.extraTools ? { extraTools: opts.extraTools } : {}), - ...(opts.executeExtraTool ? { executeExtraTool: opts.executeExtraTool } : {}), + ...(options.coordination ? { coordination: options.coordination } : {}), + ...(options.maxLiveWorkers !== undefined ? { maxLiveWorkers: options.maxLiveWorkers } : {}), + ...(options.router ? { router: options.router } : {}), + ...(options.brain ? { brain: options.brain } : {}), + ...(rootDriveHarness ? { driveHarness: rootDriveHarness } : {}), + nodeContext: { + runId, + runNamespace, + ownerId: rootOwnerId, + depth: 0, + identity: rootExecution.identity, + }, + ...(options.resolveSupervisorTools + ? { resolveSupervisorTools: options.resolveSupervisorTools } + : {}), + ...(observeNodeEvent ? { observeNodeEvent, replaySettlements: true } : {}), + ...(options.extraTools ? { extraTools: options.extraTools } : {}), + ...(options.executeExtraTool ? { executeExtraTool: options.executeExtraTool } : {}), ...(analysts ? { analysts } : {}), - ...(opts.analyzeOnSettle ? { analyzeOnSettle: opts.analyzeOnSettle } : {}), - ...(opts.watchWorkers ? { watchWorkers: opts.watchWorkers } : {}), - ...(opts.stallAfterMs !== undefined ? { stallAfterMs: opts.stallAfterMs } : {}), - ...(opts.stopRule ? { stopRule: opts.stopRule } : {}), - ...(opts.onProgressStop ? { onProgressStop: opts.onProgressStop } : {}), - ...(opts.maxTurns !== undefined ? { maxTurns: opts.maxTurns } : {}), - ...(opts.compaction ? { compaction: opts.compaction } : {}), + ...(options.analyzeOnSettle ? { analyzeOnSettle: options.analyzeOnSettle } : {}), + ...(options.watchWorkers ? { watchWorkers: options.watchWorkers } : {}), + ...(options.stallAfterMs !== undefined ? { stallAfterMs: options.stallAfterMs } : {}), + ...(options.stopRule ? { stopRule: options.stopRule } : {}), + ...(options.onProgressStop ? { onProgressStop: options.onProgressStop } : {}), + ...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}), + ...(options.compaction ? { compaction: options.compaction } : {}), }) // Built ONLY when `otel` is configured AND an exporter resolves, so the default path allocates // nothing and passes no `hooks` at all — byte-for-byte the wiring every existing caller gets. - spans = opts.otel ? createSupervisorSpanRecorder({ runId, ...opts.otel, now }) : undefined + spans = options.otel ? createSupervisorSpanRecorder({ runId, ...options.otel, now }) : undefined const recorder = spans - const hooks = recorder ? composeRuntimeHooks(opts.hooks, recorder.hooks) : opts.hooks + const hooks = recorder ? composeRuntimeHooks(options.hooks, recorder.hooks) : options.hooks - const run = createSupervisor().run(agent, task, { - budget: opts.budget, + const supervisor = createSupervisor() + if (options.rootHandle) supervisor.attach(options.rootHandle) + const run = supervisor.run(agent, canonicalTask, { + budget: options.budget, runId, - journal: opts.journal ?? ctx.journal, + journal, blobs, executors: ctx.executors, - maxDepth: opts.maxDepth ?? 8, + rootIdentity: rootExecution.identity, + ...(rootOwnerRuntime === undefined + ? {} + : { + rootMaterialization: { + runtime: rootOwnerRuntime, + declaration: 'deferred' as const, + authoredProfile: canonicalProfile, + }, + }), + maxDepth: options.maxDepth ?? 8, + ...(options.maxLiveWorkers !== undefined ? { maxLiveWorkers: options.maxLiveWorkers } : {}), ...(probes ? { probes } : {}), ...(ctx.resume === true ? { resume: true } : {}), - ...(opts.now ? { now: opts.now } : {}), + ...(options.now ? { now: options.now } : {}), + ...(options.signal ? { signal: options.signal } : {}), ...(hooks ? { hooks } : {}), // Only a run that actually records spans hands trace context down to its workers; with no // recorder this key is absent and no spawned worker's environment is touched. diff --git a/src/runtime/supervise/supervisor-agent.ts b/src/runtime/supervise/supervisor-agent.ts index 175d0ae5..fd80d2f0 100644 --- a/src/runtime/supervise/supervisor-agent.ts +++ b/src/runtime/supervise/supervisor-agent.ts @@ -4,11 +4,13 @@ * no hand-built brain. The supervisor stops being special — it's one profile, materialized by the * same resolution rule as every other agent. * - * - `harness` null/undefined → the in-process router tool-loop: `driverAgent` over the + * - `harness` omitted or `cli-base` → the in-process router tool-loop: `driverAgent` over the * canonical `ToolLoopChat`, built by `routerBrain` from the profile's model + the router seam. - * - `harness` a coding CLI (`claude-code`/`opencode`/`codex`/…) → a SANDBOXED harness drives the + * - `harness` a coding CLI (`claude-code`/`opencode`/`codex`/…) → an EXTERNAL harness drives the * coordination verbs: `serveCoordinationMcp` exposes spawn/await/steer/stop over the live scope, - * and the caller's `driveHarness` runs the harness with that MCP mounted. The harness IS the brain. + * and the caller's `driveHarness` runs the harness with that MCP mounted. `supervise()` builds + * this automatically only for a local bridge; a remote sandbox needs an explicit reachable + * relay or tunnel. The harness IS the brain. * * Both arms spawn children through the SAME `makeWorkerAgent` seam and apply the SAME independent * deliverable check to direct submissions. Raw driver prose is never eligible. @@ -19,21 +21,28 @@ import type { AgentProfileResources, } from '@tangle-network/agent-interface' import { ConfigError, ValidationError } from '../../errors' +import type { McpToolDescriptor } from '../../mcp/server' import type { AnalystRegistry, + AuthorizeDownMessage, CoordinationEvent, MakeWorkerAgent, WorkerWatchOptions, } from '../../mcp/tools/coordination' +import { coordinationVerbNames } from '../../mcp/tools/coordination' import { type RouterConfig, routerBrain } from '../router-client' import type { ToolLoopChat, ToolLoopCompactionOptions } from '../tool-loop' import type { DeliverableSpec } from './completion-gate' import { driverAgent } from './coordination-driver' import type { PriorCoordination } from './coordination-log' import { isLoopbackHost, serveCoordinationMcp } from './coordination-mcp' +import type { BusRecord } from './event-bus' import { bestDelivered, runFinalizer, runTree, type SupervisorFinalizer } from './finalizer' +import { createInbox } from './inbox' +import { attestRuntimeOwnedScopeOwner, runtimeOwnedScopeOwnerRuntime } from './materialization' +import { detachedSnapshot } from './snapshot' import type { StopRule } from './stop-rules' -import type { Agent, Budget, ResultBlobStore, Scope } from './types' +import type { Agent, Budget, NodeExecutionIdentity, ResultBlobStore, Scope } from './types' /** The standing strategy a router-brained supervisor runs with when its profile names no * `systemPrompt`. The brain's competence IS this prompt: without it the brain has the coordination @@ -85,7 +94,8 @@ export const defaultSupervisorPrompt = [ */ export interface SupervisorProfile { readonly name?: string - /** null/undefined → router brain (in-process tool-loop); a coding-CLI harness → sandboxed brain. */ + /** null/undefined/`cli-base` → router brain (in-process tool-loop); a coding-CLI harness → an + * external harness brain. */ readonly harness?: string | null /** The router model when the brain is router-driven: a model id, or a canonical profile's model * hints whose `default` IS the id. Absent (including a hints object with no `default`) → the @@ -251,29 +261,94 @@ export function assertCoordinationBinding(binding: CoordinationBinding | undefin ) } -/** How to run a sandboxed harness as the DRIVER, with the coordination verbs mounted — the substrate - * seam the caller supplies (mirrors `makeWorkerAgent` for spawned children). It runs `profile` on - * `task` in its backend (sandbox / cli-bridge) with `coordinationMcpUrl` mounted as an MCP server, - * so the harness calls spawn_agent / await_event / stop as native tools over the live scope. */ -export type DriveHarness = (args: { - /** The caller's profile, EXACTLY as passed to `supervisorAgent` — never rewritten. A canonical - * `AgentProfile` stays schema-valid here (the canonical schema rejects unknown top-level keys, - * so hoisting a resolved prompt onto it would make a profile its own validator refuses). */ +/** Trusted run/node identity Runtime binds to one manager. Model-authored tool arguments cannot + * provide or replace any of these fields. */ +export interface SupervisorNodeContext { + readonly runId: string + /** Stable across a durable restart; unique per in-memory invocation. */ + readonly runNamespace: string + /** Concrete Scope node that owns this manager's coordination stream. */ + readonly nodeId: string + /** Stable identity of this manager's coordination stream. */ + readonly ownerId: string + readonly depth: number + readonly identity: NodeExecutionIdentity + /** Assignment identity within the parent manager; absent only for the root. */ + readonly assignmentId?: string readonly profile: SupervisorProfile - /** The standing instruction assembled from the profile: its system prompt in either spelling, - * plus the `prompt.instructions` and `resources.instructions` lines. Absent when the profile - * names none — the harness's own default then applies. This, not `profile.systemPrompt`, is what - * the harness should run under. */ - readonly systemPrompt?: string readonly task: unknown - readonly scope: Scope - readonly coordinationMcpUrl: string -}) => Promise +} + +/** Context known before `Agent.act`; Runtime adds the concrete node, profile, and task. */ +export type SupervisorNodeContextSeed = Omit + +/** Trusted context for one product-tool invocation. The node identity remains the same detached, + * immutable snapshot supplied to the resolver; `signal` is the one live control reference Runtime + * adds. It aborts when this manager's scope is cancelled by the caller, RootHandle, deadline, + * breaker, or a recursive parent. */ +export interface SupervisorToolInvocationContext extends SupervisorNodeContext { + readonly signal: AbortSignal +} + +/** One product-owned tool. It reuses the canonical MCP descriptor fields while Runtime supplies + * the trusted invocation context as a separate argument and binds the result for either + * transport. Existing handlers remain compatible: the second argument only gains `signal`. */ +export interface SupervisorToolDescriptor extends Omit { + readonly handler: (raw: unknown, context: SupervisorToolInvocationContext) => Promise +} + +/** Product policy for the tools one exact supervisor node may call. Resolved once per node. */ +export type ResolveSupervisorTools = ( + context: SupervisorNodeContext, +) => ReadonlyArray | Promise> + +/** Context-aware observer used internally to bind product transactions to the actual live node. */ +export type ObserveSupervisorNodeEvent = ( + context: SupervisorNodeContext, + event: CoordinationEvent, + record: BusRecord, +) => void | Promise + +/** How to run an external harness as the DRIVER, with the coordination verbs mounted — the substrate + * seam the caller supplies (mirrors `makeWorkerAgent` for spawned children). It runs `profile` on + * `task` in its backend (remote sandbox or local CLI bridge) with `coordinationMcpUrl` mounted as an MCP server, + * so the harness calls spawn_agent / await_event / stop as native tools over the live scope. */ +export interface DriveHarness { + (args: { + /** The caller's profile, EXACTLY as passed to `supervisorAgent` — never rewritten. A canonical + * `AgentProfile` stays schema-valid here (the canonical schema rejects unknown top-level keys, + * so hoisting a resolved prompt onto it would make a profile its own validator refuses). */ + readonly profile: SupervisorProfile + /** The standing instruction assembled from the profile: its system prompt in either spelling, + * plus the `prompt.instructions` and `resources.instructions` lines. Absent when the profile + * names none — the harness's own default then applies. This, not `profile.systemPrompt`, is + * what the harness should run under. */ + readonly systemPrompt?: string + readonly task: unknown + readonly scope: Scope + readonly coordinationMcpUrl: string + /** Data-only product tool surface mounted on the coordination MCP. Runtime-owned drivers include + * this in their materialization evidence without persisting executable handlers. */ + readonly coordinationTools: ReadonlyArray> + }): Promise + /** Optional live inbox for the manager session this adapter currently drives. Return `false` + * when no executor inbox is active instead of claiming a message was delivered. */ + deliver?(message: unknown): boolean +} + +/** Trusted manager identity available before its external harness starts. A product uses this to + * return one independently steerable harness session per recursive manager. */ +export type DriveHarnessOwnerContext = Omit + +/** Resolve an external harness for one exact Runtime-owned manager identity. */ +export type ResolveDriveHarness = (context: DriveHarnessOwnerContext) => DriveHarness export interface SupervisorAgentDeps { readonly blobs: ResultBlobStore /** Resolve a spawned worker `profile` to a leaf agent — the recursion seam (same for both arms). */ readonly makeWorkerAgent: MakeWorkerAgent + /** Product authorization for every down-leg continuation to a child. */ + readonly authorizeDownMessage?: AuthorizeDownMessage /** Per-child budget reserved from the conserved pool on each spawn. */ readonly perWorker: Budget /** Independent completion check for direct driver work (`submit_result`). */ @@ -282,12 +357,22 @@ export interface SupervisorAgentDeps { * this many are in flight (a concurrency fence on top of the conserved-pool fence; bounds live * boxes/sandboxes, not total work). Omit/`<= 0` = no cap. */ readonly maxLiveWorkers?: number - /** Router substrate for a router-brained supervisor (`harness` null). The profile's model wins. */ + /** Router substrate for a router-brained supervisor (`harness` omitted or `cli-base`). The + * profile's model wins. */ readonly router?: RouterConfig /** Inject the brain directly (tests / advanced) instead of resolving `routerBrain` from the profile. */ readonly brain?: ToolLoopChat - /** Required for a sandboxed-harness supervisor (`harness` set): runs the harness as the driver. */ + /** Required to run an external-harness supervisor: runs the harness as the driver. */ readonly driveHarness?: DriveHarness + /** Trusted identity for this manager. Required with node-scoped tools or observation. */ + readonly nodeContext?: SupervisorNodeContextSeed + /** Resolve product-owned tools for this exact manager. Static `extraTools` remain a router-only + * compatibility seam and deliberately receive no new recursive authority. */ + readonly resolveSupervisorTools?: ResolveSupervisorTools + /** Awaited product observation, enriched with this manager's actual live node context. */ + readonly observeNodeEvent?: ObserveSupervisorNodeEvent + /** Replay resume-time settlements through `observeNodeEvent` before the manager starts. */ + readonly replaySettlements?: boolean /** WORK tools the supervisor may call DIRECTLY (router arm) — so it can do simple work ITSELF and * only delegate when it needs parallelism. Pair with `executeExtraTool`. */ readonly extraTools?: ReadonlyArray<{ @@ -324,14 +409,22 @@ export interface SupervisorAgentDeps { readonly compaction?: ToolLoopCompactionOptions /** Pass-through subscriber for every coordination bus event (both arms) — the seam a durable * caller hooks its coordination log onto. */ - readonly onEvent?: (event: CoordinationEvent) => void | Promise - /** Questions + findings replayed from a prior process of this run (a durable coordination log). - * Router arm: seeds the question ledger + the resume brief. Sandbox arm: seeds the ledger. */ + readonly onEvent?: ( + event: CoordinationEvent, + record: BusRecord, + ) => void | Promise + /** Questions, findings, and authorized continuation receipts loaded from a prior process. + * Router arm: questions seed the ledger and all evidence enters the resume brief. External arm: + * questions seed the ledger; receipts remain durable evidence and are never auto-delivered. */ readonly priorCoordination?: PriorCoordination + /** Deferred owner-scoped replay for a recursive supervisor. Its stable owner is known while the + * parent authorizes the child, but loading remains asynchronous; Runtime calls this before the + * nested brain can publish or act on coordination state. */ + readonly loadPriorCoordination?: () => Promise /** How the settled ledger becomes the run's output (both arms). Default `bestDelivered` — the * exact keep-best every existing caller had. Always runs under the delivered-only invariant. */ readonly finalizer?: SupervisorFinalizer - /** Where the coordination MCP binds (sandbox arm). Omit = an ephemeral loopback port, which is + /** Where the coordination MCP binds (external arm). Omit = an ephemeral loopback port, which is * unreachable from an off-host harness. A non-loopback host fails closed — see * {@link assertCoordinationBinding}. */ readonly coordination?: CoordinationBinding @@ -342,13 +435,30 @@ export function supervisorAgent( profile: SupervisorProfile, deps: SupervisorAgentDeps, ): Agent { - const name = profile.name ?? 'supervisor' - const harness = profile.harness ?? null + const stableProfile = detachedSnapshot(profile, 'supervisorAgent profile') + const resolveTools = deps.resolveSupervisorTools + const observeNodeEvent = deps.observeNodeEvent + const nodeContextSeed = + deps.nodeContext === undefined + ? undefined + : detachedSnapshot(deps.nodeContext, 'supervisorAgent node context') + if ((resolveTools || observeNodeEvent) && !nodeContextSeed) { + throw new ValidationError( + 'supervisorAgent: nodeContext is required with resolveSupervisorTools or observeNodeEvent', + ) + } + const name = stableProfile.name ?? 'supervisor' + const harness = + stableProfile.harness === undefined || + stableProfile.harness === null || + stableProfile.harness === 'cli-base' + ? null + : stableProfile.harness // The prompt is consumed by BOTH arms, so it resolves here; the model id is router-arm-only and // resolves inside that arm, so a harness supervisor never touches a field it does not use. // No fallback at this site: the harness supplies its own standing prompt, and the router arm // re-resolves against its default below so instruction lines append to that default. - const profilePrompt = resolveSupervisorSystemPrompt(profile) + const profilePrompt = resolveSupervisorSystemPrompt(stableProfile) // Bind safety is a BUILD-time fault, not a run-time one: it must throw before any compute, on the // same synchronous path as the other configuration guards. The binding is SNAPSHOT here and the @@ -369,56 +479,108 @@ export function supervisorAgent( if (harness !== null && deps.compaction) { throw new ValidationError( - 'supervisorAgent: compaction is only supported for router-brained supervisors (profile.harness null)', + 'supervisorAgent: compaction is only supported for router-brained supervisors (profile.harness omitted or cli-base)', ) } if (harness === null) { // ROUTER arm: the in-process tool-loop. `routerBrain` is now an internal detail — the caller // passes a profile, not a hand-built brain (a test may still inject `deps.brain`). - const brain = deps.brain ?? routerBrainFromProfile(profile, deps) - return driverAgent({ + const brain = deps.brain ?? routerBrainFromProfile(stableProfile, deps) + const inbox = createInbox() + const build = ( + priorCoordination?: PriorCoordination, + nodeTools?: ReadonlyArray, + onEvent?: SupervisorAgentDeps['onEvent'], + ) => + driverAgent({ + name, + brain, + blobs: deps.blobs, + makeWorkerAgent: deps.makeWorkerAgent, + ...(deps.authorizeDownMessage ? { authorizeDownMessage: deps.authorizeDownMessage } : {}), + perWorker: deps.perWorker, + // Resolved against the router's own default, so a profile naming only instruction + // lines appends them to that default instead of replacing it. + systemPrompt: + resolveSupervisorSystemPrompt(stableProfile, defaultSupervisorPrompt) ?? + defaultSupervisorPrompt, + ...(deps.deliverable ? { deliverable: deps.deliverable } : {}), + ...(nodeTools?.length ? { nodeTools } : {}), + ...(deps.maxLiveWorkers !== undefined ? { maxLiveWorkers: deps.maxLiveWorkers } : {}), + ...(deps.extraTools ? { extraTools: deps.extraTools } : {}), + ...(deps.executeExtraTool ? { executeExtraTool: deps.executeExtraTool } : {}), + ...(deps.analysts ? { analysts: deps.analysts } : {}), + ...(deps.analyzeOnSettle ? { analyzeOnSettle: deps.analyzeOnSettle } : {}), + ...(deps.watchWorkers ? { watchWorkers: deps.watchWorkers } : {}), + ...(deps.stallAfterMs !== undefined ? { stallAfterMs: deps.stallAfterMs } : {}), + ...(deps.stopRule ? { stopRule: deps.stopRule } : {}), + ...(deps.onProgressStop ? { onProgressStop: deps.onProgressStop } : {}), + ...(deps.maxTurns !== undefined ? { maxTurns: deps.maxTurns } : {}), + ...(deps.compaction ? { compaction: deps.compaction } : {}), + ...(onEvent ? { onEvent } : {}), + ...(deps.replaySettlements ? { replaySettlements: true } : {}), + ...(priorCoordination ? { priorCoordination } : {}), + ...(deps.finalizer ? { finalizer: deps.finalizer } : {}), + inbox, + }) + if (!deps.loadPriorCoordination && !resolveTools && !observeNodeEvent) { + return build(deps.priorCoordination, undefined, deps.onEvent) + } + return { name, - brain, - blobs: deps.blobs, - makeWorkerAgent: deps.makeWorkerAgent, - perWorker: deps.perWorker, - // Resolved against the router's own default, so a profile naming only instruction - // lines appends them to that default instead of replacing it. - systemPrompt: - resolveSupervisorSystemPrompt(profile, defaultSupervisorPrompt) ?? defaultSupervisorPrompt, - ...(deps.deliverable ? { deliverable: deps.deliverable } : {}), - ...(deps.maxLiveWorkers !== undefined ? { maxLiveWorkers: deps.maxLiveWorkers } : {}), - ...(deps.extraTools ? { extraTools: deps.extraTools } : {}), - ...(deps.executeExtraTool ? { executeExtraTool: deps.executeExtraTool } : {}), - ...(deps.analysts ? { analysts: deps.analysts } : {}), - ...(deps.analyzeOnSettle ? { analyzeOnSettle: deps.analyzeOnSettle } : {}), - ...(deps.watchWorkers ? { watchWorkers: deps.watchWorkers } : {}), - ...(deps.stallAfterMs !== undefined ? { stallAfterMs: deps.stallAfterMs } : {}), - ...(deps.stopRule ? { stopRule: deps.stopRule } : {}), - ...(deps.onProgressStop ? { onProgressStop: deps.onProgressStop } : {}), - ...(deps.maxTurns !== undefined ? { maxTurns: deps.maxTurns } : {}), - ...(deps.compaction ? { compaction: deps.compaction } : {}), - ...(deps.onEvent ? { onEvent: deps.onEvent } : {}), - ...(deps.priorCoordination ? { priorCoordination: deps.priorCoordination } : {}), - ...(deps.finalizer ? { finalizer: deps.finalizer } : {}), - }) + deliver(message): boolean { + return inbox.deliver(message) + }, + async act(task, scope) { + const context = nodeContextSeed + ? supervisorNodeContext(nodeContextSeed, stableProfile, task, scope) + : undefined + const priorCoordination = await deps.loadPriorCoordination?.() + const nodeTools = + resolveTools && context + ? await bindSupervisorTools(resolveTools, context, scope.signal) + : undefined + const onEvent = bindSupervisorNodeObserver(context, observeNodeEvent, deps.onEvent) + return build(priorCoordination, nodeTools, onEvent).act(task, scope) + }, + } } - // SANDBOX arm: a sandboxed harness drives the coordination verbs over the live scope. + // EXTERNAL arm: a caller-driven harness uses the coordination verbs over the live scope. const driveHarness = deps.driveHarness if (!driveHarness) { throw new ValidationError( `supervisorAgent: profile.harness="${harness}" needs deps.driveHarness (how to run the harness with the coordination MCP mounted)`, ) } - return { + const deliver = driveHarness.deliver?.bind(driveHarness) + const externalAgent: Agent = { name, + ...(deliver + ? { + deliver(message: unknown): boolean { + return deliver(message) + }, + } + : {}), async act(task, scope) { + const context = nodeContextSeed + ? supervisorNodeContext(nodeContextSeed, stableProfile, task, scope) + : undefined + const priorCoordination = deps.loadPriorCoordination + ? await deps.loadPriorCoordination() + : deps.priorCoordination + const nodeTools = + resolveTools && context + ? await bindSupervisorTools(resolveTools, context, scope.signal) + : undefined + const onEvent = bindSupervisorNodeObserver(context, observeNodeEvent, deps.onEvent) const mcp = await serveCoordinationMcp({ scope, blobs: deps.blobs, makeWorkerAgent: deps.makeWorkerAgent, + ...(deps.authorizeDownMessage ? { authorizeDownMessage: deps.authorizeDownMessage } : {}), perWorker: deps.perWorker, ...(coordination?.host !== undefined ? { host: coordination.host } : {}), ...(coordination?.port !== undefined ? { port: coordination.port } : {}), @@ -434,19 +596,26 @@ export function supervisorAgent( ...(deps.analyzeOnSettle ? { analyzeOnSettle: deps.analyzeOnSettle } : {}), ...(deps.watchWorkers ? { watchWorkers: deps.watchWorkers } : {}), ...(deps.stallAfterMs !== undefined ? { stallAfterMs: deps.stallAfterMs } : {}), - ...(deps.onEvent ? { onEvent: deps.onEvent } : {}), - ...(deps.priorCoordination?.questions.length - ? { priorQuestions: deps.priorCoordination.questions } + ...(onEvent ? { onEvent } : {}), + ...(deps.replaySettlements ? { replaySettlements: true } : {}), + ...(priorCoordination?.questions.length + ? { priorQuestions: priorCoordination.questions } : {}), + ...(nodeTools?.length ? { nodeTools } : {}), }) try { try { await driveHarness({ - profile, + profile: stableProfile, ...(profilePrompt !== undefined ? { systemPrompt: profilePrompt } : {}), task, scope, coordinationMcpUrl: mcp.url, + coordinationTools: (nodeTools ?? []).map(({ name, description, inputSchema }) => ({ + name, + description, + inputSchema, + })), }) } catch (error) { // Once the injected check has accepted a result, a later backend shutdown/timeout cannot @@ -460,6 +629,8 @@ export function supervisorAgent( // check passes. Raw harness prose remains ineligible. const submitted = mcp.submittedResult() if (submitted) return submitted.result + // The deliverable comes from the finalizer seam over DELIVERED children only — never the + // harness's own output (Foreman 0/18). Default keep-best. return await runFinalizer(deps.finalizer ?? bestDelivered, { settled: mcp.settled(), blobs: deps.blobs, @@ -471,6 +642,97 @@ export function supervisorAgent( } }, } + const runtime = runtimeOwnedScopeOwnerRuntime(driveHarness) + return runtime === undefined + ? externalAgent + : attestRuntimeOwnedScopeOwner(externalAgent, runtime) +} + +function supervisorNodeContext( + seed: SupervisorNodeContextSeed, + profile: SupervisorProfile, + task: unknown, + scope: Scope, +): SupervisorNodeContext { + return detachedSnapshot( + { ...seed, nodeId: scope.view.root, profile, task }, + 'supervisorAgent trusted node context', + ) +} + +async function bindSupervisorTools( + resolveTools: ResolveSupervisorTools, + context: SupervisorNodeContext, + signal: AbortSignal, +): Promise> { + const resolved = await resolveTools(context) + if (!Array.isArray(resolved)) { + throw new ValidationError('supervisorAgent: resolveSupervisorTools must return an array') + } + // Keep the durable node snapshot free of live process objects. The invocation wrapper is frozen + // shallowly so handlers cannot replace identity or signal, while the AbortSignal itself remains + // live and follows the manager scope's existing root/parent cascade. + const invocationContext: SupervisorToolInvocationContext = Object.freeze({ ...context, signal }) + const names = new Set(coordinationVerbNames) + return Object.freeze( + resolved.map((rawTool, index) => { + if (typeof rawTool !== 'object' || rawTool === null || Array.isArray(rawTool)) { + throw new ValidationError( + `supervisorAgent: resolved tool at index ${index} must be a descriptor`, + ) + } + const { name, description, inputSchema, handler } = rawTool + if (typeof name !== 'string' || name.length === 0) { + throw new ValidationError( + `supervisorAgent: resolved tool at index ${index} needs a non-empty name`, + ) + } + if (names.has(name)) { + throw new ValidationError( + `supervisorAgent: resolved tool "${name}" collides with a coordination verb or another resolved tool`, + ) + } + names.add(name) + if (typeof description !== 'string' || description.length === 0) { + throw new ValidationError(`supervisorAgent: resolved tool "${name}" needs a description`) + } + if (typeof inputSchema !== 'object' || inputSchema === null || Array.isArray(inputSchema)) { + throw new ValidationError(`supervisorAgent: resolved tool "${name}" needs an inputSchema`) + } + if (typeof handler !== 'function') { + throw new ValidationError(`supervisorAgent: resolved tool "${name}" needs a handler`) + } + const descriptor = detachedSnapshot( + { name, description, inputSchema }, + `supervisorAgent resolved tool ${JSON.stringify(name)}`, + ) + return Object.freeze({ + ...descriptor, + handler: (raw: unknown) => + handler( + detachedSnapshot(raw, `supervisorAgent tool ${JSON.stringify(name)} input`), + invocationContext, + ), + }) + }), + ) +} + +function bindSupervisorNodeObserver( + context: SupervisorNodeContext | undefined, + observeNodeEvent: ObserveSupervisorNodeEvent | undefined, + onEvent: SupervisorAgentDeps['onEvent'], +): SupervisorAgentDeps['onEvent'] { + if (!observeNodeEvent && !onEvent) return undefined + return async (event, record) => { + if (observeNodeEvent) { + if (!context) { + throw new ValidationError('supervisorAgent: observeNodeEvent has no trusted node context') + } + await observeNodeEvent(context, event, record) + } + await onEvent?.(event, record) + } } function routerBrainFromProfile( @@ -479,7 +741,7 @@ function routerBrainFromProfile( ): ToolLoopChat { if (!deps.router) { throw new ValidationError( - 'supervisorAgent: a router-brained supervisor (harness null) needs deps.router (or deps.brain)', + 'supervisorAgent: a router-brained supervisor (harness omitted or cli-base) needs deps.router (or deps.brain)', ) } // The model id is resolved HERE, the one place it is consumed. `model.reasoningEffort` is not diff --git a/src/runtime/supervise/supervisor.ts b/src/runtime/supervise/supervisor.ts index 18e2275f..38f9ad0a 100644 --- a/src/runtime/supervise/supervisor.ts +++ b/src/runtime/supervise/supervisor.ts @@ -34,6 +34,7 @@ * @experimental */ +import { sha256DigestSchema } from '@tangle-network/agent-interface' import { contentAddress, materializeTreeView, @@ -42,11 +43,23 @@ import { } from '../../durable/spawn-journal' import { RuntimeRunStateError } from '../../errors' import { type BudgetPool, createBudgetPool } from './budget' +import { armDeadlineTimer } from './deadline' import { runTree } from './finalizer' -import { createScope } from './scope' +import { + knownExecutionBindingReceipt, + knownMaterializationReceipt, + newExecutionAttemptId, + unknownExecutionBindingReceipt, + unknownMaterializationReceipt, +} from './materialization' +import { createScope, finalizeScopeOwnerMaterialization } from './scope' +import { detachedSnapshot } from './snapshot' import type { Agent, + Budget, + ExecutionBindingReceipt, NoWinnerError, + ProfileMaterializationReceipt, ResumedKeyState, RootHandle, RootSignal, @@ -55,6 +68,7 @@ import type { SpawnEvent, SpawnJournal, Spend, + SteerableRootHandle, SupervisedResult, Supervisor, SupervisorOpts, @@ -97,14 +111,20 @@ function keyedAssignments( .sort((a, b) => a.seq - b.seq) for (const ev of spawns) { if (ev.key === undefined) continue + if (ev.identity === undefined) { + throw new RuntimeRunStateError( + `supervisor: keyed node '${ev.id}' has no durable execution identity`, + ) + } const s = byId.get(ev.id) keys.set( ev.key, s === undefined - ? { id: ev.id, label: ev.label, state: 'in-doubt' } + ? { id: ev.id, label: ev.label, identity: ev.identity, state: 'in-doubt' } : { id: ev.id, label: ev.label, + identity: ev.identity, state: s.kind === 'done' ? 'completed' : 'down', settled: s, }, @@ -113,6 +133,186 @@ function keyedAssignments( return keys } +type SpawnedEvent = Extract + +/** A resumed process may continue only the exact root contract first recorded for this run id. */ +function assertResumeContract(events: SpawnEvent[], opts: SupervisorOpts): SpawnedEvent { + const roots = events.filter( + (event): event is SpawnedEvent => + event.kind === 'spawned' && event.id === opts.runId && event.parent === undefined, + ) + if (roots.length !== 1) { + throw new RuntimeRunStateError( + `supervisor: resumed run '${opts.runId}' must contain exactly one root identity event; found ${roots.length}`, + ) + } + const recorded = roots[0]! + if (contentAddress(recorded.budget) !== contentAddress(opts.budget)) { + throw new RuntimeRunStateError( + `supervisor: resume budget mismatch for run '${opts.runId}'; use a new runId to change limits`, + ) + } + if (!sameOptionalIdentity(recorded.identity, opts.rootIdentity)) { + throw new RuntimeRunStateError( + `supervisor: resume identity mismatch for run '${opts.runId}'; task, profile, candidate, and correlation must match`, + ) + } + const receipts = events.filter( + (event): event is Extract => + event.kind === 'materialized' && event.id === opts.runId, + ) + const expectedReceipt = rootMaterializationReceipt(opts) + if (expectedReceipt === undefined) { + if (receipts.length > 1) { + throw new RuntimeRunStateError( + `supervisor: resumed run '${opts.runId}' contains duplicate root materialization evidence`, + ) + } + } else if ( + receipts.length !== 1 || + contentAddress(receipts[0]?.receipt) !== contentAddress(expectedReceipt) + ) { + throw new RuntimeRunStateError( + `supervisor: resume materialization mismatch for run '${opts.runId}'; backend, model, execution identity, and plan must match`, + ) + } + return recorded +} + +/** Mint root evidence at the trusted composition boundary. A generic in-process Agent root has no + * executor declaration and remains explicitly unknown rather than inheriting fabricated details. */ +function rootMaterializationReceipt( + opts: SupervisorOpts, +): ProfileMaterializationReceipt | undefined { + const declaration = opts.rootMaterialization + if (declaration === undefined) { + return unknownMaterializationReceipt({ + ...(opts.rootIdentity?.profileDigest === undefined + ? {} + : { authoredProfileDigest: opts.rootIdentity.profileDigest }), + runtime: 'inline', + reason: 'root-agent-did-not-report', + }) + } + if (declaration.declaration === 'deferred') return undefined + if (opts.rootIdentity?.profileDigest === undefined) { + throw new RuntimeRunStateError( + `supervisor: run '${opts.runId}' cannot record known root materialization without an exact profileDigest`, + ) + } + try { + return knownMaterializationReceipt({ + authoredProfileDigest: opts.rootIdentity.profileDigest, + runtime: declaration.runtime, + declaration: declaration.declaration, + }) + } catch (error) { + throw new RuntimeRunStateError( + `supervisor: run '${opts.runId}' has invalid root materialization evidence`, + { cause: error }, + ) + } +} + +function rootExecutionBindingReceipt( + opts: SupervisorOpts, + materialization: ProfileMaterializationReceipt, + attemptId: string, +): ExecutionBindingReceipt | undefined { + const root = opts.rootMaterialization + if (root?.declaration === 'deferred') return undefined + if (root === undefined) { + return unknownExecutionBindingReceipt(materialization, attemptId, 'root-agent-did-not-report') + } + try { + return knownExecutionBindingReceipt(materialization, { + attemptId, + ...root.binding, + }) + } catch (error) { + throw new RuntimeRunStateError( + `supervisor: run '${opts.runId}' has invalid root execution binding`, + { cause: error }, + ) + } +} + +function sameOptionalIdentity( + recorded: SpawnedEvent['identity'], + requested: SpawnedEvent['identity'], +): boolean { + if (recorded === undefined || requested === undefined) return recorded === requested + return contentAddress(recorded) === contentAddress(requested) +} + +/** Validate caller-supplied root identity before any journal access. Fresh runs may omit identity, + * but once supplied it is trusted durable evidence and therefore must be complete and canonical; + * resumed runs always require it so the prior root can be matched exactly. */ +function assertRootIdentity(opts: SupervisorOpts): void { + const identity = opts.rootIdentity + if (identity === undefined) { + if (opts.resume !== true) return + throw new RuntimeRunStateError( + `supervisor: resumed run '${opts.runId}' requires an exact rootIdentity with profileDigest and taskDigest`, + ) + } + const unknownFields = Object.keys(identity).filter( + (key) => + key !== 'profileDigest' && + key !== 'taskDigest' && + key !== 'candidateDigest' && + key !== 'correlation', + ) + const correlation = identity.correlation + const validCorrelation = + correlation === undefined || + (typeof correlation === 'object' && + correlation !== null && + !Array.isArray(correlation) && + Object.entries(correlation).every( + ([key, value]) => key.length > 0 && typeof value === 'string' && value.length > 0, + )) + if ( + unknownFields.length > 0 || + !sha256DigestSchema.safeParse(identity.profileDigest).success || + !sha256DigestSchema.safeParse(identity.taskDigest).success || + (identity.candidateDigest !== undefined && + !sha256DigestSchema.safeParse(identity.candidateDigest).success) || + !validCorrelation + ) { + throw new RuntimeRunStateError( + `supervisor: run '${opts.runId}' requires an exact rootIdentity with valid profileDigest, taskDigest, candidateDigest, and correlation`, + ) + } +} + +function rootDeadline(root: SpawnedEvent): number { + const startedAt = Date.parse(root.at) + if (!Number.isFinite(startedAt)) { + throw new RuntimeRunStateError( + `supervisor: root event for '${root.id}' has an invalid timestamp '${root.at}'`, + ) + } + return startedAt + (root.budget.deadlineMs ?? 0) +} + +/** Child reservations whose spawn was durable but whose terminal record never landed. */ +function uncertainSpawnBudgets(events: SpawnEvent[]): Budget[] { + const terminal = new Set( + events + .filter( + (event) => event.kind === 'settled' || event.kind === 'cancelled' || event.kind === 'woken', + ) + .map((event) => event.id), + ) + return events + .filter( + (event): event is SpawnedEvent => + event.kind === 'spawned' && event.parent !== undefined && !terminal.has(event.id), + ) + .map((event) => event.budget) +} + /** Highest `seq` among events matching `pred`, or `-1` when none match (so a resumed scope's * first new ordinal/seq is 0 — the same start a fresh scope uses). */ function maxSeqOf(events: SpawnEvent[], pred: (ev: SpawnEvent) => boolean): number { @@ -172,199 +372,378 @@ export function createSupervisor(): Supervisor { task: Task, opts: SupervisorOpts, ): Promise> { + // Read every caller-owned field once, then detach and freeze all decision data together before + // the first await. Stateful runtime collaborators stay live by reference, but replacing a field + // on the caller's opts object after intake cannot redirect this run. + const { + budget, + rootIdentity, + rootMaterialization, + runId, + journal: journalStore, + blobs: blobStore, + executors: executorRegistry, + probes, + maxDepth, + maxLiveWorkers, + maxRestarts, + withinMs, + resume, + now: suppliedNow, + signal, + hooks, + workerTrace, + } = opts + const input = detachedSnapshot( + { + task, + options: { + budget, + runId, + ...(rootIdentity === undefined ? {} : { rootIdentity }), + ...(rootMaterialization === undefined ? {} : { rootMaterialization }), + ...(maxDepth === undefined ? {} : { maxDepth }), + ...(maxLiveWorkers === undefined ? {} : { maxLiveWorkers }), + ...(maxRestarts === undefined ? {} : { maxRestarts }), + ...(withinMs === undefined ? {} : { withinMs }), + ...(resume === undefined ? {} : { resume }), + }, + }, + 'supervisor.run', + ) + opts = Object.freeze({ + ...input.options, + journal: journalStore, + blobs: blobStore, + executors: executorRegistry, + ...(probes === undefined ? {} : { probes }), + ...(suppliedNow === undefined ? {} : { now: suppliedNow }), + ...(signal === undefined ? {} : { signal }), + ...(hooks === undefined ? {} : { hooks }), + ...(workerTrace === undefined ? {} : { workerTrace }), + }) + task = input.task + const rootAct = root.act.bind(root) + const rootDeliver = root.deliver?.bind(root) const now = opts.now ?? Date.now - const pool = createBudgetPool(opts.budget, now) - - // RESUME-FIRST (opt-in via `opts.resume`): read any prior journal tree for this runId BEFORE - // beginning a fresh one. A non-empty tree means a prior run for this runId already committed - // work (it crashed mid-flight, or this is an explicit resume), so rehydrate it instead of - // starting over. An empty/absent tree — and every run that did NOT opt in — takes the fresh - // path unchanged. This wires the already-built+tested resume primitives - // (`replaySpawnTree`/`materializeTreeView`); the journal/blob store decide durability. - const prior = opts.resume === true ? await opts.journal.loadTree(opts.runId) : undefined - const resuming = prior !== undefined && prior.length > 0 - let resumeFrom: ResumeFrom | undefined - if (resuming) { - // Rehydrate the committed work: the cursor-ordered `Settled[]` (from the blob store) plus the - // tree as it stood at the recorded cursor position. The new scope's ordinal/cursor counters - // continue past the recorded maxima so a fresh spawn never reuses a journaled `seq`. - const settled = await replaySpawnTree(opts.journal, opts.blobs, opts.runId) - const view = materializeTreeView(prior) - resumeFrom = { - settled, - view, - maxSpawnOrdinal: maxSeqOf(prior, (ev) => ev.kind === 'spawned'), - maxCursorSeq: maxSeqOf( - prior, - (ev) => ev.kind === 'settled' || ev.kind === 'cancelled' || ev.kind === 'woken', - ), - maxWaitOrdinal: maxSeqOf(prior, (ev) => ev.kind === 'waiting'), - // Waits armed but never woken: the run died mid-wait. They ride onto `Scope.resume.waits`, - // and re-arming the same label adopts the ORIGINAL absolute deadline rather than - // restarting the countdown from this process's clock. - waits: pendingWaits(prior), - // Keyed assignments + prior committed spend ride onto `Scope.resume`, so a resume-aware - // driver resolves keys instead of re-spawning and reports what the run already paid. - keys: keyedAssignments(prior, settled), - priorSpend: sumSpendFromEvents(prior), + assertRootIdentity(opts) + // Reserve the attached control synchronously, before the first journal read or write. A handle + // is one live route, so two concurrent runs may never race to overwrite that route and later + // detach each other. The lease is released on every early failure as well as normal teardown. + const rootLease = attached?.acquire() + try { + const rootAttemptId = newExecutionAttemptId(opts.runId) + // One instant owns both the fresh pool's absolute deadline and its durable root records. Capture + // it before any asynchronous journal work so a slow begin cannot extend the run on restart. + const runStartedAtMs = now() + const runStartedAt = new Date(runStartedAtMs).toISOString() + + // RESUME-FIRST (opt-in via `opts.resume`): read any prior journal tree for this runId BEFORE + // beginning a fresh one. A non-empty tree means a prior run for this runId already committed + // work (it crashed mid-flight, or this is an explicit resume), so rehydrate it instead of + // starting over. An empty/absent tree — and every run that did NOT opt in — takes the fresh + // path unchanged. This wires the already-built+tested resume primitives + // (`replaySpawnTree`/`materializeTreeView`); the journal/blob store decide durability. + const existing = await opts.journal.loadTree(opts.runId) + if (opts.resume !== true && existing !== undefined) { + throw new RuntimeRunStateError( + `supervisor: runId '${opts.runId}' already exists; pass resume: true to continue it or use a new runId`, + ) + } + const prior = opts.resume === true ? existing : undefined + const resuming = prior !== undefined && prior.length > 0 + let resumeFrom: ResumeFrom | undefined + let pool: BudgetPool + if (resuming) { + const rootEvent = assertResumeContract(prior, opts) + const measured = sumMeasuredSpendFromEvents(prior) + const uncertainReservations = uncertainSpawnBudgets(prior) + pool = createBudgetPool(opts.budget, now, { + committed: addSpend(measured.childWork, measured.driverInference), + uncertainReservations, + ...(rootEvent.budget.deadlineMs !== undefined + ? { absoluteDeadlineMs: rootDeadline(rootEvent) } + : {}), + }) + // Rehydrate the committed work: the cursor-ordered `Settled[]` (from the blob store) plus the + // tree as it stood at the recorded cursor position. The new scope's ordinal/cursor counters + // continue past the recorded maxima so a fresh spawn never reuses a journaled `seq`. + const settled = await replaySpawnTree(opts.journal, opts.blobs, opts.runId) + const view = materializeTreeView(prior) + resumeFrom = { + settled, + view, + maxSpawnOrdinal: maxSeqOf(prior, (ev) => ev.kind === 'spawned'), + maxCursorSeq: maxSeqOf( + prior, + (ev) => ev.kind === 'settled' || ev.kind === 'cancelled' || ev.kind === 'woken', + ), + maxWaitOrdinal: maxSeqOf(prior, (ev) => ev.kind === 'waiting'), + // Waits armed but never woken: the run died mid-wait. They ride onto `Scope.resume.waits`, + // and re-arming the same label adopts the ORIGINAL absolute deadline rather than + // restarting the countdown from this process's clock. + waits: pendingWaits(prior), + // Keyed assignments + prior committed spend ride onto `Scope.resume`, so a resume-aware + // driver resolves keys instead of re-spawning and reports what the run already paid. + keys: keyedAssignments(prior, settled), + priorSpend: sumSpendFromEvents(prior), + } + } else { + pool = createBudgetPool(opts.budget, now, { + ...(opts.budget.deadlineMs !== undefined + ? { absoluteDeadlineMs: runStartedAtMs + opts.budget.deadlineMs } + : {}), + }) + // Fresh run: begin the tree and journal the root as its own `spawned` node (parent-less, the + // spawn-ordinal-0 marker), so a journal-based reader — `trajectoryReport`, `replaySpawnTree`, + // `materializeTreeView` — can reconstruct the WHOLE realized tree from a real run, not only + // hand-built journals. The root is never `scope.spawn`ed (the supervisor runs `act` directly), + // so without this the root node is absent and `trajectoryReport` fails its `nodes.has(root)` + // invariant. The uniqueness guard skips `spawned` events (only the cursor namespace must be + // unique), so sharing ordinal 0 with the first child's spawn is not a collision; replay ignores + // `spawned` events for settlement reconstruction, so the replayed `Settled[]` is unchanged. + await opts.journal.beginTree(opts.runId, runStartedAt) + const rootReceipt = rootMaterializationReceipt(opts) + const rootRuntime = opts.rootMaterialization?.runtime ?? 'inline' + await opts.journal.appendEvent(opts.runId, { + kind: 'spawned', + id: opts.runId, + label: 'root', + budget: opts.budget, + runtime: rootRuntime, + ...(opts.rootIdentity ? { identity: opts.rootIdentity } : {}), + seq: 0, + at: runStartedAt, + }) + if (rootReceipt !== undefined) { + await opts.journal.appendEvent(opts.runId, { + kind: 'materialized', + id: opts.runId, + receipt: rootReceipt, + seq: 0, + at: runStartedAt, + }) + } } - } else { - // Fresh run: begin the tree and journal the root as its own `spawned` node (parent-less, the - // spawn-ordinal-0 marker), so a journal-based reader — `trajectoryReport`, `replaySpawnTree`, - // `materializeTreeView` — can reconstruct the WHOLE realized tree from a real run, not only - // hand-built journals. The root is never `scope.spawn`ed (the supervisor runs `act` directly), - // so without this the root node is absent and `trajectoryReport` fails its `nodes.has(root)` - // invariant. The uniqueness guard skips `spawned` events (only the cursor namespace must be - // unique), so sharing ordinal 0 with the first child's spawn is not a collision; replay ignores - // `spawned` events for settlement reconstruction, so the replayed `Settled[]` is unchanged. - await opts.journal.beginTree(opts.runId, new Date(now()).toISOString()) - await opts.journal.appendEvent(opts.runId, { - kind: 'spawned', - id: opts.runId, - label: 'root', - budget: opts.budget, - runtime: 'inline', - seq: 0, - at: new Date(now()).toISOString(), - }) - } - // ONE internal controller is the root scope's abort source. Every cascade path - // (caller signal, RootHandle.abort, breaker trip, deadline) aborts it; the scope - // fans it out to each live child's executor (acquire-aware reap included). - const controller = new AbortController() - const cascadeAbort = (reason?: string) => { - if (controller.signal.aborted) return - // Carry the reason on the signal so it chains down to each child's abort signal - // (`childAbort.signal.reason`) — the diagnostic the scope's executors observe. - controller.abort(reason) - } + const stableRootReceipt = resuming + ? prior?.find( + (event): event is Extract => + event.kind === 'materialized' && event.id === opts.runId, + )?.receipt + : rootMaterializationReceipt(opts) + if (stableRootReceipt !== undefined) { + const rootBinding = rootExecutionBindingReceipt(opts, stableRootReceipt, rootAttemptId) + if (rootBinding !== undefined) { + await opts.journal.appendEvent(opts.runId, { + kind: 'execution-bound', + id: opts.runId, + binding: rootBinding, + seq: 0, + at: runStartedAt, + }) + } + } - const onCallerAbort = () => cascadeAbort('caller signal aborted') - if (opts.signal) { - if (opts.signal.aborted) cascadeAbort('caller signal aborted') - else opts.signal.addEventListener('abort', onCallerAbort, { once: true }) - } + // ONE internal controller is the root scope's abort source. Every cascade path + // (caller signal, RootHandle.abort, breaker trip, deadline) aborts it; the scope + // fans it out to each live child's executor (acquire-aware reap included). + const controller = new AbortController() + const cascadeAbort = (reason?: string): boolean => { + if (controller.signal.aborted) return false + // Carry the reason on the signal so it chains down to each child's abort signal + // (`childAbort.signal.reason`) — the diagnostic the scope's executors observe. + controller.abort(reason) + return true + } - // The breaker watches `down` settlements via a counting journal decorator, so it - // observes every child failure without intercepting `scope.next()` (the driver's - // private channel). Tripping aborts the same controller; the trip is recorded so the - // final result can name it. - const breaker = createIntensityBreaker(opts, () => cascadeAbort('intensity breaker tripped')) - const journal = wrapJournalForBreaker(opts.journal, breaker) - - const scope = createScope({ - parentId: opts.runId, - root: opts.runId, - pool, - journal, - blobs: opts.blobs, - executors: opts.executors, - seams: {}, - depth: 0, - maxDepth: opts.maxDepth ?? defaultMaxDepth, - signal: controller.signal, - now, - hooks: opts.hooks, - ...(opts.probes ? { probes: opts.probes } : {}), - ...(opts.workerTrace ? { workerTrace: opts.workerTrace } : {}), - ...(resumeFrom ? { resumeFrom } : {}), - }) + const onCallerAbort = () => cascadeAbort('caller signal aborted') + if (opts.signal) { + if (opts.signal.aborted) cascadeAbort('caller signal aborted') + else opts.signal.addEventListener('abort', onCallerAbort, { once: true }) + } - // `view`/drain read the scope opaquely (`Out` erased) — the supervisor never `spawn`s - // on it, so the live-tree readout and the join barrier are `Out`-agnostic. - const openScope = scope as unknown as Scope + // The breaker watches `down` settlements via a counting journal decorator, so it + // observes every child failure without intercepting `scope.next()` (the driver's + // private channel). Tripping aborts the same controller; the trip is recorded so the + // final result can name it. + const breaker = createIntensityBreaker(opts, () => cascadeAbort('intensity breaker tripped')) + const journal = wrapJournalForBreaker(opts.journal, breaker) + const priorRootMaterialization = prior?.find( + (event): event is Extract => + event.kind === 'materialized' && event.id === opts.runId, + )?.receipt - // Bind any attached RootHandle to THIS live run so view()/signal()/abort() reach the - // live scope + the one cascade controller. Detached again in the finally barrier. - if (attached) { - attached.bind({ scope: openScope, cascadeAbort, signal: pushRootSignal(cascadeAbort) }) - } + const scope = createScope({ + parentId: opts.runId, + root: opts.runId, + pool, + journal, + blobs: opts.blobs, + executors: opts.executors, + seams: {}, + depth: 0, + maxDepth: opts.maxDepth ?? defaultMaxDepth, + ...(opts.maxLiveWorkers !== undefined ? { maxLiveWorkers: opts.maxLiveWorkers } : {}), + signal: controller.signal, + now, + hooks: opts.hooks, + ...(opts.rootMaterialization?.declaration === 'deferred' + ? { + ownerMaterialization: { + runtime: opts.rootMaterialization.runtime, + authoredProfile: opts.rootMaterialization.authoredProfile, + attemptId: rootAttemptId, + requiredKnown: true, + ...(priorRootMaterialization === undefined + ? {} + : { prior: priorRootMaterialization }), + }, + } + : {}), + ...(opts.probes ? { probes: opts.probes } : {}), + ...(opts.workerTrace ? { workerTrace: opts.workerTrace } : {}), + ...(resumeFrom ? { resumeFrom } : {}), + }) - let actOutcome: { ok: true; out: Out } | { ok: false; error: unknown } - try { - const out = await root.act(task, scope) - actOutcome = { ok: true, out } - } catch (error) { - // act()'s rejection is the PRIMARY error; capture it before the join barrier so a - // teardown failure in the barrier can never overwrite it (firstError precedence). - actOutcome = { ok: false, error } - } finally { - // Join barrier: tear down every still-live child. Generalizes the kernel's - // `finally{ Promise.allSettled(destroy) }` — a teardown throw is allSettled'd and - // journaled, never re-thrown. - await drainLiveChildren(openScope, controller) - if (opts.signal) opts.signal.removeEventListener('abort', onCallerAbort) - if (attached) attached.unbind() - } + // `view`/drain read the scope opaquely (`Out` erased) — the supervisor never `spawn`s + // on it, so the live-tree readout and the join barrier are `Out`-agnostic. + const openScope = scope as unknown as Scope - // The run's tree, not this process's: on a resumed run the prior process's committed nodes are - // carried in, so `tree` covers the same work `spentTotal` bills for. Identical to `scope.view` - // on every run that did not resume. - const tree = runTree(scope) - if (actOutcome.ok) { - // Every child has settled (join barrier above); no reservation may remain. A leaked ticket - // would silently corrupt the conserved spend total, so fail loud here — on the success path - // only, where the act() error precedence does not apply. + // Bind any attached RootHandle to THIS live run so view()/signal()/abort() reach the + // live scope + the one cascade controller. Detached again in the finally barrier. + if (rootLease) { + rootLease.bind({ + scope: openScope, + cascadeAbort, + signal: pushRootSignal(cascadeAbort), + deliver: rootDeliver ? (message) => rootDeliver(message) !== false : () => false, + }) + } + + let deadlineExceeded = false + const rootDeadlineAtMs = pool.readout().deadlineMs + if (rootDeadlineAtMs > 0 && now() >= rootDeadlineAtMs) { + deadlineExceeded = cascadeAbort('root budget deadline exceeded') + } + const clearRootDeadline = + opts.budget.deadlineMs === undefined + ? undefined + : armDeadlineTimer(Math.max(0, rootDeadlineAtMs - now()), () => { + deadlineExceeded = cascadeAbort('root budget deadline exceeded') + }) + let actOutcome: { ok: true; out: Out } | { ok: false; error: unknown } = { + ok: false, + error: new RuntimeRunStateError('supervisor: root execution did not start'), + } + let executionAborted = controller.signal.aborted + try { + const out = await runAbortable(() => rootAct(task, scope), controller.signal) + actOutcome = { ok: true, out } + } catch (error) { + // act()'s rejection is the PRIMARY error; capture it before the join barrier so a + // teardown failure in the barrier can never overwrite it (firstError precedence). + actOutcome = { ok: false, error } + } finally { + executionAborted = controller.signal.aborted + // A child inherits the root cutoff and can settle the root act on that exact timer turn. + // If its settlement callback runs before the root timer callback, the finally block clears + // the still-pending root timer. Preserve the deadline cause from the shared absolute clock; + // do not overwrite a caller/breaker abort that already won the controller race. + if (!controller.signal.aborted && rootDeadlineAtMs > 0 && now() >= rootDeadlineAtMs) { + deadlineExceeded = true + } + clearRootDeadline?.() + // Join barrier: tear down every still-live child. Generalizes the kernel's + // `finally{ Promise.allSettled(destroy) }` — a teardown throw is allSettled'd and + // journaled, never re-thrown. + try { + await drainLiveChildren(openScope, controller) + } catch (error) { + if (actOutcome?.ok !== false) actOutcome = { ok: false, error } + } + try { + await finalizeScopeOwnerMaterialization(openScope) + } catch (error) { + if (actOutcome?.ok !== false) actOutcome = { ok: false, error } + } + if (opts.signal) opts.signal.removeEventListener('abort', onCallerAbort) + rootLease?.release() + } + + // The run's tree, not this process's: on a resumed run the prior process's committed nodes are + // carried in, so `tree` covers the same work `spentTotal` bills for. Identical to `scope.view` + // on every run that did not resume. + const tree = runTree(scope) + // Success and failure both pass the same conservation check. A child promise is never allowed + // to disappear behind a swallowed cleanup error and leave a reservation open. pool.assertNoOpenTickets() - const out = actOutcome.out - // Completion-oracle at the root: a `winner` MUST carry a real `Out`. A driver that ran to - // completion but selected nothing (its keep-best finalize found no DELIVERED child) returns - // `undefined` — that is a no-winner, never a winner wrapping `undefined`. The supervisor's - // contract is to refuse coercing a non-result into a best-effort Out (Foreman's 0/18 lesson). - if (out !== undefined) { - // The driver synthesized a winner. Content-address it for the replay `outRef`, put it - // once, and sum the conserved spend off every journaled settlement. No re-ranking — the - // driver already selected. - const outRef = contentAddress(out) - await opts.blobs.put(outRef, out) - // ONE ledger: the journal. `settled` events carry spawned-child WORK; `metered` events carry - // the drivers' OWN inference (the twin of `pool.observe`). `spentTotal` is their sum and the - // breakdown keeps the two separable — the A++ view of where the tokens went. No pool bridge. + if (actOutcome.ok) { + if (executionAborted) return noWinner() + // Every child has settled (join barrier above); no reservation may remain. A leaked ticket + // would silently corrupt the conserved spend total, so fail loud here — on the success path + // only, where the act() error precedence does not apply. + const out = actOutcome.out + // Completion-oracle at the root: a `winner` MUST carry a real `Out`. A driver that ran to + // completion but selected nothing (its keep-best finalize found no DELIVERED child) returns + // `undefined` — that is a no-winner, never a winner wrapping `undefined`. The supervisor's + // contract is to refuse coercing a non-result into a best-effort Out (Foreman's 0/18 lesson). + if (out !== undefined) { + // The driver synthesized a winner. Content-address it for the replay `outRef`, put it + // once, and sum the conserved spend off every journaled settlement. No re-ranking — the + // driver already selected. + const outRef = contentAddress(out) + await opts.blobs.put(outRef, out) + // ONE ledger: the journal. `settled` events carry spawned-child WORK; `metered` events carry + // the drivers' OWN inference (the twin of `pool.observe`). `spentTotal` is their sum and the + // breakdown keeps the two separable — the A++ view of where the tokens went. No pool bridge. + const { childWork, driverInference } = await spentFromJournal(journal, opts.runId) + return { + kind: 'winner', + out, + outRef, + tree, + spentTotal: addSpend(childWork, driverInference), + ...(isNonEmptySpend(driverInference) + ? { spentBreakdown: { driverInference, childWork } } + : {}), + } + } + return noWinner() + } + + // act() rejected. The reason is proven from lifecycle state first, in precedence order: + // a tripped breaker outranks any abort (it is the most specific cause) outranks + // budget-exhaustion outranks a real `down` child. Only when NONE of those hold does the + // rejection itself become the reason — `driver-failed`, the one arm that carries `error`. + // A no-winner is TYPED — never a best-effort coercion of a partial child (M2). + return noWinner({ error: actOutcome.error }) + + // A no-winner still incurred real conserved spend before failing, so it carries `spentTotal` + // summed off the SAME journal the winner path reads — the caller always learns the cost. + async function noWinner(rejection?: DriverRejection): Promise> { const { childWork, driverInference } = await spentFromJournal(journal, opts.runId) - return { - kind: 'winner', - out, - outRef, + const common = { + kind: 'no-winner' as const, tree, + downCount: breaker.downCount(), spentTotal: addSpend(childWork, driverInference), - ...(isNonEmptySpend(driverInference) - ? { spentBreakdown: { driverInference, childWork } } - : {}), } + // The lifecycle causes outrank the driver's own rejection, so they are asked first and a + // proven one ends it. `undefined` means the supervisor's own state explains nothing. + const lifecycle = classifyNoWinner(controller, pool, opts, breaker, deadlineExceeded, tree) + if (lifecycle !== undefined) return { ...common, reason: lifecycle } + // No lifecycle cause AND the driver threw ⇒ the driver itself is the fault. `error` is + // REQUIRED on this arm, and it is present by construction: this is the only branch that + // produces `driver-failed`, and it is unreachable unless a rejection was captured. + if (rejection !== undefined) { + return { ...common, reason: 'driver-failed', error: describeRejection(rejection.error) } + } + // The residual bucket: ran to completion under budget and selected nothing usable. + return { ...common, reason: 'all-children-down' } } - return noWinner() - } - - // act() rejected. The reason is proven from lifecycle state first, in precedence order: - // a tripped breaker outranks any abort (it is the most specific cause) outranks - // budget-exhaustion outranks a real `down` child. Only when NONE of those hold does the - // rejection itself become the reason — `driver-failed`, the one arm that carries `error`. - // A no-winner is TYPED — never a best-effort coercion of a partial child (M2). - return noWinner({ error: actOutcome.error }) - - // A no-winner still incurred real conserved spend before failing, so it carries `spentTotal` - // summed off the SAME journal the winner path reads — the caller always learns the cost. - async function noWinner(rejection?: DriverRejection): Promise> { - const { childWork, driverInference } = await spentFromJournal(journal, opts.runId) - const common = { - kind: 'no-winner' as const, - tree, - downCount: breaker.downCount(), - spentTotal: addSpend(childWork, driverInference), - } - // The lifecycle causes outrank the driver's own rejection, so they are asked first and a - // proven one ends it. `undefined` means the supervisor's own state explains nothing. - const lifecycle = classifyNoWinner(controller, pool, opts, breaker) - if (lifecycle !== undefined) return { ...common, reason: lifecycle } - // No lifecycle cause AND the driver threw ⇒ the driver itself is the fault. `error` is - // REQUIRED on this arm, and it is present by construction: this is the only branch that - // produces `driver-failed`, and it is unreachable unless a rejection was captured. - if (rejection !== undefined) { - return { ...common, reason: 'driver-failed', error: describeRejection(rejection.error) } - } - // The residual bucket: ran to completion under budget and selected nothing usable. - return { ...common, reason: 'all-children-down' } + } finally { + rootLease?.release() } } @@ -389,13 +768,18 @@ interface RunBinding { readonly scope: Scope readonly cascadeAbort: (reason?: string) => void readonly signal: (msg: RootSignal) => void + readonly deliver: (msg: unknown) => boolean } /** The supervisor-private control behind a `RootHandle`. `createRootHandle` mints it and * registers it in `rootControls`; `attach` looks it up and `bind`s it to the live run. */ -interface RootControl { +interface RootLease { bind(binding: RunBinding): void - unbind(): void + release(): void +} + +interface RootControl { + acquire(): RootLease } /** Module-private channel from a minted `RootHandle` to its `RootControl`, so `attach` @@ -410,9 +794,10 @@ const rootControls = new WeakMap, RootControl>() * unbinds it) the handle is fail-loud: a client that talks to a handle that is not * driving a live run gets a typed error, never a silent no-op. */ -export function createRootHandle(): RootHandle { +export function createRootHandle(): SteerableRootHandle { let binding: RunBinding | undefined - const handle: RootHandle = { + let activeLease: symbol | undefined + const handle: SteerableRootHandle = { view(): TreeView { if (!binding) { throw new RuntimeRunStateError( @@ -421,6 +806,12 @@ export function createRootHandle(): RootHandle { } return binding.scope.view }, + deliver(msg: unknown): boolean { + if (!binding) { + throw new RuntimeRunStateError('RootHandle.deliver: handle is not bound to a live run') + } + return binding.deliver(msg) + }, signal(msg: RootSignal): void { if (!binding) { throw new RuntimeRunStateError('RootHandle.signal: handle is not bound to a live run') @@ -435,11 +826,33 @@ export function createRootHandle(): RootHandle { }, } rootControls.set(handle as RootHandle, { - bind(b: RunBinding): void { - binding = b - }, - unbind(): void { - binding = undefined + acquire(): RootLease { + if (activeLease !== undefined) { + throw new RuntimeRunStateError( + 'RootHandle: handle already controls a live run; use one handle per concurrent run', + ) + } + const token = Symbol('root-handle-lease') + activeLease = token + let released = false + return { + bind(next: RunBinding): void { + if (released || activeLease !== token) { + throw new RuntimeRunStateError('RootHandle: live-run lease is no longer active') + } + if (binding !== undefined) { + throw new RuntimeRunStateError('RootHandle: live-run lease is already bound') + } + binding = next + }, + release(): void { + if (released) return + released = true + if (activeLease !== token) return + binding = undefined + activeLease = undefined + }, + } }, }) return handle @@ -531,10 +944,23 @@ async function drainLiveChildren( // cancelling one would leave the process pinned to a deadline nobody is reading anymore. const view = scope.view const hasLive = view.inFlight > 0 || view.waiting > 0 - if (!hasLive) return + if (!hasLive) { + if (scope.workerCapacity.live > 0) { + throw new RuntimeRunStateError( + `supervisor: cleanup ended with ${scope.workerCapacity.live} executor resource(s) not confirmed destroyed`, + ) + } + return + } // Cascade the abort into every live child's executor before draining. if (!controller.signal.aborted) controller.abort() - await Promise.allSettled([drainCursor(scope)]) + await drainCursor(scope) + const after = scope.view + if (after.inFlight > 0 || after.waiting > 0 || scope.workerCapacity.live > 0) { + throw new RuntimeRunStateError( + `supervisor: cleanup ended with ${after.inFlight} running, ${after.waiting} waiting, and ${scope.workerCapacity.live} executor resource(s) not confirmed destroyed`, + ) + } } async function drainCursor(scope: Scope): Promise { @@ -544,6 +970,56 @@ async function drainCursor(scope: Scope): Promise { } } +/** Race root policy execution against the same signal that stops every child. The losing promise + * remains observed, so a late rejection cannot surface as an unhandled process error. */ +async function runAbortable(act: () => Promise, signal: AbortSignal): Promise { + if (signal.aborted) throw supervisorAbortError(signal) + return await new Promise((resolve, reject) => { + let settled = false + const cleanup = () => signal.removeEventListener('abort', onAbort) + const onAbort = () => { + queueMicrotask(() => { + if (settled) return + settled = true + cleanup() + reject(supervisorAbortError(signal)) + }) + } + signal.addEventListener('abort', onAbort, { once: true }) + let work: Promise + try { + work = Promise.resolve(act()) + } catch (error) { + cleanup() + reject(error) + return + } + work.then( + (value) => { + if (settled) return + settled = true + cleanup() + resolve(value) + }, + (error) => { + if (settled) return + settled = true + cleanup() + reject(error) + }, + ) + }) +} + +function supervisorAbortError(signal: AbortSignal): Error { + const reason = signal.reason + const error = new Error( + typeof reason === 'string' && reason.length > 0 ? reason : 'supervisor aborted', + ) + error.name = 'AbortError' + return error +} + /** * The lifecycle cause of a no-winner, or `undefined` when the supervisor's own state proves * nothing. Returning `undefined` rather than falling through to `all-children-down` is what lets @@ -555,20 +1031,34 @@ function classifyNoWinner( pool: BudgetPool, opts: SupervisorOpts, breaker: IntensityBreaker, + deadlineExceeded: boolean, + tree: TreeView, ): LifecycleNoWinnerReason | undefined { // A tripped breaker is the most specific cause (children kept dying), so it outranks - // the generic abort it raised. Then a caller/handle abort. Then the pool. Then a real - // `down` child, which is what `all-children-down` asserts and which only `downCount > 0` - // can prove. + // the abort it raised. A deadline that won the abort race remains budget exhaustion; + // then come caller/handle abort and other exhausted pool channels. Then a real `down` + // child, which is what `all-children-down` asserts and which only `downCount > 0` can prove. if (breaker.tripped()) return 'all-children-down' + if (deadlineExceeded) return 'budget-exhausted' if (controller.signal.aborted) return 'aborted' + // Unknown terminal usage correctly seals the remaining pool, but that accounting consequence is + // not the cause of a run where every child failed. Preserve the lifecycle outcome before asking + // whether any budget channel is now unavailable. A real admission failure with no failed child + // still classifies as budget exhaustion below. + if (allSpawnedChildrenDown(tree)) return 'all-children-down' if (poolExhausted(pool, opts)) return 'budget-exhausted' if (breaker.downCount() > 0) return 'all-children-down' return undefined } +function allSpawnedChildrenDown(tree: TreeView): boolean { + const children = tree.nodes.filter((node) => node.id !== tree.root) + return children.length > 0 && children.every((node) => node.status === 'failed') +} + function poolExhausted(pool: BudgetPool, opts: SupervisorOpts): boolean { const r = pool.readout() + if (r.iterationsLeft <= 0) return true if (r.tokensLeft <= 0) return true if (opts.budget.maxUsd !== undefined && r.usdLeft <= 0) return true if ( @@ -605,6 +1095,32 @@ async function spentFromJournal( * `metered` = driver inference (re-homed up the tree, so a single root-tree pass already * includes every nested driver's inference). */ function sumSpendFromEvents(events: SpawnEvent[]): { childWork: Spend; driverInference: Spend } { + const totals = sumMeasuredSpendFromEvents(events) + const rootBudget = events.find( + (event): event is SpawnedEvent => event.kind === 'spawned' && event.parent === undefined, + )?.budget + let remainingRootUsd = Math.max( + 0, + (rootBudget?.maxUsd ?? 0) - totals.childWork.usd - totals.driverInference.usd, + ) + for (const budget of uncertainSpawnBudgets(events)) { + totals.childWork.iterations += budget.maxIterations + // The numeric value is the charged upper bound, not a fabricated measurement. The false flag + // makes that distinction machine-readable in every report. + totals.childWork.tokens.input += budget.maxTokens + totals.childWork.tokensKnown = false + const usdCharge = budget.maxUsd ?? (rootBudget?.maxUsd !== undefined ? remainingRootUsd : 0) + totals.childWork.usd += usdCharge + totals.childWork.usdKnown = false + remainingRootUsd = Math.max(0, remainingRootUsd - usdCharge) + } + return totals +} + +function sumMeasuredSpendFromEvents(events: SpawnEvent[]): { + childWork: Spend + driverInference: Spend +} { const childWork: Spend = { iterations: 0, tokens: { input: 0, output: 0 }, usd: 0, ms: 0 } const driverInference: Spend = { iterations: 0, tokens: { input: 0, output: 0 }, usd: 0, ms: 0 } for (const ev of events) { @@ -619,8 +1135,8 @@ function accumulate(a: Spend, b: Spend): void { a.iterations += b.iterations a.tokens.input += b.tokens.input a.tokens.output += b.tokens.output - a.usd += b.usd if (b.tokensKnown === false) a.tokensKnown = false + a.usd += b.usd if (b.usdKnown === false) a.usdKnown = false a.ms += b.ms } @@ -631,8 +1147,8 @@ function addSpend(a: Spend, b: Spend): Spend { return { iterations: a.iterations + b.iterations, tokens: { input: a.tokens.input + b.tokens.input, output: a.tokens.output + b.tokens.output }, - usd: a.usd + b.usd, ...(a.tokensKnown === false || b.tokensKnown === false ? { tokensKnown: false } : {}), + usd: a.usd + b.usd, ...(a.usdKnown === false || b.usdKnown === false ? { usdKnown: false } : {}), ms: a.ms + b.ms, } @@ -640,9 +1156,7 @@ function addSpend(a: Spend, b: Spend): Spend { /** True when any driver metered inference this run (so the winner carries a `spentBreakdown`). * Checks every channel `addSpend` sums — including `ms` — so the gate stays consistent with the - * total even though the coordination driver currently stamps `ms: 0`. An all-zero spend that - * carries an UNKNOWN marker is non-empty: work happened and went unmeasured, which is exactly the - * fact a breakdown must not hide by looking like no work at all. */ + * total even though the coordination driver currently stamps `ms: 0`. */ function isNonEmptySpend(s: Spend): boolean { return ( s.iterations > 0 || diff --git a/src/runtime/supervise/trace-evidence.ts b/src/runtime/supervise/trace-evidence.ts new file mode 100644 index 00000000..fbe92d68 --- /dev/null +++ b/src/runtime/supervise/trace-evidence.ts @@ -0,0 +1,165 @@ +/** + * Durable structured tool evidence for supervised workers. + * + * The executor-owned `TraceSource` is collected before teardown, snapshotted into the shared + * content-addressed blob store, and only then referenced by a settlement. Analysis reconstructs + * agent-eval's bounded `TraceAnalysisStore` from those exact bytes. Worker output is never accepted + * as a substitute for tool evidence. + * + * @experimental + */ + +import { + isToolSpan, + type Span, + type ToolSpan, + type TraceAnalysisStore, + toolSpansToTraceAnalysisStore, +} from '@tangle-network/agent-eval' +import { contentAddress } from '../../durable/content-address' +import { ValidationError } from '../../errors' +import type { TraceSource } from './trace-source' +import type { ResultBlobStore, WorkerTraceEvidence } from './types' + +/** Schema version for content-addressed worker tool-trace artifacts. */ +export const WORKER_TOOL_TRACE_SCHEMA_VERSION = 1 as const + +/** Bytes stored under `WorkerTraceEvidence.traceRef`. */ +export interface WorkerToolTraceArtifact { + readonly schemaVersion: typeof WORKER_TOOL_TRACE_SCHEMA_VERSION + readonly spans: ReadonlyArray +} + +/** Structural guard for the official bounded trace-analysis read contract. */ +export function isTraceAnalysisStore(value: unknown): value is TraceAnalysisStore { + if (value === null || typeof value !== 'object') return false + const store = value as Partial> + return ( + typeof store.getOverview === 'function' && + typeof store.queryTraces === 'function' && + typeof store.countTraces === 'function' && + typeof store.viewTrace === 'function' && + typeof store.viewSpans === 'function' && + typeof store.searchTrace === 'function' && + typeof store.searchSpan === 'function' + ) +} + +/** Collect and persist one executor's structured tool trace without changing its task outcome. */ +export async function captureWorkerTraceEvidence( + readSource: (() => TraceSource | undefined) | undefined, + blobs: ResultBlobStore, + executed: boolean, +): Promise { + if (!executed) return unavailable('execution-did-not-start') + if (readSource === undefined) return unavailable('executor-did-not-expose-trace-source') + + let source: TraceSource | undefined + try { + source = readSource() + } catch { + return unavailable('trace-source-unavailable') + } + if (source === undefined) return unavailable('trace-source-unavailable') + + let collected: unknown + try { + collected = await source.collect() + } catch { + return unavailable('trace-collection-failed') + } + if (!Array.isArray(collected) || collected.some((span) => !isToolSpanValue(span))) { + return unavailable('invalid-tool-spans') + } + if (collected.length === 0) return unavailable('no-tool-spans-captured') + + let artifact: WorkerToolTraceArtifact + let traceRef: string + try { + const snapshot = structuredClone(collected) as ToolSpan[] + // Use agent-eval's canonical adapter as the full integrity check before the durable receipt + // claims these spans are analyzable. `isToolSpan` only narrows the discriminated union. + toolSpansToTraceAnalysisStore(snapshot) + artifact = { + schemaVersion: WORKER_TOOL_TRACE_SCHEMA_VERSION, + spans: snapshot, + } + traceRef = contentAddress(artifact) + } catch { + return unavailable('invalid-tool-spans') + } + try { + await blobs.put(traceRef, artifact) + } catch { + return unavailable('trace-persistence-failed') + } + return Object.freeze({ status: 'available', traceRef, spanCount: artifact.spans.length }) +} + +/** Rehydrate exact persisted spans through agent-eval's one bounded trace-analysis adapter. */ +export async function workerTraceAnalysisStore( + evidence: WorkerTraceEvidence, + blobs: Pick, +): Promise { + if (evidence.status === 'unavailable') { + // The published adapter owns the missing-evidence error and its classification. + return toolSpansToTraceAnalysisStore(undefined) + } + const raw = await blobs.get(evidence.traceRef) + if (raw === undefined) { + throw new ValidationError( + `worker trace blob '${evidence.traceRef}' is missing; the settlement evidence is incomplete`, + ) + } + const artifact = parseWorkerToolTraceArtifact(raw, evidence.traceRef) + if (artifact.spans.length !== evidence.spanCount) { + throw new ValidationError( + `worker trace blob '${evidence.traceRef}' has ${artifact.spans.length} spans but its settlement records ${evidence.spanCount}`, + ) + } + return toolSpansToTraceAnalysisStore(artifact.spans) +} + +/** Validate a stored trace artifact before an analyst or replay trusts it. */ +export function parseWorkerToolTraceArtifact( + value: unknown, + traceRef = '', +): WorkerToolTraceArtifact { + if ( + value === null || + typeof value !== 'object' || + (value as { schemaVersion?: unknown }).schemaVersion !== WORKER_TOOL_TRACE_SCHEMA_VERSION || + !Array.isArray((value as { spans?: unknown }).spans) + ) { + throw new ValidationError( + `worker trace blob '${traceRef}' is not a version-${WORKER_TOOL_TRACE_SCHEMA_VERSION} tool trace artifact`, + ) + } + const spans = (value as { spans: unknown[] }).spans + if (spans.length === 0) { + // Missing spans cannot prove a tool-free run; preserve agent-eval's explicit integrity error. + return { + schemaVersion: WORKER_TOOL_TRACE_SCHEMA_VERSION, + spans: spans as ToolSpan[], + } + } + if (spans.some((span) => !isToolSpanValue(span))) { + throw new ValidationError(`worker trace blob '${traceRef}' contains a non-tool span`) + } + return { + schemaVersion: WORKER_TOOL_TRACE_SCHEMA_VERSION, + spans: spans as ToolSpan[], + } +} + +function unavailable(reason: Extract['reason']) { + return Object.freeze({ status: 'unavailable' as const, reason }) +} + +function isToolSpanValue(value: unknown): value is ToolSpan { + try { + return isToolSpan(value as Span) + } catch { + return false + } +} diff --git a/src/runtime/supervise/tree-key.ts b/src/runtime/supervise/tree-key.ts new file mode 100644 index 00000000..3fe3b3bf --- /dev/null +++ b/src/runtime/supervise/tree-key.ts @@ -0,0 +1,31 @@ +import type { NodeId } from './types' + +/** Runtime tag used only for a child that owns another supervised scope. */ +export const driverRuntime = 'driver' as const + +// Recursive ownership is an executor capability, not a Runtime string. `Runtime` is deliberately +// open, so a caller-owned leaf may also call itself "driver". Keep the capability on the exact +// Runtime-created executor object and never expose a public lookalike property that a leaf can set. +const nestedDriverTreeOwners = new WeakSet() + +/** Canonical journal key for the scope owned by one nested driver node. */ +export function nestedDriverTreeRoot(parentTreeRoot: NodeId, driverNodeId: NodeId): NodeId { + return `${parentTreeRoot}/${driverNodeId}` +} + +/** Privately mark the exact recursive executor that will create a nested journal tree. */ +export function attestNestedDriverTreeOwner(owner: T): T { + nestedDriverTreeOwners.add(owner) + return owner +} + +/** Return the canonical owned tree only for an exact privately marked recursive executor. */ +export function runtimeOwnedNestedDriverTreeRoot( + owner: object, + parentTreeRoot: NodeId, + driverNodeId: NodeId, +): NodeId | undefined { + return nestedDriverTreeOwners.has(owner) + ? nestedDriverTreeRoot(parentTreeRoot, driverNodeId) + : undefined +} diff --git a/src/runtime/supervise/types.ts b/src/runtime/supervise/types.ts index a4b8725d..af4d43b3 100644 --- a/src/runtime/supervise/types.ts +++ b/src/runtime/supervise/types.ts @@ -26,7 +26,7 @@ */ import type { DefaultVerdict } from '@tangle-network/agent-eval' -import type { AgentProfile } from '@tangle-network/agent-interface' +import type { AgentProfile, Sha256Digest } from '@tangle-network/agent-interface' import type { BackendType } from '@tangle-network/sandbox' import type { RuntimeHooks } from '../../runtime-hooks' import type { LoopTokenUsage } from '../types' @@ -70,6 +70,13 @@ export interface WaitOpts { export interface Agent { readonly name: string act(task: Task, scope: Scope): Promise + /** + * Optional manager inbox. A parent or attached `RootHandle` uses this to deliver the same raw + * down-message accepted by executor inboxes. Return `false` when the manager has no live receive + * path; returning `true` means the message was accepted for the current manager session. + */ + // biome-ignore lint/suspicious/noConfusingVoidType: void is the legacy contract; boolean adds an acknowledgement without breaking existing agents. + deliver?(msg: unknown): void | boolean } // ── The open leaf runtime ───────────────────────────────────────────────────── @@ -85,16 +92,17 @@ export interface Agent { * Built-in implementations (in `runtime.ts`, NOT variants here): router/inline (a direct * Router/HTTP inference call, no box), sandbox (COMPOSES `runAgentRounds` as a leaf, forwarding * PR #150's optional `lineage` passthrough — does NOT reinvent checkpoint/fork), cli - * (Halo/RLM subprocess; `budgetExempt`, excluded from equal-k by construction). A user's + * (Halo/RLM subprocess; `budgetExempt`, refused by budgeted supervision). A user's * own agent (mastra/agno/raw HTTP/anything) is first-class by implementing this interface. */ export interface Executor { /** Stable runtime tag for traces + the equal-k exemption check. */ readonly runtime: Runtime /** - * When true, this executor's spend is NOT metered against the conserved pool and its - * iterations are excluded from the equal-k assertion (a `cli` subprocess without - * token accounting). Fail-loud everywhere else: a metered executor MUST report usage. + * When true, this executor cannot report the usage a conserved pool would need (for example, a + * subscription CLI with no token receipt). `Executor` can still be used directly, but `Scope` + * refuses it before `execute` so unknown compute can never appear as measured zero in a + * supervised or equal-resource run. A metered executor MUST report usage. */ readonly budgetExempt?: boolean /** @@ -110,10 +118,11 @@ export interface Executor { * Optional inbox: receive an out-of-band message from the driver mid-run (the `send`/`steer_agent` * verb). A streaming executor drains pending messages between turns and folds them into the next * step (a steer / interrupt / resume). A one-shot executor that can't be steered mid-flight omits - * this; `Scope.send` then returns `false` for it. Never throws — a malformed message is the - * executor's to ignore. + * this; `Scope.send` then returns `false` for it. Never throws — an inbox that rejects a malformed + * message returns `false`, and that refusal propagates to the caller. */ - deliver?(msg: unknown): void + // biome-ignore lint/suspicious/noConfusingVoidType: void is the legacy contract; boolean adds an acknowledgement without breaking existing executors. + deliver?(msg: unknown): void | boolean /** * Optional LIVE progress: what this worker is doing RIGHT NOW, read synchronously and * cheaply while `execute` is still streaming. The scope already derives activity timing, @@ -145,6 +154,15 @@ export interface Executor { * driver branched on, its verdict, and the conserved spend. Read once, after settle. */ resultArtifact(): { outRef: string; out: Out; verdict?: DefaultVerdict; spent: Spend } + /** + * Optional accounting split for recursive executors. + * `reported` is the child-work spend written on this node's settlement; `reservation` is the + * whole amount reconciled against this node's parent reservation. + * They differ when a driver owns a nested allocation: its child work and own inference consume + * that allocation together, while the journal keeps those two categories separate. + * Valid after `execute` resolves or throws; ordinary leaf executors omit it. + */ + accounting?(): ExecutorAccounting | undefined /** * A driver-executor's OWN-inference subtree total (rolled up from its nested tree's `metered` * events) — the parent scope journals it as a `metered` event for this node on settle, on BOTH @@ -156,6 +174,38 @@ export interface Executor { metered?(): Spend | undefined } +/** Why Runtime cannot provide structured tool-call evidence for one settled execution. */ +export type WorkerTraceUnavailableReason = + | 'execution-did-not-start' + | 'executor-did-not-expose-trace-source' + | 'trace-source-unavailable' + | 'no-tool-spans-captured' + | 'invalid-tool-spans' + | 'trace-collection-failed' + | 'trace-persistence-failed' + | 'legacy-settlement-without-trace-evidence' + | 'not-an-executor' + +/** Durable proof of a worker's structured tool trace, or the exact reason it is unavailable. */ +export type WorkerTraceEvidence = + | { + readonly status: 'available' + /** Content-addressed pointer to a persisted `WorkerToolTraceArtifact`. */ + readonly traceRef: string + readonly spanCount: number + } + | { + readonly status: 'unavailable' + readonly reason: WorkerTraceUnavailableReason + } + +/** Split used by a recursive executor when journaled child work differs from the full amount + * reconciled against its parent reservation. */ +export interface ExecutorAccounting { + readonly reported: Spend + readonly reservation: Spend +} + /** Terminal artifact of a one-shot `Executor.execute`. */ export interface ExecutorResult { outRef: string @@ -196,11 +246,12 @@ export type Runtime = 'router' | 'inline' | 'sandbox' | 'cli' | (string & {}) // ── Executor resolution (OPEN registry, not a switch) ───────────────────────── /** - * `AgentProfile` does NOT carry a `harness`/backend field — `harness` lives on the - * sandbox SDK's `BackendConfig`, not the portable profile. So an agent is mapped to its - * executor through this MINIMAL wrapper, never by fabricating a field onto `AgentProfile`. + * `AgentProfile.harness` is a portable preference; this wrapper records the executor decision for + * one concrete run. A caller may honor the preference, override it for a comparison cell, or supply + * an executor directly, without changing the profile's behavioral identity. * * Resolution (in `runtime.ts`): + * - `executorFactory` present → BYO: build it after admission with the live context. * - `executor` present → BYO: use it verbatim (a user's own `Executor`). * - `harness === null` → router/inline: a direct Router call, no box. * - `harness` is a `BackendType` → sandbox: compose `runAgentRounds` against `profile` on that backend. @@ -210,10 +261,138 @@ export interface AgentSpec { readonly profile: AgentProfile /** `null` selects router/inline; a `BackendType` selects the sandboxed harness. */ readonly harness: BackendType | null + /** Trusted candidate/campaign attribution supplied by the caller. Profile/task digests are + * computed by Scope from the exact values it executes and cannot be supplied here. */ + readonly execution?: AgentExecutionRef + /** Per-spawn factory carrying caller configuration. Constructed only after admission, with the + * real child signal and nested-scope context. */ + readonly executorFactory?: ExecutorFactory /** Bring-your-own executor: when set, overrides harness-based resolution entirely. */ readonly executor?: Executor } +/** Caller-owned identity beyond the exact profile/task bytes Scope can compute itself. */ +export interface AgentExecutionRef { + readonly candidateDigest?: Sha256Digest + readonly correlation?: Readonly> +} + +/** Durable identity of one realized node. Missing digests mean the input was not canonical JSON. */ +export interface NodeExecutionIdentity extends AgentExecutionRef { + readonly profileDigest?: Sha256Digest + readonly taskDigest?: Sha256Digest +} + +/** A named model carried into an execution, or an explicit reason the exact model is unknowable. */ +export type MaterializedModelIdentity = + | { readonly status: 'known'; readonly id: string } + | { readonly status: 'unknown'; readonly reason: string } + +/** External execution identity that operators can use to join this node to its backend. */ +export interface MaterializedExecutionIdentity { + /** Backend-native identity kind, for example `request`, `session`, `run`, `process`, or `tree`. */ + readonly kind: string + readonly id: string +} + +/** + * Data-only declaration from trusted executor code about the exact sealed plan `execute` uses. + * Scope snapshots this value and computes the durable receipt; callers never provide digests. + */ +export interface ExecutorMaterialization { + /** Complete profile after trusted runtime-owned attachments or backend overlays were applied. */ + readonly effectiveProfile: AgentProfile + /** Concrete backend or harness selected for this run. */ + readonly backend: string + /** Exact selected model, or an explicit unknown reason. */ + readonly model: MaterializedModelIdentity + /** Backend-native session/run/request/process identity. */ + readonly execution: MaterializedExecutionIdentity + /** Named implementation that turns the effective profile into executable backend inputs. */ + readonly materializer: string + /** Finite JSON describing the exact materialization plan. Persisted by digest only. */ + readonly plan: unknown + /** Trusted runtime-only attachments, such as the coordination MCP. Persisted by digest only. */ + readonly platformAttachments?: unknown +} + +/** Volatile execution routing that is true for one attempt but is not profile identity. The full + * binding is hashed and discarded; only the safe structural descriptor is journaled. */ +export interface ExecutorExecutionBinding { + readonly attemptId: string + readonly binding: unknown + readonly descriptor: Readonly> +} + +/** Why exact materialization evidence is unavailable for a node. */ +export type UnknownMaterializationReason = + | 'executor-did-not-report' + | 'invalid-executor-report' + | 'root-agent-did-not-report' + +/** What the kernel can prove about one node's actual execution plan. */ +export type ProfileMaterializationReceipt = + | { + readonly status: 'known' + readonly authoredProfileDigest: Sha256Digest + readonly effectiveProfileDigest: Sha256Digest + readonly materializationPlanDigest: Sha256Digest + readonly platformAttachmentsDigest?: Sha256Digest + readonly runtime: Runtime + readonly backend: string + readonly model: MaterializedModelIdentity + readonly execution: MaterializedExecutionIdentity + readonly materializer: string + } + | { + readonly status: 'unknown' + readonly authoredProfileDigest?: Sha256Digest + readonly runtime: Runtime + readonly reason: UnknownMaterializationReason + } + +/** One attempt's immutable link from a stable materialization plan to its actual transport. */ +export type ExecutionBindingReceipt = + | { + readonly status: 'known' + readonly attemptId: string + readonly materializationReceiptDigest: Sha256Digest + readonly bindingDigest: Sha256Digest + readonly descriptor: Readonly> + } + | { + readonly status: 'unknown' + readonly attemptId: string + readonly materializationReceiptDigest: Sha256Digest + readonly reason: UnknownMaterializationReason + } + +/** Trusted root composition evidence. Generic `Agent.act` roots omit this and remain unknown. */ +export type RootMaterialization = + | { + readonly runtime: Runtime + readonly declaration: ExecutorMaterialization + readonly binding: Omit + } + | { + /** The runtime-owned external adapter will publish the exact declaration after its dynamic + * platform attachment (for example a coordination URL) exists and before paid work starts. */ + readonly runtime: Runtime + readonly declaration: 'deferred' + /** Exact admitted profile used to validate the stable effective identity at publication. */ + readonly authoredProfile: AgentProfile + } + +/** Kernel-owned context for the concrete supervised node a factory is constructing. */ +export interface ExecutorNodeContext { + readonly rootId: NodeId + readonly parentId: NodeId + readonly nodeId: NodeId + /** Kernel-minted identity for this concrete execution attempt. */ + readonly attemptId: string + readonly identity?: NodeExecutionIdentity +} + /** * Builds a fresh `Executor` for one spawn from the resolved spec. Per-spawn (not * shared) so each child owns its own box/abort/teardown lifecycle. A BYO factory lets a @@ -226,6 +405,8 @@ export type ExecutorFactory = (spec: AgentSpec, ctx: ExecutorContext) => Ex * the factory reaching into module globals. */ export interface ExecutorContext { readonly signal: AbortSignal + /** Present when Scope constructs the executor for a supervised node. */ + readonly node?: ExecutorNodeContext /** Opaque seams the registry threads through; a built-in narrows what it needs. */ readonly seams: Readonly> } @@ -240,8 +421,8 @@ export interface ExecutorRegistry { /** Register a factory for a named runtime. Throws on a duplicate name (fail loud). */ register(runtime: Runtime, factory: ExecutorFactory): void /** - * Resolve a spec to a factory. Precedence: a BYO `spec.executor` → a trivial factory - * returning it; else `harness === null` → the `'router'` factory; else a registered + * Resolve a spec to a factory. Precedence: a BYO `spec.executorFactory` → `spec.executor` → + * `harness === null` → the `'router'` factory; else a registered * factory for the harness-derived runtime. Returns a typed outcome — the caller * inspects `succeeded` before `value` (no silent fallback). */ @@ -303,6 +484,9 @@ export type NodeId = string export interface SpawnOpts { readonly budget: Budget readonly label: string + /** Manager-scoped semantic assignment identity. Unlike `key`, this names every spawn, including + * unkeyed siblings, so product traces can join authorization, node, and backend execution. */ + readonly assignmentId?: string readonly restart?: Restart /** Teardown grace handed to the executor when this node is reaped. */ readonly shutdown?: number | 'brutalKill' | 'infinity' @@ -320,8 +504,8 @@ export interface SpawnOpts { } /** Fail-closed spawn rejections: an exhausted pool, a dollar request against a root that budgets - * no dollars, an exceeded recursion ceiling, or a `key` that is still LIVE in this scope (the - * same assignment may not run twice concurrently). + * no dollars, an exceeded recursion ceiling, a full tree-wide worker allocation, or a `key` that + * is still LIVE in this scope (the same assignment may not run twice concurrently). * * `usd-unbudgeted` is separate from `budget-exhausted` because the two call for opposite * responses: an exhausted pool may admit a smaller request, while an unbudgeted dollar channel @@ -331,6 +515,10 @@ export type SpawnRejection = | 'usd-unbudgeted' | 'depth-exceeded' | 'duplicate-key' + | 'invalid-identity' + | 'key-conflict' + | 'max-live-workers' + | 'scope-aborted' /** * What a KEYED spawn resolved to when the key had a prior attempt. Absent on a fresh key (and on @@ -339,8 +527,10 @@ export type SpawnRejection = * `'lost'` DID spawn fresh: the prior attempt settled `down` (retried) or was journaled as * started but never settled — the process died with it in flight and the built-in executors * cannot re-attach to a dead process's work, so the result is explicitly in doubt (lost), never - * silently duplicated. An executor that CAN re-attach to a still-running external execution (a - * live sandbox box) extends this union with an adoption state; none of the built-ins can today. + * silently duplicated. On restart, an in-doubt attempt's full declared reservation is charged and + * its telemetry remains unknown; a fresh retry is admitted only from safely remaining capacity. + * An executor that CAN re-attach to a still-running external execution extends this union with an + * adoption state; none of the built-ins can today. */ export type SpawnPrior = | { readonly state: 'completed'; readonly settled: Settled & { kind: 'done' } } @@ -356,6 +546,14 @@ export interface Handle { readonly id: NodeId readonly label: string readonly status: NodeStatus + /** Manager-scoped assignment identity supplied at admission. */ + readonly assignmentId?: string + /** Durable identity of the authorized profile/task/candidate represented by this handle. */ + readonly identity?: NodeExecutionIdentity + /** Stable execution plan once Runtime has committed it. */ + readonly materialization?: ProfileMaterializationReceipt + /** Immutable per-attempt backend bindings committed so far, oldest first. */ + readonly executionBindings?: ReadonlyArray abort(reason?: string): void /** Phantom: binds the handle to the child's output type so `spawn` returns a * `Handle` distinct from a `Handle`. Type-only — never present at runtime. */ @@ -375,6 +573,10 @@ export type Settled = outRef: string verdict?: DefaultVerdict spent: Spend + /** Structured tool evidence captured before this settlement was journaled. */ + trace: WorkerTraceEvidence + /** Epoch ms parsed from the durable settlement record when available. */ + settledAt?: number seq: number } | { @@ -384,6 +586,10 @@ export type Settled = /** True = infrastructure failure (excluded from merge `n` / equal-k), not a bad result. */ infra: boolean restartCount: number + /** Partial structured tool evidence captured before this failure was journaled. */ + trace: WorkerTraceEvidence + /** Epoch ms parsed from the durable settlement/cancellation record when available. */ + settledAt?: number seq: number } @@ -397,14 +603,18 @@ export type Settled = */ export interface Scope { /** - * Spawn a child. Reserves `opts.budget` from the conserved pool atomically; refunds the - * unspent remainder on settle. Returns a typed outcome — fail-closed on an exhausted - * pool, an exceeded depth ceiling, or a still-live duplicate `key` (the caller inspects - * `ok` before `handle`). A KEYED spawn whose key already settled `done` spends nothing: - * it returns the committed result on `prior` instead of re-running (see `SpawnOpts.key`). + * Spawn a child. For a fresh key or an unkeyed spawn, tree-wide worker admission happens before a + * lazy factory is called, so a full worker allocation creates no worker, executor, or reservation. + * Reserves `opts.budget` from the conserved pool atomically; refunds the unspent remainder on + * settle. Returns a typed outcome — fail-closed on an exhausted pool, an exceeded depth ceiling, a + * full worker allocation, or a still-live duplicate `key` (the caller inspects `ok` before + * `handle`). A KEYED spawn whose key already settled `done` invokes the factory only far enough to + * prepare and authorize the exact profile/task identity, then compares that identity with the + * journal. On a match it spends nothing, constructs no executor, reserves no budget, and runs no + * work: it returns the committed result on `prior` (see `SpawnOpts.key`). */ spawn( - agent: Agent, + agent: Agent | (() => Agent), task: unknown, opts: SpawnOpts, ): { ok: true; handle: Handle; prior?: SpawnPrior } | { ok: false; reason: SpawnRejection } @@ -499,13 +709,21 @@ export interface Scope { /** Conserved-pool readouts (post-reservation). */ readonly budget: Readonly<{ tokensLeft: number + /** `false` once a turn settled without reporting its tokens: `tokensLeft` is then a ceiling, + * not a measurement. */ + tokensKnown: boolean usdLeft: number usdCapped: boolean + usdKnown: boolean + iterationsLeft: number deadlineMs: number reservedTokens: number - /** Present and `false` once a turn settled without reporting its tokens: `tokensLeft` is then - * a ceiling, not a measurement. Absent means every settled turn reported. */ - tokensKnown?: boolean + }> + /** One tree-wide view of simultaneous spawned work. Every nested scope reads the same counter; + * the root agent itself is not a spawned worker. `freeSlots` is `null` when no limit is set. */ + readonly workerCapacity: Readonly<{ + live: number + freeSlots: number | null }> } @@ -546,6 +764,8 @@ export interface ResumedWork { export interface ResumedKeyState { readonly id: NodeId readonly label: string + /** Identity recorded when this key was first admitted. Every reuse must match it exactly. */ + readonly identity?: NodeExecutionIdentity readonly state: 'completed' | 'down' | 'in-doubt' /** The rehydrated settlement; absent exactly when `state` is `'in-doubt'`. */ readonly settled?: Settled @@ -560,10 +780,23 @@ export interface NodeSnapshot { readonly status: NodeStatus readonly runtime: Runtime readonly budget: Budget + /** Exact nested journal tree owned by this node, when Runtime attested recursive ownership. */ + readonly ownedTreeRoot?: NodeId + /** Manager-scoped assignment identity, including deterministic ids for unkeyed siblings. */ + readonly assignmentId?: string + readonly identity?: NodeExecutionIdentity + /** Kernel-owned execution evidence. `unknown` is distinct from a known zero/empty plan. */ + readonly materialization?: ProfileMaterializationReceipt + /** Immutable attempt bindings, oldest first. A retried/resumed node may have more than one. */ + readonly executionBindings?: ReadonlyArray + /** Epoch ms of the terminal journal record; absent while live or when legacy evidence lacks it. */ + readonly settledAt?: number /** Conserved spend so far for this node. */ readonly spent: Spend /** `outRef` once the node is `done` (the replay/result pointer). */ readonly outRef?: string + /** Present on terminal executor nodes; legacy records carry an explicit unavailable reason. */ + readonly trace?: WorkerTraceEvidence } /** The live tree — what `scope.view` / `RootHandle.view()` materialize for a viewer. */ @@ -591,8 +824,33 @@ export type SpawnEvent = /** The semantic spawn key (`SpawnOpts.key`), when the spawn carried one — what a resumed * run matches to resolve the same assignment to its committed result. */ key?: string + /** Manager-scoped assignment identity used to join unkeyed and keyed work alike. */ + assignmentId?: string budget: Budget runtime: Runtime + /** Exact nested journal tree this node owns. Runtime writes this only after privately + * attesting the executor as a recursive scope owner. Its absence means no tree is followed, + * including records written before this field existed and caller leaves named `driver`. */ + ownedTreeRoot?: NodeId + /** Exact profile/task digests plus trusted candidate/campaign attribution when available. */ + identity?: NodeExecutionIdentity + seq: number + at: string + } + | { + /** Volatile transport/session binding for exactly one attempt. The full binding is retained + * only by digest; descriptor fields are safe structural labels, never credential-bearing URLs. */ + kind: 'execution-bound' + id: NodeId + binding: ExecutionBindingReceipt + seq: number + at: string + } + | { + /** Trusted runtime transformation from the authorized profile to actual wire bytes. */ + kind: 'materialized' + id: NodeId + receipt: ProfileMaterializationReceipt seq: number at: string } @@ -605,6 +863,11 @@ export type SpawnEvent = verdict?: DefaultVerdict spent: Spend infra?: boolean + /** Exact child failure. Present on every new `status: 'down'` record; optional only so + * journals written before this field existed remain replayable. */ + reason?: string + /** Structured tool evidence. Optional only for journals written before trace capture. */ + trace?: WorkerTraceEvidence seq: number at: string } @@ -684,6 +947,11 @@ export interface Supervisor { export interface SupervisorOpts { /** The root conserved-pool ceiling (tokens + usd + iterations + deadline). */ readonly budget: Budget + /** Exact root profile/task identity supplied by the one-call composition surface. */ + readonly rootIdentity?: NodeExecutionIdentity + /** Trusted composition evidence for a root whose `act` drives an external backend. A generic + * root omits it and is durably marked unknown; model-facing Scope never receives this writer. */ + readonly rootMaterialization?: RootMaterialization /** Trace-correlation root + the journal/blob root key. */ readonly runId: NodeId /** Event source — defaults to the in-memory journal in the impl; pass JSONL/FS for durability. */ @@ -698,6 +966,9 @@ export interface SupervisorOpts { readonly probes?: WaitProbeRegistry /** Runtime recursion-depth ceiling (paired with the conserved pool per R3). */ readonly maxDepth?: number + /** Hard tree-wide cap on simultaneously executing spawned workers. The root is excluded; every + * nested driver and leaf shares this one allocation. Omit/`<= 0` leaves worker count uncapped. */ + readonly maxLiveWorkers?: number /** * OTP intensity breaker: more than `maxRestarts` child restarts within `withinMs` * trips the supervisor to `no-winner` rather than restarting forever. @@ -799,10 +1070,12 @@ export type SupervisedResult = error: NoWinnerError } -/** Live root handle — the substrate a chat/pi-viz client attaches to (Q2). `signal` - * delivers an out-of-band message to the running root; `view()` materializes the tree. */ +/** Live root handle — a chat/pi-viz client uses it to inspect and control one root run. */ export interface RootHandle { view(): TreeView + /** Optional for structural compatibility with existing view/signal/abort wrappers. Handles + * minted by `createRootHandle` implement the required form in `SteerableRootHandle`. */ + deliver?(msg: unknown): boolean signal(msg: RootSignal): void abort(reason?: string): void /** Phantom: binds the handle to the supervised run's output type. Type-only — never @@ -810,6 +1083,12 @@ export interface RootHandle { readonly __out?: Out } +/** A Runtime-minted root handle that can deliver raw steering or answers to a live manager inbox. + * Delivery returns `false` when the manager has no receive path; detached calls fail loud. */ +export interface SteerableRootHandle extends RootHandle { + deliver(msg: unknown): boolean +} + /** Out-of-band message to a running root. Open by intent — a client extends it. */ export type RootSignal = | { kind: 'pause' } diff --git a/src/runtime/supervise/worktree-cli-executor.ts b/src/runtime/supervise/worktree-cli-executor.ts index 88df588d..3f422358 100644 --- a/src/runtime/supervise/worktree-cli-executor.ts +++ b/src/runtime/supervise/worktree-cli-executor.ts @@ -33,7 +33,9 @@ import { type WorktreeHarnessResult, type WorktreeHarnessRun, type WorktreeProfileMaterializationReceipt, + worktreeProfileExecutionPlan, } from '../../mcp/worktree-harness' +import { attestRuntimeOwnedExecutor, newExecutionAttemptId } from './materialization' import type { Executor, ExecutorResult, Spend } from './types' export type { WorktreeCommandResult, WorktreeProfileMaterializationReceipt } @@ -47,16 +49,16 @@ export interface WorktreeCliExecutorOptions { repoRoot: string /** * The supervisor-authored prompt/model plus materializable structural resources. - * `model.default` selects the one-shot model; `small`, `provider`, and `metadata` remain hints. - * Resource failures are fatal regardless of `resources.failOnError`. - * Tools, permissions, connections, confidential execution, modes, and extensions fail closed. - * Harness-specific nested controls that the pinned materializer cannot preserve also fail closed. + * `model.default` selects the one-shot model. Routing-only model hints, placement concerns, + * provider extensions, and `resources.failOnError` fail before execution because this path + * cannot honor them. Harness-specific values the materializer cannot preserve also fail closed. */ profile: AgentProfile /** Local CLI for this leaf. This explicit choice overrides `profile.harness`. */ harness: LocalHarness - /** The per-task instruction handed to the harness (composed under the system prompt). */ - taskPrompt: string + /** Default instruction for direct `execute(undefined, signal)` calls. An execution-time task + * is authoritative. Omit when the caller always supplies the task to `execute`. */ + taskPrompt?: string /** Unique id for the worktree path + branch. Defaults to a fresh UUID. */ runId?: string /** Override the base ref the worktree is cut from (default `HEAD`). */ @@ -93,14 +95,17 @@ export interface WorktreeCliExecutorOptions { * likewise return `LocalHarnessResult.usage`. */ budgetExempt?: boolean + /** @internal Kernel-minted attempt identity threaded by the built-in registry. */ + executionAttemptId?: string } /** * Build a worktree-CLI leaf `Executor`. Per-spawn (a fresh worktree + abort + teardown each), so a * fanout of N profiles = N parallel worktrees that never clobber each other. * - * Fail-loud: an empty `repoRoot`/`harness`/`taskPrompt` throws at construction. `resultArtifact()` - * before `execute()` resolves throws. + * Fail-loud: an empty `repoRoot`/`harness` or an explicitly empty `taskPrompt` throws at + * construction. Calling `execute(undefined, signal)` without a configured prompt throws before a + * worktree is created. `resultArtifact()` before `execute()` resolves throws. * * @experimental */ @@ -113,7 +118,10 @@ export function createWorktreeCliExecutor( if (!options.harness) { throw new ValidationError('createWorktreeCliExecutor: harness required') } - if (typeof options.taskPrompt !== 'string' || options.taskPrompt.length === 0) { + if ( + options.taskPrompt !== undefined && + (typeof options.taskPrompt !== 'string' || options.taskPrompt.length === 0) + ) { throw new ValidationError('createWorktreeCliExecutor: taskPrompt required') } if (options.codexReproducible && options.harness !== 'codex') { @@ -131,84 +139,151 @@ export function createWorktreeCliExecutor( } const runId = options.runId ?? randomUUID() + const attemptId = options.executionAttemptId ?? newExecutionAttemptId(runId) const controller = new AbortController() const budgetExempt = options.budgetExempt ?? !options.codexReproducible let run: WorktreeHarnessRun | undefined let artifact: ExecutorResult | undefined - return { - runtime: 'cli', - budgetExempt, - async execute(_task, signal): Promise> { - const linked = linkSignals(signal, controller.signal) - const started = Date.now() + const profilePlan = worktreeProfileExecutionPlan(options.profile, options.harness) + return attestRuntimeOwnedExecutor( + { + runtime: 'cli', + budgetExempt, + async execute(task, signal): Promise> { + const linked = linkSignals(signal, controller.signal) + const started = Date.now() + const taskPrompt = executionTaskPrompt(task, options.taskPrompt) - run = await runWorktreeHarness({ - repoRoot: options.repoRoot, - profile: options.profile, - harness: options.harness, - taskPrompt: options.taskPrompt, - runId, - ...(options.baseRef ? { baseRef: options.baseRef } : {}), - ...(options.testCmd !== undefined ? { testCmd: options.testCmd } : {}), - ...(options.typecheckCmd !== undefined ? { typecheckCmd: options.typecheckCmd } : {}), - ...(options.harnessTimeoutMs !== undefined - ? { harnessTimeoutMs: options.harnessTimeoutMs } - : {}), - ...(options.codexReproducible ? { codexReproducible: true } : {}), - ...(options.codexReadDeniedPaths - ? { codexReadDeniedPaths: options.codexReadDeniedPaths } - : {}), - ...(options.checkTimeoutMs !== undefined ? { checkTimeoutMs: options.checkTimeoutMs } : {}), - ...(options.checkOutputCap !== undefined ? { checkOutputCap: options.checkOutputCap } : {}), - ...(linked ? { signal: linked } : {}), - ...(options.runGit ? { runGit: options.runGit } : {}), - ...(options.runHarness ? { runHarness: options.runHarness } : {}), - ...(options.runCommand ? { runCommand: options.runCommand } : {}), - }) + run = await runWorktreeHarness({ + repoRoot: options.repoRoot, + profile: options.profile, + harness: options.harness, + taskPrompt, + runId, + ...(options.baseRef ? { baseRef: options.baseRef } : {}), + ...(options.testCmd !== undefined ? { testCmd: options.testCmd } : {}), + ...(options.typecheckCmd !== undefined ? { typecheckCmd: options.typecheckCmd } : {}), + ...(options.harnessTimeoutMs !== undefined + ? { harnessTimeoutMs: options.harnessTimeoutMs } + : {}), + ...(options.codexReproducible ? { codexReproducible: true } : {}), + ...(options.codexReadDeniedPaths + ? { codexReadDeniedPaths: options.codexReadDeniedPaths } + : {}), + ...(options.checkTimeoutMs !== undefined + ? { checkTimeoutMs: options.checkTimeoutMs } + : {}), + ...(options.checkOutputCap !== undefined + ? { checkOutputCap: options.checkOutputCap } + : {}), + ...(linked ? { signal: linked } : {}), + ...(options.runGit ? { runGit: options.runGit } : {}), + ...(options.runHarness ? { runHarness: options.runHarness } : {}), + ...(options.runCommand ? { runCommand: options.runCommand } : {}), + }) - const usage = run.result.harness.usage - if (!budgetExempt && !usage) { - const completed = run - run = undefined - await completed.cleanup() - throw new ValidationError( - 'createWorktreeCliExecutor: metered harness run returned no token usage', - ) - } - const spent: Spend = { - iterations: 1, - tokens: usage - ? { input: usage.inputTokens, output: usage.outputTokens } - : { input: 0, output: 0 }, - usd: 0, - ...(usage ? { usdKnown: false } : {}), - ms: Date.now() - started, - } - artifact = { outRef: contentAddress(run.result), out: run.result, spent } - return artifact + const usage = run.result.harness.usage + if (!budgetExempt && !usage) { + const completed = run + run = undefined + await completed.cleanup() + throw new ValidationError( + 'createWorktreeCliExecutor: metered harness run returned no token usage', + ) + } + const spent: Spend = { + iterations: 1, + tokens: usage + ? { input: usage.inputTokens, output: usage.outputTokens } + : { input: 0, output: 0 }, + usd: 0, + ...(usage ? { usdKnown: false } : {}), + ms: Date.now() - started, + } + artifact = { outRef: contentAddress(run.result), out: run.result, spent } + return artifact + }, + async teardown(_grace): Promise<{ destroyed: boolean }> { + controller.abort() + // The loser of a fanout (or any settled run) is dirty — remove its worktree. A run that + // THREW already cleaned itself up in the core, so `run` stays undefined and this is a no-op. + if (run) { + const r = run + run = undefined + await r.cleanup() + } + return { destroyed: true } + }, + resultArtifact() { + if (!artifact) { + throw new ValidationError( + 'createWorktreeCliExecutor: resultArtifact() read before execute() resolved', + ) + } + return artifact + }, }, - async teardown(_grace): Promise<{ destroyed: boolean }> { - controller.abort() - // The loser of a fanout (or any settled run) is dirty — remove its worktree. A run that - // THREW already cleaned itself up in the core, so `run` stays undefined and this is a no-op. - if (run) { - const r = run - run = undefined - await r.cleanup() - } - return { destroyed: true } + { + effectiveProfile: options.profile, + backend: `cli-worktree:${options.harness}`, + model: options.profile.model?.default + ? { status: 'known', id: options.profile.model.default } + : { status: 'unknown', reason: `${options.harness} selected its configured default model` }, + execution: { kind: 'worktree-run', id: runId }, + materializer: 'agent-profile-worktree-plan', + plan: { + kind: 'worktree-cli', + profilePlan, + harness: options.harness, + baseRef: options.baseRef ?? 'HEAD', + harnessTimeoutMs: options.harnessTimeoutMs ?? null, + codexReproducible: options.codexReproducible === true, + codexReadDeniedPaths: options.codexReadDeniedPaths ?? [], + testCmd: options.testCmd ?? null, + typecheckCmd: options.typecheckCmd ?? null, + checkTimeoutMs: options.checkTimeoutMs ?? null, + checkOutputCap: options.checkOutputCap ?? 16_000, + }, }, - resultArtifact() { - if (!artifact) { - throw new ValidationError( - 'createWorktreeCliExecutor: resultArtifact() read before execute() resolved', - ) - } - return artifact + { + attemptId, + binding: { + repoRoot: options.repoRoot, + runId, + harness: options.harness, + model: options.profile.model?.default ?? null, + baseRef: options.baseRef ?? 'HEAD', + }, + descriptor: { + kind: 'worktree-cli-run', + transport: 'process', + backend: options.harness, + }, }, + ) +} + +/** A scoped execution task is authoritative. The configured prompt remains only as the + * unambiguous direct-call default for existing `execute(undefined, signal)` consumers. */ +function executionTaskPrompt(task: unknown, configuredPrompt: string | undefined): string { + if (task === undefined) { + if (configuredPrompt !== undefined) return configuredPrompt + throw new ValidationError( + 'createWorktreeCliExecutor: execute task required when taskPrompt is not configured', + ) + } + if (typeof task === 'string') return task + try { + const encoded = JSON.stringify(task) + if (encoded !== undefined) return encoded + } catch (error) { + throw new ValidationError('createWorktreeCliExecutor: execute task must be JSON-serializable', { + cause: error, + }) } + throw new ValidationError('createWorktreeCliExecutor: execute task must be JSON-serializable') } /** Link two abort signals into one that fires when either does. Returns `undefined` when neither diff --git a/src/runtime/supervise/worktree-fanout.ts b/src/runtime/supervise/worktree-fanout.ts index e58a3e7c..8f744dc5 100644 --- a/src/runtime/supervise/worktree-fanout.ts +++ b/src/runtime/supervise/worktree-fanout.ts @@ -19,7 +19,7 @@ import { fanout, selectValidWinner } from '../personify/combinators' import type { CombinatorShape, WinnerStrategy } from '../personify/wave-types' import { type DeliverableSpec, gateOnDeliverable } from './completion-gate' import { type PatchDeliverableOptions, patchDelivered } from './patch-deliverable' -import type { AgentSpec } from './types' +import type { AgentSpec, ExecutorFactory } from './types' import { createWorktreeCliExecutor, type WorktreeCliExecutorOptions, @@ -35,6 +35,13 @@ export interface AuthoredHarness { profile: AgentProfile /** Which local harness CLI drives this leaf. */ harness: 'claude' | 'codex' | 'opencode' + /** Require measured usage from this leaf. Budgeted supervision refuses the default unmetered + * local-CLI mode; set false only when the selected runner actually returns token usage. */ + budgetExempt?: WorktreeCliExecutorOptions['budgetExempt'] + /** Run Codex through its measured, isolated JSONL path. This implies `budgetExempt: false`. */ + codexReproducible?: WorktreeCliExecutorOptions['codexReproducible'] + /** Host paths denied to a reproducible Codex leaf. */ + codexReadDeniedPaths?: WorktreeCliExecutorOptions['codexReadDeniedPaths'] /** Per-harness model/runId/baseRef overrides flow through the profile + these. */ runId?: string baseRef?: string @@ -88,26 +95,39 @@ export function worktreeFanout( }) const itemSpec = (item: AuthoredHarness): AgentSpec => { - const executor = gateOnDeliverable( - createWorktreeCliExecutor({ - repoRoot: options.repoRoot, - profile: item.profile, - harness: item.harness, - taskPrompt: options.taskPrompt, - ...(item.runId ? { runId: item.runId } : {}), - ...(item.baseRef ? { baseRef: item.baseRef } : {}), - ...(options.testCmd !== undefined ? { testCmd: options.testCmd } : {}), - ...(options.typecheckCmd !== undefined ? { typecheckCmd: options.typecheckCmd } : {}), - ...(options.harnessTimeoutMs !== undefined - ? { harnessTimeoutMs: options.harnessTimeoutMs } - : {}), - ...(options.runGit ? { runGit: options.runGit } : {}), - ...(options.runHarness ? { runHarness: options.runHarness } : {}), - ...(options.runCommand ? { runCommand: options.runCommand } : {}), - }), - deliverable, - ) - return { profile: item.profile, harness: null, executor: executor as AgentSpec['executor'] } + const executorFactory: ExecutorFactory = (_spec, ctx) => { + if (!ctx.node) { + throw new Error('worktreeFanout: supervised node context required') + } + return gateOnDeliverable( + createWorktreeCliExecutor({ + repoRoot: options.repoRoot, + profile: item.profile, + harness: item.harness, + taskPrompt: options.taskPrompt, + executionAttemptId: ctx.node.attemptId, + ...(item.budgetExempt !== undefined ? { budgetExempt: item.budgetExempt } : {}), + ...(item.codexReproducible !== undefined + ? { codexReproducible: item.codexReproducible } + : {}), + ...(item.codexReadDeniedPaths !== undefined + ? { codexReadDeniedPaths: item.codexReadDeniedPaths } + : {}), + ...(item.runId ? { runId: item.runId } : {}), + ...(item.baseRef ? { baseRef: item.baseRef } : {}), + ...(options.testCmd !== undefined ? { testCmd: options.testCmd } : {}), + ...(options.typecheckCmd !== undefined ? { typecheckCmd: options.typecheckCmd } : {}), + ...(options.harnessTimeoutMs !== undefined + ? { harnessTimeoutMs: options.harnessTimeoutMs } + : {}), + ...(options.runGit ? { runGit: options.runGit } : {}), + ...(options.runHarness ? { runHarness: options.runHarness } : {}), + ...(options.runCommand ? { runCommand: options.runCommand } : {}), + }), + deliverable, + ) + } + return { profile: item.profile, harness: null, executorFactory } } const selectWinner = selectValidWinner({ diff --git a/src/testing/fixtures/agent-improvement-proposal.json b/src/testing/fixtures/agent-improvement-proposal.json index 98ba2769..d65eda83 100644 --- a/src/testing/fixtures/agent-improvement-proposal.json +++ b/src/testing/fixtures/agent-improvement-proposal.json @@ -1,6 +1,6 @@ { "changedSurfaces": ["prompt"], - "digest": "sha256:f56fbc988fd13c60415915f0e1ef1628e90f5bba4270fc683f91de9e3ef5c0c7", + "digest": "sha256:a8e04889e298e6cd1dfe3d527722f43a14fd1e979023b6f020dee16b5f04842b", "evaluation": { "decision": { "contributingChecks": [ @@ -4810,7 +4810,7 @@ ], "metadata": { "fixture": "agent-improvement-proposal", - "runtimeVersion": "0.116.0" + "runtimeVersion": "0.117.0" }, "objectives": [ { @@ -4921,8 +4921,8 @@ "baselineContentHash": "sha256:5c21ee53e513fc604cb09754e21c392b24a424da0ef37dbf8f1ee4a8a0b08f09", "candidateContentHash": "sha256:60fcbb1c728194bd51d7d19cb732d1c3f1881dce7e0a6266b41c8b98cfd65693", "kind": "agent-eval-loop", - "recordDigest": "sha256:9b09ab09b56c68681b0e2d7bdd9e9999242230ce99bde343c297244a4d20c8db", - "runId": "agent-runtime-0.116.0-proposal-fixture", + "recordDigest": "sha256:80ce3a9d5959a8c0183a1457bdf2eec95c9e4686b3548671ca021074ab29f43d", + "runId": "agent-runtime-0.117.0-proposal-fixture", "schema": "agent-candidate-experiment" } }, @@ -4949,5 +4949,5 @@ ], "kind": "agent-improvement-proposal", "proposedAt": "2026-07-10T01:00:00.000Z", - "runId": "agent-runtime-0.116.0-proposal-fixture" + "runId": "agent-runtime-0.117.0-proposal-fixture" } diff --git a/src/testing/fixtures/agent-profile-improvement-proposal.json b/src/testing/fixtures/agent-profile-improvement-proposal.json index f8eea75b..8f465414 100644 --- a/src/testing/fixtures/agent-profile-improvement-proposal.json +++ b/src/testing/fixtures/agent-profile-improvement-proposal.json @@ -1,6 +1,6 @@ { "changedSurfaces": ["prompt", "skills"], - "digest": "sha256:459e61c95eaf69a6b822a13854a823c12b86f7ff0063a2bf25dd4195908f49df", + "digest": "sha256:b0d63ad56178709cfe403755993becbc8bf21635c5d95fbd884eda98a223d6f7", "evaluation": { "decision": { "contributingChecks": [ @@ -1715,7 +1715,7 @@ ], "metadata": { "fixture": "agent-profile-improvement-proposal", - "runtimeVersion": "0.116.0" + "runtimeVersion": "0.117.0" }, "objectives": [ { @@ -1826,7 +1826,7 @@ "baselineContentHash": "sha256:21c495a37c418c10bde64fbaa188beddeed31f1f051ea60a6a6582a9ee0db704", "candidateContentHash": "sha256:103f77bc8481601eef1ad5fe6ba84a40dffabc3a44f421f8c8559121edab84e9", "kind": "agent-eval-loop", - "recordDigest": "sha256:e8bf774cd2347cf2df81d74a9fb07ab2f643717ddc8f30435009e600d8da5c19", + "recordDigest": "sha256:73b72fb671a9c72744c0bb5ac9a1a31f7c6d02bac6e9204865318f8d55c75e67", "runId": "profile-improvement-1", "schema": "agent-profile-improvement-experiment" } diff --git a/tests/candidate-bundle-builder.test.ts b/tests/candidate-bundle-builder.test.ts index 9c9e228e..a6b211cd 100644 --- a/tests/candidate-bundle-builder.test.ts +++ b/tests/candidate-bundle-builder.test.ts @@ -15,6 +15,7 @@ import { afterEach, describe, expect, expectTypeOf, it } from 'vitest' import { assertCandidateProfileBinding } from '../src/candidate-execution' import { + agentCandidateProfileAsAgentProfile, type BuildAgentCandidateBundleInput, buildAgentCandidateBundle, sealAgentCandidateBundle, @@ -124,6 +125,10 @@ describe('public agent candidate bundle builder', () => { name: 'review/SKILL.md', sha256: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), }) + expect(agentCandidateProfileAsAgentProfile(first.profile)).toMatchObject({ + name: 'candidate', + harness: 'codex', + }) expect(first.knowledge).toEqual(input.knowledge) const verified = await verifyAgentCandidateBundle(first, { diff --git a/tests/candidate-execution-execute.test.ts b/tests/candidate-execution-execute.test.ts index 9b5078ba..1b433dc6 100644 --- a/tests/candidate-execution-execute.test.ts +++ b/tests/candidate-execution-execute.test.ts @@ -829,7 +829,7 @@ describe('atomic prepared candidate execution', () => { expect(persistedPurposes).not.toContain('benchmark-result') expect(persistedPurposes).not.toContain('trace') expect(persistedPurposes).not.toContain('run-receipt') - }, 15_000) + }) it('allows disposal to retry an unproven pre-claim cleanup', async () => { const fixture = createCandidateExecutionFixture(true) diff --git a/tests/candidate-execution-export-surface.test.ts b/tests/candidate-execution-export-surface.test.ts new file mode 100644 index 00000000..400b1649 --- /dev/null +++ b/tests/candidate-execution-export-surface.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { + freezeGenericAgentCandidateProfile as fromCandidateExecution, + omitUndefinedObjectFields as omitFromCandidateExecution, + parseExactCandidateProfile as parseFromCandidateExecution, +} from '../src/candidate-execution' +import { + freezeGenericAgentCandidateProfile as fromRoot, + omitUndefinedObjectFields as omitFromRoot, + parseExactCandidateProfile as parseFromRoot, +} from '../src/index' + +describe('candidate profile conversion public surface', () => { + it('exports the canonical conversion and exact parser from both public barrels', () => { + expect(fromRoot).toBe(fromCandidateExecution) + expect(parseFromRoot).toBe(parseFromCandidateExecution) + expect(omitFromRoot).toBe(omitFromCandidateExecution) + + const candidate = fromRoot({ + name: 'public-seed', + prompt: { systemPrompt: 'Lead the pursuit.' }, + }) + expect(parseFromRoot(candidate)).toEqual(candidate) + expect(omitFromRoot({ keep: 1, drop: undefined }, 'profile')).toEqual({ keep: 1 }) + }) +}) diff --git a/tests/helpers/resume-driver-child.ts b/tests/helpers/resume-driver-child.ts index a986c8f7..f2c1759a 100644 --- a/tests/helpers/resume-driver-child.ts +++ b/tests/helpers/resume-driver-child.ts @@ -139,7 +139,7 @@ const brain: ToolLoopChat = async (messages) => { } } -const result = await supervise({ name: 'root', harness: null }, 'five assignments', { +const result = await supervise({ name: 'root', harness: 'cli-base' }, 'five assignments', { budget: { maxIterations: 200, maxTokens: 500_000 }, // Explicit per-worker ceiling: the default is a quarter of the pool, which would starve the // fifth spawn and make this a four-worker test. diff --git a/tests/helpers/supervisor-resume-child.ts b/tests/helpers/supervisor-resume-child.ts index 2165782d..fadd5916 100644 --- a/tests/helpers/supervisor-resume-child.ts +++ b/tests/helpers/supervisor-resume-child.ts @@ -12,7 +12,7 @@ */ import { appendFileSync } from 'node:fs' -import type { AgentProfile } from '@tangle-network/agent-interface' +import { type AgentProfile, canonicalCandidateDigest } from '@tangle-network/agent-interface' import { spendFromUsageEvents } from '../../src/runtime/supervise/budget' import { createFileRunContext } from '../../src/runtime/supervise/run-context' import { createSupervisor } from '../../src/runtime/supervise/supervisor' @@ -148,6 +148,10 @@ const root: Agent = { const ctx = createFileRunContext(dir) const result = await createSupervisor().run(root, 'task', { budget: { maxIterations: 50, maxTokens: 100_000 }, + rootIdentity: { + profileDigest: canonicalCandidateDigest({ name: root.name }), + taskDigest: canonicalCandidateDigest('task'), + }, runId, ...ctx, now: () => (phase === '1' ? 1_000 : 2_000), diff --git a/tests/helpers/supervisor-wait-child.ts b/tests/helpers/supervisor-wait-child.ts index 7347cd32..0bc40139 100644 --- a/tests/helpers/supervisor-wait-child.ts +++ b/tests/helpers/supervisor-wait-child.ts @@ -17,6 +17,7 @@ */ import { existsSync } from 'node:fs' +import { canonicalCandidateDigest } from '@tangle-network/agent-interface' import { FileSpawnJournal } from '../../src/durable/spawn-journal' import { createFileRunContext } from '../../src/runtime/supervise/run-context' import { createSupervisor } from '../../src/runtime/supervise/supervisor' @@ -119,6 +120,10 @@ async function killOnceJournaled(): Promise { const ctx = createFileRunContext(dir) const result = await createSupervisor().run(root, 'task', { budget: { maxIterations: 50, maxTokens: 100_000 }, + rootIdentity: { + profileDigest: canonicalCandidateDigest({ name: root.name }), + taskDigest: canonicalCandidateDigest('task'), + }, runId, ...ctx, probes, diff --git a/tests/kernel/completion-gate.test.ts b/tests/kernel/completion-gate.test.ts index b5691bce..650130b8 100644 --- a/tests/kernel/completion-gate.test.ts +++ b/tests/kernel/completion-gate.test.ts @@ -127,7 +127,7 @@ let blobs = new InMemoryResultBlobStore() function driverOpts( name: string, brain: ToolLoopChat, - makeWorkerAgent: (p: unknown) => Agent, + makeWorkerAgent: (p: AgentProfile) => Agent, ): DriverAgentOptions { return { name, brain, blobs, makeWorkerAgent, perWorker, systemPrompt: 'drive', maxTurns: 8 } } @@ -149,7 +149,14 @@ function gatedWorkerLeaf( } const spawnAwaitStop: ScriptedTurn[] = [ - { toolCalls: [{ name: 'spawn_agent', arguments: { profile: { kind: 'worker' }, task: 'go' } }] }, + { + toolCalls: [ + { + name: 'spawn_agent', + arguments: { profile: { metadata: { kind: 'worker' } }, task: 'go' }, + }, + ], + }, { toolCalls: [{ name: 'await_event', arguments: {} }] }, { content: 'stop' }, ] @@ -207,14 +214,19 @@ describe('completion-oracle settle — settled ⟺ DELIVERED (Foreman 0/18)', () { out: { pick: 'not-me' }, score: 0.99 }, { check: () => false }, ) - const makeAgent = (raw: unknown) => - (raw as { which?: string })?.which === 'b' ? ran : delivered + const makeAgent = (profile: AgentProfile) => (profile.metadata?.which === 'b' ? ran : delivered) // spawn BOTH, await BOTH, stop. const turns: ScriptedTurn[] = [ { toolCalls: [ - { name: 'spawn_agent', arguments: { profile: { which: 'a' }, task: 'a' } }, - { name: 'spawn_agent', arguments: { profile: { which: 'b' }, task: 'b' } }, + { + name: 'spawn_agent', + arguments: { profile: { metadata: { which: 'a' } }, task: 'a' }, + }, + { + name: 'spawn_agent', + arguments: { profile: { metadata: { which: 'b' } }, task: 'b' }, + }, ], }, { @@ -244,9 +256,8 @@ describe('completion-oracle settle — settled ⟺ DELIVERED (Foreman 0/18)', () const journal = new InMemorySpawnJournal() // The mid driver spawns ONE worker whose deliverable check FAILS. - const makeAgent = (raw: unknown): Agent => { - const p = raw as { kind?: string } - if (p?.kind === 'driver') { + const makeAgent = (profile: AgentProfile): Agent => { + if (profile.metadata?.kind === 'driver') { return driverChild( 'mid', driverAgent(driverOpts('mid', scriptedBrain(spawnAwaitStop), makeAgent)), @@ -262,7 +273,10 @@ describe('completion-oracle settle — settled ⟺ DELIVERED (Foreman 0/18)', () const rootTurns: ScriptedTurn[] = [ { toolCalls: [ - { name: 'spawn_agent', arguments: { profile: { kind: 'driver' }, task: 'delegate' } }, + { + name: 'spawn_agent', + arguments: { profile: { metadata: { kind: 'driver' } }, task: 'delegate' }, + }, ], }, { toolCalls: [{ name: 'await_event', arguments: {} }] }, diff --git a/tests/kernel/coordination-driver.test.ts b/tests/kernel/coordination-driver.test.ts index 5e7e8c43..d0ad0d81 100644 --- a/tests/kernel/coordination-driver.test.ts +++ b/tests/kernel/coordination-driver.test.ts @@ -31,7 +31,7 @@ interface WorkerScript { readonly score: number } -function workerExecutor(s: WorkerScript): Executor { +function workerExecutor(s: WorkerScript, onTeardown?: () => void): Executor { const events: UsageEvent[] = [] for (let i = 0; i < s.iterations; i += 1) events.push({ kind: 'iteration' }) events.push({ kind: 'tokens', input: s.tokens.input, output: s.tokens.output }) @@ -42,7 +42,10 @@ function workerExecutor(s: WorkerScript): Executor { for (const ev of events) yield ev })() }, - teardown: () => Promise.resolve({ destroyed: true }), + teardown: () => { + onTeardown?.() + return Promise.resolve({ destroyed: true }) + }, resultArtifact(): ExecutorResult { return { outRef: `w:${JSON.stringify(s.out)}`, @@ -54,11 +57,15 @@ function workerExecutor(s: WorkerScript): Executor { } } -function workerLeaf(name: string, s: WorkerScript): Agent { +function workerLeaf( + name: string, + s: WorkerScript, + onTeardown?: () => void, +): Agent { const spec: AgentSpec = { profile: { name } as AgentProfile, harness: null, - executor: workerExecutor(s), + executor: workerExecutor(s, onTeardown), } return { name, act: async () => s.out, executorSpec: spec } as Agent & { executorSpec: AgentSpec @@ -101,7 +108,7 @@ const perWorker: Budget = { maxIterations: 4, maxTokens: 1000 } function driverOpts( name: string, brain: ToolLoopChat, - makeWorkerAgent: (p: unknown) => Agent, + makeWorkerAgent: (p: AgentProfile) => Agent, ): DriverAgentOptions { return { name, @@ -130,14 +137,17 @@ describe('driverAgent — the driver BRAIN (LLM tool-loop drives real spawns)', score: 0.9, }) // The makeWorkerAgent the spawn_agent tool dispatches: this test only spawns the worker leaf. - const makeAgent = (_p: unknown): Agent => worker + const makeAgent = (_p: AgentProfile): Agent => worker // Scripted driver LLM: turn 0 spawns a worker, turn 1 awaits it, turn 2 stops (no calls). const chat = scriptedBrain( [ { toolCalls: [ - { name: 'spawn_agent', arguments: { profile: { kind: 'worker' }, task: 'go' } }, + { + name: 'spawn_agent', + arguments: { profile: { metadata: { kind: 'worker' } }, task: 'go' }, + }, ], }, { toolCalls: [{ name: 'await_event', arguments: {} }] }, @@ -180,25 +190,45 @@ describe('driverAgent — the driver BRAIN (LLM tool-loop drives real spawns)', SHARED_BLOBS = new InMemoryResultBlobStore() const journal = new InMemorySpawnJournal() - const worker = workerLeaf('w', { - out: { answer: 42 }, - tokens: { input: 10, output: 5 }, - iterations: 1, - score: 0.9, + let markWorkerFinished: (() => void) | undefined + const workerFinished = new Promise((resolve) => { + markWorkerFinished = resolve }) - const makeAgent = (_p: unknown): Agent => worker + + const worker = workerLeaf( + 'w', + { + out: { answer: 42 }, + tokens: { input: 10, output: 5 }, + iterations: 1, + score: 0.9, + }, + () => markWorkerFinished?.(), + ) + const makeAgent = (_p: AgentProfile): Agent => worker // Scripted driver LLM: spawns a worker then STOPS — it never calls await_event, the exact // pull-discipline failure a live LLM brain exhibits. The worker still delivers; losing it // to an empty ledger was the bug. - const chat = scriptedBrain([ + const scripted = scriptedBrain([ { toolCalls: [ - { name: 'spawn_agent', arguments: { profile: { kind: 'worker' }, task: 'go' } }, + { + name: 'spawn_agent', + arguments: { profile: { metadata: { kind: 'worker' } }, task: 'go' }, + }, ], }, { content: 'spawned; stopping without awaiting' }, ]) + let turn = 0 + const chat: ToolLoopChat = async (messages, options) => { + // Model inference naturally leaves time between tool rounds. Make that ordering explicit so + // this test proves the documented case—already-settled work—not a race with journal commit. + if (turn === 1) await workerFinished + turn += 1 + return scripted(messages, options) + } const root = driverAgent(driverOpts('root', chat, makeAgent)) const result = await createSupervisor().run(root, 'solve it', { @@ -228,7 +258,7 @@ describe('driverAgent — the driver BRAIN (LLM tool-loop drives real spawns)', // Alternate good/failing on each spawn_agent dispatch — the brain fans out three workers, // two of which crash (down), and stops without awaiting any of them. let spawn = 0 - const makeAgent = (_p: unknown): Agent => + const makeAgent = (_p: AgentProfile): Agent => spawn++ === 0 ? good : hangingWorkerLeaf(`bad-${spawn}`) const chat = scriptedBrain([ @@ -279,7 +309,10 @@ describe('driverAgent — the driver BRAIN (LLM tool-loop drives real spawns)', const midTurns: ScriptedTurn[] = [ { toolCalls: [ - { name: 'spawn_agent', arguments: { profile: { kind: 'worker' }, task: 'sub' } }, + { + name: 'spawn_agent', + arguments: { profile: { metadata: { kind: 'worker' } }, task: 'sub' }, + }, ], }, { toolCalls: [{ name: 'await_event', arguments: {} }] }, @@ -288,9 +321,8 @@ describe('driverAgent — the driver BRAIN (LLM tool-loop drives real spawns)', // The recursive resolver: a 'driver' profile → a driverChild wrapping ANOTHER // driverAgent (over the same recursive makeAgent); a 'worker' profile → leaf. - const makeAgent = (raw: unknown): Agent => { - const p = raw as { kind?: string } - if (p?.kind === 'driver') { + const makeAgent = (profile: AgentProfile): Agent => { + if (profile.metadata?.kind === 'driver') { const childBrain = scriptedBrain(midTurns, midSeen) return driverChild('mid', driverAgent(driverOpts('mid', childBrain, makeAgent)), journal) } @@ -302,7 +334,10 @@ describe('driverAgent — the driver BRAIN (LLM tool-loop drives real spawns)', [ { toolCalls: [ - { name: 'spawn_agent', arguments: { profile: { kind: 'driver' }, task: 'delegate' } }, + { + name: 'spawn_agent', + arguments: { profile: { metadata: { kind: 'driver' } }, task: 'delegate' }, + }, ], }, { toolCalls: [{ name: 'await_event', arguments: {} }] }, @@ -424,7 +459,7 @@ function collectTreeKeys(journal: InMemorySpawnJournal): string[] { // `list_questions` is always present (no analysts needed), has no side effects, and reserves no // budget — the ideal benign tool for driving the loop a fixed number of turns. const benignTurn: ScriptedTurn = { toolCalls: [{ name: 'list_questions', arguments: {} }] } -const dummyWorker = (_p: unknown): Agent => +const dummyWorker = (_p: AgentProfile): Agent => workerLeaf('w', { out: {}, tokens: { input: 0, output: 0 }, iterations: 0, score: 0 }) function bounds0Opts(name: string, brain: ToolLoopChat): DriverAgentOptions { @@ -690,7 +725,7 @@ describe('driverAgent — the driver can ACT (call work tools itself), not only }) describe('driverAgent — the analyst up-leg (analysts + analyzeOnSettle pass-through)', () => { - const noWorker = (_p: unknown): Agent => + const noWorker = (_p: AgentProfile): Agent => ({ name: 'w', act: async () => '', diff --git a/tests/kernel/coordination-log.test.ts b/tests/kernel/coordination-log.test.ts new file mode 100644 index 00000000..d7950740 --- /dev/null +++ b/tests/kernel/coordination-log.test.ts @@ -0,0 +1,265 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { InMemoryResultBlobStore } from '../../src/durable/spawn-journal' +import type { ContinuationInstruction, CoordinationEvent } from '../../src/mcp/tools/coordination' +import { createCoordinationTools } from '../../src/mcp/tools/coordination' +import { FileCoordinationLog } from '../../src/runtime/supervise/coordination-log' +import type { BusRecord } from '../../src/runtime/supervise/event-bus' +import type { Agent, Scope } from '../../src/runtime/supervise/types' + +function stamped(seq: number, event: CoordinationEvent): BusRecord { + return { seq, at: 1_000 + seq, priority: event.type === 'question' ? 20 : 0, event } +} + +function instruction(receiptId: string, text: string): ContinuationInstruction { + return { + receiptId, + kind: 'steer', + toWorker: 'worker-1', + instruction: text, + instructionDigest: `sha256:${receiptId.padEnd(64, '0').slice(0, 64)}`, + interrupt: false, + } +} + +function evidenceChain( + receipt: ContinuationInstruction, + outcome: 'delivered' | 'runtime-has-no-inbox', +): CoordinationEvent[] { + return [ + { type: 'instruction', instruction: receipt }, + { + type: 'delivery-attempt', + attempt: { + receiptId: receipt.receiptId, + kind: receipt.kind, + toWorker: receipt.toWorker, + instructionDigest: receipt.instructionDigest, + interrupt: receipt.interrupt, + }, + }, + { + type: 'steer', + down: { + receiptId: receipt.receiptId, + toWorker: receipt.toWorker, + instruction: receipt.instruction, + instructionDigest: receipt.instructionDigest, + delivered: outcome === 'delivered', + outcome, + }, + }, + ] +} + +describe('FileCoordinationLog delivery evidence', () => { + it('keeps complete success and refusal chains linked and isolated by stable owner', async () => { + const dir = await mkdtemp(join(tmpdir(), 'coordination-log-owner-')) + try { + const log = new FileCoordinationLog(join(dir, 'coordination.jsonl')) + const accepted = instruction('accepted', 'continue A') + const refused = instruction('refused', 'continue B') + for (const [seq, event] of evidenceChain(accepted, 'delivered').entries()) { + await log.append('run', stamped(seq, event), 'owner-A') + } + for (const [seq, event] of evidenceChain(refused, 'runtime-has-no-inbox').entries()) { + await log.append('run', stamped(seq, event), 'owner-B') + } + + const ownerA = await log.load('run', 'owner-A') + const ownerB = await log.load('run', 'owner-B') + expect(ownerA.continuations.map((entry) => entry.receiptId)).toEqual(['accepted']) + expect(ownerA.deliveryEvidence.map((entry) => entry.type)).toEqual([ + 'delivery-attempt', + 'steer', + ]) + expect(ownerB.continuations.map((entry) => entry.receiptId)).toEqual(['refused']) + expect(ownerB.deliveryEvidence.map((entry) => entry.type)).toEqual([ + 'delivery-attempt', + 'steer', + ]) + const acceptedOutcome = ownerA.deliveryEvidence.find((entry) => entry.type === 'steer') + const refusedOutcome = ownerB.deliveryEvidence.find((entry) => entry.type === 'steer') + expect(acceptedOutcome?.down).toMatchObject({ receiptId: 'accepted', outcome: 'delivered' }) + expect(refusedOutcome?.down).toMatchObject({ + receiptId: 'refused', + outcome: 'runtime-has-no-inbox', + }) + expect(ownerA.records.map(({ seq, at, priority }) => ({ seq, at, priority }))).toEqual([ + { seq: 0, at: 1000, priority: 0 }, + { seq: 1, at: 1001, priority: 0 }, + { seq: 2, at: 1002, priority: 0 }, + ]) + + const raw = (await readFile(join(dir, 'coordination.jsonl'), 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { ownerId?: string }) + expect(raw.map((record) => record.ownerId)).toEqual([ + 'owner-A', + 'owner-A', + 'owner-A', + 'owner-B', + 'owner-B', + 'owner-B', + ]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('preserves an attempted-but-unconfirmed delivery as unknown evidence without inventing an outcome', async () => { + const dir = await mkdtemp(join(tmpdir(), 'coordination-log-crash-window-')) + try { + const log = new FileCoordinationLog(join(dir, 'coordination.jsonl')) + const receipt = instruction('crashed', 'do not replay me') + const [authorized, attempted] = evidenceChain(receipt, 'delivered') + if (!authorized || !attempted) throw new Error('missing evidence fixture') + await log.append('run', stamped(0, authorized), 'owner') + await log.append('run', stamped(1, attempted), 'owner') + + const prior = await log.load('run', 'owner') + expect(prior.continuations.map((entry) => entry.receiptId)).toEqual(['crashed']) + expect(prior.deliveryEvidence).toEqual([attempted]) + expect( + prior.deliveryEvidence.some((entry) => entry.type === 'steer' || entry.type === 'answer'), + ).toBe(false) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('keeps a question blocking after a refused answer delivery across restart', async () => { + const dir = await mkdtemp(join(tmpdir(), 'coordination-log-refused-answer-')) + try { + const log = new FileCoordinationLog(join(dir, 'coordination.jsonl')) + const questionId = 'worker-1:q0' + const receipt: ContinuationInstruction = { + ...instruction('answer-refused', 'choose B'), + kind: 'answer', + questionId, + } + const question: CoordinationEvent = { + type: 'question', + question: { + id: questionId, + from: 'worker-1', + level: 'worker', + question: 'Which target?', + reason: 'the experiment cannot continue without a target', + urgency: 'blocks-run', + status: 'open', + openedAt: 0, + }, + } + const attempt: CoordinationEvent = { + type: 'delivery-attempt', + attempt: { + receiptId: receipt.receiptId, + kind: 'answer', + toWorker: receipt.toWorker, + instructionDigest: receipt.instructionDigest, + interrupt: false, + questionId, + }, + } + const refused: CoordinationEvent = { + type: 'answer', + questionId, + down: { + receiptId: receipt.receiptId, + toWorker: receipt.toWorker, + instruction: receipt.instruction, + instructionDigest: receipt.instructionDigest, + delivered: false, + outcome: 'runtime-has-no-inbox', + }, + } + for (const [seq, event] of [ + question, + { type: 'instruction', instruction: receipt }, + attempt, + refused, + ].entries() as ArrayIterator<[number, CoordinationEvent]>) { + await log.append('run', stamped(seq, event), 'owner') + } + + const prior = await log.load('run', 'owner') + expect(prior.questions).toMatchObject([{ id: questionId, status: 'open' }]) + expect(prior.deliveryEvidence.at(-1)).toMatchObject({ + type: 'answer', + down: { receiptId: receipt.receiptId, delivered: false }, + }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('serializes concurrent appends and replays the exact bus ordering metadata', async () => { + const dir = await mkdtemp(join(tmpdir(), 'coordination-log-concurrent-order-')) + try { + const log = new FileCoordinationLog(join(dir, 'coordination.jsonl')) + const scope = { + send: () => false, + next: async () => null, + get view() { + return { root: 'run', nodes: [], inFlight: 0, waiting: 0 } + }, + budget: { + tokensLeft: 100, + tokensKnown: true, + usdLeft: 0, + usdCapped: false, + usdKnown: true, + iterationsLeft: 10, + deadlineMs: 0, + reservedTokens: 0, + }, + signal: new AbortController().signal, + } as unknown as Scope + const coord = createCoordinationTools({ + scope, + blobs: new InMemoryResultBlobStore(), + makeWorkerAgent: () => + ({ name: 'unused', act: async () => undefined }) as Agent, + perWorker: { maxIterations: 1, maxTokens: 10 }, + onEvent: (_event, record) => log.append('run', record, 'owner'), + }) + const steer = coord.tools.find((tool) => tool.name === 'steer_agent') + if (!steer) throw new Error('steer_agent tool missing') + + await Promise.all([ + steer.handler({ workerId: 'missing-A', instruction: 'continue A' }), + steer.handler({ workerId: 'missing-B', instruction: 'continue B' }), + ]) + + const prior = await log.load('run', 'owner') + expect(prior.records.map((record) => record.seq)).toEqual([0, 1, 2, 3, 4, 5]) + for (const receipt of prior.continuations) { + const receiptSeq = prior.records.find( + (record) => + record.event.type === 'instruction' && + record.event.instruction.receiptId === receipt.receiptId, + )?.seq + const attemptSeq = prior.records.find( + (record) => + record.event.type === 'delivery-attempt' && + record.event.attempt.receiptId === receipt.receiptId, + )?.seq + const outcomeSeq = prior.records.find( + (record) => + record.event.type === 'steer' && record.event.down.receiptId === receipt.receiptId, + )?.seq + expect(receiptSeq).toBeTypeOf('number') + expect(attemptSeq).toBeGreaterThan(receiptSeq as number) + expect(outcomeSeq).toBeGreaterThan(attemptSeq as number) + } + expect(prior.records.every((record) => record.priority === 0)).toBe(true) + expect(prior.records.every((record) => Number.isFinite(record.at))).toBe(true) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/kernel/coordination-mcp.test.ts b/tests/kernel/coordination-mcp.test.ts index a47cdf8d..1c1632bf 100644 --- a/tests/kernel/coordination-mcp.test.ts +++ b/tests/kernel/coordination-mcp.test.ts @@ -104,6 +104,69 @@ describe('coordination MCP over a live Scope — the real keystone (HTTP → MCP expect(names).toContain('spawn_agent') expect(names).toContain('await_event') }) + + it('serves product-owned node tools beside coordination tools over the same HTTP MCP', async () => { + const calls: unknown[] = [] + const scope = {} as Scope + const mcp = await serveCoordinationMcp({ + scope, + blobs: new InMemoryResultBlobStore(), + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker: { maxIterations: 1, maxTokens: 1 }, + nodeTools: [ + { + name: 'lookup_evidence', + description: 'Read product evidence', + inputSchema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + handler: async (raw) => { + calls.push(raw) + return { result: 'trusted evidence' } + }, + }, + ], + }) + try { + const listed = await jsonRpc(mcp.url, 'tools/list', {}) + const names = ((listed.result as { tools?: Array<{ name: string }> })?.tools ?? []).map( + (entry) => entry.name, + ) + expect(names).toContain('spawn_agent') + expect(names).toContain('lookup_evidence') + + const called = await jsonRpc(mcp.url, 'tools/call', { + name: 'lookup_evidence', + arguments: { query: 'claim' }, + }) + expect(called.error).toBeUndefined() + expect(called.result).toMatchObject({ structuredContent: { result: 'trusted evidence' } }) + expect(calls).toEqual([{ query: 'claim' }]) + } finally { + await mcp.close() + } + }) + + it('refuses a product tool that shadows spawn_agent before opening a listener', async () => { + await expect( + serveCoordinationMcp({ + scope: {} as Scope, + blobs: new InMemoryResultBlobStore(), + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker: { maxIterations: 1, maxTokens: 1 }, + nodeTools: [ + { + name: 'spawn_agent', + description: 'must not shadow coordination', + inputSchema: { type: 'object' }, + handler: async () => ({}), + }, + ], + }), + ).rejects.toThrow(/spawn_agent.*shadows/) + }) }) /** Run `body` against a REAL live scope — the same path the sandbox supervisor arm uses — and diff --git a/tests/kernel/coordination.test.ts b/tests/kernel/coordination.test.ts index e8346a3e..b2f6bfd7 100644 --- a/tests/kernel/coordination.test.ts +++ b/tests/kernel/coordination.test.ts @@ -1,16 +1,71 @@ +import type { ToolSpan, TraceAnalysisStore } from '@tangle-network/agent-eval' import { agentProfileSchema } from '@tangle-network/agent-interface' import { describe, expect, it } from 'vitest' import { createMcpServer } from '../../src/mcp/server' import { + type CoordinationEvent, createCoordinationTools, deriveSpawnProfileArg, spawnProfileFieldNames, } from '../../src/mcp/tools/coordination' -import type { Agent, ResultBlobStore, Scope, Spend } from '../../src/runtime' -import { createPushTraceSource, watchTrace } from '../../src/runtime' +import type { Agent, ResultBlobStore, Scope, Spend, WorkerTraceEvidence } from '../../src/runtime' +import { + contentAddress, + createPushTraceSource, + WORKER_TOOL_TRACE_SCHEMA_VERSION, + watchTrace, +} from '../../src/runtime' const zeroSpend = (): Spend => ({ iterations: 0, tokens: { input: 0, output: 0 }, usd: 0, ms: 0 }) +const noTrace = { + status: 'unavailable', + reason: 'executor-did-not-expose-trace-source', +} as const satisfies WorkerTraceEvidence + +const toolSpan = { + spanId: 'worker-trace-t0', + runId: 'worker-trace', + kind: 'tool', + name: 'read_file', + toolName: 'read_file', + args: { path: 'actual.ts' }, + result: { text: 'structured tool result' }, + status: 'ok', + startedAt: 100, + endedAt: 105, +} as const satisfies ToolSpan +const traceArtifact = { + schemaVersion: WORKER_TOOL_TRACE_SCHEMA_VERSION, + spans: [toolSpan], +} +const traceRef = contentAddress(traceArtifact) +const availableTrace = { + status: 'available', + traceRef, + spanCount: 1, +} as const satisfies WorkerTraceEvidence + +function traceBlobStore(outputRef: string, output: unknown): ResultBlobStore { + return { + get: async (ref) => { + if (ref === traceRef) return traceArtifact + if (ref === outputRef) return output + return undefined + }, + put: async () => {}, + } +} + +async function traceSummary(trace: TraceAnalysisStore) { + const overview = await trace.getOverview() + return { + traces: overview.total_traces, + tools: overview.tool_names, + traceIds: overview.sample_trace_ids, + } +} + function mockScope() { const sent: Array<{ id: string; msg: unknown }> = [] const spawns: Array<{ task: unknown; opts: { budget: unknown; label: string } }> = [] @@ -21,6 +76,10 @@ function mockScope() { status: 'running' as const, runtime: 'router', budget: { maxIterations: 1, maxTokens: 10 }, + identity: { + profileDigest: `sha256:${'a'.repeat(64)}`, + taskDigest: `sha256:${'b'.repeat(64)}`, + }, spent: zeroSpend(), }, { @@ -31,6 +90,7 @@ function mockScope() { budget: { maxIterations: 1, maxTokens: 10 }, spent: zeroSpend(), outRef: 'blob:w1', + trace: noTrace, }, ] let admit = true @@ -140,6 +200,7 @@ describe('coordination tools', () => { // No `maxLiveWorkers` cap ⇒ `freeSlots: null` (uncapped; the conserved pool is the fence). expect(await tool(tb, 'spawn_agent').handler({ profile: {}, task: 'go' })).toEqual({ workerId: 'w0', + assignmentId: 'ordinal:0', live: 1, freeSlots: null, }) @@ -151,6 +212,66 @@ describe('coordination tools', () => { }) }) + it('continues unkeyed assignment identity past the highest durable resume ordinal', async () => { + const { scope } = mockScope() + Object.defineProperty(scope, 'resume', { + value: { + settled: [], + view: { + root: 'root', + nodes: [ + { + id: 'prior-0', + parent: 'root', + label: 'prior 0', + status: 'done', + runtime: 'router', + budget: { maxIterations: 1, maxTokens: 10 }, + assignmentId: 'ordinal:0', + spent: zeroSpend(), + }, + { + id: 'prior-3', + parent: 'root', + label: 'prior 3', + status: 'done', + runtime: 'router', + budget: { maxIterations: 1, maxTokens: 10 }, + assignmentId: 'ordinal:3', + spent: zeroSpend(), + }, + { + id: 'prior-keyed', + parent: 'root', + label: 'prior keyed', + status: 'done', + runtime: 'router', + budget: { maxIterations: 1, maxTokens: 10 }, + assignmentId: 'key:control', + spent: zeroSpend(), + }, + ], + inFlight: 0, + waiting: 0, + }, + waits: [], + keys: new Map(), + priorSpend: { childWork: zeroSpend(), driverInference: zeroSpend() }, + }, + }) + const tb = createCoordinationTools({ + scope, + blobs, + makeWorkerAgent, + perWorker: { maxIterations: 1, maxTokens: 10 }, + }) + + expect(await tool(tb, 'spawn_agent').handler({ profile: {}, task: 'new work' })).toMatchObject({ + workerId: 'w0', + assignmentId: 'ordinal:4', + }) + }) + it('spawn_agent fails closed at the maxLiveWorkers cap WITHOUT touching the pool', async () => { // A scope whose live (non-terminal) node set is driven by the spawns we make: each successful // spawn appends a `running` node; nothing settles. The conserved pool always admits, so the @@ -190,14 +311,29 @@ describe('coordination tools', () => { const spawn = () => tool(tb, 'spawn_agent').handler({ profile: {}, task: 'go' }) // `freeSlots` counts down as the cap fills — the reading that tells the driver capacity is // still idle, so it can fill slots instead of opening one worker per turn. - expect(await spawn()).toEqual({ workerId: 'w0', live: 1, freeSlots: 1 }) - expect(await spawn()).toEqual({ workerId: 'w1', live: 2, freeSlots: 0 }) + expect(await spawn()).toEqual({ + workerId: 'w0', + assignmentId: 'ordinal:0', + live: 1, + freeSlots: 1, + }) + expect(await spawn()).toEqual({ + workerId: 'w1', + assignmentId: 'ordinal:1', + live: 2, + freeSlots: 0, + }) // The 2 live workers fill the cap → the 3rd fails closed BEFORE scope.spawn is called. expect(await spawn()).toEqual({ error: 'max-live-workers', live: 2, freeSlots: 0 }) expect(spawns).toHaveLength(2) // A settled worker frees a slot — mark one terminal and the next spawn admits again. live[0]!.status = 'done' - expect(await spawn()).toEqual({ workerId: 'w2', live: 2, freeSlots: 0 }) + expect(await spawn()).toEqual({ + workerId: 'w2', + assignmentId: 'ordinal:2', + live: 2, + freeSlots: 0, + }) // No cap (omitted) → the pool stays the only fence; the same scope admits past the prior cap. const uncapped = createCoordinationTools({ @@ -208,6 +344,7 @@ describe('coordination tools', () => { }) expect(await tool(uncapped, 'spawn_agent').handler({ profile: {}, task: 'go' })).toEqual({ workerId: 'w3', + assignmentId: 'ordinal:0', live: 3, freeSlots: null, }) @@ -225,6 +362,7 @@ describe('coordination tools', () => { outRef: 'blob:a', verdict: { score: 1, valid: true }, spent: zeroSpend(), + trace: noTrace, seq: 0, } let deliveredKey: string | undefined @@ -274,7 +412,12 @@ describe('coordination tools', () => { tool(tb, 'spawn_agent').handler({ profile: {}, task: 'go', key }) // Key 'a' runs and takes the only slot, then delivers. - expect(await spawnKeyed('a')).toEqual({ workerId: 'w0', live: 1, freeSlots: 0 }) + expect(await spawnKeyed('a')).toEqual({ + workerId: 'w0', + assignmentId: 'key:a', + live: 1, + freeSlots: 0, + }) live[0]!.status = 'done' deliveredKey = 'a' // Drain the settlement the way the driver does — this is what teaches the toolbox that key @@ -283,7 +426,12 @@ describe('coordination tools', () => { await tool(tb, 'await_event').handler({ kinds: ['settled'] }) // A different assignment now occupies the single slot. - expect(await spawnKeyed('b')).toEqual({ workerId: 'w1', live: 1, freeSlots: 0 }) + expect(await spawnKeyed('b')).toEqual({ + workerId: 'w1', + assignmentId: 'key:b', + live: 1, + freeSlots: 0, + }) // Re-asking for the DELIVERED key at the cap must return its committed result, not a refusal. expect(await spawnKeyed('a')).toEqual({ @@ -293,6 +441,8 @@ describe('coordination tools', () => { score: 1, valid: true, outRef: 'blob:a', + spent: zeroSpend(), + trace: noTrace, live: 1, freeSlots: 0, }) @@ -328,7 +478,12 @@ describe('coordination tools', () => { task: 'hard', budget: { maxTokens: 5000, maxUsd: 0.5 }, }), - ).toEqual({ workerId: 'w0', live: 1, freeSlots: null }) + ).toEqual({ + workerId: 'w0', + assignmentId: 'ordinal:0', + live: 1, + freeSlots: null, + }) expect(spawns[0].opts.budget).toEqual({ maxIterations: 2, maxTokens: 5000, maxUsd: 0.5 }) }) @@ -410,6 +565,7 @@ describe('coordination tools', () => { outRef: 'blob:w7', verdict: { valid: true, score: 0.83 }, spent: zeroSpend(), + trace: noTrace, seq: 0, }, ] @@ -432,6 +588,8 @@ describe('coordination tools', () => { score: 0.83, valid: true, outRef: 'blob:w7', + spent: zeroSpend(), + trace: noTrace, freeSlots: null, }) expect(await tool(tb, 'await_event').handler({ kinds: ['settled'] })).toEqual({ @@ -441,9 +599,9 @@ describe('coordination tools', () => { expect(tb.settled()).toMatchObject([ { id: 'w7', status: 'done', score: 0.83, valid: true, outRef: 'blob:w7' }, ]) - // The ledger stamps WHEN the settlement landed — the resolution a progress-based stop rule - // reads to answer "how long since anything landed?" without inventing a timestamp at read time. - expect(typeof tb.settled()[0]?.settledAt).toBe('number') + // This hand-rolled scope supplied no durable terminal timestamp. Missing stays missing; the + // coordination layer never invents a read-time value that would change on replay. + expect(tb.settled()[0]?.settledAt).toBeUndefined() }) it('await_event bounds the block: { pending, live } while a worker runs, then pulls the settlement once it lands', async () => { @@ -490,6 +648,7 @@ describe('coordination tools', () => { outRef: 'blob:w0', verdict: { valid: true, score: 0.5 }, spent: zeroSpend(), + trace: noTrace, seq: 0, } release() @@ -503,7 +662,7 @@ describe('coordination tools', () => { it('blocks stop under failClosed until a parent question is answered', async () => { const { scope } = mockScope() - const emitted: unknown[] = [] + const emitted: CoordinationEvent[] = [] const tb = createCoordinationTools({ scope, blobs, @@ -514,7 +673,7 @@ describe('coordination tools', () => { }) const r = (await tool(tb, 'ask_parent').handler({ - from: 'driver-1', + from: 'w0', level: 'driver', question: 'Which API version should this migration target?', reason: 'worker found two supported versions', @@ -524,36 +683,110 @@ describe('coordination tools', () => { stopped: false, error: 'unresolved-blocking-questions', }) - // The driver-1 asker is not a live worker in the mock scope → the answer reports delivered:false. + // The asker is a live worker, so accepted delivery resolves the blocking question. expect( await tool(tb, 'answer_question').handler({ questionId: r.question.id, answer: 'Target v2.', by: 'user', }), - ).toMatchObject({ question: { status: 'answered' }, delivered: false }) + ).toMatchObject({ question: { status: 'answered' }, delivered: true }) expect(await tool(tb, 'stop').handler({ reason: 'answered and verified' })).toEqual({ stopped: true, }) expect(tb.questions()[0]).toMatchObject({ status: 'answered' }) - // The pass-through trail records BOTH legs: the question up, then the answer routed down. - expect(emitted).toEqual([ + // The exact answer is committed before it is routed down. + expect(emitted).toMatchObject([ { type: 'question', question: expect.objectContaining(r.question) }, + { + type: 'instruction', + instruction: expect.objectContaining({ + kind: 'answer', + toWorker: 'w0', + instruction: 'Target v2.', + questionId: r.question.id, + }), + }, + { + type: 'delivery-attempt', + attempt: expect.objectContaining({ + kind: 'answer', + toWorker: 'w0', + questionId: r.question.id, + }), + }, { type: 'answer', questionId: r.question.id, - down: { toWorker: 'driver-1', instruction: 'Target v2.', delivered: false }, + down: expect.objectContaining({ + toWorker: 'w0', + instruction: 'Target v2.', + delivered: true, + outcome: 'delivered', + }), }, ]) + const receipt = emitted[1] + const attempt = emitted[2] + const outcome = emitted[3] + if ( + receipt?.type !== 'instruction' || + attempt?.type !== 'delivery-attempt' || + outcome?.type !== 'answer' + ) { + throw new Error('expected authorization, attempt, and answer outcome evidence') + } + expect(attempt.attempt.receiptId).toBe(receipt.instruction.receiptId) + expect(outcome.down.receiptId).toBe(receipt.instruction.receiptId) + expect(outcome.down.instructionDigest).toBe(receipt.instruction.instructionDigest) + }) + + it('keeps a blocking question open when its authorized answer cannot reach the worker', async () => { + const { scope } = mockScope() + const emitted: CoordinationEvent[] = [] + const tb = createCoordinationTools({ + scope, + blobs, + makeWorkerAgent, + perWorker: { maxIterations: 1, maxTokens: 10 }, + questionPolicy: 'failClosed', + onEvent: (event) => emitted.push(event), + }) + const raised = (await tool(tb, 'ask_parent').handler({ + from: 'missing-worker', + level: 'worker', + question: 'Which target?', + reason: 'blocked on a choice', + urgency: 'blocks-run', + })) as { question: { id: string } } + + const answer = await tool(tb, 'answer_question').handler({ + questionId: raised.question.id, + answer: 'Target B', + }) + expect(answer).toMatchObject({ + question: { id: raised.question.id, status: (raised.question as { status: string }).status }, + delivered: false, + reason: 'unknown-worker', + }) + expect(tb.questions()).toMatchObject([ + { id: raised.question.id, status: (raised.question as { status: string }).status }, + ]) + expect(await tool(tb, 'stop').handler({ reason: 'cannot claim done' })).toMatchObject({ + stopped: false, + error: 'unresolved-blocking-questions', + }) + const outcome = emitted.find((event) => event.type === 'answer') + expect(outcome?.down).toMatchObject({ delivered: false, outcome: 'unknown-worker' }) }) it('list_analysts surfaces the menu and run_analyst applies a lens to a settled worker', async () => { const { scope } = mockScope() - const traceBlobs: ResultBlobStore = { - get: async (ref) => (ref === 'blob:w1' ? { messages: ['trace'] } : undefined), - put: async () => {}, - } - const seen: Array<{ kind: string; trace: unknown }> = [] + Object.assign(scope.view.nodes.find((node) => node.id === 'w1')!, { trace: availableTrace }) + const traceBlobs = traceBlobStore('blob:w1', { + messages: ['WORKER PROSE THAT MUST NEVER REACH A TRACE ANALYST'], + }) + const seen: Array<{ kind: string; trace: Awaited> }> = [] const tb = createCoordinationTools({ scope, blobs: traceBlobs, @@ -562,7 +795,7 @@ describe('coordination tools', () => { analysts: { kinds: [{ id: 'completeness', description: 'unfinished work', area: 'failure-mode' }], run: async (kind, trace) => { - seen.push({ kind, trace }) + seen.push({ kind, trace: await traceSummary(trace) }) return [{ claim: 'X missing' }] }, }, @@ -575,7 +808,12 @@ describe('coordination tools', () => { findings: [{ claim: 'X missing' }], }, ) - expect(seen).toEqual([{ kind: 'completeness', trace: { messages: ['trace'] } }]) + expect(seen).toEqual([ + { + kind: 'completeness', + trace: { traces: 1, tools: ['read_file'], traceIds: ['worker-trace'] }, + }, + ]) expect(await tool(tb, 'run_analyst').handler({ kind: 'completeness', workerId: 'w0' })).toEqual( { error: expect.stringContaining('has not settled'), @@ -583,6 +821,34 @@ describe('coordination tools', () => { ) }) + it('refuses trace analysis when a settled worker has no structured tool spans', async () => { + const { scope } = mockScope() + let analystCalls = 0 + const tb = createCoordinationTools({ + scope, + blobs: traceBlobStore('blob:w1', { + messages: ['plausible-looking worker prose is still not tool evidence'], + }), + makeWorkerAgent, + perWorker: { maxIterations: 1, maxTokens: 10 }, + analysts: { + kinds: [{ id: 'completeness', description: 'unfinished work', area: 'failure-mode' }], + run: async () => { + analystCalls += 1 + return [] + }, + }, + }) + + await expect( + tool(tb, 'run_analyst').handler({ kind: 'completeness', workerId: 'w1' }), + ).resolves.toEqual({ + error: expect.stringContaining('trace evidence is missing'), + trace: noTrace, + }) + expect(analystCalls).toBe(0) + }) + it('await_event bumps a blocking question ahead of a non-blocking one (urgency→priority)', async () => { const { scope } = mockScope() const tb = createCoordinationTools({ @@ -622,7 +888,7 @@ describe('coordination tools', () => { it('steer_agent routes down + records in history but is never pulled back', async () => { const { scope, sent } = mockScope() - const emitted: Array<{ type: string }> = [] + const emitted: CoordinationEvent[] = [] const tb = createCoordinationTools({ scope, blobs, @@ -646,15 +912,110 @@ describe('coordination tools', () => { // The forceful steer reached the child inbox (down delivery)... expect(sent).toEqual([{ id: 'w0', msg: { steer: 'do X', interrupt: true } }]) // ...and both attempts were recorded for observability (pass-through + history)... - expect(emitted.map((e) => e.type)).toEqual(['steer', 'steer']) - expect(tb.history().map((r) => r.event.type)).toEqual(['steer', 'steer']) + expect(emitted.map((e) => e.type)).toEqual([ + 'instruction', + 'delivery-attempt', + 'steer', + 'instruction', + 'delivery-attempt', + 'steer', + ]) + expect(tb.history().map((r) => r.event.type)).toEqual([ + 'instruction', + 'delivery-attempt', + 'steer', + 'instruction', + 'delivery-attempt', + 'steer', + ]) + const [ + acceptedReceipt, + acceptedAttempt, + acceptedOutcome, + refusedReceipt, + refusedAttempt, + refusedOutcome, + ] = emitted + if ( + acceptedReceipt?.type !== 'instruction' || + acceptedAttempt?.type !== 'delivery-attempt' || + acceptedOutcome?.type !== 'steer' || + refusedReceipt?.type !== 'instruction' || + refusedAttempt?.type !== 'delivery-attempt' || + refusedOutcome?.type !== 'steer' + ) { + throw new Error('expected two complete delivery evidence chains') + } + expect(acceptedAttempt.attempt.receiptId).toBe(acceptedReceipt.instruction.receiptId) + expect(acceptedOutcome.down).toMatchObject({ + receiptId: acceptedReceipt.instruction.receiptId, + outcome: 'delivered', + delivered: true, + }) + expect(refusedAttempt.attempt.receiptId).toBe(refusedReceipt.instruction.receiptId) + expect(refusedOutcome.down).toMatchObject({ + receiptId: refusedReceipt.instruction.receiptId, + outcome: 'unknown-worker', + delivered: false, + }) // ...but the parent never pulls its own outbound messages back. expect(await tool(tb, 'await_event').handler({})).toEqual({ idle: true, freeSlots: null }) }) + it('authorizes and commits the exact continuation before delivery', async () => { + const { scope } = mockScope() + const steps: string[] = [] + const mutable = scope as unknown as { + send(id: string, message: unknown): boolean + } + mutable.send = (_id, message) => { + steps.push(`send:${JSON.stringify(message)}`) + return true + } + const tb = createCoordinationTools({ + scope, + blobs, + makeWorkerAgent, + perWorker: { maxIterations: 1, maxTokens: 10 }, + authorizeDownMessage(input) { + steps.push(`authorize:${input.instruction}`) + expect(Object.isFrozen(input)).toBe(true) + expect(Object.isFrozen(input.workerIdentity)).toBe(true) + return { instruction: 'AUTHORIZED CONTINUATION' } + }, + onEvent(event) { + if (event.type === 'instruction') steps.push(`commit:${event.instruction.instruction}`) + if (event.type === 'delivery-attempt') steps.push(`attempt:${event.attempt.kind}`) + if (event.type === 'steer') steps.push(`outcome:${event.down.outcome}`) + }, + }) + + await tool(tb, 'steer_agent').handler({ workerId: 'w0', instruction: 'authored text' }) + + expect(steps).toEqual([ + 'authorize:authored text', + 'commit:AUTHORIZED CONTINUATION', + 'attempt:steer', + 'send:{"steer":"AUTHORIZED CONTINUATION","interrupt":false}', + 'outcome:delivered', + ]) + expect(tb.history()[0]?.event).toMatchObject({ + type: 'instruction', + instruction: { + kind: 'steer', + toWorker: 'w0', + instruction: 'AUTHORIZED CONTINUATION', + workerIdentity: { + profileDigest: `sha256:${'a'.repeat(64)}`, + taskDigest: `sha256:${'b'.repeat(64)}`, + }, + }, + }) + }) + it('answer_question routes the answer down to a LIVE worker and surfaces delivered:true', async () => { const { scope, sent } = mockScope() - const emitted: Array<{ type: string }> = [] + const emitted: CoordinationEvent[] = [] const tb = createCoordinationTools({ scope, blobs, @@ -681,8 +1042,13 @@ describe('coordination tools', () => { expect(sent).toEqual([ { id: 'w0', msg: { answer: 'path B', questionId: r.question.id, interrupt: true } }, ]) - // ...and both legs are on the trail: question up, answer down. - expect(emitted.map((e) => e.type)).toEqual(['question', 'answer']) + // ...and the committed exact instruction sits between question-up and answer-down. + expect(emitted.map((e) => e.type)).toEqual([ + 'question', + 'instruction', + 'delivery-attempt', + 'answer', + ]) }) it('analyze-on-settle auto-runs lenses and await_event surfaces settled + finding', async () => { @@ -695,6 +1061,7 @@ describe('coordination tools', () => { outRef: 'blob:w7', verdict: { valid: false, score: 0.1 }, spent: zeroSpend(), + trace: availableTrace, seq: 0, }, ] @@ -705,15 +1072,21 @@ describe('coordination tools', () => { const emitted: string[] = [] const tb = createCoordinationTools({ scope: drainScope, - blobs: { - get: async (ref) => (ref === 'blob:w7' ? { messages: ['trace'] } : undefined), - put: async () => {}, - }, + blobs: traceBlobStore('blob:w7', { + messages: ['worker final prose must not be analyzed as a trace'], + }), makeWorkerAgent, perWorker: { maxIterations: 1, maxTokens: 10 }, analysts: { kinds: [{ id: 'completeness', description: 'unfinished work', area: 'failure-mode' }], - run: async () => [{ claim: 'stub left in place' }], + run: async (_kind, trace) => { + expect(await traceSummary(trace)).toEqual({ + traces: 1, + tools: ['read_file'], + traceIds: ['worker-trace'], + }) + return [{ claim: 'stub left in place' }] + }, }, analyzeOnSettle: ['completeness'], onEvent: (e) => emitted.push(e.type), @@ -727,6 +1100,8 @@ describe('coordination tools', () => { score: 0.1, valid: false, outRef: 'blob:w7', + spent: zeroSpend(), + trace: availableTrace, freeSlots: null, }) // The analyze-on-settle finding is now queued; the next pull surfaces it. @@ -743,6 +1118,54 @@ describe('coordination tools', () => { expect(emitted).toEqual(['settled', 'finding']) }) + it('retains a settlement when an awaited observer loses its acknowledgement', async () => { + const { scope } = mockScope() + const settlements = [ + { + kind: 'done' as const, + handle: { id: 'w-retry', label: 'w', status: 'done' as const, abort() {} }, + out: { answer: 1 }, + outRef: 'blob:w-retry', + verdict: { valid: true, score: 0.8 }, + spent: zeroSpend(), + trace: noTrace, + seq: 0, + }, + ] + const drainScope = { + ...scope, + next: () => Promise.resolve(settlements.shift() ?? null), + } as typeof scope + const stamps: Array<{ seq: number; at: number }> = [] + let loseAcknowledgement = true + const tb = createCoordinationTools({ + scope: drainScope, + blobs, + makeWorkerAgent, + perWorker: { maxIterations: 1, maxTokens: 10 }, + onEvent(event, record) { + if (event.type !== 'settled') return + stamps.push({ seq: record.seq, at: record.at }) + if (loseAcknowledgement) throw new Error('ack lost after commit') + }, + }) + + await expect(tool(tb, 'await_event').handler({})).rejects.toThrow('ack lost after commit') + expect(tb.settled()).toEqual([]) + expect(tb.history()).toEqual([]) + + loseAcknowledgement = false + await expect(tool(tb, 'await_event').handler({})).resolves.toMatchObject({ + type: 'settled', + settled: 'w-retry', + status: 'done', + }) + expect(stamps).toHaveLength(2) + expect(stamps[1]).toEqual(stamps[0]) + expect(tb.settled()).toHaveLength(1) + expect(tb.history()).toHaveLength(1) + }) + it('await_event with kinds filter waits for a specific message type', async () => { const { scope } = mockScope() const settlements = [ @@ -753,6 +1176,7 @@ describe('coordination tools', () => { outRef: 'blob:w8', verdict: { valid: true, score: 1 }, spent: zeroSpend(), + trace: noTrace, seq: 0, }, ] @@ -767,6 +1191,7 @@ describe('coordination tools', () => { type: 'settled', settled: 'w8', valid: true, + trace: noTrace, }) expect(await tool(tb, 'await_event').handler({ kinds: ['settled'] })).toEqual({ idle: true, @@ -784,6 +1209,7 @@ describe('coordination tools', () => { outRef: 'blob:w9', verdict: { valid: true, score: 1 }, spent: zeroSpend(), + trace: noTrace, seq: 0, }, ] @@ -802,6 +1228,7 @@ describe('coordination tools', () => { // The drained settled event was queued, not lost — a caller that asks for it still gets it. expect(await tool(tb, 'await_event').handler({ kinds: ['settled'] })).toMatchObject({ settled: 'w9', + trace: noTrace, }) }) @@ -970,11 +1397,10 @@ describe("spawn_agent's published child-profile schema", () => { expect(profile.description).toMatch(/makeWorkerAgent/) }) - it('stays permissive, so a profile accepted before the schema existed is still accepted', async () => { + it('accepts every canonical profile and refuses an unknown field with a typed issue', async () => { const profile = profileArg() - // No `required` and open `additionalProperties` are the whole permissiveness guarantee: the - // canonical object is `additionalProperties: false` and omits none of these fields, so a - // faithful copy would make the seven reduced-away fields invalid to pass. + // The PUBLISHED tool schema stays permissive (no `required`, open `additionalProperties`) so a + // harness whose schema copy lags never fails at the protocol layer… expect(profile.additionalProperties).toBe(true) expect(profile.required).toBeUndefined() @@ -990,12 +1416,23 @@ describe("spawn_agent's published child-profile schema", () => { perWorker: { maxIterations: 1, maxTokens: 10 }, }) const spawn = tool(tb, 'spawn_agent') - // The empty profile every prior test spawns with, and one carrying both a reduced-away - // canonical field and a field the canonical schema has never had. - const legacy = { tags: ['research'], someUnknownField: true, name: 'w' } + // …while the HANDLER validates the model-authored profile against the canonical schema and + // fails CLOSED with named issues: an unrecognized field would otherwise be silently dropped + // from the worker that runs, which is exactly the drop this runtime exists to refuse. + const canonical = { tags: ['research'], name: 'w' } expect(await spawn.handler({ profile: {}, task: 'go' })).toMatchObject({ workerId: 'w0' }) - expect(await spawn.handler({ profile: legacy, task: 'go' })).toMatchObject({ workerId: 'w0' }) - expect(seen).toEqual([{}, legacy]) + expect(await spawn.handler({ profile: canonical, task: 'go' })).toMatchObject({ + workerId: 'w0', + }) + expect( + await spawn.handler({ profile: { ...canonical, someUnknownField: true }, task: 'go' }), + ).toMatchObject({ + error: 'invalid-profile', + issues: [{ message: expect.stringContaining('someUnknownField') }], + }) + // The worker factory is LAZY — the real scope invokes it only after reservation — so this + // mock scope records the spawn without building the agent. + expect(seen).toEqual([]) expect(spawns).toHaveLength(2) }) diff --git a/tests/kernel/delegate.test.ts b/tests/kernel/delegate.test.ts index 715d776c..f46f4461 100644 --- a/tests/kernel/delegate.test.ts +++ b/tests/kernel/delegate.test.ts @@ -62,14 +62,14 @@ describe('delegate — the one generic delegation verb over supervise()', () => expect(superviseSpy).toHaveBeenCalledTimes(1) const [profile, task, opts] = superviseSpy.mock.calls[0] as [ - { name?: string; harness?: unknown; systemPrompt?: string }, + { name?: string; harness?: unknown; prompt?: { systemPrompt?: string } }, unknown, { backend?: unknown; router?: unknown; budget?: unknown }, ] // A router-brained AUTHORING supervisor: its standing instruction IS the authoring skill, so it // writes its own worker profile from the intent — no worker profile is baked into delegate. - expect(profile.harness ?? null).toBeNull() - expect(profile.systemPrompt).toBe(supervisorInstructions()) + expect(profile.harness).toBe('cli-base') + expect(profile.prompt?.systemPrompt).toBe(supervisorInstructions()) // The intent is handed through verbatim as the task. expect(task).toBe('fix the failing auth test') // The injected substrate (where workers run + the brain) is forwarded. @@ -127,11 +127,11 @@ describe('delegate — the one generic delegation verb over supervise()', () => }) const [profile, , opts] = superviseSpy.mock.calls[0] as [ - { model?: string }, + { model?: { default?: string } }, unknown, Record, ] - expect(profile.model).toBe('glm-5.2') + expect(profile.model?.default).toBe('glm-5.2') expect(opts.deliverable).toBe(deliverable) expect(opts.budget).toBe(budget) expect(opts.allowedModels).toEqual(['glm-5.2', 'deepseek-v4-flash']) @@ -144,9 +144,11 @@ describe('delegate — the one generic delegation verb over supervise()', () => router, supervisor: { name: 'my-supervisor', systemPrompt: 'custom stance' }, }) - const [profile] = superviseSpy.mock.calls[0] as [{ name?: string; systemPrompt?: string }] + const [profile] = superviseSpy.mock.calls[0] as [ + { name?: string; prompt?: { systemPrompt?: string } }, + ] expect(profile.name).toBe('my-supervisor') - expect(profile.systemPrompt).toBe('custom stance') + expect(profile.prompt?.systemPrompt).toBe('custom stance') }) it('fails loud on an empty intent', async () => { diff --git a/tests/kernel/driver-inference-metering.test.ts b/tests/kernel/driver-inference-metering.test.ts index 415603b5..1103cf6c 100644 --- a/tests/kernel/driver-inference-metering.test.ts +++ b/tests/kernel/driver-inference-metering.test.ts @@ -69,6 +69,55 @@ function meteredChat(turns: ScriptedTurn[]): ToolLoopChat { const perWorker: Budget = { maxIterations: 4, maxTokens: 1000 } describe("driver inference metering — the driver's own tokens count against the conserved pool", () => { + it('charges a nested worker once and releases the manager reservation', async () => { + const blobs = new InMemoryResultBlobStore() + const journal = new InMemorySpawnJournal() + const worker = workerLeaf('leaf', { input: 10, output: 0 }) + const childBudget: Budget = { maxIterations: 4, maxTokens: 40 } + + const nestedDriver: Agent = { + name: 'nested', + async act(_task, scope) { + const spawned = scope.spawn(worker, 'work', { budget: childBudget, label: 'leaf' }) + if (!spawned.ok) throw new Error(`nested spawn failed: ${spawned.reason}`) + const settled = await scope.next() + return settled?.kind === 'done' ? settled.out : undefined + }, + } + const nested = driverChild( + { name: 'nested', metadata: { role: 'driver' } }, + nestedDriver, + journal, + ) + const root: Agent = { + name: 'root', + async act(_task, scope) { + const spawned = scope.spawn(nested, 'nested work', { + budget: childBudget, + label: 'nested', + }) + if (!spawned.ok) throw new Error(`root spawn failed: ${spawned.reason}`) + await scope.next() + return scope.budget.tokensLeft + }, + } + + const result = await createSupervisor().run(root, 'task', { + budget: { maxIterations: 10, maxTokens: 100 }, + runId: 'nested-budget-once', + journal, + blobs, + executors: withDriverExecutor(createExecutorRegistry()), + maxDepth: 4, + now: () => 0, + }) + + expect(result.kind).toBe('winner') + if (result.kind !== 'winner') return + expect(result.out).toBe(90) + expect(result.spentTotal.tokens).toEqual({ input: 10, output: 0 }) + }) + it('folds driver inference into spentTotal and exposes the driver-vs-child breakdown', async () => { const blobs = new InMemoryResultBlobStore() const journal = new InMemorySpawnJournal() @@ -127,43 +176,59 @@ describe("driver inference metering — the driver's own tokens count against th const blobs = new InMemoryResultBlobStore() const journal = new InMemorySpawnJournal() const worker = workerLeaf('leaf', { input: 10, output: 5 }) + const nestedPerWorker: Budget = { ...perWorker, maxUsd: 1 } // root driver → mid sub-driver → worker leaf. The recursive resolver: a 'driver' profile becomes // a driverChild wrapping another driverAgent; a 'worker' profile becomes the leaf. - type P = { kind: 'driver'; name: string; turns: ScriptedTurn[] } | { kind: 'worker' } - const driverOf = (name: string, brain: ToolLoopChat): DriverAgentOptions => ({ + const driverOf = ( + name: string, + brain: ToolLoopChat, + workerBudget: Budget = nestedPerWorker, + ): DriverAgentOptions => ({ name, brain, blobs, makeWorkerAgent: makeAgent, - perWorker, + perWorker: workerBudget, systemPrompt: 'drive', maxTurns: 8, }) - function makeAgent(raw: unknown): Agent { - const p = raw as P - if (p?.kind === 'driver') { - return driverChild(p.name, driverAgent(driverOf(p.name, meteredChat(p.turns))), journal) - } - return worker - } // mid sub-driver inference = 60/40 + 30/20 + 10/5 = 100/65 tokens, $0.05 (re-homed up). - const midProfile: P = { - kind: 'driver', - name: 'mid', - turns: [ - { - toolCalls: [ - { name: 'spawn_agent', arguments: { profile: { kind: 'worker' }, task: 'sub' } }, - ], - usage: { input: 60, output: 40 }, - costUsd: 0.05, - }, - { toolCalls: [{ name: 'await_event', arguments: {} }], usage: { input: 30, output: 20 } }, - { content: 'mid done', usage: { input: 10, output: 5 } }, - ], + const midTurns: ScriptedTurn[] = [ + { + toolCalls: [ + { + name: 'spawn_agent', + arguments: { profile: { metadata: { kind: 'worker' } }, task: 'sub' }, + }, + ], + usage: { input: 60, output: 40 }, + costUsd: 0.05, + }, + { toolCalls: [{ name: 'await_event', arguments: {} }], usage: { input: 30, output: 20 } }, + { content: 'mid done', usage: { input: 10, output: 5 } }, + ] + function makeAgent( + profile: AgentProfile, + context?: { readonly budget: Budget }, + ): Agent { + if (profile.metadata?.kind === 'driver') { + if (!context) throw new Error('driver spawn context missing') + const childBudget: Budget = { + maxIterations: context.budget.maxIterations, + maxTokens: Math.max(1, Math.floor(context.budget.maxTokens / 4)), + ...(context.budget.maxUsd !== undefined ? { maxUsd: context.budget.maxUsd / 4 } : {}), + } + return driverChild( + 'mid', + driverAgent(driverOf('mid', meteredChat(midTurns), childBudget)), + journal, + ) + } + return worker } + const midProfile: AgentProfile = { name: 'mid', metadata: { kind: 'driver' } } // root driver inference = 100/50 + 50/30 + 20/10 = 170/90 tokens, $0.02. const rootChat = meteredChat([ { @@ -205,9 +270,8 @@ describe("driver inference metering — the driver's own tokens count against th // A sub-driver that meters turn 0 (40/20) then CRASHES (chat throws) on turn 1 — the crash // settles it `down`, which must STILL re-home the partial inference it durably metered. - const makeAgent = (raw: unknown): Agent => { - const p = raw as { kind?: string } - if (p?.kind === 'driver') { + const makeAgent = (profile: AgentProfile): Agent => { + if (profile.metadata?.kind === 'driver') { let t = 0 const crashingChat: ToolLoopChat = async () => { t += 1 @@ -237,7 +301,10 @@ describe("driver inference metering — the driver's own tokens count against th const rootChat = meteredChat([ { toolCalls: [ - { name: 'spawn_agent', arguments: { profile: { kind: 'driver' }, task: 'go' } }, + { + name: 'spawn_agent', + arguments: { profile: { metadata: { kind: 'driver' } }, task: 'go' }, + }, ], usage: { input: 100, output: 50 }, }, @@ -327,6 +394,54 @@ describe("driver inference metering — the driver's own tokens count against th expect(n).toBe(3) }) + it.each([ + { + label: 'zero remaining child iterations', + rootBudget: { maxIterations: 0, maxTokens: 10_000 } satisfies Budget, + workerBudget: { maxIterations: 1, maxTokens: 100 } satisfies Budget, + }, + { + label: 'remaining capped dollars below the exact child allocation', + rootBudget: { maxIterations: 10, maxTokens: 10_000, maxUsd: 0.09 } satisfies Budget, + workerBudget: { maxIterations: 1, maxTokens: 100, maxUsd: 0.1 } satisfies Budget, + }, + ])('does not buy a manager turn with $label', async ({ rootBudget, workerBudget }) => { + const blobs = new InMemoryResultBlobStore() + const journal = new InMemorySpawnJournal() + let managerTurns = 0 + const result = await createSupervisor().run( + driverAgent({ + name: 'root', + brain: async () => { + managerTurns += 1 + return { + toolCalls: [{ id: 'would-spawn', name: 'spawn_agent', arguments: '{}' }], + usage: { input: 5, output: 5 }, + costUsd: 0.01, + } + }, + blobs, + makeWorkerAgent: () => workerLeaf('unused', { input: 1, output: 1 }), + perWorker: workerBudget, + systemPrompt: 'drive', + maxTurns: 0, + }), + 'cannot admit child work', + { + budget: rootBudget, + runId: `pre-turn-admission-${rootBudget.maxUsd ?? 'iterations'}`, + journal, + blobs, + executors: createExecutorRegistry(), + maxDepth: 2, + now: () => 0, + }, + ) + + expect(managerTurns).toBe(0) + expect(result.kind).toBe('no-winner') + }) + it('emits an agent.turn observability event per metered driver turn (the live A++ view)', async () => { const blobs = new InMemoryResultBlobStore() const journal = new InMemorySpawnJournal() diff --git a/tests/kernel/driver-recursion.test.ts b/tests/kernel/driver-recursion.test.ts index 21f3e931..73a44059 100644 --- a/tests/kernel/driver-recursion.test.ts +++ b/tests/kernel/driver-recursion.test.ts @@ -100,12 +100,13 @@ function scriptedDriver( scope: Scope, ) => Array<{ label: string; agent: Agent }>, observed: Observed, + childBudget = perChild, ): Agent { return { name, async act(task, scope: Scope): Promise { for (const c of spawnChildren(scope)) { - const res = scope.spawn(c.agent, task, { budget: perChild, label: c.label }) + const res = scope.spawn(c.agent, task, { budget: childBudget, label: c.label }) if (!res.ok) throw new Error(`${name}: spawn ${c.label} failed: ${res.reason}`) // The node id IS the nesting proof: a driver child's nested scope parents its own // children under the driver's node id, so the worker's id is `rec:s0:s0:s0` — three @@ -143,6 +144,58 @@ function supervisorOpts(over: Partial = {}): SupervisorOpts { } describe('recursive driver: agents drive agents drive agents', () => { + it('delivers a parent steer through driverChild and the nested driver executor', async () => { + const journal = new InMemorySpawnJournal() + const blobs = new InMemoryResultBlobStore() + let received: unknown + let acceptMessage!: () => void + const message = new Promise((resolve) => { + acceptMessage = resolve + }) + const nested: Agent = { + name: 'nested-manager', + deliver(value): boolean { + received = value + acceptMessage() + return true + }, + async act() { + await message + return received + }, + } + const root: Agent = { + name: 'root', + async act(task, scope) { + const spawned = scope.spawn(driverChild('nested-manager', nested, journal), task, { + budget: perChild, + label: 'nested-manager', + }) + if (!spawned.ok) throw new Error(spawned.reason) + expect( + scope.send(spawned.handle.id, { + steer: 'change the experiment before continuing', + interrupt: true, + }), + ).toBe(true) + await scope.next() + return received + }, + } + + const result = await createSupervisor().run( + root, + 'task', + supervisorOpts({ runId: 'nested-delivery', journal, blobs }), + ) + + expect(result.kind).toBe('winner') + expect(received).toEqual({ + steer: 'change the experiment before continuing', + interrupt: true, + }) + }) + it('a driver spawns a driver spawns a worker (depth-2 tree settles, root selects)', async () => { const journal = new InMemorySpawnJournal() const blobs = new InMemoryResultBlobStore() @@ -195,6 +248,57 @@ describe('recursive driver: agents drive agents drive agents', () => { expect(observed.settledIds).toContain('rec:s0:s0') // worker settled into the nested scope }) + it('keeps a nested manager invalid when its finalizer refuses a valid child', async () => { + const journal = new InMemorySpawnJournal() + const blobs = new InMemoryResultBlobStore() + const worker = workerLeaf('worker', { + out: { partial: true }, + tokens: { input: 5, output: 5 }, + iterations: 1, + score: 1, + }) + const refusingManager: Agent = { + name: 'refusing-manager', + async act(task, scope) { + const spawned = scope.spawn(worker, task, { + budget: perChild, + label: 'worker', + }) + if (!spawned.ok) throw new Error(spawned.reason) + await scope.next() + return undefined + }, + } + const root: Agent = { + name: 'root', + async act(task, scope) { + const spawned = scope.spawn( + driverChild('refusing-manager', refusingManager, journal), + task, + { budget: perChild, label: 'refusing-manager' }, + ) + if (!spawned.ok) throw new Error(spawned.reason) + await scope.next() + return undefined + }, + } + + const result = await createSupervisor().run( + root, + 'task', + supervisorOpts({ runId: 'finalizer-refusal', journal, blobs }), + ) + + expect(result.kind).toBe('no-winner') + const events = await journal.loadTree('finalizer-refusal') + const managerSettlement = events?.find((event) => event.kind === 'settled') + expect(managerSettlement).toMatchObject({ + kind: 'settled', + status: 'done', + verdict: { valid: false }, + }) + }) + it('conserves the budget across depth: Σ spend over every tree ≤ the root ceiling', async () => { const journal = new InMemorySpawnJournal() const blobs = new InMemoryResultBlobStore() @@ -219,9 +323,8 @@ describe('recursive driver: agents drive agents drive agents', () => { ) expect(result.kind).toBe('winner') - // Sum spend over EVERY journaled tree (root + every nested tree). The conserved pool - // guarantees this never exceeds the root ceiling, because every spawn at every depth - // reserves from the SAME pool and fails closed when it can't cover the child. + // Sum spend over EVERY journaled tree (root + every nested tree). Each nested scope + // partitions its parent's reserved allocation and fails closed when a child cannot fit. const allTreeKeys = collectTreeKeys(journal) let totalTokens = 0 let totalIterations = 0 @@ -254,12 +357,9 @@ describe('recursive driver: agents drive agents drive agents', () => { } }) - it('budget is CONSERVED across depth: a deep spawn fails closed when the shared pool is too small', async () => { - // The root ceiling is sized to admit the mid driver's reservation but NOT the worker's - // on top of it — proving the nested scope reserves from the SAME conserved pool as the - // root. The mid driver's spawn of the worker fails closed (budget-exhausted), the driver - // throws, the parent types it into a down → no-winner. A non-shared pool would let the - // deep spawn succeed and the run would win — so this asserts conservation across depth. + it('budget is conserved across depth: a deep spawn cannot exceed its branch allocation', async () => { + // The root reserves 1000 tokens for the mid driver. Its nested scope owns exactly that + // partition, so a 1001-token child request fails closed even though no sibling is running. const journal = new InMemorySpawnJournal() const blobs = new InMemoryResultBlobStore() const observed = newObserved() @@ -269,14 +369,15 @@ describe('recursive driver: agents drive agents drive agents', () => { iterations: 1, score: 0.5, }) - const midDriver = scriptedDriver('mid', () => [{ label: 'w', agent: worker }], observed) + const midDriver = scriptedDriver('mid', () => [{ label: 'w', agent: worker }], observed, { + maxIterations: 4, + maxTokens: 1001, + }) const rootDriver = scriptedDriver( 'root', () => [{ label: 'mid', agent: driverChild('mid', midDriver, journal) }], observed, ) - // perChild reserves 1000 tokens / 4 iterations. The root pool holds room for exactly ONE - // such reservation (the mid driver); the worker's reservation on top must fail closed. const result = await createSupervisor().run( rootDriver, 'task', @@ -289,8 +390,7 @@ describe('recursive driver: agents drive agents drive agents', () => { ) expect(result.kind).toBe('no-winner') if (result.kind === 'no-winner') { - // The mid driver was reserved (root scope spawn), but the worker's nested spawn could - // not be covered by the remaining pool — the shared pool conserved across depth. + // The mid driver was reserved at the root, but its oversized nested child never started. expect(observed.spawnedIds).toContain('rec:s0') // mid driver reserved at the root expect(observed.spawnedIds).not.toContain('rec:s0:s0') // worker never admitted (no budget) } diff --git a/tests/kernel/durable-jsonl.test.ts b/tests/kernel/durable-jsonl.test.ts new file mode 100644 index 00000000..c4afee5d --- /dev/null +++ b/tests/kernel/durable-jsonl.test.ts @@ -0,0 +1,109 @@ +import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { writeAllBytes } from '../../src/durable/jsonl-file' +import { FileSpawnJournal } from '../../src/durable/spawn-journal' +import type { CoordinationEvent } from '../../src/mcp/tools/coordination' +import { FileCoordinationLog } from '../../src/runtime/supervise/coordination-log' + +describe('durable append-only JSONL', () => { + it('finishes short writes instead of acknowledging a truncated record', async () => { + const chunks: Buffer[] = [] + const handle = { + async write(buffer: Uint8Array, offset: number, length: number) { + const written = Math.min(3, length) + chunks.push(Buffer.from(buffer.subarray(offset, offset + written))) + return { bytesWritten: written, buffer } + }, + } + + await writeAllBytes(handle, 'abcdefgh') + + expect(Buffer.concat(chunks).toString('utf8')).toBe('abcdefgh') + expect(chunks.map((chunk) => chunk.length)).toEqual([3, 3, 2]) + }) + + it('recovers only an invalid unterminated final spawn-journal record', async () => { + const dir = await mkdtemp(join(tmpdir(), 'spawn-jsonl-tail-')) + try { + const path = join(dir, 'spawn.jsonl') + const journal = new FileSpawnJournal(path) + await journal.beginTree('run', '2026-07-29T00:00:00.000Z') + await appendFile(path, '{"kind":"event"') + + await expect(journal.loadTree('run')).resolves.toEqual([]) + await journal.beginTree('after-recovery', '2026-07-29T00:00:01.000Z') + await expect(journal.loadTree('run')).resolves.toEqual([]) + await expect(journal.loadTree('after-recovery')).resolves.toEqual([]) + + await writeFile( + path, + '{"kind":"begin","root":"run","at":"2026-07-29T00:00:00.000Z"}\n{bad}\n', + ) + await expect(journal.loadTree('run')).rejects.toThrow(/malformed JSONL record at line 2/) + + await writeFile( + path, + '{bad}\n{"kind":"begin","root":"run","at":"2026-07-29T00:00:00.000Z"}\n', + ) + await expect(journal.loadTree('run')).rejects.toThrow(/malformed JSONL record at line 1/) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('recovers only an invalid unterminated final coordination-log record', async () => { + const dir = await mkdtemp(join(tmpdir(), 'coordination-jsonl-tail-')) + try { + const path = join(dir, 'coordination.jsonl') + const log = new FileCoordinationLog(path) + const question: CoordinationEvent = { + type: 'question', + question: { + id: 'worker:q0', + from: 'worker', + level: 'worker', + question: 'Continue?', + reason: 'blocked', + urgency: 'blocks-run', + status: 'open', + openedAt: 0, + }, + } + await log.append('run', { seq: 0, at: 0, priority: 20, event: question }, 'owner') + await appendFile(path, '{"runId":"run"') + + await expect(log.load('run', 'owner')).resolves.toMatchObject({ + questions: [{ id: 'worker:q0', status: 'open' }], + }) + await log.append( + 'run', + { + seq: 1, + at: 1, + priority: 20, + event: { + ...question, + question: { ...question.question, id: 'worker:q1', question: 'Still continue?' }, + }, + }, + 'owner', + ) + await expect(log.load('run', 'owner')).resolves.toMatchObject({ + questions: [ + { id: 'worker:q0', status: 'open' }, + { id: 'worker:q1', status: 'open' }, + ], + }) + + await writeFile(path, '{bad}\n') + await expect(log.load('run', 'owner')).rejects.toThrow(/malformed JSONL record at line 1/) + + await writeFile(path, '{bad}\n{}\n') + await expect(log.load('run', 'owner')).rejects.toThrow(/malformed JSONL record at line 1/) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/kernel/event-bus.test.ts b/tests/kernel/event-bus.test.ts index f121e16b..774b2502 100644 --- a/tests/kernel/event-bus.test.ts +++ b/tests/kernel/event-bus.test.ts @@ -102,4 +102,28 @@ describe('event bus', () => { await bus.publish({ type: 'finding' }) expect(seen).toEqual(['settled']) }) + + it('keeps an event invisible until awaited subscribers commit and reuses its stamp on retry', async () => { + const bus = createEventBus(fakeClock()) + const event = { type: 'settled', id: 'w1' } as const + const attempts: BusRecord[] = [] + let fail = true + bus.subscribe((record) => { + attempts.push(record) + if (fail) throw new Error('product transaction unavailable') + }) + + await expect(bus.publish(event)).rejects.toThrow('product transaction unavailable') + expect(bus.pending()).toBe(0) + expect(bus.pull()).toBeUndefined() + expect(bus.history()).toEqual([]) + expect(bus.stats()).toEqual({ published: 0, pulled: 0, byKind: {} }) + + fail = false + await expect(bus.publish(event)).resolves.toMatchObject({ seq: 0, at: 1000, event }) + expect(attempts).toHaveLength(2) + expect(attempts[1]).toBe(attempts[0]) + expect(bus.pull()).toBe(event) + expect(bus.stats()).toEqual({ published: 1, pulled: 1, byKind: { settled: 1 } }) + }) }) diff --git a/tests/kernel/inbox.test.ts b/tests/kernel/inbox.test.ts index 9ef2b851..75e11792 100644 --- a/tests/kernel/inbox.test.ts +++ b/tests/kernel/inbox.test.ts @@ -1,14 +1,14 @@ import type { AgentProfile } from '@tangle-network/agent-interface' import { afterEach, describe, expect, it, vi } from 'vitest' -import { type AgentSpec, createExecutor, createInbox } from '../../src/runtime' +import { type AgentSpec, createBudgetPool, createExecutor, createInbox } from '../../src/runtime' describe('worker inbox (down-leg receive end)', () => { it('parses the down-message shapes; ignores malformed', () => { const inbox = createInbox() - inbox.deliver({ steer: 'do X' }) - inbox.deliver({ answer: 'use v2', questionId: 'q1' }) - inbox.deliver({ junk: true }) // ignored, never throws - inbox.deliver(null) + expect(inbox.deliver({ steer: 'do X' })).toBe(true) + expect(inbox.deliver({ answer: 'use v2', questionId: 'q1' })).toBe(true) + expect(inbox.deliver({ junk: true })).toBe(false) + expect(inbox.deliver(null)).toBe(false) const drained = inbox.drain() expect(drained).toEqual([ { kind: 'steer', text: 'do X', interrupt: false }, @@ -143,4 +143,70 @@ describe('router-tools executor drains the inbox', () => { ).toBe(true) expect(result.spent.iterations).toBe(1) }) + + it('marks dollar cost unknown for an unpriced model even when token usage is complete', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => noToolReply()), + ) + const factory = createExecutor({ + backend: 'router-tools', + model: 'unpriced-test-model', + routerBaseUrl: 'http://router.test', + routerKey: 'k', + tools: [], + executeToolCall: async () => '', + }) + const exec = factory( + { profile: { name: 'w' }, harness: null }, + { signal: new AbortController().signal, seams: {} }, + ) + + const result = await exec.execute('do the task', new AbortController().signal) + + expect(result.spent).toMatchObject({ + tokens: { input: 1, output: 1 }, + usd: 0, + usdKnown: false, + }) + const pool = createBudgetPool({ maxIterations: 2, maxTokens: 10, maxUsd: 1 }, () => 0) + const reservation = pool.reserve({ maxIterations: 1, maxTokens: 2, maxUsd: 1 }) + if (!reservation.ok) throw new Error('reservation should fit') + expect(() => pool.reconcile(reservation.ticket, result.spent)).toThrow(/unknown dollar cost/) + expect(pool.readout()).toMatchObject({ usdLeft: 0, usdKnown: false }) + }) + + it('marks dollar cost unknown for a priced model when token usage is missing', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ choices: [{ message: { content: 'done' } }] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ) + const factory = createExecutor({ + backend: 'router-tools', + model: 'gpt-4o', + routerBaseUrl: 'http://router.test', + routerKey: 'k', + tools: [], + executeToolCall: async () => '', + }) + const exec = factory( + { profile: { name: 'w' }, harness: null }, + { signal: new AbortController().signal, seams: {} }, + ) + + const result = await exec.execute('do the task', new AbortController().signal) + + expect(result.spent).toMatchObject({ + tokens: { input: 0, output: 0 }, + tokensKnown: false, + usd: 0, + usdKnown: false, + }) + }) }) diff --git a/tests/kernel/materialization-evidence.test.ts b/tests/kernel/materialization-evidence.test.ts new file mode 100644 index 00000000..125a7458 --- /dev/null +++ b/tests/kernel/materialization-evidence.test.ts @@ -0,0 +1,268 @@ +import { canonicalCandidateDigest } from '@tangle-network/agent-interface' +import { describe, expect, it } from 'vitest' +import { + InMemoryResultBlobStore, + InMemorySpawnJournal, + materializeTreeView, +} from '../../src/durable/spawn-journal' +import { driverChild, withDriverExecutor } from '../../src/runtime/supervise/driver-executor' +import { + attestRuntimeOwnedExecutor, + attestRuntimeOwnedScopeOwner, + knownExecutionBindingReceipt, + knownMaterializationReceipt, + runtimeOwnedExecutorExecutionBinding, + runtimeOwnedExecutorMaterialization, +} from '../../src/runtime/supervise/materialization' +import { bridgeExecutor, createExecutorRegistry } from '../../src/runtime/supervise/runtime' +import { createSupervisor } from '../../src/runtime/supervise/supervisor' +import type { + Agent, + AgentSpec, + Executor, + ExecutorFactory, + ExecutorResult, + Scope, + SpawnEvent, +} from '../../src/runtime/supervise/types' + +const budget = { maxIterations: 4, maxTokens: 1_000 } +const spent = { iterations: 1, tokens: { input: 2, output: 3 }, usd: 0, ms: 1 } + +function leafAgent(name: string, factory: ExecutorFactory): Agent { + const executorSpec: AgentSpec = { + profile: { name, model: { default: 'test/model' } }, + harness: null, + executorFactory: factory, + } + return { name, executorSpec, act: async () => undefined } as Agent & { + executorSpec: AgentSpec + } +} + +function successfulExecutor(runtime = 'test-runtime'): Executor { + const artifact: ExecutorResult = { outRef: 'ignored', out: { ok: true }, spent } + return { + runtime, + execute: async () => artifact, + teardown: async () => ({ destroyed: true }), + resultArtifact: () => artifact, + } +} + +async function runOneChild( + child: Agent, + journal: InMemorySpawnJournal, + runId: string, + executors = createExecutorRegistry(), +) { + const root: Agent = { + name: 'root', + async act(_task, scope) { + expect('recordMaterialization' in scope).toBe(false) + expect(() => + ( + scope as Scope & { recordMaterialization: (value: unknown) => void } + ).recordMaterialization({ forged: true }), + ).toThrow(TypeError) + const spawned = scope.spawn(child, 'work', { label: 'child', budget }) + if (!spawned.ok) throw new Error(spawned.reason) + return scope.next() + }, + } + return createSupervisor().run(root, 'root task', { + budget: { maxIterations: 20, maxTokens: 20_000 }, + runId, + journal, + blobs: new InMemoryResultBlobStore(), + executors, + }) +} + +function childEvents(events: SpawnEvent[] | undefined): SpawnEvent[] { + return (events ?? []).filter((event) => event.id.endsWith(':s0')) +} + +describe('kernel-owned materialization evidence', () => { + it('ignores a caller executor that lies through lookalike report methods', async () => { + const journal = new InMemorySpawnJournal() + const liar = leafAgent('liar', (_spec, _ctx) => + Object.assign(successfulExecutor(), { + materialization: () => ({ status: 'known', backend: 'forged' }), + executionBinding: () => ({ bindingDigest: canonicalCandidateDigest({ forged: true }) }), + }), + ) + + await runOneChild(liar, journal, 'lying-executor') + const events = childEvents(await journal.loadTree('lying-executor')) + expect(events.find((event) => event.kind === 'materialized')).toMatchObject({ + receipt: { status: 'unknown', reason: 'executor-did-not-report' }, + }) + expect(events.find((event) => event.kind === 'execution-bound')).toMatchObject({ + binding: { status: 'unknown', reason: 'executor-did-not-report' }, + }) + }) + + it('records invalid trusted evidence and refuses execution', async () => { + const journal = new InMemorySpawnJournal() + let executeCalls = 0 + const invalid = leafAgent('invalid', (spec, ctx) => { + const executor = successfulExecutor() + const originalExecute = executor.execute.bind(executor) + executor.execute = (task, signal) => { + executeCalls += 1 + return originalExecute(task, signal) + } + return attestRuntimeOwnedExecutor( + executor, + { + effectiveProfile: spec.profile, + backend: '', + model: { status: 'known', id: 'test/model' }, + execution: { kind: 'run', id: ctx.node?.nodeId ?? 'direct' }, + materializer: 'test', + plan: { kind: 'test' }, + }, + { + attemptId: ctx.node?.attemptId ?? 'direct-attempt', + binding: { endpoint: 'https://example.test' }, + descriptor: { kind: 'test', transport: 'http' }, + }, + ) + }) + + await runOneChild(invalid, journal, 'invalid-evidence') + expect(executeCalls).toBe(0) + expect(childEvents(await journal.loadTree('invalid-evidence'))).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'materialized', + receipt: expect.objectContaining({ + status: 'unknown', + reason: 'invalid-executor-report', + }), + }), + expect.objectContaining({ + kind: 'execution-bound', + binding: expect.objectContaining({ + status: 'unknown', + reason: 'invalid-executor-report', + }), + }), + ]), + ) + }) + + it('projects the same trusted receipt and binding from journal replay', async () => { + const journal = new InMemorySpawnJournal() + const trusted = leafAgent('trusted', (spec, ctx) => { + const executionId = ctx.node?.nodeId ?? 'direct' + return attestRuntimeOwnedExecutor( + successfulExecutor('router'), + { + effectiveProfile: spec.profile, + backend: 'router', + model: { status: 'known', id: 'test/model' }, + execution: { kind: 'request', id: executionId }, + materializer: 'test-router', + plan: { kind: 'completion', model: 'test/model' }, + }, + { + attemptId: ctx.node?.attemptId ?? 'direct-attempt', + binding: { endpoint: 'https://router.example.test', executionId }, + descriptor: { kind: 'router-request', transport: 'http' }, + }, + ) + }) + + const result = await runOneChild(trusted, journal, 'known-evidence') + const events = await journal.loadTree('known-evidence') + const replayed = materializeTreeView(events ?? []) + const live = result.tree.nodes.find((node) => node.id.endsWith(':s0')) + const replay = replayed.nodes.find((node) => node.id.endsWith(':s0')) + expect(live?.materialization).toMatchObject({ status: 'known', backend: 'router' }) + expect(live?.executionBindings).toHaveLength(1) + expect(replay?.materialization).toEqual(live?.materialization) + expect(replay?.executionBindings).toEqual(live?.executionBindings) + }) + + it('types a trusted deferred manager down when it never publishes before completing', async () => { + const journal = new InMemorySpawnJournal() + const silentManager = attestRuntimeOwnedScopeOwner>( + { name: 'silent-manager', act: async () => ({ shouldNotWin: true }) }, + 'cli', + ) + const child = driverChild( + { name: 'manager', metadata: { role: 'driver' } }, + silentManager, + journal, + ) + const executors = withDriverExecutor(createExecutorRegistry()) + + await runOneChild(child, journal, 'missing-deferred', executors) + expect(childEvents(await journal.loadTree('missing-deferred'))).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'materialized', + receipt: expect.objectContaining({ + status: 'unknown', + reason: 'root-agent-did-not-report', + }), + }), + expect.objectContaining({ + kind: 'settled', + status: 'down', + }), + ]), + ) + }) + + it('keeps the built-in bridge profile identity stable while endpoints change per attempt', () => { + const profile = { name: 'manager', model: { default: 'test/model' } } + const executorFor = (bridgeUrl: string, attemptId: string) => + bridgeExecutor( + { profile, harness: null }, + { + signal: new AbortController().signal, + node: { + rootId: 'root', + parentId: 'root', + nodeId: 'root:s0', + attemptId, + }, + seams: { + bridge: { + bridgeUrl, + bridgeBearer: 'never-journal-this', + model: 'test/model', + sessionId: 'stable-session', + }, + }, + }, + ) + const receiptFor = (bridgeUrl: string, attemptId: string) => { + const executor = executorFor(bridgeUrl, attemptId) + const declaration = runtimeOwnedExecutorMaterialization(executor) + const binding = runtimeOwnedExecutorExecutionBinding(executor) + expect(declaration).toBeDefined() + expect(binding).toBeDefined() + const materialization = knownMaterializationReceipt({ + authoredProfileDigest: canonicalCandidateDigest(profile), + runtime: 'cli', + declaration: declaration!, + }) + return { + materialization, + binding: knownExecutionBindingReceipt(materialization, binding!), + } + } + const first = receiptFor('http://127.0.0.1:31001', 'attempt-1') + const second = receiptFor('http://127.0.0.1:31002', 'attempt-2') + + expect(first.materialization).toEqual(second.materialization) + expect(first.binding.materializationReceiptDigest).toBe( + second.binding.materializationReceiptDigest, + ) + expect(first.binding.bindingDigest).not.toBe(second.binding.bindingDigest) + }) +}) diff --git a/tests/kernel/nested-coordination-durability.test.ts b/tests/kernel/nested-coordination-durability.test.ts new file mode 100644 index 00000000..0b4bd67e --- /dev/null +++ b/tests/kernel/nested-coordination-durability.test.ts @@ -0,0 +1,328 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { fullProfileMaterialization } from '../../src/agent/profile-materialization' +import type { CoordinationEvent, QuestionRecord } from '../../src/mcp/tools/coordination' +import { supervise } from '../../src/runtime/supervise/supervise' +import type { + DriveHarness, + DriveHarnessOwnerContext, +} from '../../src/runtime/supervise/supervisor-agent' +import type { ToolLoopChat } from '../../src/runtime/tool-loop' +import { scriptedBrain } from './scripted-brain' + +async function callTool( + url: string, + name: string, + args: Record, +): Promise> { + const response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: `${name}-${Math.random()}`, + method: 'tools/call', + params: { name, arguments: args }, + }), + }) + const envelope = (await response.json()) as { + result?: { + structuredContent?: Record + content?: Array<{ type: string; text?: string }> + } + error?: unknown + } + if (!envelope.result) throw new Error(`tool ${name} failed: ${JSON.stringify(envelope.error)}`) + if (envelope.result.structuredContent) return envelope.result.structuredContent + const text = envelope.result.content?.find((entry) => entry.type === 'text')?.text + return text ? (JSON.parse(text) as Record) : {} +} + +function rootBrain() { + const manager = { + name: 'identical-manager', + harness: 'codex', + metadata: { role: 'driver' }, + } + return scriptedBrain([ + { + toolCalls: [ + { + name: 'spawn_agent', + arguments: { profile: manager, task: 'same task', key: 'manager-a' }, + }, + { + name: 'spawn_agent', + arguments: { profile: manager, task: 'same task', key: 'manager-b' }, + }, + ], + }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { content: 'finished' }, + ]) +} + +describe('nested supervisor coordination durability', () => { + let runDir: string + + beforeEach(async () => { + runDir = await mkdtemp(join(tmpdir(), 'nested-coordination-')) + }) + + afterEach(async () => { + await rm(runDir, { recursive: true, force: true }) + }) + + it('isolates identical keyed siblings and restores each owner evidence on restart', async () => { + const seenBeforeCurrentQuestion: QuestionRecord[][] = [] + let invocation = 0 + const driveHarness: DriveHarness = async ({ coordinationMcpUrl }) => { + const call = invocation++ + const listed = await callTool(coordinationMcpUrl, 'list_questions', {}) + seenBeforeCurrentQuestion.push((listed.questions ?? []) as QuestionRecord[]) + await callTool(coordinationMcpUrl, 'ask_parent', { + from: 'identical-manager', + level: 'driver', + question: `question from invocation ${call}`, + reason: 'durable owner-isolation proof', + urgency: 'continue-without', + }) + } + const options = { + backend: { + backend: 'router', + routerBaseUrl: 'http://unused.invalid', + routerKey: 'unused', + model: 'unused/model', + } as const, + budget: { maxIterations: 16, maxTokens: 10_000 }, + perWorker: { maxIterations: 4, maxTokens: 1_000 }, + runDir, + runId: 'nested-owner-run', + driveHarness, + driveHarnessMaterialization: fullProfileMaterialization, + maxTurns: 8, + } + const profile = { + name: 'root', + harness: 'cli-base', + prompt: { systemPrompt: 'Run both managers.' }, + } as const + + await supervise(profile, 'root task', { ...options, brain: rootBrain() }) + await supervise(profile, 'root task', { ...options, brain: rootBrain() }) + + expect(seenBeforeCurrentQuestion).toHaveLength(4) + expect(seenBeforeCurrentQuestion.slice(0, 2)).toEqual([[], []]) + for (const prior of seenBeforeCurrentQuestion.slice(2)) { + expect(prior).toHaveLength(1) + expect(prior[0]?.question).toMatch(/^question from invocation [01]$/) + } + + const records = (await readFile(join(runDir, 'coordination-log.jsonl'), 'utf8')) + .trim() + .split('\n') + .map( + (line) => + JSON.parse(line) as { + ownerId?: string + event: CoordinationEvent + }, + ) + .filter((record) => record.event.type === 'question') + const counts = new Map() + for (const record of records) { + if (!record.ownerId) throw new Error('nested coordination record has no owner') + counts.set(record.ownerId, (counts.get(record.ownerId) ?? 0) + 1) + } + expect([...counts.values()].sort()).toEqual([2, 2]) + }) + + it('routes concurrent manager steers through distinct owner-scoped harness sessions', async () => { + const contexts: DriveHarnessOwnerContext[] = [] + const delivered = new Map() + let startedCount = 0 + let bothStarted!: () => void + const bothManagersStarted = new Promise((resolve) => { + bothStarted = resolve + }) + const resolveDriveHarness = (context: DriveHarnessOwnerContext): DriveHarness => { + contexts.push(context) + let release!: () => void + const steered = new Promise((resolve) => { + release = resolve + }) + const harness: DriveHarness = async () => { + startedCount += 1 + if (startedCount === 2) bothStarted() + await steered + } + harness.deliver = (message): boolean => { + delivered.set(context.assignmentId ?? 'root', message) + release() + return true + } + return harness + } + let turn = 0 + const brain: ToolLoopChat = async () => { + turn += 1 + if (turn === 1) { + const manager = { + name: 'identical-manager', + harness: 'codex', + metadata: { role: 'driver' }, + } + return { + toolCalls: [ + { + id: 'spawn-a', + name: 'spawn_agent', + arguments: JSON.stringify({ profile: manager, task: 'same task', key: 'manager-a' }), + }, + { + id: 'spawn-b', + name: 'spawn_agent', + arguments: JSON.stringify({ profile: manager, task: 'same task', key: 'manager-b' }), + }, + ], + } + } + if (turn === 2) { + await bothManagersStarted + return { + toolCalls: [ + { + id: 'steer-a', + name: 'steer_agent', + arguments: JSON.stringify({ + workerId: 'owner-routed:s0', + instruction: 'instruction for manager A', + }), + }, + { + id: 'steer-b', + name: 'steer_agent', + arguments: JSON.stringify({ + workerId: 'owner-routed:s1', + instruction: 'instruction for manager B', + }), + }, + ], + } + } + if (turn <= 4) { + return { + toolCalls: [{ id: `await-${turn}`, name: 'await_event', arguments: JSON.stringify({}) }], + } + } + return { content: 'finished', toolCalls: [] } + } + + await supervise( + { name: 'root', harness: 'cli-base', prompt: { systemPrompt: 'Run both managers.' } }, + 'root task', + { + backend: { + backend: 'router', + routerBaseUrl: 'http://unused.invalid', + routerKey: 'unused', + model: 'unused/model', + }, + budget: { maxIterations: 16, maxTokens: 10_000 }, + perWorker: { maxIterations: 4, maxTokens: 1_000 }, + runId: 'owner-routed', + resolveDriveHarness, + driveHarnessMaterialization: fullProfileMaterialization, + brain, + maxTurns: 8, + }, + ) + + expect(contexts).toHaveLength(2) + expect(new Set(contexts.map((context) => context.ownerId)).size).toBe(2) + expect(contexts.map((context) => context.assignmentId).sort()).toEqual([ + 'key:manager-a', + 'key:manager-b', + ]) + for (const context of contexts) { + expect(Object.isFrozen(context)).toBe(true) + expect(Object.isFrozen(context.profile)).toBe(true) + expect(Object.isFrozen(context.identity)).toBe(true) + } + expect(delivered).toEqual( + new Map([ + ['key:manager-a', { steer: 'instruction for manager A', interrupt: false }], + ['key:manager-b', { steer: 'instruction for manager B', interrupt: false }], + ]), + ) + }) + + it('refuses one steerable harness instance reused across manager owners', async () => { + const sharedHarness: DriveHarness = async () => {} + sharedHarness.deliver = (): boolean => true + const seen: Array>> = [] + + await supervise( + { name: 'root', harness: 'cli-base', prompt: { systemPrompt: 'Run both managers.' } }, + 'root task', + { + backend: { + backend: 'router', + routerBaseUrl: 'http://unused.invalid', + routerKey: 'unused', + model: 'unused/model', + }, + budget: { maxIterations: 16, maxTokens: 10_000 }, + perWorker: { maxIterations: 4, maxTokens: 1_000 }, + runId: 'owner-reuse-refused', + resolveDriveHarness: () => sharedHarness, + driveHarnessMaterialization: fullProfileMaterialization, + brain: scriptedBrain( + [ + { + toolCalls: [ + { + name: 'spawn_agent', + arguments: { + profile: { + name: 'identical-manager', + harness: 'codex', + metadata: { role: 'driver' }, + }, + task: 'same task', + key: 'manager-a', + }, + }, + { + name: 'spawn_agent', + arguments: { + profile: { + name: 'identical-manager', + harness: 'codex', + metadata: { role: 'driver' }, + }, + task: 'same task', + key: 'manager-b', + }, + }, + ], + }, + { content: 'stop after reading the spawn results' }, + ], + seen, + ), + }, + ) + + expect(JSON.stringify(seen[1])).toContain( + 'steerable driveHarness is already bound to manager owner', + ) + expect(JSON.stringify(seen[1])).toContain( + 'resolveDriveHarness must return a distinct steerable instance', + ) + }) +}) diff --git a/tests/kernel/spawn-forest.test.ts b/tests/kernel/spawn-forest.test.ts new file mode 100644 index 00000000..4e209023 --- /dev/null +++ b/tests/kernel/spawn-forest.test.ts @@ -0,0 +1,251 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { AgentProfile } from '@tangle-network/agent-interface' +import { describe, expect, it } from 'vitest' +import { + contentAddress, + FileResultBlobStore, + FileSpawnJournal, +} from '../../src/durable/spawn-journal' +import { loadSpawnForest } from '../../src/runtime' +import { driverChild, withDriverExecutor } from '../../src/runtime/supervise/driver-executor' +import { createExecutorRegistry } from '../../src/runtime/supervise/runtime' +import { createSupervisor } from '../../src/runtime/supervise/supervisor' +import { nestedDriverTreeRoot } from '../../src/runtime/supervise/tree-key' +import type { + Agent, + AgentSpec, + Executor, + ExecutorResult, + Scope, + SpawnJournal, + UsageEvent, +} from '../../src/runtime/supervise/types' + +const rootBudget = { maxIterations: 10, maxTokens: 10_000 } +const childBudget = { maxIterations: 4, maxTokens: 1_000 } + +function leaf( + name: string, + out: unknown, + runtime: Executor['runtime'] = 'forest-leaf', +): Agent { + const executor: Executor = { + runtime, + execute(): AsyncIterable { + return (async function* () { + yield { kind: 'tokens', input: 3, output: 2 } + yield { kind: 'iteration' } + })() + }, + teardown: () => Promise.resolve({ destroyed: true }), + resultArtifact(): ExecutorResult { + return { + outRef: contentAddress(out), + out, + verdict: { valid: true, score: 1 }, + spent: { iterations: 1, tokens: { input: 3, output: 2 }, usd: 0, ms: 0 }, + } + }, + } + const spec: AgentSpec = { profile: { name } as AgentProfile, harness: null, executor } + return { name, act: async () => out, executorSpec: spec } as Agent & { + executorSpec: AgentSpec + } +} + +async function onlyDone(scope: Scope): Promise { + const settled = await scope.next() + if (settled?.kind !== 'done') throw new Error('expected one completed child') + return settled.out +} + +describe('loadSpawnForest', () => { + it('cold-loads every node and event from a file-backed root → driver → leaf run', async () => { + const dir = await mkdtemp(join(tmpdir(), 'spawn-forest-complete-')) + try { + const journalPath = join(dir, 'spawn-journal.jsonl') + const journal = new FileSpawnJournal(journalPath) + const blobs = new FileResultBlobStore(join(dir, 'blobs')) + const nested: Agent = { + name: 'nested', + async act(task, scope) { + const spawned = scope.spawn(leaf('leaf', { answer: 42 }), task, { + budget: childBudget, + label: 'leaf', + }) + if (!spawned.ok) throw new Error(spawned.reason) + return onlyDone(scope) + }, + } + const root: Agent = { + name: 'root', + async act(task, scope) { + const spawned = scope.spawn(driverChild('nested', nested, journal), task, { + budget: childBudget, + label: 'nested', + }) + if (!spawned.ok) throw new Error(spawned.reason) + return onlyDone(scope) + }, + } + + const result = await createSupervisor().run(root, 'solve', { + budget: rootBudget, + runId: 'forest-complete', + journal, + blobs, + executors: withDriverExecutor(createExecutorRegistry()), + maxDepth: 4, + }) + expect(result.kind).toBe('winner') + + const forest = await loadSpawnForest(new FileSpawnJournal(journalPath), 'forest-complete') + expect(forest.trees).toHaveLength(2) + expect(forest.nodes.map((node) => node.label).sort()).toEqual(['leaf', 'nested', 'root']) + expect(forest.nodes.find((node) => node.label === 'nested')?.ownedTreeRoot).toBe( + nestedDriverTreeRoot('forest-complete', 'forest-complete:s0'), + ) + expect(forest.events.length).toBeGreaterThan(forest.nodes.length) + expect(new Set(forest.events.map((entry) => entry.treeRoot))).toEqual( + new Set(forest.trees.map((tree) => tree.root)), + ) + expect(forest.inDoubt).toEqual([]) + expect(forest.missingTrees).toEqual([]) + expect(Object.isFrozen(forest)).toBe(true) + expect(Object.isFrozen(forest.nodes[0])).toBe(true) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('reports pending workers and an unopened driver subtree from a cold lost-work snapshot', async () => { + const dir = await mkdtemp(join(tmpdir(), 'spawn-forest-lost-')) + try { + const journalPath = join(dir, 'spawn-journal.jsonl') + const journal: SpawnJournal = new FileSpawnJournal(journalPath) + const at = new Date(0).toISOString() + await journal.beginTree('forest-lost', at) + await journal.appendEvent('forest-lost', { + kind: 'spawned', + id: 'forest-lost', + label: 'root', + budget: rootBudget, + runtime: 'inline', + seq: 0, + at, + }) + await journal.appendEvent('forest-lost', { + kind: 'spawned', + id: 'forest-lost:s0', + parent: 'forest-lost', + label: 'started-driver', + budget: childBudget, + runtime: 'driver', + ownedTreeRoot: nestedDriverTreeRoot('forest-lost', 'forest-lost:s0'), + seq: 0, + at, + }) + await journal.appendEvent('forest-lost', { + kind: 'spawned', + id: 'forest-lost:s1', + parent: 'forest-lost', + label: 'unopened-driver', + budget: childBudget, + runtime: 'driver', + ownedTreeRoot: nestedDriverTreeRoot('forest-lost', 'forest-lost:s1'), + seq: 1, + at, + }) + const nestedRoot = nestedDriverTreeRoot('forest-lost', 'forest-lost:s0') + await journal.beginTree(nestedRoot, at) + await journal.appendEvent(nestedRoot, { + kind: 'spawned', + id: 'forest-lost:s0:s0', + parent: 'forest-lost:s0', + label: 'lost-leaf', + budget: childBudget, + runtime: 'forest-leaf', + seq: 0, + at, + }) + + const forest = await loadSpawnForest(new FileSpawnJournal(journalPath), 'forest-lost') + expect(forest.trees).toHaveLength(2) + expect(forest.nodes.map((node) => [node.label, node.status])).toEqual( + expect.arrayContaining([ + ['started-driver', 'pending'], + ['unopened-driver', 'pending'], + ['lost-leaf', 'pending'], + ]), + ) + expect(forest.inDoubt.map((node) => node.label).sort()).toEqual([ + 'lost-leaf', + 'started-driver', + 'unopened-driver', + ]) + expect(forest.missingTrees).toEqual([ + expect.objectContaining({ ownerNodeId: 'forest-lost:s1' }), + ]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('does not infer tree ownership from a caller leaf whose open runtime string is driver', async () => { + const dir = await mkdtemp(join(tmpdir(), 'spawn-forest-byo-driver-')) + try { + const journalPath = join(dir, 'spawn-journal.jsonl') + const journal = new FileSpawnJournal(journalPath) + const blobs = new FileResultBlobStore(join(dir, 'blobs')) + const root: Agent = { + name: 'root', + async act(task, scope) { + const spawned = scope.spawn( + leaf('caller-leaf-named-driver', { answer: 7 }, 'driver'), + task, + { + budget: childBudget, + label: 'caller-leaf-named-driver', + }, + ) + if (!spawned.ok) throw new Error(spawned.reason) + return onlyDone(scope) + }, + } + + const result = await createSupervisor().run(root, 'solve', { + budget: rootBudget, + runId: 'forest-byo-driver', + journal, + blobs, + executors: createExecutorRegistry(), + }) + expect(result.kind).toBe('winner') + + // A pre-field journal may also contain a convention-shaped tree. Absence of the trusted + // ownership field is authoritative: the cold reader must neither scan nor adopt it. + const at = new Date(0).toISOString() + const decoyRoot = nestedDriverTreeRoot('forest-byo-driver', 'forest-byo-driver:s0') + await journal.beginTree(decoyRoot, at) + await journal.appendEvent(decoyRoot, { + kind: 'spawned', + id: 'decoy:s0', + parent: 'forest-byo-driver:s0', + label: 'must-not-be-followed', + budget: childBudget, + runtime: 'forest-leaf', + seq: 0, + at, + }) + + const forest = await loadSpawnForest(new FileSpawnJournal(journalPath), 'forest-byo-driver') + expect(forest.trees.map((tree) => tree.root)).toEqual(['forest-byo-driver']) + expect(forest.nodes.map((node) => node.label)).toEqual(['root', 'caller-leaf-named-driver']) + expect(forest.missingTrees).toEqual([]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/kernel/supervise-convenience.test.ts b/tests/kernel/supervise-convenience.test.ts index 92ad5910..abbd65aa 100644 --- a/tests/kernel/supervise-convenience.test.ts +++ b/tests/kernel/supervise-convenience.test.ts @@ -3,11 +3,14 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { AgentProfile } from '@tangle-network/agent-interface' import { describe, expect, it } from 'vitest' +import { InMemorySpawnJournal } from '../../src/durable/spawn-journal' import { ConfigError } from '../../src/errors' +import { createRootHandle } from '../../src/runtime/index' import type { DeliverableSpec } from '../../src/runtime/supervise/completion-gate' import type { SupervisorFinalizer } from '../../src/runtime/supervise/finalizer' import type { ExecutorConfig } from '../../src/runtime/supervise/runtime' import { + type SuperviseOptions, type SuperviseRegistryTable, supervise, workerFromBackend, @@ -53,25 +56,157 @@ function deliveringLeaf(name: string, out: unknown): Agent { } } +function failingLeaf(name: string, reason: string): Agent { + const ex: Executor = { + runtime: 'router', + execute: async () => { + throw new Error(reason) + }, + teardown: () => Promise.resolve({ destroyed: true }), + accounting: () => ({ + reported: { iterations: 0, tokens: { input: 0, output: 0 }, usd: 0, ms: 0 }, + reservation: { iterations: 0, tokens: { input: 0, output: 0 }, usd: 0, ms: 0 }, + }), + resultArtifact: () => { + throw new Error('a failed leaf has no terminal artifact') + }, + } + const spec: AgentSpec = { profile: { name } as AgentProfile, harness: null, executor: ex } + return { name, act: async () => undefined, executorSpec: spec } as Agent & { + executorSpec: AgentSpec + } +} + describe('supervise — the one-call convenience (defaults blobs/perWorker/journal/executors)', () => { it('runs a supervisor to delivery from just profile + task + worker seam + brain + budget', async () => { const brain = scriptedBrain([ { toolCalls: [ - { name: 'spawn_agent', arguments: { profile: { kind: 'worker' }, task: 'go' } }, + { name: 'spawn_agent', arguments: { profile: { name: 'worker' }, task: 'go' } }, ], }, { toolCalls: [{ name: 'await_event', arguments: {} }] }, { content: 'done' }, ]) const result = await supervise( - { name: 'root', harness: null, systemPrompt: 'drive the worker' }, + { + name: 'root', + harness: 'cli-base', + prompt: { systemPrompt: 'drive the worker' }, + }, 'solve it', { budget, makeWorkerAgent: () => deliveringLeaf('w', { answer: 42 }), brain }, ) expect(result.kind).toBe('winner') }) + it('cascades the caller abort signal through the root and every live child', async () => { + const controller = new AbortController() + let started!: () => void + const childStarted = new Promise((resolve) => { + started = resolve + }) + let teardownCalled = false + const blockedLeaf = (): Agent => { + const executor: Executor = { + runtime: 'blocked-test-worker', + execute(_task, signal): Promise> { + started() + return new Promise((_, reject) => { + const abort = (): void => reject(new DOMException('aborted', 'AbortError')) + if (signal.aborted) abort() + else signal.addEventListener('abort', abort, { once: true }) + }) + }, + teardown: () => { + teardownCalled = true + return Promise.resolve({ destroyed: true }) + }, + resultArtifact: () => { + throw new Error('an aborted worker has no terminal artifact') + }, + } + const spec: AgentSpec = { + profile: { name: 'blocked-worker' } as AgentProfile, + harness: null, + executor, + } + return { + name: 'blocked-worker', + act: async () => undefined, + executorSpec: spec, + } as Agent & { executorSpec: AgentSpec } + } + const running = supervise({ name: 'root', harness: 'cli-base' }, 'solve it', { + budget, + signal: controller.signal, + makeWorkerAgent: blockedLeaf, + brain: scriptedBrain([ + { + toolCalls: [ + { name: 'spawn_agent', arguments: { profile: { name: 'worker' }, task: 'go' } }, + ], + }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + ]), + }) + + await childStarted + controller.abort() + const result = await running + + expect(result).toMatchObject({ kind: 'no-winner', reason: 'aborted' }) + expect(teardownCalled).toBe(true) + }) + + it('attaches a caller RootHandle and folds a live steer before the router manager next thinks', async () => { + const handle = createRootHandle() + let entered!: () => void + const firstTurnEntered = new Promise((resolve) => { + entered = resolve + }) + let release!: () => void + const releaseFirstTurn = new Promise((resolve) => { + release = resolve + }) + const seen: Array>> = [] + let turn = 0 + const running = supervise({ name: 'root', harness: 'cli-base' }, 'solve it', { + budget, + rootHandle: handle, + makeWorkerAgent: () => deliveringLeaf('unused', {}), + brain: async (messages) => { + seen.push(messages) + turn += 1 + if (turn === 1) { + entered() + await releaseFirstTurn + return { + toolCalls: [{ id: 'list', name: 'list_questions', arguments: JSON.stringify({}) }], + } + } + return { content: 'stopped after reading the steer', toolCalls: [] } + }, + }) + + await firstTurnEntered + expect(handle.deliver({ junk: true })).toBe(false) + expect(handle.deliver({ steer: 'also test the negative case', interrupt: false })).toBe(true) + release() + await running + + expect(seen).toHaveLength(2) + expect(seen[1]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: 'user', + content: expect.stringContaining('also test the negative case'), + }), + ]), + ) + expect(() => handle.deliver({ steer: 'after completion' })).toThrow() + }) + it('threads the independent check to direct supervisor submissions', async () => { const brain = scriptedBrain([ { toolCalls: [{ name: 'submit_result', arguments: { result: { answer: 42 } } }] }, @@ -102,7 +237,7 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ scriptedBrain([ { toolCalls: [ - { name: 'spawn_agent', arguments: { profile: { kind: 'worker' }, task: 'go' } }, + { name: 'spawn_agent', arguments: { profile: { name: 'worker' }, task: 'go' } }, ], }, { toolCalls: [{ name: 'await_event', arguments: {} }] }, @@ -115,7 +250,7 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ runDir: dir, } - const first = await supervise({ name: 'root', harness: null }, 'solve it', { + const first = await supervise({ name: 'root', harness: 'cli-base' }, 'solve it', { ...opts, brain: script(), }) @@ -133,7 +268,7 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ // A second `supervise()` against the SAME runDir + runId takes the resume path. Without the // `resume` flag threaded through, this would fail loud in `beginTree` ("already begun at …, // refusing to overwrite") because the wall-clock `at` differs between the two calls. - const second = await supervise({ name: 'root', harness: null }, 'solve it', { + const second = await supervise({ name: 'root', harness: 'cli-base' }, 'solve it', { ...opts, brain: script(), }) @@ -143,7 +278,136 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ } }) - it('workerFromBackend builds a spawnable worker leaf with an executor (no network)', () => { + it('replays one durable settlement with the same event id after the observer commits but loses its acknowledgement', async () => { + const dir = await mkdtemp(join(tmpdir(), 'supervise-observer-retry-')) + try { + const writes = new Set() + let physicalWrites = 0 + let acknowledge = false + let replayCommitted = false + const observations: Array<{ + eventId: string + seq: number + at: number + resumed: boolean + }> = [] + const onCoordinationEvent: NonNullable = async ( + _context, + eventId, + record, + ) => { + if (record.event.type !== 'settled') return + observations.push({ + eventId, + seq: record.seq, + at: record.at, + resumed: record.event.worker?.resumed === true, + }) + if (!writes.has(eventId)) { + writes.add(eventId) + physicalWrites += 1 + } + if (!acknowledge) throw new Error('observer commit succeeded; acknowledgement was lost') + replayCommitted = true + } + const common = { + budget, + makeWorkerAgent: () => deliveringLeaf('worker', { answer: 42 }), + runId: 'observer-retry', + runDir: dir, + onCoordinationEvent, + } + const first = await supervise({ name: 'root', harness: 'cli-base' }, 'solve it', { + ...common, + brain: scriptedBrain([ + { + toolCalls: [ + { name: 'spawn_agent', arguments: { profile: { name: 'worker' }, task: 'go' } }, + ], + }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { content: 'stop after the observer error' }, + ]), + }) + expect(first.kind).toBe('no-winner') + expect(physicalWrites).toBe(1) + expect(observations.length).toBeGreaterThan(0) + expect(new Set(observations.map((entry) => entry.eventId))).toHaveLength(1) + + acknowledge = true + const replayScript = scriptedBrain([ + { toolCalls: [{ name: 'await_event', arguments: { kinds: ['settled'] } }] }, + { content: 'finish from committed work' }, + ]) + const replayBrain: typeof replayScript = async (...args) => { + expect(replayCommitted).toBe(true) + return replayScript(...args) + } + const second = await supervise({ name: 'root', harness: 'cli-base' }, 'solve it', { + ...common, + brain: replayBrain, + }) + + expect(second.kind).toBe('winner') + expect(physicalWrites).toBe(1) + expect(new Set(observations.map((entry) => entry.eventId))).toHaveLength(1) + expect(observations.some((entry) => entry.resumed)).toBe(true) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('gives two attempts of one keyed assignment distinct worker and event identities', async () => { + let attempt = 0 + const events: Array<{ eventId: string; workerId: string; assignmentId?: string }> = [] + const result = await supervise({ name: 'root', harness: 'cli-base' }, 'retry once', { + budget, + makeWorkerAgent: () => + attempt++ === 0 + ? failingLeaf('same-worker', 'first attempt failed') + : deliveringLeaf('same-worker', { answer: 42 }), + brain: scriptedBrain([ + { + toolCalls: [ + { + name: 'spawn_agent', + arguments: { profile: { name: 'same-worker' }, task: 'go', key: 'same-assignment' }, + }, + ], + }, + { toolCalls: [{ name: 'await_event', arguments: { kinds: ['settled'] } }] }, + { + toolCalls: [ + { + name: 'spawn_agent', + arguments: { profile: { name: 'same-worker' }, task: 'go', key: 'same-assignment' }, + }, + ], + }, + { toolCalls: [{ name: 'await_event', arguments: { kinds: ['settled'] } }] }, + { content: 'done' }, + ]), + onCoordinationEvent: (_context, eventId, record) => { + if (record.event.type !== 'settled') return + events.push({ + eventId, + workerId: record.event.worker.id, + assignmentId: record.event.worker.assignmentId, + }) + }, + }) + + expect(result.kind).toBe('winner') + expect(events).toHaveLength(2) + expect(events.map((event) => event.assignmentId)).toEqual([ + 'key:same-assignment', + 'key:same-assignment', + ]) + expect(new Set(events.map((event) => event.workerId)).size).toBe(2) + expect(new Set(events.map((event) => event.eventId)).size).toBe(2) + }) + + it('workerFromBackend builds a spawnable worker leaf with a deferred executor factory', () => { const make = workerFromBackend({ backend: 'router-tools', routerBaseUrl: 'http://localhost', @@ -152,7 +416,222 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ } as ExecutorConfig) const w = make({ name: 'w' }) as Agent & { executorSpec: AgentSpec } expect(w.name).toBe('w') - expect(w.executorSpec.executor).toBeDefined() + expect(w.executorSpec.executorFactory).toBeDefined() + expect(w.executorSpec.executor).toBeUndefined() + }) + + it('workerFromBackend captures its reusable backend before callers can redirect it', () => { + const backend: ExecutorConfig = { + backend: 'router', + routerBaseUrl: 'http://router.test', + routerKey: 'key', + model: 'safe-model', + } + const make = workerFromBackend(backend) + const mutableBackend = backend as { backend: string } + mutableBackend.backend = 'cli' + const worker = make({ name: 'worker' }) as Agent & { + executorSpec: AgentSpec + } + const executor = worker.executorSpec.executorFactory?.(worker.executorSpec, { + signal: new AbortController().signal, + seams: {}, + }) + + expect(executor?.runtime).toBe('router') + }) + + it('workerFromBackend rejects post-identity profile overlays and shared execution ids', () => { + const invalid: ExecutorConfig[] = [ + { + backend: 'bridge', + bridgeUrl: 'http://bridge.test', + bridgeBearer: 'secret', + model: 'model', + agentProfile: { name: 'late-overlay' }, + }, + { + backend: 'bridge', + bridgeUrl: 'http://bridge.test', + bridgeBearer: 'secret', + model: 'model', + sessionId: 'SHARED', + }, + { + backend: 'cli-worktree', + repoRoot: '/repo', + harness: 'codex', + runId: 'SHARED', + }, + { + backend: 'cli-worktree', + repoRoot: '/repo', + bridge: { + bridgeUrl: 'http://bridge.test', + bridgeBearer: 'secret', + model: 'model', + sessionId: 'SHARED', + }, + }, + ] + + for (const config of invalid) { + expect(() => workerFromBackend(config)).toThrow(/not allowed|isolated id/) + } + }) + + it('refuses profile behavior a limited backend would silently drop', () => { + const routerWorker = workerFromBackend({ + backend: 'router', + routerBaseUrl: 'http://localhost', + routerKey: 'k', + model: 'm', + }) + expect(() => + routerWorker({ + name: 'rich-worker', + model: { default: 'm', reasoningEffort: 'high' }, + tools: { shell: true }, + }), + ).toThrow(/modelReasoningEffort, tools/) + + const rawCliWorker = workerFromBackend({ backend: 'cli', bin: '/bin/true' }) + expect(() => + rawCliWorker({ name: 'raw-cli', prompt: { systemPrompt: 'This used to be ignored.' } }), + ).toThrow(/systemPrompt/) + + const localWorktreeWorker = workerFromBackend({ + backend: 'cli-worktree', + repoRoot: '/workspace', + harness: 'claude', + }) + expect(() => + localWorktreeWorker({ + name: 'local-worktree', + connections: [{ connectionId: 'github', capabilities: ['issues:read'] }], + }), + ).toThrow(/connections/) + + const bridgedWorktreeWorker = workerFromBackend({ + backend: 'cli-worktree', + repoRoot: '/workspace', + bridge: { bridgeUrl: 'http://localhost', bridgeBearer: 'secret', model: 'm' }, + }) + expect(() => + bridgedWorktreeWorker({ + name: 'bridged-worktree', + connections: [{ connectionId: 'github', capabilities: ['issues:read'] }], + }), + ).not.toThrow() + }) + + it('refuses noncanonical or forged root identity before compute starts', () => { + const makeWorkerAgent = () => deliveringLeaf('w', {}) + expect(() => + supervise( + { name: 'root', harness: 'cli-base' }, + { value: 1n }, + { + budget, + makeWorkerAgent, + brain: scriptedBrain([{ content: 'unused' }]), + }, + ), + ).toThrow(/canonical JSON/) + expect(() => + supervise({ name: 'root', harness: 'cli-base' }, 'task', { + budget, + makeWorkerAgent, + brain: scriptedBrain([{ content: 'unused' }]), + execution: { candidateDigest: 'not-a-digest' as never }, + }), + ).toThrow(/candidateDigest must be a sha256 digest/) + }) + + it('refuses an unsafe authored profile before reserving budget or starting a worker', async () => { + const journal = new InMemorySpawnJournal() + const brain = scriptedBrain([ + { + toolCalls: [ + { + name: 'spawn_agent', + arguments: { + profile: { + name: 'unsafe-worker', + prompt: { systemPrompt: 'run the task' }, + hooks: { beforeTool: [{ command: 'curl https://example.test' }] }, + }, + task: 'go', + }, + }, + ], + }, + { content: 'profile was refused' }, + ]) + const result = await supervise({ name: 'root', harness: 'cli-base' }, 't', { + budget, + backend: { + backend: 'bridge', + bridgeUrl: 'http://127.0.0.1:1', + bridgeBearer: 'unused', + model: 'codex/test', + }, + brain, + journal, + runId: 'unsafe-profile', + }) + + expect(result.kind).toBe('no-winner') + const events = await journal.loadTree('unsafe-profile') + expect(events?.filter((event) => event.kind === 'spawned').map((event) => event.id)).toEqual([ + 'unsafe-profile', + ]) + }) + + it.each([ + { + capability: 'remote MCP', + profile: { + name: 'remote-mcp-worker', + mcp: { + metadata: { transport: 'http' as const, url: 'http://169.254.169.254/latest/meta-data' }, + }, + }, + }, + { + capability: 'hub connection', + profile: { + name: 'connected-worker', + connections: [{ connectionId: 'private-mail', capabilities: ['read'] }], + }, + }, + ])('fails closed on an authored $capability unless the caller grants it', async ({ profile }) => { + const journal = new InMemorySpawnJournal() + const brain = scriptedBrain([ + { + toolCalls: [{ name: 'spawn_agent', arguments: { profile, task: 'go' } }], + }, + { content: 'profile was refused' }, + ]) + + const result = await supervise({ name: 'root', harness: 'cli-base' }, 't', { + budget, + backend: { + backend: 'bridge', + bridgeUrl: 'http://127.0.0.1:1', + bridgeBearer: 'unused', + model: 'codex/test', + }, + brain, + journal, + runId: `unsafe-${profile.name}`, + }) + + expect(result.kind).toBe('no-winner') + const events = await journal.loadTree(`unsafe-${profile.name}`) + expect(events?.filter((event) => event.kind === 'spawned').map((event) => event.id)).toEqual([ + `unsafe-${profile.name}`, + ]) }) // The seam is where the skill's flat vocabulary meets the leaves' canonical one: the profile the @@ -180,14 +659,48 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ }) it('fails loud with neither backend nor makeWorkerAgent', () => { - expect(() => supervise({ name: 'r', harness: null }, 't', { budget })).toThrow( + expect(() => supervise({ name: 'r', harness: 'cli-base' }, 't', { budget })).toThrow( /backend|makeWorkerAgent/, ) }) + it('refuses spawn authorization with a caller-owned worker factory before anything starts', async () => { + const journal = new InMemorySpawnJournal() + let factoryCalls = 0 + let brainCalls = 0 + let authorizationCalls = 0 + + expect(() => + supervise({ name: 'r', harness: 'cli-base' }, 't', { + budget, + journal, + runId: 'invalid-custom-authority', + makeWorkerAgent: () => { + factoryCalls += 1 + return deliveringLeaf('unused', {}) + }, + brain: async () => { + brainCalls += 1 + return { toolCalls: [], content: 'unused' } + }, + authorizeSpawn(input) { + authorizationCalls += 1 + return { profile: input.profile } + }, + }), + ).toThrow(/authorizeSpawn cannot be combined with caller-owned makeWorkerAgent/) + + expect({ factoryCalls, brainCalls, authorizationCalls }).toEqual({ + factoryCalls: 0, + brainCalls: 0, + authorizationCalls: 0, + }) + expect(await journal.loadTree('invalid-custom-authority')).toBeUndefined() + }) + it('allowedModels rejects a profile model outside the allowed set', () => { expect(() => - supervise({ name: 'r', harness: null, model: 'gpt-4.1' }, 't', { + supervise({ name: 'r', harness: 'cli-base', model: { default: 'gpt-4.1' } }, 't', { budget, makeWorkerAgent: () => deliveringLeaf('w', {}), allowedModels: ['deepseek-v4-flash'], @@ -195,9 +708,86 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ ).toThrow(/gpt-4\.1.*not in the allowed set/) }) + it.each([ + { + field: 'small model', + profile: { model: { default: 'safe', small: 'unsafe-small' } }, + rejected: 'unsafe-small', + }, + { + field: 'subagent model', + profile: { subagents: { critic: { model: 'unsafe-subagent' } } }, + rejected: 'unsafe-subagent', + }, + { + field: 'mode model', + profile: { modes: { review: { model: 'unsafe-mode' } } }, + rejected: 'unsafe-mode', + }, + ])('allowedModels rejects a hidden $field', ({ profile, rejected }) => { + expect(() => + supervise({ name: 'r', harness: 'cli-base', ...profile }, 't', { + budget, + makeWorkerAgent: () => deliveringLeaf('w', {}), + allowedModels: ['safe'], + }), + ).toThrow(new RegExp(`${rejected}.*not in the allowed set`)) + }) + + it('refuses every backend profile overlay before it can bypass authorization', () => { + expect(() => + supervise({ name: 'r', harness: 'cli-base', model: { default: 'safe' } }, 't', { + budget, + backend: { + backend: 'bridge', + bridgeUrl: 'http://127.0.0.1:1', + bridgeBearer: 'unused', + model: 'safe', + agentProfile: { model: { default: 'unsafe-overlay' } }, + }, + allowedModels: ['safe'], + }), + ).toThrow(/backend agentProfile overlays are not allowed/) + }) + + it('refuses a fixed session id on the reusable driver backend', () => { + expect(() => + supervise({ name: 'r', harness: 'codex' }, 't', { + budget, + backend: { + backend: 'bridge', + bridgeUrl: 'http://127.0.0.1:1', + bridgeBearer: 'unused', + model: 'worker-model', + }, + driverBackend: { + backend: 'bridge', + bridgeUrl: 'http://127.0.0.1:1', + bridgeBearer: 'unused', + model: 'driver-model', + sessionId: 'SHARED', + }, + }), + ).toThrow(/driveHarnessFromBackend: fixed sessionId.*isolated id/) + }) + + it('refuses an automatic external supervisor on a backend that cannot receive coordination tools', () => { + expect(() => + supervise({ name: 'r', harness: 'codex' }, 't', { + budget, + backend: { + backend: 'router-tools', + routerBaseUrl: 'http://127.0.0.1:1', + routerKey: 'unused', + model: 'safe', + }, + }), + ).toThrow(/requires a local bridge driverBackend.*explicit driveHarness.*resolveDriveHarness/) + }) + it('allowedModels rejects a router model outside the allowed set', () => { expect(() => - supervise({ name: 'r', harness: null }, 't', { + supervise({ name: 'r', harness: 'cli-base' }, 't', { budget, makeWorkerAgent: () => deliveringLeaf('w', {}), router: { routerBaseUrl: 'http://localhost', routerKey: 'k', model: 'gpt-4.1' }, @@ -208,7 +798,7 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ it('allowedModels rejects a backend model outside the allowed set', () => { expect(() => - supervise({ name: 'r', harness: null }, 't', { + supervise({ name: 'r', harness: 'cli-base' }, 't', { budget, backend: { backend: 'router-tools', @@ -222,14 +812,22 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ }) it('allowedModels passes when every configured model is in the set', async () => { - const brain = scriptedBrain([{ content: 'done' }]) const result = await supervise( - { name: 'root', harness: null, model: 'deepseek-v4-flash' }, + { + name: 'root', + harness: 'cli-base', + model: { default: 'deepseek-v4-flash' }, + }, 't', { budget, makeWorkerAgent: () => deliveringLeaf('w', { answer: 1 }), - brain, + router: { + routerBaseUrl: 'http://unused.test', + routerKey: 'test', + model: 'deepseek-v4-flash', + complete: async () => ({ choices: [{ message: { content: 'done' } }] }), + }, allowedModels: ['deepseek-v4-flash'], }, ) @@ -237,12 +835,20 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ }) it('allowedModels unset is unrestricted (any model passes)', async () => { - const brain = scriptedBrain([{ content: 'done' }]) - const result = await supervise({ name: 'root', harness: null, model: 'anything' }, 't', { - budget, - makeWorkerAgent: () => deliveringLeaf('w', { answer: 1 }), - brain, - }) + const result = await supervise( + { name: 'root', harness: 'cli-base', model: { default: 'anything' } }, + 't', + { + budget, + makeWorkerAgent: () => deliveringLeaf('w', { answer: 1 }), + router: { + routerBaseUrl: 'http://unused.test', + routerKey: 'test', + model: 'anything', + complete: async () => ({ choices: [{ message: { content: 'done' } }] }), + }, + }, + ) expect(result.kind).toBeDefined() }) diff --git a/tests/kernel/supervise-deadline.test.ts b/tests/kernel/supervise-deadline.test.ts new file mode 100644 index 00000000..c88d97df --- /dev/null +++ b/tests/kernel/supervise-deadline.test.ts @@ -0,0 +1,254 @@ +import type { AgentProfile } from '@tangle-network/agent-interface' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { InMemoryResultBlobStore, InMemorySpawnJournal } from '../../src/durable/spawn-journal' +import { createBudgetPool } from '../../src/runtime/supervise/budget' +import { armDeadlineTimer, teardownExecutor } from '../../src/runtime/supervise/deadline' +import { createExecutorRegistry } from '../../src/runtime/supervise/runtime' +import { createScope } from '../../src/runtime/supervise/scope' +import { createSupervisor } from '../../src/runtime/supervise/supervisor' +import type { + Agent, + AgentSpec, + Budget, + Executor, + ExecutorResult, + Scope, + Spend, + SupervisorOpts, +} from '../../src/runtime/supervise/types' + +const zeroSpend: Spend = { + iterations: 0, + tokens: { input: 0, output: 0 }, + usd: 0, + ms: 0, +} + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('supervision deadlines', () => { + it('bounds a teardown promise that never acknowledges brutal kill', async () => { + const executor = teardownOnlyExecutor(() => new Promise(() => {})) + + await expect( + teardownExecutor(executor, 'brutalKill', Date.now() + 10, Date.now), + ).rejects.toThrow(/teardown did not acknowledge/) + }) + + it('refuses a teardown receipt that admits the resource survived', async () => { + const executor = teardownOnlyExecutor(async () => ({ destroyed: false })) + + await expect(teardownExecutor(executor, 'brutalKill', undefined, Date.now)).rejects.toThrow( + /destroyed=false/, + ) + }) + + it('chunks delays beyond the Node timer limit instead of firing them after 1 ms', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const onDeadline = vi.fn() + const maxTimerDelayMs = 2_147_483_647 + + armDeadlineTimer(maxTimerDelayMs + 500, onDeadline) + await vi.advanceTimersByTimeAsync(maxTimerDelayMs) + expect(onDeadline).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(500) + expect(onDeadline).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it('the root deadline aborts live work and remains a budget exhaustion', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const started = deferred() + const root: Agent = { + name: 'deadline-root', + async act(task, scope: Scope): Promise { + const spawned = scope.spawn(blockingLeaf('blocked'), task, { + budget: { maxIterations: 1, maxTokens: 10 }, + label: 'blocked', + }) + expect(spawned.ok).toBe(true) + started.resolve() + const settled = await scope.next() + if (settled?.kind === 'down') throw new Error(settled.reason) + return settled?.out + }, + } + + const running = createSupervisor().run( + root, + 'task', + supervisorOpts({ budget: { maxIterations: 1, maxTokens: 10, deadlineMs: 25 } }), + ) + await started.promise + await vi.advanceTimersByTimeAsync(25) + + const result = await running + expect(result).toMatchObject({ kind: 'no-winner', reason: 'budget-exhausted' }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('unrefs and clears the root timer when work finishes before the deadline', async () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout') + const root: Agent = { + name: 'quick-root', + act: async () => 'done', + } + + const result = await createSupervisor().run( + root, + 'task', + supervisorOpts({ budget: { maxIterations: 1, maxTokens: 10, deadlineMs: 60_000 } }), + ) + + expect(result.kind).toBe('winner') + expect(setTimeoutSpy).toHaveBeenCalledTimes(1) + const timer = setTimeoutSpy.mock.results[0]?.value + expect(timer).toBeDefined() + expect(timer.hasRef()).toBe(false) + expect(clearTimeoutSpy).toHaveBeenCalledWith(timer) + }) + + it.each([ + { + name: 'the child duration is shorter', + parentDeadlineMs: 100, + childDeadlineMs: 20, + expectedMs: 20, + }, + { + name: 'the parent duration is shorter', + parentDeadlineMs: 20, + childDeadlineMs: 100, + expectedMs: 20, + }, + ])('aborts a child at the earlier cutoff when $name', async (testCase) => { + vi.useFakeTimers() + vi.setSystemTime(2_000) + const { scope } = await beginScope({ + maxIterations: 1, + maxTokens: 10, + deadlineMs: testCase.parentDeadlineMs, + }) + const spawned = scope.spawn(blockingLeaf('child'), 'task', { + budget: { + maxIterations: 1, + maxTokens: 10, + deadlineMs: testCase.childDeadlineMs, + }, + label: 'child', + }) + expect(spawned.ok).toBe(true) + + await vi.advanceTimersByTimeAsync(testCase.expectedMs - 1) + expect(scope.view.inFlight).toBe(1) + await vi.advanceTimersByTimeAsync(1) + + const settled = await scope.next() + expect(settled).toMatchObject({ kind: 'down', reason: 'aborted before settle' }) + expect(scope.view.inFlight).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('clears a child deadline when the child finishes first', async () => { + vi.useFakeTimers() + vi.setSystemTime(3_000) + const { scope } = await beginScope({ maxIterations: 1, maxTokens: 10 }) + const spawned = scope.spawn(immediateLeaf('child'), 'task', { + budget: { maxIterations: 1, maxTokens: 10, deadlineMs: 100 }, + label: 'child', + }) + expect(spawned.ok).toBe(true) + + expect((await scope.next())?.kind).toBe('done') + expect(vi.getTimerCount()).toBe(0) + }) +}) + +async function beginScope(budget: Budget): Promise<{ scope: Scope }> { + const journal = new InMemorySpawnJournal() + await journal.beginTree('deadline-scope', new Date(Date.now()).toISOString()) + return { + scope: createScope({ + parentId: 'deadline-scope', + root: 'deadline-scope', + pool: createBudgetPool(budget, Date.now), + journal, + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + seams: {}, + depth: 0, + signal: new AbortController().signal, + now: Date.now, + }), + } +} + +function blockingLeaf(name: string): Agent { + return leaf( + name, + (signal) => + new Promise>((resolve) => { + const finish = () => resolve({ outRef: `blocked:${name}`, out: name, spent: zeroSpend }) + if (signal.aborted) finish() + else signal.addEventListener('abort', finish, { once: true }) + }), + ) +} + +function immediateLeaf(name: string): Agent { + return leaf(name, async () => ({ outRef: `done:${name}`, out: name, spent: zeroSpend })) +} + +function teardownOnlyExecutor(teardown: Executor['teardown']): Executor { + return { + runtime: 'router', + execute: async () => ({ outRef: 'unused', out: undefined, spent: zeroSpend }), + teardown, + resultArtifact: () => ({ outRef: 'unused', out: undefined, spent: zeroSpend }), + } +} + +function leaf( + name: string, + execute: (signal: AbortSignal) => Promise>, +): Agent { + const executor: Executor = { + runtime: 'router', + execute: (_task, signal) => execute(signal), + teardown: async () => ({ destroyed: true }), + } + const executorSpec: AgentSpec = { + profile: { name } as AgentProfile, + harness: null, + executor, + } + return { name, act: async () => name, executorSpec } as Agent & { + executorSpec: AgentSpec + } +} + +function supervisorOpts(over: Partial = {}): SupervisorOpts { + return { + budget: over.budget ?? { maxIterations: 1, maxTokens: 10 }, + runId: 'deadline-supervisor', + journal: new InMemorySpawnJournal(), + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + now: Date.now, + } +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} diff --git a/tests/kernel/supervise-full-profile-bridge.test.ts b/tests/kernel/supervise-full-profile-bridge.test.ts new file mode 100644 index 00000000..04d573c7 --- /dev/null +++ b/tests/kernel/supervise-full-profile-bridge.test.ts @@ -0,0 +1,1175 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { createServer, type Server, type ServerResponse } from 'node:http' +import type { AddressInfo } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { type AgentProfile, canonicalCandidateDigest } from '@tangle-network/agent-interface' +import { afterEach, describe, expect, it } from 'vitest' +import { InMemorySpawnJournal } from '../../src/durable/spawn-journal' +import type { ExecutorConfig } from '../../src/runtime/supervise/runtime' +import { supervise } from '../../src/runtime/supervise/supervise' +import { createRootHandle } from '../../src/runtime/supervise/supervisor' + +type BridgeRequest = { + model: string + run_id: string + session_id: string + agent_profile: AgentProfile + messages: Array<{ role: string; content: string }> +} + +const TEST_RUN_DIGEST = `sha256:${'c'.repeat(64)}` + +function numberSseDataFrames(body: string): string { + let seq = 0 + return body.replace(/^data: (?!\[DONE\])/gmu, () => `id: ${++seq}\ndata: `) +} + +function respondWithBridgeStream( + res: ServerResponse, + request: BridgeRequest, + stream: string, +): void { + res.writeHead(200, { + 'content-type': 'text/event-stream', + 'x-run-id': request.run_id, + 'x-run-request-digest': TEST_RUN_DIGEST, + }) + res.end(numberSseDataFrames(stream)) +} + +function cancelledRunId(url: string | undefined): string | undefined { + const match = url?.match(/^\/v1\/runs\/([^/]+)\/cancel(?:\?|$)/u) + return match?.[1] ? decodeURIComponent(match[1]) : undefined +} + +function respondWithTerminalCancellation(res: ServerResponse, runId: string): void { + res.writeHead(200, { + 'content-type': 'application/json', + 'x-run-id': runId, + 'x-run-request-digest': TEST_RUN_DIGEST, + }) + res.end( + JSON.stringify({ + terminal: true, + run: { + id: runId, + requestDigest: TEST_RUN_DIGEST, + terminal: true, + }, + }), + ) +} + +async function readJson(req: AsyncIterable): Promise { + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(Buffer.from(chunk)) + return JSON.parse(Buffer.concat(chunks).toString('utf8')) as BridgeRequest +} + +async function callCoordination(url: string, name: string, args: unknown): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: `${name}-${Date.now()}`, + method: 'tools/call', + params: { name, arguments: args }, + }), + }) + if (!response.ok) throw new Error(`coordination ${name} returned ${response.status}`) +} + +function successStream(content: string): string { + return [ + `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}`, + `data: ${JSON.stringify({ usage: { prompt_tokens: 11, completion_tokens: 7, cost: 0.01 } })}`, + 'data: [DONE]', + '', + ].join('\n\n') +} + +function unknownCostStream(content: string): string { + return [ + `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}`, + `data: ${JSON.stringify({ usage: { prompt_tokens: 11, completion_tokens: 7 } })}`, + 'data: [DONE]', + '', + ].join('\n\n') +} + +function unknownTokenStream(content: string): string { + return [ + `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}`, + `data: ${JSON.stringify({ usage: { cost: 0.01 } })}`, + 'data: [DONE]', + '', + ].join('\n\n') +} + +describe('supervise — complete profiles over recursive cli-bridge managers', () => { + let server: Server | undefined + + afterEach(async () => { + if (server) await new Promise((resolve) => server?.close(resolve)) + server = undefined + }) + + it('forcefully steers a live bridge root and resumes the same manager session', async () => { + const requests: BridgeRequest[] = [] + const cancelled: string[] = [] + let markFirstRequest!: () => void + const firstRequest = new Promise((resolve) => { + markFirstRequest = resolve + }) + server = createServer(async (req, res) => { + const cancelledId = cancelledRunId(req.url) + if (cancelledId !== undefined) { + cancelled.push(cancelledId) + respondWithTerminalCancellation(res, cancelledId) + return + } + const body = await readJson(req) + requests.push(body) + if (requests.length === 1) { + res.writeHead(200, { + 'content-type': 'text/event-stream', + 'x-run-id': body.run_id, + 'x-run-request-digest': TEST_RUN_DIGEST, + }) + res.write( + `id: 1\ndata: ${JSON.stringify({ usage: { prompt_tokens: 5, completion_tokens: 2, cost: 0.01 } })}\n\n`, + ) + markFirstRequest() + return + } + respondWithBridgeStream(res, body, successStream('corrected manager turn')) + }) + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + const handle = createRootHandle() + const running = supervise( + { + name: 'pi-leader', + harness: 'codex', + prompt: { systemPrompt: 'Lead the pursuit.' }, + model: { default: 'gpt-5.6' }, + }, + 'Choose the next experiment.', + { + rootHandle: handle, + backend: { + backend: 'bridge', + bridgeUrl: `http://127.0.0.1:${port}`, + bridgeBearer: 'test-token', + model: 'codex/gpt-5.6', + }, + budget: { maxIterations: 4, maxTokens: 10_000 }, + }, + ) + + await firstRequest + expect( + handle.deliver({ + steer: 'replace the weak plan with the falsification test', + interrupt: true, + }), + ).toBe(true) + await running + + expect(requests).toHaveLength(2) + expect(cancelled).toEqual([requests[0]?.run_id]) + expect(requests[1]?.session_id).toBe(requests[0]?.session_id) + expect(requests[1]?.messages).toEqual([ + { + role: 'user', + content: expect.stringContaining('replace the weak plan with the falsification test'), + }, + ]) + expect(() => handle.deliver({ steer: 'after completion' })).toThrow() + }) + + it('selects heterogeneous leaf completion checks from the exact authorized spawn context', async () => { + const requests: BridgeRequest[] = [] + server = createServer(async (req, res) => { + const body = await readJson(req) + requests.push(body) + const content = + body.agent_profile.name === 'worker' + ? 'WORK=42' + : body.agent_profile.name === 'evaluator' + ? 'EVAL=pass' + : 'FALLBACK=ready' + respondWithBridgeStream(res, body, successStream(content)) + }) + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + const contexts: Array<{ + name: string | undefined + task: unknown + key: string | undefined + assignmentId: string + depth: number + frozen: boolean + candidateDigest: string | undefined + }> = [] + let turn = 0 + const result = await supervise( + { name: 'root', harness: 'cli-base', prompt: { systemPrompt: 'Run all checks.' } }, + 'Compare implementation and evaluation evidence.', + { + backend: { + backend: 'bridge', + bridgeUrl: `http://127.0.0.1:${port}`, + bridgeBearer: 'test-token', + model: 'codex/test', + }, + budget: { maxIterations: 20, maxTokens: 20_000 }, + perWorker: { maxIterations: 4, maxTokens: 2_000 }, + deliverable: { + check: (out) => + typeof out === 'object' && + out !== null && + (out as { content?: unknown }).content === 'FALLBACK=ready', + describe: 'fallback artifact is ready', + }, + brain: async () => { + turn += 1 + if (turn === 1) { + return { + toolCalls: [ + { + id: 'worker', + name: 'spawn_agent', + arguments: JSON.stringify({ + profile: { name: 'worker' }, + task: { kind: 'implement', expected: 'WORK=42' }, + key: 'worker', + }), + }, + { + id: 'evaluator', + name: 'spawn_agent', + arguments: JSON.stringify({ + profile: { name: 'evaluator' }, + task: { kind: 'evaluate', expected: 'EVAL=pass' }, + key: 'evaluator', + }), + }, + { + id: 'fallback', + name: 'spawn_agent', + arguments: JSON.stringify({ + profile: { name: 'fallback' }, + task: { kind: 'archive', expected: 'FALLBACK=ready' }, + key: 'fallback', + }), + }, + ], + } + } + if (turn <= 4) { + return { + toolCalls: [ + { id: `await-${turn}`, name: 'await_event', arguments: JSON.stringify({}) }, + ], + } + } + return { content: 'done', toolCalls: [] } + }, + authorizeSpawn: (input) => ({ + profile: input.profile, + execution: { + candidateDigest: canonicalCandidateDigest({ candidate: input.profile.name }), + correlation: { campaign: 'heterogeneous-checks' }, + }, + }), + resolveDeliverable: (input) => { + contexts.push({ + name: input.profile.name, + task: input.task, + key: input.key, + assignmentId: input.assignmentId, + depth: input.depth, + frozen: + Object.isFrozen(input) && + Object.isFrozen(input.profile) && + Object.isFrozen(input.parent) && + Object.isFrozen(input.parentIdentity) && + Object.isFrozen(input.execution) && + Object.isFrozen(input.task) && + Object.isFrozen(input.budget), + candidateDigest: input.execution.candidateDigest, + }) + if (input.profile.name === 'fallback') return undefined + const expected = input.profile.name === 'worker' ? 'WORK=42' : 'EVAL=pass' + return { + check: (out) => + typeof out === 'object' && + out !== null && + (out as { content?: unknown }).content === expected, + describe: `${input.profile.name} emits ${expected}`, + } + }, + }, + ) + + expect(result.kind).toBe('winner') + expect(requests.map((request) => request.agent_profile.name).sort()).toEqual([ + 'evaluator', + 'fallback', + 'worker', + ]) + expect(contexts).toHaveLength(3) + expect(contexts.map((context) => context.name).sort()).toEqual([ + 'evaluator', + 'fallback', + 'worker', + ]) + expect(contexts.every((context) => context.frozen)).toBe(true) + expect(contexts.map((context) => context.depth)).toEqual([1, 1, 1]) + expect(contexts.map((context) => context.assignmentId).sort()).toEqual([ + 'key:evaluator', + 'key:fallback', + 'key:worker', + ]) + for (const context of contexts) { + expect(context.candidateDigest).toBe(canonicalCandidateDigest({ candidate: context.name })) + expect(context.task).toMatchObject({ expected: expect.any(String) }) + expect(context.key).toBe(context.name) + } + }) + + it('reuses the recorded per-spawn completion result on durable resume', async () => { + let requests = 0 + server = createServer(async (req, res) => { + const body = await readJson(req) + requests += 1 + respondWithBridgeStream(res, body, successStream('RESULT=durable')) + }) + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + const runDir = await mkdtemp(join(tmpdir(), 'per-spawn-deliverable-resume-')) + let resolutions = 0 + const makeBrain = () => { + let turn = 0 + return async () => { + turn += 1 + if (turn === 1) { + return { + toolCalls: [ + { + id: 'spawn', + name: 'spawn_agent', + arguments: JSON.stringify({ + profile: { name: 'durable-worker' }, + task: 'produce the durable result', + key: 'durable-worker', + }), + }, + ], + } + } + if (turn === 2) { + return { + toolCalls: [{ id: 'await', name: 'await_event', arguments: JSON.stringify({}) }], + } + } + return { content: 'done', toolCalls: [] } + } + } + const common = { + backend: { + backend: 'bridge' as const, + bridgeUrl: `http://127.0.0.1:${port}`, + bridgeBearer: 'test-token', + model: 'codex/test', + }, + budget: { maxIterations: 8, maxTokens: 10_000 }, + perWorker: { maxIterations: 2, maxTokens: 1_000 }, + runDir, + runId: 'per-spawn-deliverable-resume', + resolveDeliverable: () => { + resolutions += 1 + return { + check: (out: unknown) => + typeof out === 'object' && + out !== null && + (out as { content?: unknown }).content === 'RESULT=durable', + } + }, + } + const profile = { name: 'root', harness: 'cli-base' as const } + try { + const first = await supervise(profile, 'resume the exact result', { + ...common, + brain: makeBrain(), + }) + const second = await supervise(profile, 'resume the exact result', { + ...common, + brain: makeBrain(), + }) + + expect(first.kind).toBe('winner') + expect(second.kind).toBe('winner') + expect(requests).toBe(1) + // The resumed manager re-authorizes and re-resolves the exact keyed request, but Scope returns + // the recorded settlement before constructing or executing a replacement backend worker. + expect(resolutions).toBe(2) + } finally { + await rm(runDir, { recursive: true, force: true }) + } + }) + + it('runs PI → nested supervisor → worker without dropping any authored profile axis', async () => { + const requests: BridgeRequest[] = [] + const journal = new InMemorySpawnJournal() + const resolvedDeliverables: string[] = [] + const classifications: Array<{ + name: string | undefined + metadataRole: unknown + experimentId: string | undefined + frozen: boolean + isDriver: boolean + }> = [] + const authorizations: Array<{ + depth: number + frozen: boolean + profile: AgentProfile + task: unknown + }> = [] + server = createServer(async (req, res) => { + try { + const body = await readJson(req) + requests.push(body) + const profile = body.agent_profile + const coordination = profile.mcp?.['agent-runtime-coordination'] + const depth = profile.metadata?.depth + + if (coordination?.enabled !== false && coordination?.url) { + if (depth === 0) { + await callCoordination(coordination.url, 'spawn_agent', { + profile: { + name: 'methods-supervisor', + description: 'Run the discriminating experiment', + harness: 'codex', + prompt: { systemPrompt: 'Supervise one empirical worker.' }, + model: { default: 'gpt-5.6', reasoningEffort: 'high' }, + tools: { shell: true }, + resources: { + skills: [ + { + kind: 'inline', + name: 'experimental-method', + content: '# Experimental method\nChange one variable at a time.', + }, + ], + failOnError: true, + }, + subagents: { + critic: { description: 'Find a confound', prompt: 'Challenge the result.' }, + }, + modes: { adversarial: { prompt: 'Try to falsify the claim.' } }, + // Deliberately false model-authored authority: product authorization below makes + // this profile recursive even though the authored metadata calls it a worker. + metadata: { role: 'worker', depth: 1, family: 'scientific-method' }, + }, + task: 'Run one experiment and return its measured result.', + }) + } else { + await callCoordination(coordination.url, 'spawn_agent', { + profile: { + name: 'experiment-worker', + description: 'Execute and report the measurement', + harness: 'codex', + prompt: { systemPrompt: 'Return the exact measured result.' }, + model: { default: 'gpt-5.6', reasoningEffort: 'medium' }, + permissions: { shell: 'allow' }, + tools: { shell: true, web: false }, + resources: { + files: [ + { + path: 'protocol.txt', + resource: { + kind: 'inline', + name: 'protocol', + content: 'Measure twice; report both observations.', + }, + }, + ], + failOnError: true, + }, + // Deliberately false in the other direction: model-authored metadata cannot make a + // profile recursive when product authorization classifies it as a leaf. + metadata: { role: 'driver', depth: 2, family: 'scientific-method' }, + }, + task: 'Measure the system and report RESULT=42.', + }) + } + await callCoordination(coordination.url, 'await_event', {}) + } + + respondWithBridgeStream( + res, + body, + successStream(profile.name === 'experiment-worker' ? 'RESULT=42' : 'managed'), + ) + } catch (error) { + res.writeHead(500, { 'content-type': 'text/plain' }) + res.end(error instanceof Error ? error.message : String(error)) + } + }) + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + const backend: ExecutorConfig = { + backend: 'bridge', + bridgeUrl: `http://127.0.0.1:${port}`, + bridgeBearer: 'test-token', + model: 'codex/gpt-5.6', + } + const rootProfile: AgentProfile = { + name: 'pi-leader', + description: 'Lead the full pursuit', + harness: 'codex', + prompt: { systemPrompt: 'Choose and supervise the most informative experiment.' }, + model: { default: 'gpt-5.6', reasoningEffort: 'xhigh' }, + tools: { web: true }, + mcp: { + literature: { transport: 'http', url: 'https://papers.example.test/mcp' }, + }, + resources: { + instructions: 'Keep hypotheses separate from observations.', + failOnError: true, + }, + metadata: { role: 'driver', depth: 0, family: 'discovery-native' }, + } + const rootTask = 'Resolve the pursuit with one measured experiment.' + + const result = await supervise(rootProfile, rootTask, { + backend, + budget: { maxIterations: 20, maxTokens: 100_000 }, + perWorker: { maxIterations: 8, maxTokens: 20_000 }, + maxDepth: 4, + deliverable: { + check: (out) => + typeof out === 'object' && + out !== null && + (out as { content?: unknown }).content === 'RESULT=42', + describe: 'worker reports RESULT=42', + }, + profileSecurity: { + allowLocalMcp: false, + allowHooks: false, + allowedMcpHosts: ['papers.example.test'], + }, + journal, + runId: 'identity-run', + execution: { + candidateDigest: canonicalCandidateDigest({ candidate: 'pi-leader' }), + correlation: { pursuitId: 'pursuit-1', experimentId: 'experiment-root' }, + }, + authorizeSpawn: (input) => { + authorizations.push({ + depth: input.depth, + frozen: + Object.isFrozen(input) && + Object.isFrozen(input.profile) && + Object.isFrozen(input.task) && + Object.isFrozen(input.budget), + profile: input.profile, + task: input.task, + }) + const name = input.profile.name ?? 'unnamed' + return { + profile: input.profile, + execution: { + candidateDigest: canonicalCandidateDigest({ candidate: name }), + correlation: { + pursuitId: 'pursuit-1', + experimentId: `experiment-${input.depth}`, + }, + }, + } + }, + isDriverProfile: (input) => { + const isDriver = input.execution.correlation?.experimentId === 'experiment-1' + classifications.push({ + name: input.profile.name, + metadataRole: input.profile.metadata?.role, + experimentId: input.execution.correlation?.experimentId, + frozen: + Object.isFrozen(input) && + Object.isFrozen(input.profile) && + Object.isFrozen(input.parent) && + Object.isFrozen(input.parentIdentity) && + Object.isFrozen(input.execution) && + Object.isFrozen(input.execution.correlation) && + Object.isFrozen(input.task) && + Object.isFrozen(input.budget), + isDriver, + }) + return isDriver + }, + resolveDeliverable: (input) => { + resolvedDeliverables.push(input.profile.name ?? 'unnamed') + return undefined + }, + }) + + expect(result.kind).toBe('winner') + expect(requests.map((request) => request.agent_profile.name)).toEqual([ + 'pi-leader', + 'methods-supervisor', + 'experiment-worker', + ]) + expect(requests.map((request) => request.model)).toEqual([ + 'codex/gpt-5.6', + 'codex/gpt-5.6', + 'codex/gpt-5.6', + ]) + expect(requests.map((request) => request.session_id)).toEqual([ + expect.stringMatching(/^supervised-manager-[a-f0-9]{64}$/), + expect.stringMatching(/^supervised-manager-[a-f0-9]{64}$/), + expect.stringMatching(/^supervised-worker-[a-f0-9]{64}$/), + ]) + expect(new Set(requests.map((request) => request.session_id)).size).toBe(3) + + const pi = requests[0]!.agent_profile + expect(pi.tools).toEqual({ web: true }) + expect(pi.mcp?.literature).toEqual({ + transport: 'http', + url: 'https://papers.example.test/mcp', + }) + expect(pi.mcp?.['agent-runtime-coordination']).toMatchObject({ transport: 'http' }) + + const nested = requests[1]!.agent_profile + expect(nested.resources?.skills?.[0]).toMatchObject({ name: 'experimental-method' }) + expect(nested.subagents?.critic?.prompt).toBe('Challenge the result.') + expect(nested.modes?.adversarial?.prompt).toBe('Try to falsify the claim.') + expect(nested.mcp?.['agent-runtime-coordination']).toMatchObject({ transport: 'http' }) + + const worker = requests[2]!.agent_profile + expect(worker.permissions).toEqual({ shell: 'allow' }) + expect(worker.resources?.files?.[0]?.path).toBe('protocol.txt') + expect(worker.mcp?.['agent-runtime-coordination']).toBeUndefined() + expect(requests.every((request) => request.messages[0]?.role === 'user')).toBe(true) + expect(result.spentTotal.tokens).toEqual({ input: 33, output: 21 }) + expect(result.spentTotal.iterations).toBe(1) + expect(authorizations).toEqual([ + { + depth: 1, + frozen: true, + profile: expect.objectContaining({ name: 'methods-supervisor' }), + task: 'Run one experiment and return its measured result.', + }, + { + depth: 2, + frozen: true, + profile: expect.objectContaining({ name: 'experiment-worker' }), + task: 'Measure the system and report RESULT=42.', + }, + ]) + expect(classifications).toEqual([ + { + name: 'methods-supervisor', + metadataRole: 'worker', + experimentId: 'experiment-1', + frozen: true, + isDriver: true, + }, + { + name: 'experiment-worker', + metadataRole: 'driver', + experimentId: 'experiment-2', + frozen: true, + isDriver: false, + }, + ]) + expect(resolvedDeliverables).toEqual(['experiment-worker']) + + const rootEvents = await journal.loadTree('identity-run') + expect(JSON.stringify(rootEvents)).not.toContain(backend.bridgeUrl) + expect(JSON.stringify(rootEvents)).not.toContain(backend.bridgeBearer) + expect(JSON.stringify(rootEvents)).not.toContain( + String(pi.mcp?.['agent-runtime-coordination']?.url), + ) + const rootSpawn = rootEvents?.find( + (event) => event.kind === 'spawned' && event.id === 'identity-run', + ) + const nestedSpawn = rootEvents?.find( + (event) => event.kind === 'spawned' && event.id !== 'identity-run', + ) + expect(rootSpawn?.identity).toEqual({ + profileDigest: canonicalCandidateDigest(rootProfile), + taskDigest: canonicalCandidateDigest(rootTask), + candidateDigest: canonicalCandidateDigest({ candidate: 'pi-leader' }), + correlation: { pursuitId: 'pursuit-1', experimentId: 'experiment-root' }, + }) + const rootMaterialized = rootEvents?.find((event) => event.kind === 'materialized') + expect(rootMaterialized).toMatchObject({ + kind: 'materialized', + id: 'identity-run', + receipt: { + status: 'known', + authoredProfileDigest: canonicalCandidateDigest(rootProfile), + effectiveProfileDigest: canonicalCandidateDigest(rootProfile), + runtime: 'cli', + backend: 'bridge', + model: { status: 'known', id: 'codex/gpt-5.6' }, + execution: { kind: 'session', id: requests[0]!.session_id }, + }, + }) + expect( + rootMaterialized?.kind === 'materialized' + ? rootMaterialized.receipt.platformAttachmentsDigest + : undefined, + ).toMatch(/^sha256:[a-f0-9]{64}$/) + expect( + rootEvents?.find((event) => event.kind === 'execution-bound' && event.id === 'identity-run'), + ).toMatchObject({ + binding: { + status: 'known', + descriptor: { kind: 'bridge-session', transport: 'http', coordination: true }, + }, + }) + expect(nestedSpawn?.identity).toEqual({ + profileDigest: canonicalCandidateDigest(authorizations[0]!.profile), + taskDigest: canonicalCandidateDigest(authorizations[0]!.task), + candidateDigest: canonicalCandidateDigest({ candidate: 'methods-supervisor' }), + correlation: { pursuitId: 'pursuit-1', experimentId: 'experiment-1' }, + }) + const nestedEvents = await journal.loadTree('identity-run/identity-run:s0') + expect(JSON.stringify(nestedEvents)).not.toContain(backend.bridgeUrl) + expect(JSON.stringify(nestedEvents)).not.toContain(backend.bridgeBearer) + const workerSpawn = nestedEvents?.find((event) => event.kind === 'spawned') + expect(workerSpawn?.identity).toEqual({ + profileDigest: canonicalCandidateDigest(authorizations[1]!.profile), + taskDigest: canonicalCandidateDigest(authorizations[1]!.task), + candidateDigest: canonicalCandidateDigest({ candidate: 'experiment-worker' }), + correlation: { pursuitId: 'pursuit-1', experimentId: 'experiment-2' }, + }) + }) + + it('isolates identical concurrent managers but reuses one durable manager session on restart', async () => { + const sessions: string[] = [] + server = createServer(async (req, res) => { + const body = await readJson(req) + sessions.push(body.session_id) + respondWithBridgeStream(res, body, successStream('managed')) + }) + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + const profile = { + name: 'pi-leader', + harness: 'codex', + prompt: { systemPrompt: 'Lead the pursuit.' }, + model: { default: 'gpt-5.6' }, + } as const + const backend = { + backend: 'bridge' as const, + bridgeUrl: `http://127.0.0.1:${port}`, + bridgeBearer: 'test-token', + model: 'codex/gpt-5.6', + } + const task = 'Choose the next experiment.' + + await Promise.all([ + supervise(profile, task, { + backend, + budget: { maxIterations: 4, maxTokens: 10_000 }, + runId: 'same-visible-run-id', + }), + supervise(profile, task, { + backend, + budget: { maxIterations: 4, maxTokens: 10_000 }, + runId: 'same-visible-run-id', + }), + ]) + expect(sessions).toHaveLength(2) + expect(new Set(sessions).size).toBe(2) + + sessions.length = 0 + const runDir = await mkdtemp(join(tmpdir(), 'manager-stable-session-')) + try { + const options = { + backend, + budget: { maxIterations: 4, maxTokens: 10_000 }, + runDir, + runId: 'durable-manager-session', + } + await supervise(profile, task, options) + await supervise(profile, task, options) + + expect(sessions).toHaveLength(2) + expect(sessions[0]).toMatch(/^supervised-manager-[a-f0-9]{64}$/) + expect(sessions[1]).toBe(sessions[0]) + } finally { + await rm(runDir, { recursive: true, force: true }) + } + }) + + it('captures mutable supervision policy, profiles, limits, and callback selection at intake', async () => { + const requests: BridgeRequest[] = [] + server = createServer(async (req, res) => { + const body = await readJson(req) + requests.push(body) + respondWithBridgeStream(res, body, successStream('RESULT=42')) + }) + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + + let releaseFirstTurn!: () => void + const firstTurnHeld = new Promise((resolve) => { + releaseFirstTurn = resolve + }) + let markFirstTurnEntered!: () => void + const firstTurnEntered = new Promise((resolve) => { + markFirstTurnEntered = resolve + }) + let turn = 0 + const callbackCalls: string[] = [] + const brain = async () => { + turn += 1 + if (turn === 1) { + markFirstTurnEntered() + await firstTurnHeld + return { + toolCalls: [ + { + id: 'spawn', + name: 'spawn_agent', + arguments: JSON.stringify({ + profile: { + name: 'policy-worker', + harness: 'codex', + model: { default: 'safe-model' }, + mcp: { + allowed: { transport: 'http', url: 'https://allowed.test/mcp' }, + }, + }, + task: 'Return RESULT=42.', + }), + }, + ], + usage: { input: 1, output: 1 }, + costUsd: 0, + } + } + if (turn === 2) { + return { + toolCalls: [{ id: 'await', name: 'await_event', arguments: JSON.stringify({}) }], + usage: { input: 1, output: 1 }, + costUsd: 0, + } + } + return { content: 'done', toolCalls: [], usage: { input: 1, output: 1 }, costUsd: 0 } + } + const rootProfile: AgentProfile = { + name: 'original-root', + harness: 'cli-base', + prompt: { systemPrompt: 'Use the worker.' }, + } + const backend = { + backend: 'bridge' as const, + bridgeUrl: `http://127.0.0.1:${port}`, + bridgeBearer: 'test-token', + model: 'safe-model', + } + const profileSecurity = { + allowLocalMcp: false, + allowHooks: false, + allowedMcpHosts: ['allowed.test'], + } + const perWorker = { maxIterations: 2, maxTokens: 100 } + const allowedModels = ['safe-model'] + const deliverable = { + check: (out: unknown) => + typeof out === 'object' && + out !== null && + (out as { content?: unknown }).content === 'RESULT=42', + describe: 'the measured result', + } + const options = { + backend, + budget: { maxIterations: 10, maxTokens: 10_000 }, + perWorker, + profileSecurity, + allowedModels, + deliverable, + brain, + maxTurns: 4, + authorizeSpawn: (input: { + profile: AgentProfile + parent: AgentProfile + task: unknown + budget: { maxIterations: number; maxTokens: number } + }) => { + callbackCalls.push('original-authorizer') + expect(input.parent.name).toBe('original-root') + expect(input.budget).toMatchObject({ maxIterations: 2, maxTokens: 100 }) + return { profile: input.profile } + }, + isDriverProfile: () => false, + } + + const run = supervise(rootProfile, { pursuit: 'original-task' }, options) + await firstTurnEntered + rootProfile.name = 'mutated-root' + backend.bridgeUrl = 'http://127.0.0.1:1' + backend.model = 'mutated-model' + profileSecurity.allowedMcpHosts.splice(0, 1, 'mutated.test') + perWorker.maxIterations = 0 + perWorker.maxTokens = 1 + allowedModels.splice(0, 1, 'mutated-model') + deliverable.check = () => false + options.maxTurns = 1 + options.authorizeSpawn = (input) => { + callbackCalls.push('replacement-authorizer') + return { profile: input.profile } + } + options.isDriverProfile = () => true + options.brain = async () => ({ content: 'replacement', toolCalls: [] }) + releaseFirstTurn() + + const result = await run + expect(result.kind).toBe('winner') + expect(callbackCalls).toEqual(['original-authorizer']) + expect(requests).toHaveLength(1) + expect(requests[0]).toMatchObject({ + model: 'codex/safe-model', + agent_profile: { + name: 'policy-worker', + mcp: { allowed: { url: 'https://allowed.test/mcp' } }, + }, + }) + expect(requests[0]?.agent_profile.mcp?.['agent-runtime-coordination']).toBeUndefined() + }) + + it('refuses a backend overlay before it can occupy the coordination alias', () => { + expect(() => + supervise( + { + name: 'pi-leader', + harness: 'codex', + prompt: { systemPrompt: 'Lead the pursuit.' }, + model: { default: 'gpt-5.6' }, + }, + 'Choose the next experiment.', + { + backend: { + backend: 'bridge', + bridgeUrl: 'http://127.0.0.1:1', + bridgeBearer: 'unused', + model: 'codex/gpt-5.6', + agentProfile: { + mcp: { + 'agent-runtime-coordination': { + transport: 'http', + url: 'http://169.254.169.254/latest/meta-data', + }, + }, + }, + }, + budget: { maxIterations: 2, maxTokens: 10_000 }, + }, + ), + ).toThrow(/backend agentProfile overlays are not allowed/) + }) + + it('refuses a manager with unknown cost under a dollar-capped budget', async () => { + server = createServer(async (req, res) => { + const body = await readJson(req) + respondWithBridgeStream(res, body, unknownCostStream('managed')) + }) + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + + const result = await supervise( + { + name: 'pi-leader', + harness: 'codex', + prompt: { systemPrompt: 'Lead the pursuit.' }, + model: { default: 'gpt-5.6' }, + }, + 'Choose the next experiment.', + { + backend: { + backend: 'bridge', + bridgeUrl: `http://127.0.0.1:${port}`, + bridgeBearer: 'test-token', + model: 'codex/gpt-5.6', + }, + budget: { maxIterations: 2, maxTokens: 10_000, maxUsd: 1 }, + }, + ) + + expect(result.kind).toBe('no-winner') + if (result.kind !== 'no-winner') return + // The pool REFUSES an unknown-dollar observation before mutating, so the run fails on the + // driver arm carrying the refusal — never an invented figure, never a silently consumed cap. + expect(result.reason).toBe('driver-failed') + if (result.reason === 'driver-failed') { + expect(result.error.message).toMatch(/unknown dollar cost under a dollar-capped budget/) + } + expect(result.spentTotal).toMatchObject({ usd: 0, usdKnown: false }) + }) + + it('records a manager with unknown token usage as unknown telemetry without ending the run', async () => { + server = createServer(async (req, res) => { + const body = await readJson(req) + respondWithBridgeStream(res, body, unknownTokenStream('managed')) + }) + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + + const result = await supervise( + { + name: 'pi-leader', + harness: 'codex', + prompt: { systemPrompt: 'Lead the pursuit.' }, + model: { default: 'gpt-5.6' }, + }, + 'Choose the next experiment.', + { + backend: { + backend: 'bridge', + bridgeUrl: `http://127.0.0.1:${port}`, + bridgeBearer: 'test-token', + model: 'codex/gpt-5.6', + }, + budget: { maxIterations: 2, maxTokens: 10_000 }, + }, + ) + + expect(result.kind).toBe('no-winner') + if (result.kind !== 'no-winner') return + // Tokens are always capped, so ONE unreported turn must not end the run: the manager keeps + // running, the pool marks the balance a ceiling rather than a measurement, and the terminal + // accounting carries the unknown instead of a silent zero. + expect(result.reason).toBe('all-children-down') + expect(result.spentTotal).toMatchObject({ + tokens: { input: 0, output: 0 }, + tokensKnown: false, + }) + }) + + it('records a partial manager stream as unknown and refuses a replacement after restart', async () => { + const runDir = await mkdtemp(join(tmpdir(), 'manager-partial-stream-')) + let requests = 0 + try { + server = createServer(async (req, res) => { + const cancelled = cancelledRunId(req.url) + if (cancelled !== undefined) { + respondWithTerminalCancellation(res, cancelled) + return + } + const body = await readJson(req) + requests += 1 + res.writeHead(200, { + 'content-type': 'text/event-stream', + 'x-run-id': body.run_id, + 'x-run-request-digest': TEST_RUN_DIGEST, + }) + res.write( + `id: 1\ndata: ${JSON.stringify({ usage: { prompt_tokens: 13, completion_tokens: 5, cost: 0.02 } })}\n\n`, + ) + setTimeout(() => res.socket?.destroy(new Error('socket died before terminal receipt')), 10) + }) + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + const profile = { + name: 'pi-leader', + harness: 'codex', + prompt: { systemPrompt: 'Lead the pursuit.' }, + model: { default: 'gpt-5.6' }, + } as const + const options = { + backend: { + backend: 'bridge', + bridgeUrl: `http://127.0.0.1:${port}`, + bridgeBearer: 'test-token', + model: 'codex/gpt-5.6', + } as const, + budget: { maxIterations: 2, maxTokens: 100, maxUsd: 1 }, + runDir, + runId: 'partial-manager-restart', + } + + const first = await supervise(profile, 'Choose the next experiment.', options) + expect(first.kind).toBe('no-winner') + expect(first.spentTotal).toMatchObject({ + tokens: { input: 13, output: 5 }, + tokensKnown: false, + usd: 0.02, + usdKnown: false, + }) + + const resumed = await supervise(profile, 'Choose the next experiment.', options) + expect(resumed.kind).toBe('no-winner') + expect(resumed.spentTotal).toMatchObject({ + tokens: { input: 13, output: 5 }, + tokensKnown: false, + usd: 0.02, + usdKnown: false, + }) + // One reconnect under the same durable run id proves the first transport loss before the + // repeated event id fails continuity. Resume itself starts no replacement manager. + expect(requests).toBe(2) + } finally { + await rm(runDir, { recursive: true, force: true }) + } + }) + + it("preserves a leaf's unknown dollar cost and refuses it under a dollar cap", async () => { + const requests: BridgeRequest[] = [] + server = createServer(async (req, res) => { + const body = await readJson(req) + requests.push(body) + const profile = body.agent_profile + const coordination = profile.mcp?.['agent-runtime-coordination'] + if (coordination?.enabled !== false && coordination?.url) { + await callCoordination(coordination.url, 'spawn_agent', { + profile: { + name: 'worker', + harness: 'codex', + prompt: { systemPrompt: 'Return the result.' }, + model: { default: 'gpt-5.6' }, + }, + task: 'Return RESULT=42.', + }) + await callCoordination(coordination.url, 'await_event', {}) + } + respondWithBridgeStream( + res, + body, + profile.name === 'worker' ? unknownCostStream('RESULT=42') : successStream('managed'), + ) + }) + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + + const result = await supervise( + { + name: 'pi-leader', + harness: 'codex', + prompt: { systemPrompt: 'Lead the pursuit.' }, + model: { default: 'gpt-5.6' }, + }, + 'Choose the next experiment.', + { + backend: { + backend: 'bridge', + bridgeUrl: `http://127.0.0.1:${port}`, + bridgeBearer: 'test-token', + model: 'codex/gpt-5.6', + }, + budget: { maxIterations: 4, maxTokens: 10_000, maxUsd: 1 }, + }, + ) + + expect(requests.map((request) => request.agent_profile.name)).toEqual(['pi-leader', 'worker']) + expect(result.kind).toBe('no-winner') + expect(result.spentTotal.usdKnown).toBe(false) + }) +}) diff --git a/tests/kernel/supervise-global-concurrency.test.ts b/tests/kernel/supervise-global-concurrency.test.ts new file mode 100644 index 00000000..797ecab3 --- /dev/null +++ b/tests/kernel/supervise-global-concurrency.test.ts @@ -0,0 +1,399 @@ +import type { AgentProfile } from '@tangle-network/agent-interface' +import { describe, expect, it } from 'vitest' +import { InMemoryResultBlobStore, InMemorySpawnJournal } from '../../src/durable/spawn-journal' +import type { MakeWorkerAgent } from '../../src/mcp/tools/coordination' +import { driverChild } from '../../src/runtime/supervise/driver-executor' +import { createExecutorRegistry } from '../../src/runtime/supervise/runtime' +import { supervise } from '../../src/runtime/supervise/supervise' +import { createSupervisor } from '../../src/runtime/supervise/supervisor' +import { supervisorAgent } from '../../src/runtime/supervise/supervisor-agent' +import type { + Agent, + AgentSpec, + Executor, + ExecutorResult, + Scope, +} from '../../src/runtime/supervise/types' +import { scriptedBrain } from './scripted-brain' + +const zeroCost = { iterations: 1, tokens: { input: 1, output: 1 }, usd: 0, ms: 0 } +const knownZero = { + iterations: 0, + tokens: { input: 0, output: 0 }, + usd: 0, + ms: 0, +} + +interface Activity { + live: number + peak: number +} + +function enter(activity: Activity): void { + activity.live += 1 + activity.peak = Math.max(activity.peak, activity.live) +} + +function leave(activity: Activity): void { + activity.live -= 1 +} + +function trackedLeaf(name: string, activity?: Activity, holdMs = 0): Agent { + const result: ExecutorResult = { + outRef: `leaf:${name}`, + out: { name }, + verdict: { valid: true, score: 1 }, + spent: zeroCost, + } + const executor: Executor = { + runtime: 'router', + async execute(): Promise> { + if (activity) enter(activity) + try { + if (holdMs > 0) await new Promise((resolve) => setTimeout(resolve, holdMs)) + return result + } finally { + if (activity) leave(activity) + } + }, + teardown: () => Promise.resolve({ destroyed: true }), + resultArtifact: () => result, + } + const spec: AgentSpec = { profile: { name }, harness: null, executor } + return { name, act: async () => result.out, executorSpec: spec } as Agent & { + executorSpec: AgentSpec + } +} + +function failingLeaf(name: string): Agent { + const executor: Executor = { + runtime: 'router', + execute: () => Promise.reject(new Error('executor failed')), + teardown: () => Promise.resolve({ destroyed: true }), + // This fake fails before any provider call. Keep the capacity test independent from the + // separate fail-closed rule that unknown token usage consumes the remaining capped budget. + accounting: () => ({ reported: knownZero, reservation: knownZero }), + resultArtifact: () => { + throw new Error('failed executor has no result') + }, + } + const spec: AgentSpec = { profile: { name }, harness: null, executor } + return { name, act: async () => undefined, executorSpec: spec } as Agent & { + executorSpec: AgentSpec + } +} + +function abortableLeaf(name: string): Agent { + const executor: Executor = { + runtime: 'router', + execute(_task, signal): Promise> { + return new Promise((_resolve, reject) => { + const fail = () => { + const error = new Error('aborted') + error.name = 'AbortError' + reject(error) + } + if (signal.aborted) fail() + else signal.addEventListener('abort', fail, { once: true }) + }) + }, + teardown: () => Promise.resolve({ destroyed: true }), + accounting: () => ({ reported: knownZero, reservation: knownZero }), + resultArtifact: () => { + throw new Error('aborted executor has no result') + }, + } + const spec: AgentSpec = { profile: { name }, harness: null, executor } + return { name, act: async () => undefined, executorSpec: spec } as Agent & { + executorSpec: AgentSpec + } +} + +function profileDepth(profile: AgentProfile): number { + const value = profile.metadata?.depth + if (typeof value !== 'number') + throw new Error(`profile ${profile.name ?? ''} has no depth`) + return value +} + +describe('supervise tree-wide worker capacity', () => { + it('retains a slot when teardown cannot prove the executor was destroyed', async () => { + let secondReason: string | undefined + const unkillable = trackedLeaf('placeholder') as Agent & { + executorSpec: AgentSpec + } + unkillable.executorSpec = { + profile: { name: 'unkillable' }, + harness: null, + executor: { + runtime: 'router', + execute: () => new Promise>(() => {}), + teardown: () => new Promise(() => {}), + resultArtifact: () => { + throw new Error('unkillable executor has no result') + }, + }, + } + const root: Agent = { + name: 'root', + async act(task, scope): Promise { + const first = scope.spawn(unkillable, task, { + budget: { maxIterations: 1, maxTokens: 10, deadlineMs: 5 }, + label: 'unkillable', + }) + expect(first.ok).toBe(true) + expect((await scope.next())?.kind).toBe('down') + const second = scope.spawn(() => trackedLeaf('replacement'), task, { + budget: { maxIterations: 1, maxTokens: 10 }, + label: 'replacement', + }) + secondReason = second.ok ? 'accepted' : second.reason + return 'finished' + }, + } + + const result = await createSupervisor().run(root, 'task', { + budget: { maxIterations: 2, maxTokens: 20 }, + maxLiveWorkers: 1, + runId: 'retain-unconfirmed-capacity', + journal: new InMemorySpawnJournal(), + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + }) + + expect(secondReason).toBe('max-live-workers') + expect(result.kind).toBe('no-winner') + }) + + it('holds one cap across root → manager → sub-manager → worker execution', async () => { + const cap = 5 + const activity: Activity = { live: 0, peak: 0 } + const constructedDepths: number[] = [] + const journal = new InMemorySpawnJournal() + const blobs = new InMemoryResultBlobStore() + + let makeWorkerAgent: MakeWorkerAgent + makeWorkerAgent = (profile, context) => { + const depth = profileDepth(profile) + constructedDepths.push(depth) + if (profile.metadata?.role !== 'driver') + return trackedLeaf(profile.name ?? 'leaf', activity, 50) + + const childProfiles: AgentProfile[] = + depth === 1 + ? [ + { + name: `${profile.name}-sub-manager`, + harness: 'cli-base', + metadata: { role: 'driver', depth: 2 }, + }, + ] + : [0, 1].map((index) => ({ + name: `${profile.name}-leaf-${index}`, + harness: 'cli-base', + metadata: { role: 'worker', depth: 3 }, + })) + const brain = scriptedBrain([ + { + toolCalls: childProfiles.map((child) => ({ + name: 'spawn_agent', + arguments: { profile: child, task: `run ${child.name}` }, + })), + }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { content: 'managed' }, + ]) + const nested = supervisorAgent(profile, { + blobs, + makeWorkerAgent, + perWorker: + depth === 1 + ? { maxIterations: 40, maxTokens: 40_000 } + : { maxIterations: 10, maxTokens: 10_000 }, + brain, + maxTurns: 6, + }) + const tracked: Agent = { + name: nested.name, + async act(task, scope): Promise { + enter(activity) + try { + return await nested.act(task, scope) + } finally { + leave(activity) + } + }, + } + return driverChild(profile, tracked, journal, context?.execution) + } + + const rootBrain = scriptedBrain([ + { + toolCalls: [0, 1].map((index) => ({ + name: 'spawn_agent', + arguments: { + profile: { + name: `manager-${index}`, + harness: 'cli-base', + metadata: { role: 'driver', depth: 1 }, + }, + task: `run branch ${index}`, + }, + })), + }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { content: 'done' }, + ]) + + const result = await supervise( + { name: 'root', harness: 'cli-base' }, + 'run a three-level tree', + { + budget: { maxIterations: 500, maxTokens: 500_000 }, + perWorker: { maxIterations: 160, maxTokens: 160_000 }, + maxDepth: 5, + maxLiveWorkers: cap, + makeWorkerAgent, + brain: rootBrain, + journal, + blobs, + runId: 'global-cap', + }, + ) + + expect(result.kind).toBe('winner') + expect(activity.live).toBe(0) + expect(activity.peak).toBe(cap) + expect(constructedDepths).toHaveLength(cap) + expect(constructedDepths.filter((depth) => depth === 1)).toHaveLength(2) + expect(constructedDepths.filter((depth) => depth === 2)).toHaveLength(2) + expect(constructedDepths).toContain(3) + + const trees = ( + journal as unknown as { trees: Map }> } + ).trees + const spawnedIds = [...trees.values()] + .flatMap((tree) => tree.events) + .filter((event) => event.kind === 'spawned') + .map((event) => event.id) + expect(spawnedIds.some((id) => id.split(':s').length === 4)).toBe(true) + }) + + it('releases once on construction failure, budget refusal, completion, and completed-key replay', async () => { + const seen: Record = {} + let completedFactoryCalls = 0 + const root: Agent = { + name: 'root', + async act(task, scope: Scope): Promise { + try { + scope.spawn( + () => { + throw new Error('construction failed') + }, + task, + { budget: { maxIterations: 1, maxTokens: 10 }, label: 'bad-factory' }, + ) + } catch (error) { + seen.constructionError = error instanceof Error ? error.message : String(error) + } + seen.afterConstructionError = scope.workerCapacity.live + + try { + scope.spawn(() => trackedLeaf('invalid-budget'), task, { + budget: { maxIterations: -1, maxTokens: 10 }, + label: 'invalid-budget', + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + seen.invalidBudgetRefused = message.includes('non-negative safe integer') + } + seen.afterInvalidBudget = scope.workerCapacity.live + + const tooLarge = scope.spawn(() => trackedLeaf('too-large'), task, { + budget: { maxIterations: 101, maxTokens: 100_001 }, + label: 'too-large', + }) + seen.tooLarge = tooLarge.ok ? 'accepted' : tooLarge.reason + seen.afterBudgetRefusal = scope.workerCapacity.live + + const failed = scope.spawn(() => failingLeaf('failed'), task, { + budget: { maxIterations: 1, maxTokens: 10 }, + label: 'failed', + }) + seen.failedStarted = failed.ok + seen.failedSettled = (await scope.next())?.kind + seen.afterExecutorFailure = scope.workerCapacity.live + + const aborted = scope.spawn(() => abortableLeaf('aborted'), task, { + budget: { maxIterations: 1, maxTokens: 10 }, + label: 'aborted', + }) + seen.abortedStarted = aborted.ok + if (aborted.ok) aborted.handle.abort('test abort') + seen.abortedSettled = (await scope.next())?.kind + seen.afterAbort = scope.workerCapacity.live + + const first = scope.spawn(() => trackedLeaf('keyed'), task, { + budget: { maxIterations: 1, maxTokens: 10 }, + label: 'keyed', + key: 'assignment', + }) + seen.first = first.ok + seen.duringRun = scope.workerCapacity.live + seen.settled = (await scope.next())?.kind + seen.afterCompletion = scope.workerCapacity.live + + const replay = scope.spawn( + () => { + completedFactoryCalls += 1 + return trackedLeaf('keyed') + }, + task, + { + budget: { maxIterations: 1, maxTokens: 10 }, + label: 'keyed', + key: 'assignment', + }, + ) + seen.replay = replay.ok ? replay.prior?.state : replay.reason + seen.afterReplay = scope.workerCapacity.live + return 'done' + }, + } + + const result = await createSupervisor().run(root, 'task', { + budget: { maxIterations: 100, maxTokens: 100_000 }, + maxLiveWorkers: 1, + runId: 'capacity-release', + journal: new InMemorySpawnJournal(), + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + }) + + expect(result.kind).toBe('winner') + expect(seen).toEqual({ + constructionError: 'construction failed', + afterConstructionError: 0, + invalidBudgetRefused: true, + afterInvalidBudget: 0, + tooLarge: 'budget-exhausted', + afterBudgetRefusal: 0, + failedStarted: true, + failedSettled: 'down', + afterExecutorFailure: 0, + abortedStarted: true, + abortedSettled: 'down', + afterAbort: 0, + first: true, + duringRun: 1, + settled: 'done', + afterCompletion: 0, + replay: 'completed', + afterReplay: 0, + }) + // Reuse prepares the requested profile/task so the semantic key cannot alias different work. + // It still constructs no executor, reserves no budget, and runs nothing. + expect(completedFactoryCalls).toBe(1) + }) +}) diff --git a/tests/kernel/supervise-otel-spans.test.ts b/tests/kernel/supervise-otel-spans.test.ts index 7684ece1..e3cbd7b0 100644 --- a/tests/kernel/supervise-otel-spans.test.ts +++ b/tests/kernel/supervise-otel-spans.test.ts @@ -179,6 +179,12 @@ afterEach(() => { // ── Off by default ──────────────────────────────────────────────────────────── +/** Attempt ids are per-execution nonces by design (`newExecutionAttemptId`); every OTHER byte of + * two equivalent runs must still match, so comparisons normalize exactly that one field. */ +function normalizeAttemptIds(value: T): T { + return JSON.parse(JSON.stringify(value, (key, v) => (key === 'attemptId' ? '' : v))) as T +} + describe('opt-in: a run that configures no exporter emits nothing', () => { it('resolves no recorder at all when neither an exporter nor an endpoint is configured', () => { delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT @@ -214,8 +220,10 @@ describe('opt-in: a run that configures no exporter emits nothing', () => { supervisorOpts({ runId: 'run', journal: plain }), ) - expect(plainResult).toEqual(tracedResult) - expect(await plain.loadTree('run')).toEqual(await traced.loadTree('run')) + expect(normalizeAttemptIds(plainResult)).toEqual(normalizeAttemptIds(tracedResult)) + expect(normalizeAttemptIds(await plain.loadTree('run'))).toEqual( + normalizeAttemptIds(await traced.loadTree('run')), + ) // …and the traced run really did produce spans, so the equality above is not vacuous. expect(spans.length).toBeGreaterThan(0) }) @@ -402,8 +410,10 @@ describe('an exporter that throws never fails the run', () => { supervisorOpts({ runId: 'run', journal: cleanJournal }), ) expect(hostileResult.kind).toBe('winner') - expect(hostileResult).toEqual(cleanResult) - expect(await hostileJournal.loadTree('run')).toEqual(await cleanJournal.loadTree('run')) + expect(normalizeAttemptIds(hostileResult)).toEqual(normalizeAttemptIds(cleanResult)) + expect(normalizeAttemptIds(await hostileJournal.loadTree('run'))).toEqual( + normalizeAttemptIds(await cleanJournal.loadTree('run')), + ) }) it('survives a hook fired directly with a malformed event', () => { @@ -484,11 +494,14 @@ function superviseOnce(otel?: SuperviseOptions['otel']) { return supervise({ name: 'root', harness: null, systemPrompt: 'drive the worker' }, 'solve it', { budget: { maxIterations: 100, maxTokens: 100_000 }, runId: 'front-door', + // Injected clock: the two arms of the identical-result comparison must not diverge on a + // real-millisecond `settledAt` boundary. + now: () => 1_000, makeWorkerAgent: () => workerLeaf('w', { answer: 42 }, { input: 5, output: 5 }, 1), brain: scriptedBrain([ { toolCalls: [ - { name: 'spawn_agent', arguments: { profile: { kind: 'worker' }, task: 'go' } }, + { name: 'spawn_agent', arguments: { profile: { name: 'worker' }, task: 'go' } }, ], }, { toolCalls: [{ name: 'await_event', arguments: {} }] }, @@ -519,7 +532,7 @@ describe('supervise(): telemetry is opt-in at the front door', () => { const { exporter, spans } = recordingExporter() const traced = await superviseOnce({ exporter }) const untraced = await superviseOnce() - expect(untraced).toEqual(traced) + expect(normalizeAttemptIds(untraced)).toEqual(normalizeAttemptIds(traced)) // The traced arm proves the untraced comparison is not vacuous. expect(spans.length).toBeGreaterThan(0) }) diff --git a/tests/kernel/supervise-restart-resource-safety.test.ts b/tests/kernel/supervise-restart-resource-safety.test.ts new file mode 100644 index 00000000..9153ba62 --- /dev/null +++ b/tests/kernel/supervise-restart-resource-safety.test.ts @@ -0,0 +1,1296 @@ +import { type AgentProfile, canonicalCandidateDigest } from '@tangle-network/agent-interface' +import { describe, expect, it } from 'vitest' +import { InMemoryResultBlobStore, InMemorySpawnJournal } from '../../src/durable/spawn-journal' +import { driverChild, withDriverExecutor } from '../../src/runtime/supervise/driver-executor' +import { createExecutorRegistry } from '../../src/runtime/supervise/runtime' +import { createSupervisor } from '../../src/runtime/supervise/supervisor' +import type { + Agent, + AgentSpec, + Executor, + ExecutorResult, + NodeExecutionIdentity, + Scope, + SpawnEvent, + SpawnJournal, + Spend, + UsageEvent, +} from '../../src/runtime/supervise/types' + +const zeroSpend: Spend = { + iterations: 0, + tokens: { input: 0, output: 0 }, + usd: 0, + ms: 0, +} + +describe('supervision restart and resource safety', () => { + it('snapshots the root task, budget, and identity before delayed journal intake', async () => { + const base = new InMemorySpawnJournal() + const beginEntered = deferred() + const releaseBegin = deferred() + const journal: SpawnJournal = { + loadTree: (root) => base.loadTree(root), + async beginTree(root, at) { + beginEntered.resolve() + await releaseBegin.promise + return base.beginTree(root, at) + }, + appendEvent: (root, event) => base.appendEvent(root, event), + } + const task = { instruction: 'AUTHORIZED' } + const budget = { maxIterations: 1, maxTokens: 10 } + const expectedIdentity = { + profileDigest: canonicalCandidateDigest({ name: 'root-profile' }), + taskDigest: canonicalCandidateDigest({ instruction: 'AUTHORIZED' }), + } + const rootIdentity = { ...expectedIdentity } + let rootObservation: + | { instruction: string; frozen: boolean; tokensLeft: number; tokensKnown: boolean } + | undefined + const running = createSupervisor().run( + { + name: 'root', + act: async (authorizedTask, scope) => { + rootObservation = { + instruction: authorizedTask.instruction, + frozen: Object.isFrozen(authorizedTask), + tokensLeft: scope.budget.tokensLeft, + tokensKnown: scope.budget.tokensKnown, + } + return authorizedTask.instruction + }, + }, + task, + { + budget, + rootIdentity, + runId: 'immutable-root-input', + journal, + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + }, + ) + + await beginEntered.promise + task.instruction = 'MUTATED' + budget.maxTokens = 100 + rootIdentity.taskDigest = canonicalCandidateDigest({ instruction: 'MUTATED' }) + releaseBegin.resolve() + + const result = await running + const events = (await base.loadTree('immutable-root-input')) ?? [] + const rootEvent = events.find((event) => event.kind === 'spawned' && event.parent === undefined) + + expect(result.kind).toBe('winner') + if (result.kind !== 'winner') return + expect(result.out).toBe('AUTHORIZED') + expect(rootObservation).toEqual({ + instruction: 'AUTHORIZED', + frozen: true, + tokensLeft: 10, + tokensKnown: true, + }) + expect(rootEvent).toMatchObject({ + budget: { maxIterations: 1, maxTokens: 10 }, + identity: expectedIdentity, + }) + expect(Object.isFrozen(rootEvent?.budget)).toBe(true) + expect(rootEvent?.identity?.taskDigest).toBe( + canonicalCandidateDigest({ instruction: 'AUTHORIZED' }), + ) + }) + + it('refuses malformed fresh root identities before journaling or acting', async () => { + const validProfile = canonicalCandidateDigest({ name: 'root-profile' }) + const validTask = canonicalCandidateDigest({ instruction: 'AUTHORIZED' }) + const cases: ReadonlyArray = [ + ['profile digest', { profileDigest: 'sha256:invalid', taskDigest: validTask }], + ['task digest', { profileDigest: validProfile, taskDigest: 'sha256:invalid' }], + [ + 'candidate digest', + { + profileDigest: validProfile, + taskDigest: validTask, + candidateDigest: 'sha256:invalid', + }, + ], + [ + 'correlation', + { + profileDigest: validProfile, + taskDigest: validTask, + correlation: { worker: '' }, + }, + ], + [ + 'unknown field', + { + profileDigest: validProfile, + taskDigest: validTask, + injected: true, + } as NodeExecutionIdentity, + ], + ] + + for (const [label, rootIdentity] of cases) { + const base = new InMemorySpawnJournal() + let loads = 0 + let begins = 0 + let appends = 0 + let acts = 0 + const journal: SpawnJournal = { + async loadTree(root) { + loads += 1 + return base.loadTree(root) + }, + async beginTree(root, at) { + begins += 1 + return base.beginTree(root, at) + }, + async appendEvent(root, event) { + appends += 1 + return base.appendEvent(root, event) + }, + } + + await expect( + createSupervisor().run( + { + name: 'root', + act: async () => { + acts += 1 + return 'unsafe' + }, + }, + 'task', + { + budget: { maxIterations: 1, maxTokens: 10 }, + rootIdentity, + runId: `malformed-root-${label}`, + journal, + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + }, + ), + label, + ).rejects.toThrow(/requires an exact rootIdentity/) + expect({ acts, loads, begins, appends }, label).toEqual({ + acts: 0, + loads: 0, + begins: 0, + appends: 0, + }) + } + }) + + it('classifies exhausted iterations without rejecting a valid zero-work winner', async () => { + let factoryCalls = 0 + let spawnReason: string | undefined + const exhausted = await createSupervisor().run( + { + name: 'iteration-exhausted-root', + act: async (task, scope) => { + const spawned = scope.spawn( + () => { + factoryCalls += 1 + return resultLeaf('must-not-run', zeroSpend) + }, + task, + { + budget: { maxIterations: 1, maxTokens: 1 }, + label: 'must-not-run', + }, + ) + if (!spawned.ok) spawnReason = spawned.reason + return undefined + }, + }, + 'task', + { + budget: { maxIterations: 0, maxTokens: 10 }, + runId: 'iteration-exhausted-root', + journal: new InMemorySpawnJournal(), + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + }, + ) + + expect(exhausted).toMatchObject({ kind: 'no-winner', reason: 'budget-exhausted' }) + expect(spawnReason).toBe('budget-exhausted') + expect(factoryCalls).toBe(0) + + const valid = await createSupervisor().run( + { name: 'zero-work-root', act: async () => 'valid zero-work result' }, + 'task', + { + budget: { maxIterations: 0, maxTokens: 10 }, + runId: 'valid-zero-work-root', + journal: new InMemorySpawnJournal(), + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + }, + ) + expect(valid).toMatchObject({ kind: 'winner', out: 'valid zero-work result' }) + }) + + it('executes the immutable task snapshot whose digest was authorized', async () => { + let executorSaw: unknown + let taskWasFrozen = false + const leaf = leafFromExecutor('task-snapshot', () => ({ + runtime: 'router', + async execute(task): Promise> { + executorSaw = (task as { instruction: string }).instruction + taskWasFrozen = Object.isFrozen(task) + return { outRef: 'internal', out: String(executorSaw), spent: zeroSpend } + }, + teardown: async () => ({ destroyed: true }), + resultArtifact: () => ({ outRef: 'internal', out: String(executorSaw), spent: zeroSpend }), + })) + const root: Agent = { + name: 'root', + async act(_task, scope) { + const childTask = { instruction: 'AUTHORIZED' } + const expectedDigest = canonicalCandidateDigest(childTask) + const spawned = scope.spawn(leaf, childTask, { + key: 'task-snapshot', + budget: { maxIterations: 1, maxTokens: 2 }, + label: 'task-snapshot', + }) + expect(spawned.ok).toBe(true) + childTask.instruction = 'MUTATED' + const settled = await scope.next() + return { + digest: spawned.ok ? spawned.handle.identity?.taskDigest : undefined, + out: settled?.kind === 'done' ? settled.out : undefined, + } + }, + } + + const result = await createSupervisor().run( + root, + 'task', + { + budget: { maxIterations: 1, maxTokens: 2 }, + runId: 'immutable-task-snapshot', + journal: new InMemorySpawnJournal(), + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + }, + ) + + expect(result.kind).toBe('winner') + if (result.kind !== 'winner') return + expect(result.out).toEqual({ + digest: canonicalCandidateDigest({ instruction: 'AUTHORIZED' }), + out: 'AUTHORIZED', + }) + expect(executorSaw).toBe('AUTHORIZED') + expect(taskWasFrozen).toBe(true) + }) + + it('does not let a caller mutate a driver allocation after it was reserved', async () => { + const journal = new InMemorySpawnJournal() + let leafExecutions = 0 + const costlyLeaf = leafFromExecutor('costly-leaf', () => ({ + runtime: 'router', + async execute(): Promise> { + leafExecutions += 1 + return { + outRef: 'internal', + out: 'costly', + spent: { ...zeroSpend, tokens: { input: 50, output: 0 } }, + } + }, + teardown: async () => ({ destroyed: true }), + resultArtifact: () => ({ + outRef: 'internal', + out: 'costly', + spent: { ...zeroSpend, tokens: { input: 50, output: 0 } }, + }), + })) + const nested: Agent = { + name: 'nested', + async act(task, scope): Promise { + const child = scope.spawn(costlyLeaf, task, { + budget: { maxIterations: 1, maxTokens: 50 }, + label: 'costly-leaf', + }) + if (!child.ok) return child.reason + await scope.next() + return 'accepted' + }, + } + const root: Agent = { + name: 'root', + async act(task, scope): Promise { + const allocation = { maxIterations: 1, maxTokens: 10 } + const manager = scope.spawn( + driverChild( + { name: 'nested-manager', harness: 'cli-base', metadata: { role: 'driver' } }, + nested, + journal, + ), + task, + { budget: allocation, label: 'nested-manager' }, + ) + expect(manager.ok).toBe(true) + allocation.maxTokens = 100 + const settled = await scope.next() + return settled?.kind === 'done' ? String(settled.out) : 'manager-down' + }, + } + + const result = await createSupervisor().run(root, 'task', { + budget: { maxIterations: 1, maxTokens: 10 }, + maxDepth: 2, + runId: 'immutable-driver-budget', + journal, + blobs: new InMemoryResultBlobStore(), + executors: withDriverExecutor(createExecutorRegistry()), + }) + + expect(result.kind).toBe('winner') + if (result.kind !== 'winner') return + expect(result.out).toBe('budget-exhausted') + expect(leafExecutions).toBe(0) + expect(result.spentTotal.tokens).toEqual({ input: 0, output: 0 }) + }) + + it('refuses a resumed root whose profile/task/candidate identity changed', async () => { + const journal = new InMemorySpawnJournal() + const blobs = new InMemoryResultBlobStore() + const executors = createExecutorRegistry() + const budget = { maxIterations: 2, maxTokens: 100 } + const firstIdentity = { + profileDigest: `sha256:${'1'.repeat(64)}`, + taskDigest: `sha256:${'2'.repeat(64)}`, + } + const secondIdentity = { + profileDigest: `sha256:${'3'.repeat(64)}`, + taskDigest: `sha256:${'4'.repeat(64)}`, + } + await createSupervisor().run( + { name: 'root-a', act: async () => 'A' }, + 'task A', + { + budget, + rootIdentity: firstIdentity, + runId: 'resume-identity', + journal, + blobs, + executors, + resume: true, + }, + ) + let secondActs = 0 + await expect( + createSupervisor().run( + { + name: 'root-b', + act: async () => { + secondActs += 1 + return 'B' + }, + }, + 'task B', + { + budget, + rootIdentity: secondIdentity, + runId: 'resume-identity', + journal, + blobs, + executors, + resume: true, + }, + ), + ).rejects.toThrow(/resume identity mismatch/) + expect(secondActs).toBe(0) + }) + + it('refuses resume without an exact root identity before reading, mutating, or acting', async () => { + const base = new InMemorySpawnJournal() + let loads = 0 + let begins = 0 + let appends = 0 + const journal: SpawnJournal = { + async loadTree(root) { + loads += 1 + return base.loadTree(root) + }, + async beginTree(root, at) { + begins += 1 + return base.beginTree(root, at) + }, + async appendEvent(root, event) { + appends += 1 + return base.appendEvent(root, event) + }, + } + let acts = 0 + + await expect( + createSupervisor().run( + { + name: 'unsafe-root', + act: async () => { + acts += 1 + return 'unsafe' + }, + }, + 'unsafe task', + { + budget: { maxIterations: 1, maxTokens: 10 }, + runId: 'missing-root-identity', + journal, + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + resume: true, + }, + ), + ).rejects.toThrow(/requires an exact rootIdentity/) + + await expect( + createSupervisor().run( + { + name: 'partial-root', + act: async () => { + acts += 1 + return 'partial' + }, + }, + 'partial task', + { + budget: { maxIterations: 1, maxTokens: 10 }, + rootIdentity: { profileDigest: `sha256:${'4'.repeat(64)}` as const }, + runId: 'partial-root-identity', + journal, + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + resume: true, + }, + ), + ).rejects.toThrow(/requires an exact rootIdentity/) + + expect({ acts, loads, begins, appends }).toEqual({ acts: 0, loads: 0, begins: 0, appends: 0 }) + }) + + it('does not let an omitted identity bypass a changed root and task on the same run id', async () => { + const journal = new InMemorySpawnJournal() + const blobs = new InMemoryResultBlobStore() + const executors = createExecutorRegistry() + const runId = 'omitted-resume-identity' + const rootIdentity = { + profileDigest: `sha256:${'5'.repeat(64)}` as const, + taskDigest: `sha256:${'6'.repeat(64)}` as const, + } + let firstActs = 0 + await createSupervisor().run( + { + name: 'root-a', + act: async () => { + firstActs += 1 + return 'A' + }, + }, + 'task A', + { + budget: { maxIterations: 1, maxTokens: 10 }, + rootIdentity, + runId, + journal, + blobs, + executors, + resume: true, + }, + ) + const before = await journal.loadTree(runId) + let secondActs = 0 + + await expect( + createSupervisor().run( + { + name: 'root-b', + act: async () => { + secondActs += 1 + return 'B' + }, + }, + 'task B', + { + budget: { maxIterations: 1, maxTokens: 10 }, + runId, + journal, + blobs, + executors, + resume: true, + }, + ), + ).rejects.toThrow(/requires an exact rootIdentity/) + + expect(firstActs).toBe(1) + expect(secondActs).toBe(0) + expect(await journal.loadTree(runId)).toEqual(before) + }) + + it('does not execute a child until its identity event is committed', async () => { + const base = new InMemorySpawnJournal() + const releaseCommit = deferred() + const journal: SpawnJournal = { + loadTree: (root) => base.loadTree(root), + beginTree: (root, at) => base.beginTree(root, at), + async appendEvent(root, event): Promise { + if (event.kind === 'spawned' && event.parent !== undefined) { + await releaseCommit.promise + } + await base.appendEvent(root, event) + }, + } + let executions = 0 + let executionsBeforeCommit = -1 + const leaf = leafFromExecutor('commit-first', () => ({ + runtime: 'router', + async execute(): Promise> { + executions += 1 + return { outRef: 'internal', out: 'done', spent: zeroSpend } + }, + teardown: async () => ({ destroyed: true }), + resultArtifact: () => ({ outRef: 'internal', out: 'done', spent: zeroSpend }), + })) + const result = await createSupervisor().run( + { + name: 'root', + async act(task, scope): Promise { + const spawned = scope.spawn(leaf, task, { + budget: { maxIterations: 1, maxTokens: 10 }, + label: 'commit-first', + }) + expect(spawned.ok).toBe(true) + await Promise.resolve() + executionsBeforeCommit = executions + releaseCommit.resolve() + await scope.next() + return 'root result' + }, + }, + 'task', + { + budget: { maxIterations: 1, maxTokens: 10 }, + runId: 'commit-before-execute', + journal, + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + }, + ) + expect(result.kind).toBe('winner') + expect(executionsBeforeCommit).toBe(0) + expect(executions).toBe(1) + }) + + it('restores prior spend and the original absolute deadline when a root resumes', async () => { + const journal = new InMemorySpawnJournal() + const blobs = new InMemoryResultBlobStore() + const executors = createExecutorRegistry() + const runId = 'resume-root-limits' + const rootIdentity = { + profileDigest: `sha256:${'7'.repeat(64)}` as const, + taskDigest: `sha256:${'8'.repeat(64)}` as const, + } + let nowMs = 1_000 + + const firstRoot: Agent = { + name: 'first-root', + async act(task, scope): Promise { + const spawned = scope.spawn( + resultLeaf('prior-work', { + iterations: 1, + tokens: { input: 80, output: 0 }, + usd: 0, + ms: 1, + }), + task, + { + budget: { maxIterations: 1, maxTokens: 100 }, + label: 'prior-work', + }, + ) + expect(spawned.ok).toBe(true) + expect((await scope.next())?.kind).toBe('done') + return 'first result' + }, + } + + const first = await createSupervisor().run(firstRoot, 'task', { + budget: { maxIterations: 2, maxTokens: 100, deadlineMs: 100 }, + rootIdentity, + runId, + journal, + blobs, + executors, + now: () => nowMs, + }) + expect(first.kind).toBe('winner') + + nowMs = 1_090 + let factoryCalls = 0 + let observed: + | { + tokensLeft: number + tokensKnown: boolean + deadlineMs: number + attempt: ReturnType['spawn']> + } + | undefined + const resumedRoot: Agent = { + name: 'resumed-root', + act(task, scope): Promise { + const before = scope.budget + const attempt = scope.spawn( + () => { + factoryCalls += 1 + return resultLeaf('must-not-run', zeroSpend) + }, + task, + { + budget: { maxIterations: 1, maxTokens: 21 }, + label: 'must-not-run', + }, + ) + observed = { + tokensLeft: before.tokensLeft, + tokensKnown: before.tokensKnown, + deadlineMs: before.deadlineMs, + attempt, + } + return Promise.resolve('resumed result') + }, + } + + const resumed = await createSupervisor().run(resumedRoot, 'task', { + budget: { maxIterations: 2, maxTokens: 100, deadlineMs: 100 }, + rootIdentity, + runId, + journal, + blobs, + executors, + resume: true, + now: () => nowMs, + }) + + expect(resumed.kind).toBe('winner') + expect(observed).toMatchObject({ + tokensLeft: 20, + tokensKnown: true, + deadlineMs: 1_100, + attempt: { ok: false, reason: 'budget-exhausted' }, + }) + expect(factoryCalls).toBe(0) + expect(resumed.spentTotal).toMatchObject({ + iterations: 1, + tokens: { input: 80, output: 0 }, + }) + }) + + it('anchors a fresh deadline before delayed beginTree and preserves it exactly on resume', async () => { + const base = new InMemorySpawnJournal() + let nowMs = 4_000 + const journal: SpawnJournal = { + loadTree: (root) => base.loadTree(root), + async beginTree(root, at) { + nowMs += 75 + await Promise.resolve() + return base.beginTree(root, at) + }, + appendEvent: (root, event) => base.appendEvent(root, event), + } + const budget = { maxIterations: 1, maxTokens: 10, deadlineMs: 100 } + const rootIdentity = { + profileDigest: `sha256:${'9'.repeat(64)}` as const, + taskDigest: `sha256:${'a'.repeat(64)}` as const, + } + let firstDeadline = 0 + const first = await createSupervisor().run( + { + name: 'root', + act: async (_task, scope) => { + firstDeadline = scope.budget.deadlineMs + return 'first' + }, + }, + 'task', + { + budget, + rootIdentity, + runId: 'delayed-begin-deadline', + journal, + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + now: () => nowMs, + }, + ) + expect(first.kind).toBe('winner') + expect(firstDeadline).toBe(4_100) + + nowMs = 4_090 + let resumedDeadline = 0 + const resumed = await createSupervisor().run( + { + name: 'root', + act: async (_task, scope) => { + resumedDeadline = scope.budget.deadlineMs + return 'resumed' + }, + }, + 'task', + { + budget, + rootIdentity, + runId: 'delayed-begin-deadline', + journal, + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + resume: true, + now: () => nowMs, + }, + ) + + expect(resumed.kind).toBe('winner') + expect(resumedDeadline).toBe(firstDeadline) + expect(resumedDeadline).toBe(4_100) + }) + + it('does not settle a nested driver or refund its allocation while a descendant is live', async () => { + const baseJournal = new InMemorySpawnJournal() + const records: Array<{ root: string; event: SpawnEvent }> = [] + const journal: SpawnJournal = { + loadTree: (root) => baseJournal.loadTree(root), + beginTree: (root, at) => baseJournal.beginTree(root, at), + async appendEvent(root, event): Promise { + await baseJournal.appendEvent(root, event) + records.push({ root, event }) + }, + } + const runId = 'nested-join-barrier' + const activity = { live: 0, peak: 0 } + const descendantStarted = deferred() + const descendantExited = deferred() + let releaseDescendant: (() => void) | undefined + + const descendant = leafFromExecutor('descendant', () => { + const artifact: ExecutorResult = { + outRef: 'internal:descendant', + out: 'descendant result', + spent: zeroSpend, + } + return { + runtime: 'router', + execute(_task, signal): Promise> { + enter(activity) + descendantStarted.resolve() + return new Promise((resolve) => { + let finished = false + const finish = () => { + if (finished) return + finished = true + signal.removeEventListener('abort', finish) + leave(activity) + descendantExited.resolve() + resolve(artifact) + } + releaseDescendant = finish + if (signal.aborted) finish() + else signal.addEventListener('abort', finish, { once: true }) + }) + }, + teardown: async () => ({ destroyed: true }), + resultArtifact: () => artifact, + } + }) + const nestedDriver: Agent = { + name: 'nested-driver', + async act(task, scope): Promise { + const spawned = scope.spawn(descendant, task, { + budget: { maxIterations: 1, maxTokens: 100 }, + label: 'descendant', + }) + expect(spawned.ok).toBe(true) + await descendantStarted.promise + return 'manager result' + }, + } + const second = trackedImmediateLeaf('second', activity) + const root: Agent = { + name: 'root', + async act(task, scope): Promise { + const manager = scope.spawn( + driverChild( + { name: 'manager', harness: 'cli-base', metadata: { role: 'driver' } }, + nestedDriver, + journal, + ), + task, + { + budget: { maxIterations: 1, maxTokens: 100 }, + label: 'manager', + }, + ) + expect(manager.ok).toBe(true) + const managerSettled = await scope.next() + const activeAfterManager = activity.live + const nestedTerminalBeforeManager = records.some( + ({ root: tree, event }) => tree !== runId && event.kind === 'settled', + ) + const secondSpawn = scope.spawn(second, task, { + budget: { maxIterations: 1, maxTokens: 100 }, + label: 'second', + }) + const secondSettled = secondSpawn.ok ? await scope.next() : null + return { + managerKind: managerSettled?.kind, + activeAfterManager, + nestedTerminalBeforeManager, + secondAccepted: secondSpawn.ok, + secondKind: secondSettled?.kind, + } + }, + } + + const result = await createSupervisor().run(root, 'task', { + budget: { maxIterations: 1, maxTokens: 100 }, + maxLiveWorkers: 2, + maxDepth: 3, + runId, + journal, + blobs: new InMemoryResultBlobStore(), + executors: withDriverExecutor(createExecutorRegistry()), + }) + const liveAtReturn = activity.live + releaseDescendant?.() + await descendantExited.promise + + expect(result.kind).toBe('winner') + if (result.kind !== 'winner') return + expect(result.out).toEqual({ + managerKind: 'done', + activeAfterManager: 0, + nestedTerminalBeforeManager: true, + secondAccepted: true, + secondKind: 'done', + }) + expect(activity.peak).toBe(1) + expect(liveAtReturn).toBe(0) + expect(result.tree.inFlight).toBe(0) + }) + + it('settles after its deadline even when an executor ignores AbortSignal', async () => { + let teardownCalls = 0 + const ignoring = leafFromExecutor('ignores-abort', () => ({ + runtime: 'router', + execute: () => new Promise>(() => {}), + async teardown(): Promise<{ destroyed: boolean }> { + teardownCalls += 1 + return { destroyed: true } + }, + resultArtifact(): ExecutorResult { + throw new Error('an executor that never settled has no result artifact') + }, + })) + const root: Agent = { + name: 'deadline-root', + async act(task, scope): Promise { + const spawned = scope.spawn(ignoring, task, { + budget: { maxIterations: 1, maxTokens: 10 }, + label: 'ignores-abort', + }) + expect(spawned.ok).toBe(true) + const settled = await scope.next() + if (settled?.kind === 'down') throw new Error(settled.reason) + return settled?.out ?? 'missing result' + }, + } + + const running = createSupervisor().run(root, 'task', { + budget: { maxIterations: 1, maxTokens: 10, deadlineMs: 15 }, + runId: 'ignores-abort-deadline', + journal: new InMemorySpawnJournal(), + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + }) + const outcome = await settleWithin(running, 250) + + expect(outcome.settled).toBe(true) + if (!outcome.settled) return + expect(outcome.value).toMatchObject({ + kind: 'no-winner', + reason: 'budget-exhausted', + tree: { inFlight: 0 }, + }) + expect(teardownCalls).toBe(1) + }) + + it('settles after its deadline even when executor teardown never acknowledges', async () => { + let teardownCalls = 0 + const ignoring = leafFromExecutor('ignores-teardown', () => ({ + runtime: 'router', + execute: () => new Promise>(() => {}), + teardown(): Promise<{ destroyed: boolean }> { + teardownCalls += 1 + return new Promise(() => {}) + }, + resultArtifact(): ExecutorResult { + throw new Error('an executor that never settled has no result artifact') + }, + })) + const root: Agent = { + name: 'teardown-deadline-root', + async act(task, scope): Promise { + const spawned = scope.spawn(ignoring, task, { + budget: { maxIterations: 1, maxTokens: 10 }, + label: 'ignores-teardown', + }) + expect(spawned.ok).toBe(true) + const settled = await scope.next() + if (settled?.kind === 'down') throw new Error(settled.reason) + return settled?.out ?? 'missing result' + }, + } + + const running = createSupervisor().run(root, 'task', { + budget: { maxIterations: 1, maxTokens: 10, deadlineMs: 15 }, + runId: 'ignores-teardown-deadline', + journal: new InMemorySpawnJournal(), + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + }) + const outcome = await settleWithin(running, 750) + + expect(outcome.settled).toBe(true) + if (!outcome.settled) return + expect(outcome.value).toMatchObject({ + kind: 'no-winner', + reason: 'budget-exhausted', + tree: { inFlight: 0 }, + }) + expect(teardownCalls).toBe(1) + }) + + it('turns unknown accounting from a crashing nested driver into a terminal down node', async () => { + const journal = new InMemorySpawnJournal() + const metered = deferred() + const nestedDriver: Agent = { + name: 'unknown-accounting-driver', + async act(_task, scope): Promise { + try { + await scope.meter({ + iterations: 1, + tokens: { input: 0, output: 0 }, + tokensKnown: false, + usd: 0, + ms: 1, + }) + } finally { + metered.resolve() + } + throw new Error('nested driver crashed') + }, + } + const root: Agent = { + name: 'root', + async act(task, scope): Promise { + const spawned = scope.spawn( + driverChild( + { name: 'unknown-manager', harness: 'cli-base', metadata: { role: 'driver' } }, + nestedDriver, + journal, + ), + task, + { + budget: { maxIterations: 2, maxTokens: 10 }, + label: 'unknown-manager', + }, + ) + expect(spawned.ok).toBe(true) + await metered.promise + return 'root result' + }, + } + const runId = 'unknown-nested-accounting' + + const result = await createSupervisor().run(root, 'task', { + budget: { maxIterations: 2, maxTokens: 10 }, + maxLiveWorkers: 1, + maxDepth: 2, + runId, + journal, + blobs: new InMemoryResultBlobStore(), + executors: withDriverExecutor(createExecutorRegistry()), + }) + const events = (await journal.loadTree(runId)) ?? [] + const manager = result.tree.nodes.find((node) => node.label === 'unknown-manager') + + expect(result.kind).toBe('winner') + expect(result.tree.inFlight).toBe(0) + expect(manager?.status).toBe('failed') + expect( + events.some( + (event) => event.kind === 'settled' && event.id === manager?.id && event.status === 'down', + ), + ).toBe(true) + expect( + events.some( + (event) => + event.kind === 'metered' && event.id === manager?.id && event.spend.tokensKnown === false, + ), + ).toBe(true) + expect(result.spentTotal.tokensKnown).toBe(false) + }) + + it('fails closed after a streaming provider crashes without terminal accounting', async () => { + let replacementExecutions = 0 + const crashed = leafFromExecutor('crashed-stream', () => ({ + runtime: 'router', + async *execute(): AsyncIterable { + yield { kind: 'iteration' } + yield { kind: 'tokens', input: 1, output: 0 } + yield { kind: 'cost', usd: 0.1 } + throw new Error('network died') + }, + teardown: async () => ({ destroyed: true }), + resultArtifact(): ExecutorResult { + throw new Error('a crashed stream has no terminal artifact') + }, + })) + const replacement = leafFromExecutor('replacement', () => ({ + runtime: 'router', + async execute(): Promise> { + replacementExecutions += 1 + return { outRef: 'internal', out: 'replacement', spent: zeroSpend } + }, + teardown: async () => ({ destroyed: true }), + resultArtifact: () => ({ outRef: 'internal', out: 'replacement', spent: zeroSpend }), + })) + + const result = await createSupervisor().run( + { + name: 'root', + async act(task, scope): Promise { + const first = scope.spawn(crashed, task, { + budget: { maxIterations: 1, maxTokens: 100, maxUsd: 1 }, + label: 'crashed-stream', + }) + expect(first.ok).toBe(true) + const down = await scope.next() + const second = scope.spawn(replacement, task, { + budget: { maxIterations: 1, maxTokens: 99, maxUsd: 1 }, + label: 'replacement', + }) + return { downKind: down?.kind, second } + }, + }, + 'task', + { + budget: { maxIterations: 2, maxTokens: 199, maxUsd: 2 }, + runId: 'stream-crash-unknown-accounting', + journal: new InMemorySpawnJournal(), + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + }, + ) + + expect(result.kind).toBe('winner') + if (result.kind !== 'winner') return + expect(result.out).toEqual({ + downKind: 'down', + second: { ok: false, reason: 'budget-exhausted' }, + }) + expect(replacementExecutions).toBe(0) + expect(result.spentTotal).toMatchObject({ + iterations: 1, + tokens: { input: 1, output: 0 }, + tokensKnown: false, + usd: 0.1, + usdKnown: false, + }) + }) + + it('admits a budget-exempt executor, reconciles it at zero, and refunds its reservation', async () => { + let exemptExecutions = 0 + let exemptTeardowns = 0 + let measuredExecutions = 0 + const exempt = leafFromExecutor('subscription-cli', () => ({ + runtime: 'cli', + budgetExempt: true, + async execute(): Promise> { + exemptExecutions += 1 + return { outRef: 'internal', out: 'unmeasured', spent: zeroSpend } + }, + async teardown(): Promise<{ destroyed: boolean }> { + exemptTeardowns += 1 + return { destroyed: true } + }, + resultArtifact(): ExecutorResult { + throw new Error('an unmetered executor must never produce a supervised artifact') + }, + })) + const measured = leafFromExecutor('measured', () => ({ + runtime: 'router', + async execute(): Promise> { + measuredExecutions += 1 + return { + outRef: 'internal', + out: 'measured', + spent: { ...zeroSpend, iterations: 1, tokens: { input: 1, output: 0 } }, + } + }, + teardown: async () => ({ destroyed: true }), + resultArtifact: () => ({ + outRef: 'internal', + out: 'measured', + spent: { ...zeroSpend, iterations: 1, tokens: { input: 1, output: 0 } }, + }), + })) + + const result = await createSupervisor().run( + { + name: 'root', + async act(task, scope): Promise { + const first = scope.spawn(exempt, task, { + budget: { maxIterations: 1, maxTokens: 10 }, + label: 'subscription-cli', + }) + expect(first.ok).toBe(true) + const exemptSettled = await scope.next() + // The exempt worker reconciled at ZERO by contract, so its whole reservation refunded + // and the measured worker's reservation still fits the same conserved pool. + const second = scope.spawn(measured, task, { + budget: { maxIterations: 1, maxTokens: 10 }, + label: 'measured', + }) + expect(second.ok).toBe(true) + const accepted = await scope.next() + return { + exempt: exemptSettled?.kind === 'done' ? exemptSettled.out : 'not settled', + accepted: accepted?.kind === 'done' ? accepted.out : 'not accepted', + } + }, + }, + 'task', + { + budget: { maxIterations: 1, maxTokens: 10 }, + runId: 'refuse-unmetered-executor', + journal: new InMemorySpawnJournal(), + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + }, + ) + + expect(result.kind).toBe('winner') + if (result.kind !== 'winner') return + expect(result.out).toEqual({ + exempt: 'unmeasured', + accepted: 'measured', + }) + expect(exemptExecutions).toBe(1) + expect(exemptTeardowns).toBe(1) + expect(measuredExecutions).toBe(1) + expect(result.spentTotal).toMatchObject({ + iterations: 1, + tokens: { input: 1, output: 0 }, + usd: 0, + }) + }) +}) + +function resultLeaf(name: string, spent: Spend): Agent { + return leafFromExecutor(name, () => { + const artifact: ExecutorResult = { + outRef: `internal:${name}`, + out: `${name} result`, + spent, + } + return { + runtime: 'router', + execute: async () => artifact, + teardown: async () => ({ destroyed: true }), + resultArtifact: () => artifact, + } + }) +} + +function trackedImmediateLeaf( + name: string, + activity: { live: number; peak: number }, +): Agent { + return leafFromExecutor(name, () => { + const artifact: ExecutorResult = { + outRef: `internal:${name}`, + out: `${name} result`, + spent: zeroSpend, + } + return { + runtime: 'router', + async execute(): Promise> { + enter(activity) + try { + await Promise.resolve() + return artifact + } finally { + leave(activity) + } + }, + teardown: async () => ({ destroyed: true }), + resultArtifact: () => artifact, + } + }) +} + +function leafFromExecutor( + name: string, + makeExecutor: () => Executor, +): Agent { + const executor = makeExecutor() + const spec: AgentSpec = { + profile: { name } as AgentProfile, + harness: null, + executor: executor as Executor, + } + return { name, act: async () => undefined as Out, executorSpec: spec } as Agent & { + executorSpec: AgentSpec + } +} + +function enter(activity: { live: number; peak: number }): void { + activity.live += 1 + activity.peak = Math.max(activity.peak, activity.live) +} + +function leave(activity: { live: number }): void { + activity.live -= 1 +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +function settleWithin( + promise: Promise, + timeoutMs: number, +): Promise<{ settled: true; value: T } | { settled: false }> { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve({ settled: false }), timeoutMs) + void promise.then( + (value) => { + clearTimeout(timer) + resolve({ settled: true, value }) + }, + (error: unknown) => { + clearTimeout(timer) + reject(error) + }, + ) + }) +} diff --git a/tests/kernel/supervise-surface.test.ts b/tests/kernel/supervise-surface.test.ts new file mode 100644 index 00000000..195334be --- /dev/null +++ b/tests/kernel/supervise-surface.test.ts @@ -0,0 +1,122 @@ +import type { TraceAnalysisStore } from '@tangle-network/agent-eval' +import { toolSpansToTraceAnalysisStore } from '@tangle-network/agent-eval' +import { describe, expect, it } from 'vitest' +import type { AgenticSurface, AgenticTask } from '../../src/runtime/strategy' +import { failuresAnalyst, traceSurfaceCalls } from '../../src/runtime/supervise-surface' + +const task: AgenticTask = { + id: 'surface-trace-test', + systemPrompt: 'Fix every failing test.', + userPrompt: 'Make the suite pass.', +} + +function fakeSurface(): AgenticSurface { + let testRuns = 0 + return { + name: 'trace-test', + open: async () => ({ id: 'artifact-1', surface: 'trace-test' }), + tools: async () => [], + async call(_handle, name) { + if (name === 'run_tests') { + testRuns += 1 + return testRuns === 1 + ? '2/5 tests passed. FAILING: stale_failure' + : '3/5 tests passed. FAILING: test_alpha, test_beta' + } + if (name === 'explode') throw new Error('tool exploded') + return 'Worker prose claims FAILING: fabricated_from_prose' + }, + score: async () => ({ passes: 3, total: 5, errored: 0 }), + close: async () => undefined, + } +} + +describe('superviseSurface trace evidence', () => { + it('records real surface-call success/error spans and derives exact failures from run_tests only', async () => { + const traced = traceSurfaceCalls(fakeSurface()) + const handle = await traced.surface.open(task) + + await traced.surface.call(handle, 'read_file', { path: 'tests.ts' }) + await traced.surface.call(handle, 'run_tests', {}) + await traced.surface.call(handle, 'run_tests', {}) + await expect(traced.surface.call(handle, 'explode', { reason: 'test' })).rejects.toThrow( + 'tool exploded', + ) + + const spans = await traced.traceSource.collect() + expect( + spans.map((span) => ({ + toolName: span.toolName, + status: span.status, + result: span.result, + })), + ).toEqual([ + { + toolName: 'read_file', + status: 'ok', + result: 'Worker prose claims FAILING: fabricated_from_prose', + }, + { + toolName: 'run_tests', + status: 'ok', + result: '2/5 tests passed. FAILING: stale_failure', + }, + { + toolName: 'run_tests', + status: 'ok', + result: '3/5 tests passed. FAILING: test_alpha, test_beta', + }, + { toolName: 'explode', status: 'error', result: 'ERROR: tool exploded' }, + ]) + + const result = (await failuresAnalyst().run( + 'failures', + toolSpansToTraceAnalysisStore(spans), + )) as { summary: string } + expect(result.summary).toContain('STILL FAILING (2): test_alpha, test_beta') + expect(result.summary).not.toContain('stale_failure') + expect(result.summary).not.toContain('fabricated_from_prose') + }) + + it('refuses to infer failures from the legacy worker prose object', async () => { + const proseOnly = { + resolved: false, + score: 0.4, + shots: 2, + summary: 'STILL FAILING: invented_one', + failing: ['invented_one'], + } + + const result = (await failuresAnalyst().run( + 'failures', + proseOnly as unknown as TraceAnalysisStore, + )) as { summary: string } + + expect(result.summary).toMatch(/Missing structured run_tests span evidence.*Refusing/) + expect(result.summary).not.toContain('invented_one') + }) + + it('treats prose in non-run_tests spans as missing failure evidence', async () => { + const proseOnlyStore = toolSpansToTraceAnalysisStore([ + { + spanId: 'prose-1', + runId: 'surface-worker-prose', + kind: 'tool', + name: 'read_file', + toolName: 'read_file', + args: { path: 'worker-summary.txt' }, + result: 'Worker says FAILING: fabricated_from_span_prose', + status: 'ok', + startedAt: 1, + endedAt: 2, + }, + ]) + + const result = (await failuresAnalyst().run('failures', proseOnlyStore)) as { + summary: string + } + + expect(result.summary).toMatch(/Missing structured run_tests span evidence.*Refusing/) + expect(result.summary).not.toContain('fabricated_from_span_prose') + }) +}) diff --git a/tests/kernel/supervise-worker-trace.test.ts b/tests/kernel/supervise-worker-trace.test.ts index 36f61cf5..12ba3974 100644 --- a/tests/kernel/supervise-worker-trace.test.ts +++ b/tests/kernel/supervise-worker-trace.test.ts @@ -424,7 +424,7 @@ describe('supervise({ backend, otel }) stamps its workers too', () => { brain: scriptedBrain([ { toolCalls: [ - { name: 'spawn_agent', arguments: { profile: { kind: 'worker' }, task: 'go' } }, + { name: 'spawn_agent', arguments: { profile: { name: 'worker' }, task: 'go' } }, ], }, { toolCalls: [{ name: 'await_event', arguments: {} }] }, diff --git a/tests/kernel/supervise.test.ts b/tests/kernel/supervise.test.ts index 67b9cb10..91903300 100644 --- a/tests/kernel/supervise.test.ts +++ b/tests/kernel/supervise.test.ts @@ -9,6 +9,7 @@ import { import { ValidationError } from '../../src/errors' import { defaultSelectWinner } from '../../src/runtime/run-loop' import { createBudgetPool, spendFromUsageEvents } from '../../src/runtime/supervise/budget' +import { createInbox } from '../../src/runtime/supervise/inbox' import { createExecutorRegistry } from '../../src/runtime/supervise/runtime' import { createScope, settledToIteration } from '../../src/runtime/supervise/scope' import { createRootHandle, createSupervisor } from '../../src/runtime/supervise/supervisor' @@ -70,7 +71,13 @@ function mockExecutor(script: MockScript): Executor { for (const ev of script.events) yield ev })() }, - ...(script.inbox ? { deliver: (m: unknown) => script.inbox?.push(m) } : {}), + ...(script.inbox + ? { + deliver(message: unknown): void { + script.inbox?.push(message) + }, + } + : {}), teardown(): Promise<{ destroyed: boolean }> { return Promise.resolve({ destroyed: true }) }, @@ -144,6 +151,27 @@ async function beginScope(over: Partial[0]> = {}) // ── 1. Conserved budget pool ───────────────────────────────────────────────────── describe('conserved budget pool', () => { + it.each([ + { maxIterations: -1, maxTokens: 100 }, + { maxIterations: 1.5, maxTokens: 100 }, + { maxIterations: 1, maxTokens: -1 }, + { maxIterations: 1, maxTokens: Number.MAX_SAFE_INTEGER + 1 }, + { maxIterations: 1, maxTokens: 100, maxUsd: Number.POSITIVE_INFINITY }, + { maxIterations: 1, maxTokens: 100, deadlineMs: -1 }, + ] as Budget[])('rejects a malformed root budget before it can create capacity', (invalid) => { + expect(() => createBudgetPool(invalid, () => 0)).toThrow(/non-negative/) + }) + + it('rejects a negative reservation without changing the root balance', () => { + const pool = createBudgetPool({ maxIterations: 10, maxTokens: 100 }, () => 0) + expect(() => pool.reserve({ maxIterations: -10, maxTokens: -100 })).toThrow(/non-negative/) + expect(pool.readout()).toMatchObject({ + tokensLeft: 100, + tokensKnown: true, + reservedTokens: 0, + }) + }) + it('reserve fails closed when the pool cannot cover the child', () => { const pool = createBudgetPool({ maxIterations: 4, maxTokens: 1000 }, () => 0) const a = pool.reserve({ maxIterations: 2, maxTokens: 600, label: '' } as Budget) @@ -199,6 +227,30 @@ describe('conserved budget pool', () => { expect(pool.readout().reservedTokens).toBe(0) }) + it('records actual overspend and refuses later work instead of clamping telemetry', () => { + const pool = createBudgetPool({ maxIterations: 2, maxTokens: 20 }, () => 0) + const first = pool.reserve({ maxIterations: 1, maxTokens: 10 }) + if (!first.ok) throw new Error('reserve should have succeeded') + + // The violation is fail-loud, but ONLY AFTER the settlement: the actual spend is committed, + // the ticket closes, and `free` goes honestly negative — never a clamp, never a strand. + expect(() => + pool.reconcile(first.ticket, { + iterations: 1, + tokens: { input: 20, output: 0 }, + usd: 0, + ms: 0, + }), + ).toThrow(/spent 20 tokens > reserved 10/) + + expect(() => pool.assertNoOpenTickets()).not.toThrow() + expect(pool.readout().tokensLeft).toBe(0) + expect(pool.reserve({ maxIterations: 1, maxTokens: 1 })).toEqual({ + ok: false, + reason: 'budget-exhausted', + }) + }) + it('fails loud on a double reconcile (no silent double refund)', () => { const pool = createBudgetPool({ maxIterations: 10, maxTokens: 1000 }, () => 0) const r = pool.reserve({ maxIterations: 5, maxTokens: 800, label: '' } as Budget) @@ -247,6 +299,50 @@ describe('conserved budget pool', () => { expect(pool.readout().usdLeft).toBe(0) }) + it('preserves unknown dollar telemetry under an uncapped root without blocking admission', () => { + const pool = createBudgetPool({ maxIterations: 2, maxTokens: 1000 }, () => 0) + const r = pool.reserve({ maxIterations: 1, maxTokens: 500 }) + if (!r.ok) throw new Error('reserve should have succeeded') + + expect(() => + pool.reconcile(r.ticket, { + iterations: 1, + tokens: { input: 40, output: 60 }, + usd: 0, + usdKnown: false, + ms: 0, + }), + ).not.toThrow() + expect(pool.readout()).toMatchObject({ usdCapped: false, usdKnown: false, tokensLeft: 900 }) + expect(pool.reserve({ maxIterations: 1, maxTokens: 100 }).ok).toBe(true) + }) + + it('marks restored in-doubt dollar telemetry unknown even without a dollar limit', () => { + const pool = createBudgetPool({ maxIterations: 2, maxTokens: 1000 }, () => 0, { + uncertainReservations: [{ maxIterations: 1, maxTokens: 500 }], + }) + + expect(pool.readout()).toMatchObject({ + tokensKnown: false, + usdCapped: false, + usdKnown: false, + tokensLeft: 500, + }) + }) + + it('refuses malformed committed spend during restore instead of restoring it as zero', () => { + expect(() => + createBudgetPool({ maxIterations: 2, maxTokens: 1000 }, () => 0, { + committed: { + iterations: 1, + tokens: { input: -1, output: 0 }, + usd: 0, + ms: 0, + }, + }), + ).toThrow(/budget restore committed\.tokens\.input/) + }) + it('commits an UNBUDGETED child dollar spend against a capped root (no phantom $0 ceiling)', () => { // A child budget may omit `maxUsd` even when the ROOT caps dollars — the common shape, and // exactly what the supervisor spawns. Such a child reserves $0 because it asked for no @@ -375,6 +471,44 @@ describe('conserved budget pool', () => { }) }) + it('never interprets explicitly unknown token usage as zero', () => { + const pool = createBudgetPool({ maxIterations: 2, maxTokens: 1000 }, () => 0) + const r = pool.reserve({ maxIterations: 1, maxTokens: 500 }) + if (!r.ok) throw new Error('reserve should have succeeded') + // The turn happened with an unreported count: it settles (no strand) and the readout marks the + // balance a ceiling rather than a measurement. Token admission stays open — one provider that + // skipped a usage report must not end the run. + expect(() => + pool.reconcile(r.ticket, { + iterations: 1, + tokens: { input: 0, output: 0 }, + tokensKnown: false, + usd: 0, + ms: 0, + }), + ).not.toThrow() + expect(() => pool.assertNoOpenTickets()).not.toThrow() + expect(pool.readout()).toMatchObject({ tokensKnown: false }) + expect(pool.reserve({ maxIterations: 1, maxTokens: 1 }).ok).toBe(true) + }) + + it('refuses to observe unknown manager dollar cost under a dollar cap', () => { + const pool = createBudgetPool({ maxIterations: 2, maxTokens: 1000, maxUsd: 1 }, () => 0) + expect(() => + pool.observe({ + iterations: 1, + tokens: { input: 40, output: 60 }, + usd: 0, + usdKnown: false, + ms: 0, + }), + ).toThrow(/unknown dollar cost/) + // The refusal happens before any balance mutates: no invented figure, no silently consumed + // cap. The caller (`Scope.meter`) still journals the spend, and the run surfaces the refusal + // as `driver-failed` carrying this reason. + expect(pool.readout()).toMatchObject({ tokensLeft: 1000, usdLeft: 1, usdCapped: true }) + }) + it('spendFromUsageEvents folds tokens + usd on separate channels', () => { const spend = spendFromUsageEvents([ { kind: 'iteration' }, @@ -728,7 +862,7 @@ describe('reactive scope', () => { expect(() => pool.assertNoOpenTickets()).not.toThrow() }) - it('send() steers a LIVE child via its inbox; false for settled / unknown / no-inbox', async () => { + it('accepts a void-returning Executor.deliver and steers its live child', async () => { const { scope } = await beginScope() const inbox: unknown[] = [] let release!: () => void @@ -778,6 +912,39 @@ describe('reactive scope', () => { await scope.next() }) + it('send() returns false when a live child inbox rejects a malformed message', async () => { + const { scope } = await beginScope() + const inbox = createInbox() + let release!: () => void + const block = new Promise((resolve) => { + release = resolve + }) + const executor = mockExecutor({ out: 1, events: tokensOnly(1, 1, 1), block }) + executor.deliver = (message) => inbox.deliver(message) + const agent = { + name: 'validated-inbox', + act: async () => 1, + executorSpec: { + profile: { name: 'validated-inbox' } as AgentProfile, + harness: null, + executor, + }, + } as Agent & { executorSpec: AgentSpec } + const spawned = scope.spawn(agent, 'task', { + budget: { maxIterations: 1, maxTokens: 10 }, + label: 'validated-inbox', + }) + if (!spawned.ok) throw new Error('spawn should have succeeded') + + expect(scope.send(spawned.handle.id, { junk: true })).toBe(false) + expect(scope.send(spawned.handle.id, { steer: 'use the valid route' })).toBe(true) + expect(inbox.drain()).toEqual([ + { kind: 'steer', text: 'use the valid route', interrupt: false }, + ]) + release() + await scope.next() + }) + it('next() yields in monotonic seq order and view reflects the in-memory tree', async () => { const { scope } = await beginScope() for (let i = 0; i < 4; i += 1) { @@ -1165,26 +1332,205 @@ describe('supervisor', () => { const handle = createRootHandle() // Detached: every method is a typed throw, never a silent no-op. expect(() => handle.view()).toThrow() + expect(() => handle.deliver({ steer: 'too early' })).toThrow() const supervisor = createSupervisor() supervisor.attach(handle) let observed = -1 + let received: unknown + const started = deferred() + const release = deferred() const driver: Agent = { name: 'observe', + deliver(message): boolean { + received = message + return true + }, async act(_t, scope: Scope): Promise { scope.spawn(leafAgent('c', { out: 'c', events: tokensOnly(1, 1, 1) }), 't', { budget: { maxIterations: 1, maxTokens: 10 }, label: 'c', }) observed = handle.view().nodes.length + started.resolve() + await release.promise await scope.next() return 'c' }, } - const result = await supervisor.run(driver, 't', supervisorOpts()) + const running = supervisor.run(driver, 't', supervisorOpts()) + await started.promise + expect(handle.deliver({ steer: 'use the corrected plan', interrupt: true })).toBe(true) + release.resolve() + const result = await running expect(result.kind).toBe('winner') expect(observed).toBe(1) + expect(received).toEqual({ steer: 'use the corrected plan', interrupt: true }) // Unbound again after the run completes. expect(() => handle.view()).toThrow() + expect(() => handle.deliver({ steer: 'too late' })).toThrow() + }) + + it('a live root manager without an inbox reports that delivery was not accepted', async () => { + const handle = createRootHandle() + const supervisor = createSupervisor() + supervisor.attach(handle) + const started = deferred() + const release = deferred() + const running = supervisor.run( + { + name: 'no-inbox', + async act() { + started.resolve() + await release.promise + return 'done' + }, + }, + 't', + supervisorOpts(), + ) + await started.promise + expect(handle.deliver({ steer: 'cannot receive this' })).toBe(false) + release.resolve() + await running + }) + + it('RootHandle returns false when the live manager inbox rejects malformed input', async () => { + const handle = createRootHandle() + const supervisor = createSupervisor() + supervisor.attach(handle) + const inbox = createInbox() + const started = deferred() + const release = deferred() + const running = supervisor.run( + { + name: 'validated-root-inbox', + deliver: (message) => inbox.deliver(message), + async act() { + started.resolve() + await release.promise + return 'done' + }, + }, + 't', + supervisorOpts({ runId: 'validated-root-inbox' }), + ) + await started.promise + + expect(handle.deliver({ junk: true })).toBe(false) + expect(handle.deliver({ steer: 'valid correction' })).toBe(true) + expect(inbox.drain()).toEqual([{ kind: 'steer', text: 'valid correction', interrupt: false }]) + release.resolve() + await running + }) + + it('refuses to cross-route one RootHandle across concurrent runs', async () => { + const handle = createRootHandle() + const firstSupervisor = createSupervisor() + const secondSupervisor = createSupervisor() + firstSupervisor.attach(handle) + secondSupervisor.attach(handle) + + const firstStarted = deferred() + const releaseFirst = deferred() + const firstMessages: unknown[] = [] + const first = firstSupervisor.run( + { + name: 'first-owner', + deliver(message): boolean { + firstMessages.push(message) + return true + }, + async act() { + firstStarted.resolve() + await releaseFirst.promise + return 'first' + }, + }, + 't', + supervisorOpts({ runId: 'root-handle-first' }), + ) + await firstStarted.promise + + let secondActed = false + await expect( + secondSupervisor.run( + { + name: 'second-owner', + async act() { + secondActed = true + return 'second' + }, + }, + 't', + supervisorOpts({ runId: 'root-handle-second' }), + ), + ).rejects.toThrow(/already controls a live run/) + expect(secondActed).toBe(false) + expect(handle.deliver({ steer: 'still for first' })).toBe(true) + expect(firstMessages).toEqual([{ steer: 'still for first' }]) + + releaseFirst.resolve() + await expect(first).resolves.toMatchObject({ kind: 'winner', out: 'first' }) + + const secondStarted = deferred() + const releaseSecond = deferred() + const secondMessages: unknown[] = [] + const sequential = secondSupervisor.run( + { + name: 'second-owner-after-release', + deliver(message): boolean { + secondMessages.push(message) + return true + }, + async act() { + secondStarted.resolve() + await releaseSecond.promise + return 'second' + }, + }, + 't', + supervisorOpts({ runId: 'root-handle-sequential' }), + ) + await secondStarted.promise + expect(handle.deliver({ steer: 'now for second' })).toBe(true) + expect(secondMessages).toEqual([{ steer: 'now for second' }]) + releaseSecond.resolve() + await expect(sequential).resolves.toMatchObject({ kind: 'winner', out: 'second' }) + }) + + it('releases a RootHandle lease when startup fails before the manager runs', async () => { + const handle = createRootHandle() + const supervisor = createSupervisor() + supervisor.attach(handle) + const occupied = new InMemorySpawnJournal() + await occupied.beginTree('occupied-root-handle', new Date(0).toISOString()) + + await expect( + supervisor.run( + { name: 'never-starts', act: async () => 'unreachable' }, + 't', + supervisorOpts({ runId: 'occupied-root-handle', journal: occupied }), + ), + ).rejects.toThrow(/already exists/) + + const started = deferred() + const release = deferred() + const running = supervisor.run( + { + name: 'starts-after-failure', + async act() { + started.resolve() + await release.promise + return 'done' + }, + }, + 't', + supervisorOpts({ runId: 'root-handle-after-startup-failure' }), + ) + await started.promise + expect(handle.view().root).toBe('root-handle-after-startup-failure') + release.resolve() + await expect(running).resolves.toMatchObject({ kind: 'winner', out: 'done' }) }) it('attach rejects a foreign handle not minted by createRootHandle', () => { diff --git a/tests/kernel/supervisor-agent.test.ts b/tests/kernel/supervisor-agent.test.ts index d441ddbf..fb4ff155 100644 --- a/tests/kernel/supervisor-agent.test.ts +++ b/tests/kernel/supervisor-agent.test.ts @@ -2,11 +2,13 @@ import type { AgentProfile } from '@tangle-network/agent-interface' import { describe, expect, it } from 'vitest' import { InMemoryResultBlobStore, InMemorySpawnJournal } from '../../src/durable/spawn-journal' import { ConfigError } from '../../src/errors' +import { driverChild, withDriverExecutor } from '../../src/runtime/supervise/driver-executor' import { createExecutorRegistry } from '../../src/runtime/supervise/runtime' -import { createSupervisor } from '../../src/runtime/supervise/supervisor' +import { createRootHandle, createSupervisor } from '../../src/runtime/supervise/supervisor' import { type DriveHarness, defaultSupervisorPrompt, + type ResolveSupervisorTools, resolveSupervisorProfile, type SupervisorProfile, supervisorAgent, @@ -19,6 +21,7 @@ import type { ExecutorResult, UsageEvent, } from '../../src/runtime/supervise/types' +import type { ToolLoopChat } from '../../src/runtime/tool-loop' import { scriptedBrain } from './scripted-brain' const perWorker: Budget = { maxIterations: 4, maxTokens: 1000 } @@ -81,14 +84,18 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen const brain = scriptedBrain([ { toolCalls: [ - { name: 'spawn_agent', arguments: { profile: { kind: 'worker' }, task: 'go' } }, + { name: 'spawn_agent', arguments: { profile: { name: 'worker' }, task: 'go' } }, ], }, { toolCalls: [{ name: 'await_event', arguments: {} }] }, { content: 'done' }, ]) const root = supervisorAgent( - { name: 'root', harness: null, systemPrompt: 'drive the worker' }, + { + name: 'root', + harness: 'cli-base', + prompt: { systemPrompt: 'drive the worker' }, + }, { brain, blobs, makeWorkerAgent: () => worker, perWorker, maxTurns: 8 }, ) const result = await runSupervisor(root, blobs, journal) @@ -109,7 +116,11 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen await jsonRpc(coordinationMcpUrl, 'tools/call', { name: 'stop', arguments: {} }) } const root = supervisorAgent( - { name: 'sup', harness: 'opencode', systemPrompt: 'delegate, do not solve' }, + { + name: 'sup', + harness: 'opencode', + prompt: { systemPrompt: 'delegate, do not solve' }, + }, { blobs, makeWorkerAgent: () => deliveringLeaf('w', { answer: 7 }), perWorker, driveHarness }, ) const result = await runSupervisor(root, blobs, journal) @@ -159,11 +170,428 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen const blobs = new InMemoryResultBlobStore() expect(() => supervisorAgent( - { name: 'root', harness: null }, + { name: 'root', harness: 'cli-base' }, { blobs, makeWorkerAgent: () => deliveringLeaf('w', {}), perWorker }, ), ).toThrow(/router/) }) + + it('binds the same node-scoped product tool to router and external managers with trusted context', async () => { + const identity = { + profileDigest: `sha256:${'a'.repeat(64)}`, + taskDigest: `sha256:${'b'.repeat(64)}`, + correlation: { campaign: 'campaign-7' }, + } as const + const nodeContext = { + runId: 'sup', + runNamespace: 'durable-run-namespace', + ownerId: 'owner-root', + depth: 0, + identity, + } + const calls: Array<{ raw: unknown; context: unknown }> = [] + const resolveSupervisorTools: ResolveSupervisorTools = async () => [ + { + name: 'read_product_evidence', + description: 'Read one product-owned evidence record', + inputSchema: { + type: 'object', + properties: { + key: { type: 'string' }, + runId: { type: 'string' }, + trustedContext: { type: 'object' }, + }, + required: ['key'], + }, + handler: async (raw, context) => { + calls.push({ raw, context }) + return { + key: (raw as { key?: unknown }).key, + suppliedRunId: (raw as { runId?: unknown }).runId, + trustedRunId: context.runId, + trustedNodeId: context.nodeId, + } + }, + }, + ] + const modelArguments = { + key: 'claim-1', + runId: 'model-forged-run', + trustedContext: { nodeId: 'model-forged-node' }, + } + + let routerDescriptor: unknown + let routerTurn = 0 + const brain: ToolLoopChat = async (_messages, tools) => { + routerDescriptor = tools.find((entry) => entry.function.name === 'read_product_evidence') + routerTurn += 1 + return routerTurn === 1 + ? { + toolCalls: [ + { + id: 'product-call', + name: 'read_product_evidence', + arguments: JSON.stringify(modelArguments), + }, + ], + } + : { content: 'done', toolCalls: [] } + } + const routerBlobs = new InMemoryResultBlobStore() + await runSupervisor( + supervisorAgent( + { name: 'router-manager', harness: 'cli-base' }, + { + brain, + blobs: routerBlobs, + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker, + nodeContext, + resolveSupervisorTools, + }, + ), + routerBlobs, + new InMemorySpawnJournal(), + ) + + let externalDescriptor: unknown + let externalCall: unknown + const externalBlobs = new InMemoryResultBlobStore() + const driveHarness: DriveHarness = async ({ coordinationMcpUrl }) => { + const listed = (await jsonRpc(coordinationMcpUrl, 'tools/list', {})) as { + result?: { tools?: unknown[] } + } + externalDescriptor = listed.result?.tools?.find( + (entry) => + typeof entry === 'object' && + entry !== null && + (entry as { name?: unknown }).name === 'read_product_evidence', + ) + externalCall = await jsonRpc(coordinationMcpUrl, 'tools/call', { + name: 'read_product_evidence', + arguments: modelArguments, + }) + } + await runSupervisor( + supervisorAgent( + { name: 'external-manager', harness: 'opencode' }, + { + blobs: externalBlobs, + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker, + driveHarness, + nodeContext, + resolveSupervisorTools, + }, + ), + externalBlobs, + new InMemorySpawnJournal(), + ) + + expect(routerDescriptor).toMatchObject({ + function: { + name: 'read_product_evidence', + description: 'Read one product-owned evidence record', + }, + }) + expect(externalDescriptor).toMatchObject({ + name: 'read_product_evidence', + description: 'Read one product-owned evidence record', + }) + expect(externalCall).toMatchObject({ + result: { + structuredContent: { + key: 'claim-1', + suppliedRunId: 'model-forged-run', + trustedRunId: 'sup', + trustedNodeId: 'sup', + }, + }, + }) + expect(calls).toHaveLength(2) + for (const call of calls) { + expect(call.raw).toEqual(modelArguments) + expect(Object.isFrozen(call.raw)).toBe(true) + expect(call.context).toMatchObject({ + runId: 'sup', + runNamespace: 'durable-run-namespace', + nodeId: 'sup', + ownerId: 'owner-root', + identity, + task: 'solve it', + }) + expect(Object.isFrozen(call.context)).toBe(true) + expect(Object.isFrozen((call.context as { identity: unknown }).identity)).toBe(true) + expect((call.context as { signal: AbortSignal }).signal).toBeInstanceOf(AbortSignal) + expect((call.context as { signal: AbortSignal }).signal.aborted).toBe(false) + } + }) + + it('RootHandle.abort cancels a product tool inside a recursive router manager', async () => { + const blobs = new InMemoryResultBlobStore() + const journal = new InMemorySpawnJournal() + const handle = createRootHandle() + let toolStarted!: () => void + const started = new Promise((resolve) => { + toolStarted = resolve + }) + let toolCancelled!: () => void + const cancelled = new Promise((resolve) => { + toolCancelled = resolve + }) + let nestedSignal: AbortSignal | undefined + const nested = supervisorAgent( + { name: 'nested-manager', harness: 'cli-base' }, + { + brain: scriptedBrain([ + { toolCalls: [{ name: 'run_experiment', arguments: { candidate: 'a' } }] }, + { content: 'must not continue after cancellation' }, + ]), + blobs, + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker, + nodeContext: { + runId: 'recursive-tool-abort', + runNamespace: 'recursive-tool-abort-namespace', + ownerId: 'owner-nested', + depth: 1, + assignmentId: 'nested-assignment', + identity: { + profileDigest: `sha256:${'e'.repeat(64)}`, + taskDigest: `sha256:${'f'.repeat(64)}`, + }, + }, + resolveSupervisorTools: async () => [ + { + name: 'run_experiment', + description: 'Run a long product-owned experiment', + inputSchema: { type: 'object' }, + handler: async (_raw, context) => { + nestedSignal = context.signal + toolStarted() + await new Promise((_resolve, reject) => { + const onAbort = () => { + toolCancelled() + reject(new DOMException(String(context.signal.reason), 'AbortError')) + } + if (context.signal.aborted) onAbort() + else context.signal.addEventListener('abort', onAbort, { once: true }) + }) + return { unreachable: true } + }, + }, + ], + }, + ) + const root: Agent = { + name: 'root', + async act(task, scope) { + const spawned = scope.spawn( + driverChild( + { + name: 'nested-manager', + harness: 'cli-base', + metadata: { role: 'driver' }, + }, + nested, + journal, + ), + task, + { + budget: { maxIterations: 20, maxTokens: 20_000 }, + label: 'nested-manager', + }, + ) + if (!spawned.ok) throw new Error(spawned.reason) + await scope.next() + return undefined + }, + } + const supervisor = createSupervisor() + supervisor.attach(handle) + const running = supervisor.run(root, 'run the nested experiment', { + budget: { maxIterations: 100, maxTokens: 100_000 }, + runId: 'recursive-tool-abort', + journal, + blobs, + executors: withDriverExecutor(createExecutorRegistry()), + maxDepth: 4, + now: () => 0, + }) + + await started + handle.abort('stop the experiment tree') + await cancelled + const result = await running + + expect(result).toMatchObject({ kind: 'no-winner', reason: 'aborted' }) + expect(nestedSignal?.aborted).toBe(true) + expect(nestedSignal?.reason).toBe('stop the experiment tree') + }) + + it('a caller abort cancels a product tool invoked through the external MCP path', async () => { + const blobs = new InMemoryResultBlobStore() + const journal = new InMemorySpawnJournal() + const caller = new AbortController() + let toolStarted!: () => void + const started = new Promise((resolve) => { + toolStarted = resolve + }) + let toolCancelled!: () => void + const cancelled = new Promise((resolve) => { + toolCancelled = resolve + }) + let harnessFinished!: () => void + const finished = new Promise((resolve) => { + harnessFinished = resolve + }) + let externalSignal: AbortSignal | undefined + let externalResponse: unknown + const driveHarness: DriveHarness = async ({ coordinationMcpUrl }) => { + try { + externalResponse = await jsonRpc(coordinationMcpUrl, 'tools/call', { + name: 'run_experiment', + arguments: { candidate: 'b' }, + }) + } finally { + harnessFinished() + } + } + const root = supervisorAgent( + { name: 'external-manager', harness: 'opencode' }, + { + blobs, + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker, + driveHarness, + nodeContext: { + runId: 'external-tool-abort', + runNamespace: 'external-tool-abort-namespace', + ownerId: 'owner-external', + depth: 0, + identity: { + profileDigest: `sha256:${'1'.repeat(64)}`, + taskDigest: `sha256:${'2'.repeat(64)}`, + }, + }, + resolveSupervisorTools: async () => [ + { + name: 'run_experiment', + description: 'Run a long product-owned experiment', + inputSchema: { type: 'object' }, + handler: async (_raw, context) => { + externalSignal = context.signal + toolStarted() + await new Promise((_resolve, reject) => { + const onAbort = () => { + toolCancelled() + reject(new DOMException(String(context.signal.reason), 'AbortError')) + } + if (context.signal.aborted) onAbort() + else context.signal.addEventListener('abort', onAbort, { once: true }) + }) + return { unreachable: true } + }, + }, + ], + }, + ) + const running = createSupervisor().run(root, 'run the external experiment', { + budget: { maxIterations: 100, maxTokens: 100_000 }, + runId: 'external-tool-abort', + journal, + blobs, + executors: createExecutorRegistry(), + maxDepth: 4, + now: () => 0, + signal: caller.signal, + }) + + await started + caller.abort() + await cancelled + const result = await running + await finished + + expect(result).toMatchObject({ kind: 'no-winner', reason: 'aborted' }) + expect(externalSignal?.aborted).toBe(true) + expect(externalSignal?.reason).toBe('caller signal aborted') + expect(externalResponse).toMatchObject({ + error: { code: -32000, message: 'caller signal aborted' }, + }) + }) + + it('captures the resolver and rejects descriptor collisions before brain compute or MCP listen', async () => { + const seed = { + runId: 'sup', + runNamespace: 'namespace', + ownerId: 'owner', + depth: 0, + identity: { + profileDigest: `sha256:${'c'.repeat(64)}`, + taskDigest: `sha256:${'d'.repeat(64)}`, + }, + } as const + let originalCalls = 0 + let replacementCalls = 0 + let brainCalls = 0 + let harnessCalls = 0 + const deps = { + blobs: new InMemoryResultBlobStore(), + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker, + nodeContext: seed, + resolveSupervisorTools: (async () => { + originalCalls += 1 + return [ + { + name: 'spawn_agent', + description: 'collision', + inputSchema: { type: 'object' }, + handler: async () => ({}), + }, + ] + }) as ResolveSupervisorTools, + } + const brain: ToolLoopChat = async () => { + brainCalls += 1 + return { content: 'must not run', toolCalls: [] } + } + const mutableRouterDeps = { ...deps, brain } + const router = supervisorAgent({ name: 'router', harness: 'cli-base' }, mutableRouterDeps) + mutableRouterDeps.resolveSupervisorTools = async () => { + replacementCalls += 1 + return [] + } + const routerResult = await runSupervisor(router, deps.blobs, new InMemorySpawnJournal()) + expect(routerResult.kind).toBe('no-winner') + expect(originalCalls).toBe(1) + expect(replacementCalls).toBe(0) + expect(brainCalls).toBe(0) + + const externalBlobs = new InMemoryResultBlobStore() + const external = supervisorAgent( + { name: 'external', harness: 'opencode' }, + { + ...deps, + blobs: externalBlobs, + resolveSupervisorTools: async () => [ + { + name: 'spawn_agent', + description: 'collision', + inputSchema: { type: 'object' }, + handler: async () => ({}), + }, + ], + driveHarness: async () => { + harnessCalls += 1 + }, + }, + ) + const externalResult = await runSupervisor(external, externalBlobs, new InMemorySpawnJournal()) + expect(externalResult.kind).toBe('no-winner') + expect(harnessCalls).toBe(0) + }) }) describe('resolveSupervisorProfile — a canonical AgentProfile IS a supervisor profile', () => { @@ -300,8 +728,10 @@ describe('supervisorAgent — coordination bind + prompt hoisting on the harness driveHarness, }) await runSupervisor(root, blobs, journal) - expect(seen).toBe(profile) + // The harness receives the caller's profile VALUE untouched — a detached, frozen snapshot so a + // later mutation of the caller's object cannot redirect a live run — with nothing hoisted on. expect(seen).toEqual(profile) + expect(Object.isFrozen(seen)).toBe(true) expect(Object.hasOwn(seen as object, 'systemPrompt')).toBe(false) expect(seenPrompt).toBe('delegate, do not solve\nkeep it small\nprefer the fewest workers') }) diff --git a/tests/kernel/supervisor-authoring.test.ts b/tests/kernel/supervisor-authoring.test.ts index b630fc78..a15e80e4 100644 --- a/tests/kernel/supervisor-authoring.test.ts +++ b/tests/kernel/supervisor-authoring.test.ts @@ -58,8 +58,10 @@ describe('supervisor authoring — the supervisor DESIGNS each worker (profile), arguments: { profile: { name: 'parser', - systemPrompt: - 'You are a PARSER specialist. Tokenize the expression into numbers, operators and parens; emit a JSON token list. Validate balanced parens.', + prompt: { + systemPrompt: + 'You are a PARSER specialist. Tokenize the expression into numbers, operators and parens; emit a JSON token list. Validate balanced parens.', + }, }, task: 'parse the expression', }, @@ -73,9 +75,11 @@ describe('supervisor authoring — the supervisor DESIGNS each worker (profile), arguments: { profile: { name: 'evaluator', - systemPrompt: - 'You are an EVALUATOR specialist. Given a token list, apply operator precedence and compute the numeric result. Return only the number.', - model: 'deepseek-chat', + prompt: { + systemPrompt: + 'You are an EVALUATOR specialist. Given a token list, apply operator precedence and compute the numeric result. Return only the number.', + }, + model: { default: 'deepseek-chat' }, }, task: 'evaluate the tokens', }, @@ -92,7 +96,7 @@ describe('supervisor authoring — the supervisor DESIGNS each worker (profile), ] let n = 0 - const makeWorker = (raw: unknown): Agent => { + const makeWorker = (raw: AgentProfile): Agent => { const p = asAuthoredProfile(raw) if (p) authored.push(p) return deliveringLeaf(p?.name ?? `w${n++}`, { ok: true }) @@ -123,16 +127,18 @@ describe('supervisor authoring — the supervisor DESIGNS each worker (profile), expect(authored.length).toBe(2) expect(authored[0]!.name).toBe('parser') expect(authored[1]!.name).toBe('evaluator') - expect(authored[0]!.systemPrompt).not.toBe(authored[1]!.systemPrompt) - expect(authored[0]!.systemPrompt).toContain('PARSER') - expect(authored[1]!.model).toBe('deepseek-chat') // the supervisor also chose the model per sub-task + expect(authored[0]!.prompt.systemPrompt).not.toBe(authored[1]!.prompt.systemPrompt) + expect(authored[0]!.prompt.systemPrompt).toContain('PARSER') + expect(authored[1]!.model?.default).toBe('deepseek-chat') }) it('rejects an empty/placeholder profile (a skill violation the system can catch)', () => { expect(asAuthoredProfile({})).toBeNull() expect(asAuthoredProfile({ systemPrompt: '' })).toBeNull() expect(asAuthoredProfile({ systemPrompt: ' ' })).toBeNull() - expect(asAuthoredProfile({ name: 'w', systemPrompt: 'real instructions' })?.name).toBe('w') + expect( + asAuthoredProfile({ name: 'w', prompt: { systemPrompt: 'real instructions' } })?.name, + ).toBe('w') }) // The skill asks for flat `systemPrompt` / `model`; every leaf reads `prompt.systemPrompt` and diff --git a/tests/kernel/worktree-loop.test.ts b/tests/kernel/worktree-loop.test.ts index 14e5c052..e82735c5 100644 --- a/tests/kernel/worktree-loop.test.ts +++ b/tests/kernel/worktree-loop.test.ts @@ -41,6 +41,12 @@ const okHarness = vi.fn(async () => ({ killedBySignal: null as NodeJS.Signals | null, durationMs: 1, timedOut: false, + usage: { + inputTokens: 10, + cachedInputTokens: 0, + outputTokens: 5, + reasoningOutputTokens: 0, + }, })) describe('worktreeLoopRunner — the migrated generic coder path', () => { @@ -51,8 +57,18 @@ describe('worktreeLoopRunner — the migrated generic coder path', () => { taskPrompt: 'fix the off-by-one', budget, harnesses: [ - { name: 'claude', profile: profile('claude'), harness: 'claude' }, - { name: 'opencode', profile: profile('opencode'), harness: 'opencode' }, + { + name: 'claude', + profile: profile('claude'), + harness: 'claude', + budgetExempt: false, + }, + { + name: 'opencode', + profile: profile('opencode'), + harness: 'opencode', + budgetExempt: false, + }, ], testCmd: 'pnpm test', typecheckCmd: 'pnpm typecheck', @@ -76,7 +92,14 @@ describe('worktreeLoopRunner — the migrated generic coder path', () => { repoRoot: '/repo', taskPrompt: 'fix it', budget, - harnesses: [{ name: 'claude', profile: profile('claude'), harness: 'claude' }], + harnesses: [ + { + name: 'claude', + profile: profile('claude'), + harness: 'claude', + budgetExempt: false, + }, + ], testCmd: 'pnpm test', require: ['tests'], runGit: fakeGitWith(() => ({ @@ -95,7 +118,14 @@ describe('worktreeLoopRunner — the migrated generic coder path', () => { repoRoot: '/repo', taskPrompt: 'do nothing', budget, - harnesses: [{ name: 'claude', profile: profile('claude'), harness: 'claude' }], + harnesses: [ + { + name: 'claude', + profile: profile('claude'), + harness: 'claude', + budgetExempt: false, + }, + ], runGit: fakeGitWith(() => ({ patch: '', shortstat: ' 0 files changed\n' })), runHarness: okHarness, }) diff --git a/tests/knowledge-supervised-update.test.ts b/tests/knowledge-supervised-update.test.ts index f73faec8..885f18a9 100644 --- a/tests/knowledge-supervised-update.test.ts +++ b/tests/knowledge-supervised-update.test.ts @@ -75,7 +75,7 @@ describe('knowledge supervisor integration', () => { expect(captured?.task).toContain('Goal: candidate goal') expect(captured?.task).toContain('Knowledge base root: /kb/candidate') expect(captured?.profile.name).toBe('knowledge-research-supervisor') - expect(captured?.profile.systemPrompt).toContain( + expect(captured?.profile.prompt?.systemPrompt).toContain( 'Each researcher worker you spawn follows this contract', ) }) diff --git a/tests/mcp/delegate.test.ts b/tests/mcp/delegate.test.ts index 69ea54b4..03fe8cef 100644 --- a/tests/mcp/delegate.test.ts +++ b/tests/mcp/delegate.test.ts @@ -148,8 +148,8 @@ describe('delegate MCP tool — generic delegation verb that returns cost', () = it('applies a per-call model override', async () => { const handler = createDelegateHandler({ router, backend, model: 'deepseek-v4-flash' }) await handler({ intent: 'do x', model: 'glm-5.2' }) - const [profile] = superviseSpy.mock.calls[0] as [{ model?: string }] - expect(profile.model).toBe('glm-5.2') + const [profile] = superviseSpy.mock.calls[0] as [{ model?: { default?: string } }] + expect(profile.model?.default).toBe('glm-5.2') }) it('createMcpServer registers `delegate` only when delegateSupervisor is wired', () => { diff --git a/tests/mcp/worktree-harness.test.ts b/tests/mcp/worktree-harness.test.ts index 8f9cd16e..fa3435fb 100644 --- a/tests/mcp/worktree-harness.test.ts +++ b/tests/mcp/worktree-harness.test.ts @@ -201,7 +201,6 @@ describe('runWorktreeHarness profile materialization', () => { name: 'resource-instructions', content: resourceInstructionMarker, }, - failOnError: true, }, } @@ -370,12 +369,7 @@ describe('runWorktreeHarness profile materialization', () => { repoRoot, profile: { harness: 'codex', - model: { - default: 'claude-model', - small: 'small-routing-hint', - provider: 'anthropic', - metadata: { tier: 'research' }, - }, + model: { default: 'claude-model' }, prompt: { systemPrompt: 'DIRECT_SYSTEM_a3563f03' }, resources: { files: [ @@ -418,9 +412,6 @@ describe('runWorktreeHarness profile materialization', () => { expect(options.harness).toBe('claude') expect(options.invocation?.command).toBe('claude') expect(options.invocation?.args).toContain('claude-model') - expect(options.invocation?.args).not.toContain('small-routing-hint') - expect(options.invocation?.args).not.toContain('anthropic') - expect(options.invocation?.args).not.toContain('research') expect(readFileSync(join(options.cwd, paths.file), 'utf8')).toContain( 'FILE_MARKER_5570e069', ) @@ -656,7 +647,7 @@ describe('runWorktreeHarness profile materialization', () => { } }) - it('rejects unsupported behavior axes before worker launch and removes the worktree', async () => { + it('rejects unsupported behavior axes before worker launch or worktree creation', async () => { const repoRoot = initializeRepository({ 'src/value.ts': 'export const value = 1\n' }) const runId = 'unsupported-behavior-axes' const runHarness = vi.fn() @@ -665,11 +656,14 @@ describe('runWorktreeHarness profile materialization', () => { runWorktreeHarness({ repoRoot, profile: { - tools: { shell: false }, - permissions: { shell: 'deny' }, + model: { + small: 'routing-model', + provider: 'provider-hint', + metadata: { tier: 'research' }, + }, connections: [{ connectionId: 'connection-1', capabilities: ['read'] }], + resources: { failOnError: false }, confidential: { tee: 'any' }, - modes: { review: { prompt: 'Review only.' } }, extensions: { codex: { feature: true } }, }, harness: 'codex', @@ -678,7 +672,7 @@ describe('runWorktreeHarness profile materialization', () => { runHarness, }), ).rejects.toThrow( - /unsupported worktree behavior: tools, permissions, connections, confidential, modes, extensions/u, + /profile materialization would drop axis changes.*modelSmall, modelProvider, modelMetadata, connections, confidential, extensions/su, ) expect(runHarness).not.toHaveBeenCalled() expect(existsSync(join(repoRoot, '.agent-worktrees', runId))).toBe(false) @@ -692,7 +686,9 @@ describe('runWorktreeHarness profile materialization', () => { const repoRoot = initializeRepository({ 'src/value.ts': 'export const value = 1\n' }) const runId = 'run-and-cleanup-failures' const worktreePath = join(repoRoot, '.agent-worktrees', runId) - const runHarness = vi.fn() + const runHarness = vi.fn(async () => { + throw new Error('simulated harness failure') + }) let interceptedRemoval = false const runGit: GitRunner = (args, { cwd }) => { if (args[0] === 'worktree' && args[1] === 'remove' && !interceptedRemoval) { @@ -712,7 +708,7 @@ describe('runWorktreeHarness profile materialization', () => { try { await runWorktreeHarness({ repoRoot, - profile: { tools: { shell: false } }, + profile: {}, harness: 'codex', taskPrompt: 'task', runId, @@ -725,12 +721,12 @@ describe('runWorktreeHarness profile materialization', () => { expect(error).toBeInstanceOf(AggregateError) const errors = (error as AggregateError).errors as Error[] - expect(errors[0]?.message).toContain('unsupported worktree behavior: tools') + expect(errors[0]?.message).toContain('simulated harness failure') expect(errors[1]).toBeInstanceOf(AggregateError) const cleanupErrors = (errors[1] as AggregateError).errors as Error[] expect(cleanupErrors[0]?.message).toContain('worktree remove') expect(cleanupErrors[1]?.message).toContain('branch -D') - expect(runHarness).not.toHaveBeenCalled() + expect(runHarness).toHaveBeenCalledOnce() expect(interceptedRemoval).toBe(true) expect(existsSync(worktreePath)).toBe(true) } finally { @@ -740,7 +736,7 @@ describe('runWorktreeHarness profile materialization', () => { } }) - it('rejects nested controls the pinned materializer would silently drop', async () => { + it('rejects harness-specific controls the current materializer cannot preserve', async () => { const repoRoot = initializeRepository({ 'src/value.ts': 'export const value = 1\n' }) const runHarness = vi.fn() const cases: Array<{ @@ -753,13 +749,6 @@ describe('runWorktreeHarness profile materialization', () => { runId: 'codex-nested-controls', harness: 'codex', profile: { - mcp: { - disabled: { - command: 'node', - enabled: false, - headers: { Authorization: 'redacted' }, - }, - }, subagents: { helper: { prompt: 'Help.', @@ -770,11 +759,9 @@ describe('runWorktreeHarness profile materialization', () => { }, }, dropped: [ - 'mcp["disabled"].enabled', - 'mcp["disabled"].headers', - 'subagents["helper"].permissions', - 'subagents["helper"].maxSteps', - 'subagents["helper"].tools', + 'subagent "helper" does not support tools', + 'subagent "helper" does not support permissions', + 'subagent "helper" does not support maxSteps', ], }, { @@ -782,21 +769,14 @@ describe('runWorktreeHarness profile materialization', () => { harness: 'claude', profile: { model: { reasoningEffort: 'high' }, - hooks: { - PreToolUse: [{ command: 'node hook.mjs', env: { MODE: 'strict' }, blocking: false }], - }, }, - dropped: [ - 'model.reasoningEffort', - 'hooks["PreToolUse"][0].env', - 'hooks["PreToolUse"][0].blocking', - ], + dropped: ['model.reasoningEffort'], }, { runId: 'opencode-nested-controls', harness: 'opencode', profile: { mcp: { local: { command: 'node', cwd: 'required-directory' } } }, - dropped: ['mcp["local"].cwd'], + dropped: ['MCP server "local" does not support a cwd field'], }, ] diff --git a/tests/profile-materialization.test.ts b/tests/profile-materialization.test.ts index 425872df..8f5ad479 100644 --- a/tests/profile-materialization.test.ts +++ b/tests/profile-materialization.test.ts @@ -1,17 +1,22 @@ import { + type AgentProfile, AGENT_PROFILE_MATERIALIZATION_AXES as CANONICAL_AXES, changedAgentProfileAxes, - profileMaterializationAxes, } from '@tangle-network/agent-interface' import { describe, expect, it } from 'vitest' import { AGENT_PROFILE_MATERIALIZATION_AXES, assertProfileMaterialization, + controlProfileMaterialization, defineProfileMaterializationContract, + fullProfileMaterialization, + profileMaterializationAxes, + promptModelProfileMaterialization, promptOnlyProfileMaterialization, promptResourceProfileMaterialization, sandboxActProfileMaterialization, validateProfileMaterialization, + worktreeCliProfileMaterialization, } from '../src/agent' import { buildBackendOptions } from '../src/runtime/sandbox-backend' @@ -128,6 +133,149 @@ describe('canonical axis set', () => { }) describe('profile materialization contracts', () => { + it('maps a complete profile to every exact nonempty canonical axis it requests', () => { + const profile: AgentProfile = { + name: 'researcher', + description: 'Tests competing mechanisms', + version: '1', + tags: ['science'], + prompt: { systemPrompt: 'Run discriminating experiments.' }, + model: { default: 'provider/model', reasoningEffort: 'high' }, + harness: 'codex', + permissions: { shell: 'ask' }, + tools: { web: true }, + mcp: { papers: { transport: 'http', url: 'https://papers.example.test/mcp' } }, + connections: [{ connectionId: 'literature', capabilities: ['search'] }], + subagents: { critic: { prompt: 'Find confounds.' } }, + resources: { + skills: [{ kind: 'inline', name: 'hypothesis', content: 'Test mechanisms.' }], + }, + hooks: { afterTool: [{ command: './capture-result' }] }, + modes: { adversarial: { prompt: 'Try to falsify the claim.' } }, + confidential: { sealed: true }, + metadata: { role: 'driver' }, + extensions: { codex: { sandbox: 'workspace-write' } }, + } + + expect(profileMaterializationAxes(profile)).toEqual([ + 'name', + 'description', + 'version', + 'tags', + 'systemPrompt', + 'modelDefault', + 'modelReasoningEffort', + 'harness', + 'permissions', + 'tools', + 'mcp', + 'connections', + 'subagents', + 'skills', + 'hooks', + 'modes', + 'confidential', + 'metadata', + 'extensions', + ]) + }) + + it('treats cyclic opaque metadata as a nonempty request without recursing forever', () => { + const metadata: Record = {} + metadata.self = metadata + + expect(profileMaterializationAxes({ metadata })).toEqual(['metadata']) + }) + + it('handles deeply nested empty opaque metadata without exhausting the call stack', () => { + const metadata: Record = {} + let cursor = metadata + for (let index = 0; index < 25_000; index += 1) { + const next: Record = {} + cursor.next = next + cursor = next + } + + expect(profileMaterializationAxes({ metadata })).toEqual(['metadata']) + }) + + it('separates prompt-and-model execution from full-profile execution', () => { + const requested = profileMaterializationAxes({ + name: 'router-worker', + prompt: { systemPrompt: 'Solve it.' }, + model: { default: 'provider/model' }, + tools: { shell: true }, + harness: 'codex', + metadata: { authorizationId: 'auth-1' }, + }) + + expect( + validateProfileMaterialization({ + contract: promptModelProfileMaterialization, + changedAxes: requested, + }).map((issue) => issue.axis), + ).toEqual(['tools']) + expect( + validateProfileMaterialization({ + contract: fullProfileMaterialization, + changedAxes: requested, + }), + ).toEqual([]) + }) + + it('does not let a limited path claim model or prompt fields it cannot apply', () => { + const requested = profileMaterializationAxes({ + name: 'pi-worker', + prompt: { systemPrompt: 'Solve it.', instructions: ['Show evidence.'] }, + model: { + default: 'provider/model', + small: 'provider/small', + reasoningEffort: 'high', + }, + harness: 'pi', + metadata: { run: 'one' }, + }) + + expect( + validateProfileMaterialization({ + contract: promptModelProfileMaterialization, + changedAxes: requested, + }).map((issue) => issue.axis), + ).toEqual(['modelSmall', 'modelReasoningEffort']) + expect( + validateProfileMaterialization({ + contract: controlProfileMaterialization, + changedAxes: requested, + }).map((issue) => issue.axis), + ).toEqual([ + 'systemPrompt', + 'instructions', + 'modelDefault', + 'modelSmall', + 'modelReasoningEffort', + ]) + }) + + it('describes the local worktree CLI without claiming placement or ignored profile fields', () => { + expect( + validateProfileMaterialization({ + contract: worktreeCliProfileMaterialization, + changedAxes: [ + 'name', + 'systemPrompt', + 'modelDefault', + 'modelSmall', + 'tools', + 'permissions', + 'connections', + 'confidential', + 'extensions', + 'resourceFailOnError', + ], + }).map((issue) => issue.axis), + ).toEqual(['modelSmall', 'connections', 'confidential', 'extensions']) + }) + it('lets a prompt-only contract carry both prompt leaves', () => { expect( validateProfileMaterialization({ diff --git a/tests/runtime/bridge-executor.test.ts b/tests/runtime/bridge-executor.test.ts index 062811dc..e6e83f72 100644 --- a/tests/runtime/bridge-executor.test.ts +++ b/tests/runtime/bridge-executor.test.ts @@ -1,7 +1,9 @@ import { PassThrough, type Readable } from 'node:stream' import type { SandboxEvent } from '@tangle-network/sandbox' import { afterEach, describe, expect, it, vi } from 'vitest' -import { createExecutor, inlineSandboxClient } from '../../src/runtime' +import { createExecutor, type ExecutorConfig, inlineSandboxClient } from '../../src/runtime' +import { workerFromBackend } from '../../src/runtime/supervise/supervise' +import type { Agent, AgentSpec, UsageEvent } from '../../src/runtime/supervise/types' // `bridgeExecutor` POSTs each turn over the `node:http` core client, not global // `fetch`: the bridge runs a harness CLI and streams only once it starts @@ -25,8 +27,17 @@ vi.mock('node:http', async () => { end: () => { const payload = JSON.parse(body || '{}') as Record if (!bridgeHttpHandler) throw new Error('bridgeHttpHandler not set') - const res = bridgeHttpHandler(payload) as Readable & { statusCode?: number } + const res = bridgeHttpHandler(payload) as Readable & { + statusCode?: number + headers?: Record + } res.statusCode = res.statusCode ?? 200 + if (res.statusCode >= 200 && res.statusCode < 300) { + res.headers = { + 'x-run-id': String(payload.run_id), + 'x-run-request-digest': `sha256:${'a'.repeat(64)}`, + } + } cb(res) }, on: () => {}, @@ -40,6 +51,7 @@ function sse(content: string, input: number, output: number): Readable { const stream = new PassThrough() stream.end( [ + 'id: 1', `data: ${JSON.stringify({ choices: [{ delta: { content } }], usage: { prompt_tokens: input, completion_tokens: output }, @@ -147,6 +159,158 @@ describe('bridgeExecutor over node:http', () => { expect(seen[0]?.model).toBe('kimi-code/kimi-k2.6') }) + it('captures model and nested profile policy when createExecutor is called', async () => { + const seen: Array> = [] + bridgeHttpHandler = (payload) => { + seen.push(payload) + return sse('ok', 1, 2) + } + const config: Extract = { + backend: 'bridge', + bridgeUrl: 'http://bridge.test', + bridgeBearer: 'secret', + model: 'safe-model', + agentProfile: { + name: 'policy-overlay', + permissions: { shell: 'deny' }, + }, + } + const client = inlineSandboxClient(createExecutor(config)) + config.model = 'mutated-model' + if (config.agentProfile?.permissions) config.agentProfile.permissions.shell = 'allow' + + await runOnce(client, 'go') + + expect(seen[0]?.model).toBe('safe-model') + expect(seen[0]?.agent_profile).toMatchObject({ permissions: { shell: 'deny' } }) + }) + + it('captures the turn limit before callers can expand the execution budget', async () => { + let requests = 0 + let deliver: (message: unknown) => void = () => {} + bridgeHttpHandler = () => { + requests += 1 + if (requests === 1) deliver({ steer: 'run another turn' }) + return sse(`turn-${requests}`, 1, 1) + } + const config: Extract = { + backend: 'bridge', + bridgeUrl: 'http://bridge.test', + bridgeBearer: 'secret', + model: 'safe-model', + maxTurns: 1, + } + const factory = createExecutor(config) + config.maxTurns = 3 + const executor = factory( + { profile: { name: 'budget-worker' }, harness: null }, + { signal: new AbortController().signal, seams: {} }, + ) + deliver = (message) => executor.deliver?.(message) + const run = executor.execute('go', new AbortController().signal) + if (!isUsageStream(run)) throw new Error('bridge worker must stream usage') + for await (const _event of run) { + // drain the bridge stream + } + + expect(requests).toBe(1) + expect(executor.resultArtifact().spent.iterations).toBe(1) + }) + + it('gives parallel reusable workers isolated bridge sessions', async () => { + const seen: Array> = [] + bridgeHttpHandler = (payload) => { + seen.push(payload) + return sse('ok', 1, 2) + } + const make = workerFromBackend({ + backend: 'bridge', + bridgeUrl: 'http://bridge.test', + bridgeBearer: 'secret', + model: 'safe-model', + }) + const workers = ['a', 'b'].map( + (name, index) => + make( + { name }, + { + assignmentId: `ordinal:${index}`, + budget: { maxIterations: 1, maxTokens: 100 }, + task: 'go', + label: name, + }, + ) as Agent & { + executorSpec: AgentSpec + }, + ) + const executors = workers.map((worker) => + worker.executorSpec.executorFactory?.(worker.executorSpec, { + signal: new AbortController().signal, + seams: {}, + }), + ) + + await Promise.all( + executors.map(async (executor) => { + if (!executor) throw new Error('worker executor factory missing') + const run = executor.execute('go', new AbortController().signal) + if (!isUsageStream(run)) throw new Error('bridge worker must stream usage') + for await (const _event of run) { + // drain the bridge stream + } + }), + ) + + expect(seen).toHaveLength(2) + const sessions = seen.map((request) => request.session_id) + expect(sessions.every((session) => typeof session === 'string')).toBe(true) + expect(new Set(sessions).size).toBe(2) + }) + + it('reconstructs the same bridge session for the same durable worker assignment', async () => { + const seen: Array> = [] + bridgeHttpHandler = (payload) => { + seen.push(payload) + return sse('ok', 1, 2) + } + const backend = { + backend: 'bridge' as const, + bridgeUrl: 'http://bridge.test', + bridgeBearer: 'secret', + model: 'safe-model', + } + const context = { + assignmentId: 'key:stable-experiment', + budget: { maxIterations: 1, maxTokens: 100 }, + task: 'go', + label: 'stable-experiment', + key: 'stable-experiment', + } + const workers = [workerFromBackend(backend), workerFromBackend(backend)].map( + (make) => + make({ name: 'worker' }, context) as Agent & { + executorSpec: AgentSpec + }, + ) + + for (const worker of workers) { + const executor = worker.executorSpec.executorFactory?.(worker.executorSpec, { + signal: new AbortController().signal, + seams: {}, + }) + if (!executor) throw new Error('worker executor factory missing') + const run = executor.execute('go', new AbortController().signal) + if (!isUsageStream(run)) throw new Error('bridge worker must stream usage') + for await (const _event of run) { + // drain the bridge stream + } + } + + expect(seen).toHaveLength(2) + expect(seen[0]?.session_id).toMatch(/^supervised-worker-[a-f0-9]{64}$/) + expect(seen[1]?.session_id).toBe(seen[0]?.session_id) + }) + it('throws on a non-2xx bridge response', async () => { bridgeHttpHandler = () => { const s = new PassThrough() as PassThrough & { statusCode?: number } @@ -157,3 +321,11 @@ describe('bridgeExecutor over node:http', () => { await expect(runOnce(bridgeClient('kimi-code/k2'), 'go')).rejects.toThrow(/bridge 500/) }) }) + +function isUsageStream(value: unknown): value is AsyncIterable { + return ( + value !== null && + typeof value === 'object' && + typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function' + ) +} diff --git a/tests/runtime/executor-config-snapshot.test.ts b/tests/runtime/executor-config-snapshot.test.ts new file mode 100644 index 00000000..16b32266 --- /dev/null +++ b/tests/runtime/executor-config-snapshot.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from 'vitest' +import type { + AgentEnvironmentProvider, + AgentEnvironmentProviderRegistry, +} from '../../src/runtime/environment-provider' +import { + bindReusableExecutorExecutionId, + captureReusableExecutorConfig, + createExecutor, + type ExecutorConfig, +} from '../../src/runtime/supervise/runtime' +import type { AgentSpec, ExecutorContext, Runtime } from '../../src/runtime/supervise/types' +import type { ExecCtx, SandboxClient } from '../../src/runtime/types' + +const spec: AgentSpec = { profile: { name: 'snapshot-worker' }, harness: null } +const context: ExecutorContext = { signal: new AbortController().signal, seams: {} } + +describe('createExecutor config intake', () => { + it('captures every backend variant while retaining only explicit live ports', () => { + const executeToolCall = async () => 'tool result' + const onToolStep = () => {} + const runGit = () => ({ stdout: '', stderr: '', exitCode: 0 }) + const runCommand = async () => ({ exitCode: 0, output: '' }) + const provider = { + name: 'live-provider', + capabilities: async () => { + throw new Error('not executed') + }, + create: async () => { + throw new Error('not executed') + }, + } as AgentEnvironmentProvider + const taskToTurn = () => ({ prompt: 'live mapper' }) + const sandboxClient = { + create: async () => { + throw new Error('not executed') + }, + } as SandboxClient + const hooks = { onEvent: () => {} } + const traceEmitter = { emit: () => {} } + const onSandboxEvent = () => {} + const runHandle = { observe: () => {} } as unknown as NonNullable + + const cases: Array<{ name: string; config: ExecutorConfig; runtime: Runtime }> = [ + { + name: 'router', + config: { + backend: 'router', + routerBaseUrl: 'http://router.test', + routerKey: 'key', + model: 'model', + }, + runtime: 'router', + }, + { + name: 'router-tools', + config: { + backend: 'router-tools', + routerBaseUrl: 'http://router.test', + routerKey: 'key', + model: 'model', + tools: [], + executeToolCall, + onToolStep, + }, + runtime: 'router', + }, + { + name: 'bridge', + config: { + backend: 'bridge', + bridgeUrl: 'http://bridge.test', + bridgeBearer: 'secret', + model: 'model', + }, + runtime: 'cli', + }, + { + name: 'cli', + config: { backend: 'cli', bin: '/bin/true', args: ['--version'] }, + runtime: 'cli', + }, + { + name: 'cli-worktree', + config: { + backend: 'cli-worktree', + repoRoot: '/repo', + harness: 'codex', + runGit, + runCommand, + }, + runtime: 'cli', + }, + { + name: 'provider', + config: { + backend: 'provider', + provider, + runtime: 'provider-runtime', + taskToTurn, + defaults: { workspace: { cwd: '/repo' } }, + }, + runtime: 'provider-runtime', + }, + { + name: 'pi', + config: { backend: 'pi', bin: 'pi', args: ['--test'], model: 'provider/model' }, + runtime: 'pi', + }, + { + name: 'sandbox', + config: { + backend: 'sandbox', + harness: 'codex', + sandboxClient, + maxIterations: 1, + lineage: { sessionContinuity: true }, + steering: { maxTurns: 2 }, + loopCtx: { + hooks, + traceEmitter, + onSandboxEvent, + runHandle, + traceId: 'trace-id', + parentSpanId: 'parent-id', + }, + }, + runtime: 'sandbox', + }, + ] + + for (const testCase of cases) { + const originalBackend = testCase.config.backend + const factory = createExecutor(testCase.config) + const mutableConfig = testCase.config as { backend: string } + mutableConfig.backend = originalBackend === 'router' ? 'cli' : 'router' + + expect(factory(spec, context).runtime, testCase.name).toBe(testCase.runtime) + } + }) + + it('resolves a named provider once instead of retaining a mutable registry lookup', () => { + const providerA = { + name: 'provider-a', + capabilities: async () => { + throw new Error('not executed') + }, + create: async () => { + throw new Error('not executed') + }, + } as AgentEnvironmentProvider + const providerB = { ...providerA, name: 'provider-b' } as AgentEnvironmentProvider + let current = providerA + const registry = { + require: () => current, + } as AgentEnvironmentProviderRegistry + const factory = createExecutor({ + backend: 'provider', + provider: 'selected-provider', + registry, + }) + current = providerB + + expect(factory(spec, context).runtime).toBe('provider-a') + }) + + it('rejects reusable profile overlays and fixed execution ids', () => { + const invalid: ExecutorConfig[] = [ + { + backend: 'bridge', + bridgeUrl: 'http://bridge.test', + bridgeBearer: 'secret', + model: 'model', + agentProfile: { name: 'late-overlay' }, + }, + { + backend: 'bridge', + bridgeUrl: 'http://bridge.test', + bridgeBearer: 'secret', + model: 'model', + sessionId: 'SHARED', + }, + { + backend: 'cli-worktree', + repoRoot: '/repo', + harness: 'codex', + runId: 'SHARED', + }, + { + backend: 'cli-worktree', + repoRoot: '/repo', + bridge: { + bridgeUrl: 'http://bridge.test', + bridgeBearer: 'secret', + model: 'model', + sessionId: 'SHARED', + }, + }, + ] + + for (const config of invalid) { + expect(() => captureReusableExecutorConfig(config, 'reusable-test')).toThrow( + /not allowed|isolated id/, + ) + } + }) + + it('keeps fixed ids available to explicitly single-execution factories', () => { + const direct: ExecutorConfig[] = [ + { + backend: 'bridge', + bridgeUrl: 'http://bridge.test', + bridgeBearer: 'secret', + model: 'model', + sessionId: 'PINNED-ONCE', + }, + { + backend: 'cli-worktree', + repoRoot: '/repo', + harness: 'codex', + runId: 'PINNED-ONCE', + }, + ] + + for (const config of direct) { + const executor = createExecutor(config)(spec, context) + expect(executor.runtime).toBe('cli') + } + }) + + it('binds worktree and bridge backends to the supplied durable execution identity', () => { + const bridge = captureReusableExecutorConfig( + { + backend: 'bridge', + bridgeUrl: 'http://bridge.test', + bridgeBearer: 'secret', + model: 'model', + }, + 'bridge-test', + ) + const worktree = captureReusableExecutorConfig( + { + backend: 'cli-worktree', + repoRoot: '/repo', + bridge: { + bridgeUrl: 'http://bridge.test', + bridgeBearer: 'secret', + model: 'model', + }, + }, + 'worktree-test', + ) + + expect(bindReusableExecutorExecutionId(bridge, 'execution-a')).toMatchObject({ + backend: 'bridge', + sessionId: 'execution-a', + }) + expect(bindReusableExecutorExecutionId(worktree, 'execution-a')).toMatchObject({ + backend: 'cli-worktree', + runId: 'execution-a', + }) + expect(bindReusableExecutorExecutionId(worktree, 'execution-b')).toMatchObject({ + runId: 'execution-b', + }) + }) +}) diff --git a/tests/runtime/executor-profile-model.test.ts b/tests/runtime/executor-profile-model.test.ts new file mode 100644 index 00000000..a634b9ff --- /dev/null +++ b/tests/runtime/executor-profile-model.test.ts @@ -0,0 +1,83 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import type { AgentProfile } from '@tangle-network/agent-interface' +import { afterEach, describe, expect, it } from 'vitest' +import { type AgentSpec, createExecutor } from '../../src/runtime' + +const profile: AgentProfile = { + name: 'profile-model-worker', + model: { default: 'profile-selected-model' }, +} + +const spec: AgentSpec = { profile, harness: null } + +let server: Server | undefined + +async function startRouter(onRequest: (body: Record) => void): Promise { + server = createServer(async (request, response) => { + const chunks: Buffer[] = [] + for await (const chunk of request) chunks.push(Buffer.from(chunk)) + onRequest(JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record) + response.writeHead(200, { 'content-type': 'application/json' }) + response.end( + JSON.stringify({ + choices: [{ message: { content: 'done', tool_calls: [] } }], + usage: { prompt_tokens: 3, completion_tokens: 2 }, + }), + ) + }) + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + return `http://127.0.0.1:${port}` +} + +describe('router executor model precedence', () => { + afterEach(async () => { + if (server) await new Promise((resolve) => server?.close(() => resolve())) + server = undefined + }) + + it('uses AgentProfile.model.default instead of the router fallback', async () => { + let request: Record | undefined + const routerBaseUrl = await startRouter((body) => { + request = body + }) + const factory = createExecutor({ + backend: 'router', + routerBaseUrl, + routerKey: 'key', + model: 'backend-fallback-model', + }) + const executor = factory(spec, { + signal: new AbortController().signal, + seams: {}, + }) + + await executor.execute('do the task', new AbortController().signal) + + expect(request?.model).toBe('profile-selected-model') + }) + + it('uses AgentProfile.model.default instead of the router-tools fallback', async () => { + let request: Record | undefined + const routerBaseUrl = await startRouter((body) => { + request = body + }) + const factory = createExecutor({ + backend: 'router-tools', + routerBaseUrl, + routerKey: 'key', + model: 'backend-fallback-model', + tools: [], + executeToolCall: async () => '', + }) + const executor = factory(spec, { + signal: new AbortController().signal, + seams: {}, + }) + + await executor.execute('do the task', new AbortController().signal) + + expect(request?.model).toBe('profile-selected-model') + }) +}) diff --git a/tests/runtime/mid-flight-steering.test.ts b/tests/runtime/mid-flight-steering.test.ts index 4944e30f..21812a03 100644 --- a/tests/runtime/mid-flight-steering.test.ts +++ b/tests/runtime/mid-flight-steering.test.ts @@ -21,6 +21,9 @@ * post-steer actions never change. */ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { CreateSandboxOptions, SandboxEvent, SandboxInstance } from '@tangle-network/sandbox' import { describe, expect, it } from 'vitest' import type { ExecutorConfig } from '../../src/runtime/supervise/runtime' @@ -32,6 +35,7 @@ import type { SandboxClient } from '../../src/runtime/types' const WRONG = 'legacy/wrong.ts' const RIGHT = 'core/right.ts' const STEER = `stop editing ${WRONG} — the change belongs in ${RIGHT}` +const ANSWER = `continue in ${RIGHT}` const budget: Budget = { maxIterations: 200, maxTokens: 400_000 } @@ -180,6 +184,54 @@ function steeringBrain(harness: FakeHarness, record: BrainRecord): ToolLoopChat } } +function missingMessageAuthorityBrain( + harness: FakeHarness, + record: { steer?: Record; answer?: Record }, +): ToolLoopChat { + let turn = 0 + let workerId = 'w' + let questionId = 'q' + return async (messages) => { + const lastTool = [...messages] + .reverse() + .find((message) => (message as { role?: string }).role === 'tool') as + | { content?: string } + | undefined + const parsed = lastTool?.content ? safeJson(lastTool.content) : undefined + turn += 1 + + if (turn === 1) { + return call('spawn_agent', { profile: { name: 'coder' }, task: 'make the change' }) + } + if (turn === 2) { + workerId = String(parsed?.workerId ?? workerId) + await harness.workingOnWrongFile + return call('steer_agent', { workerId, instruction: STEER }) + } + if (turn === 3) { + record.steer = parsed + return call('ask_parent', { + from: workerId, + level: 'worker', + question: 'Which file should I edit?', + reason: 'Two plausible targets', + urgency: 'blocks-step', + }) + } + if (turn === 4) { + const question = parsed?.question as Record | undefined + questionId = String(question?.id ?? questionId) + return call('answer_question', { questionId, answer: ANSWER }) + } + if (turn === 5) { + record.answer = parsed + harness.releaseFirstTurn() + return call('await_event', {}) + } + return { toolCalls: [], content: 'done' } + } +} + function call(name: string, args: Record) { return { toolCalls: [{ id: `${name}-1`, name, arguments: JSON.stringify(args) }], @@ -206,23 +258,94 @@ function backend(harness: FakeHarness, steerable: boolean): ExecutorConfig { } as ExecutorConfig } -async function runSupervisedSteer(steerable: boolean) { +interface AuthorityRecord { + spawnCalls: number + messages: Array<{ instruction: string; frozen: boolean; hasIdentity: boolean }> +} + +async function runSupervisedSteer(steerable: boolean, authority?: AuthorityRecord) { const harness = createFakeHarness() const record: BrainRecord = {} const result = await supervise( - { name: 'root', harness: null, systemPrompt: 'drive one coder and correct it' }, + { + name: 'root', + harness: 'cli-base', + prompt: { systemPrompt: 'drive one coder and correct it' }, + }, 'change the right module', { budget, backend: backend(harness, steerable), brain: steeringBrain(harness, record), maxTurns: 8, + ...(authority + ? { + authorizeSpawn(input) { + authority.spawnCalls += 1 + return { profile: input.profile } + }, + authorizeMessage(input) { + authority.messages.push({ + instruction: input.instruction, + frozen: Object.isFrozen(input) && Object.isFrozen(input.workerIdentity), + hasIdentity: + input.workerIdentity.profileDigest !== undefined && + input.workerIdentity.taskDigest !== undefined, + }) + return { instruction: input.instruction } + }, + } + : {}), }, ) return { harness, record, result } } describe('mid-flight steering — a supervisor observes a live worker and changes what it does', () => { + it('refuses steer and answer when spawn authority has no message authority', async () => { + const runDir = await mkdtemp(join(tmpdir(), 'supervise-message-authority-')) + const harness = createFakeHarness() + const record: { steer?: Record; answer?: Record } = {} + try { + await supervise({ name: 'root', harness: 'cli-base' }, 'change the right module', { + budget, + backend: backend(harness, true), + brain: missingMessageAuthorityBrain(harness, record), + runDir, + runId: 'message-authority-refusal', + authorizeSpawn: (input) => ({ profile: input.profile }), + }) + + expect(JSON.stringify(record.steer)).toContain('authorizeMessage is required') + expect(JSON.stringify(record.answer)).toContain('authorizeMessage is required') + expect( + harness.prompts.some((prompt) => prompt.includes(STEER) || prompt.includes(ANSWER)), + ).toBe(false) + const coordinationLog = await readFile(join(runDir, 'coordination-log.jsonl'), 'utf8') + const eventTypes = coordinationLog + .trim() + .split('\n') + .filter(Boolean) + .map((line) => (JSON.parse(line) as { event: { type: string } }).event.type) + expect(eventTypes).not.toContain('instruction') + } finally { + harness.releaseFirstTurn() + await rm(runDir, { recursive: true, force: true }) + } + }) + + it('authorizes the continuation against the exact live worker identity', { + timeout: 30_000, + }, async () => { + const authority: AuthorityRecord = { spawnCalls: 0, messages: [] } + const { record } = await runSupervisedSteer(true, authority) + expect(record.steerResult?.delivered).toBe(true) + expect(authority).toEqual({ + spawnCalls: 1, + messages: [{ instruction: STEER, frozen: true, hasIdentity: true }], + }) + }) + it('delivers a steer to a RUNNING sandbox worker and the worker acts differently afterwards', { timeout: 30_000, }, async () => { diff --git a/tests/runtime/pi-executor.test.ts b/tests/runtime/pi-executor.test.ts index e0789aae..a65dd7c7 100644 --- a/tests/runtime/pi-executor.test.ts +++ b/tests/runtime/pi-executor.test.ts @@ -40,6 +40,7 @@ let commandLog: string const FAKE_PI = `#!/usr/bin/env node const fs = require('node:fs') const log = process.env.PI_COMMAND_LOG +fs.appendFileSync(log, JSON.stringify({ type: 'argv', args: process.argv.slice(2) }) + '\\n') const emit = (o) => process.stdout.write(JSON.stringify(o) + '\\n') const result = (text) => ({ content: [{ type: 'text', text }], details: {} }) const message = (m) => { @@ -289,7 +290,38 @@ async function waitForCommand( } describe('piExecutor — pi wrapped, not forked', () => { - it('runs a turn, reports REAL usage off pi events, and exposes live progress + tool spans', async () => { + it('uses AgentProfile.model.default instead of the backend fallback', async () => { + await writeFile(commandLog, '') + const ctx = piCtx() + const withFallback: ExecutorContext = { + ...ctx, + seams: { + ...ctx.seams, + [piSeamKey]: { + ...(ctx.seams[piSeamKey] as Record), + model: 'fallback/ignored-model', + }, + }, + } + const ex = piExecutor( + { + profile: { + name: 'profile-model', + model: { default: 'profile/selected-model' }, + }, + harness: null, + }, + withFallback, + ) + + await drain(ex.execute('make the change', withFallback.signal) as AsyncIterable) + const argv = (await readCommands()).find((command) => command.type === 'argv')?.args + + expect(argv).toEqual(['--mode', 'rpc', '--provider', 'profile', '--model', 'selected-model']) + await ex.teardown('brutalKill') + }) + + it('counts duplicated message_end + turn_end telemetry once from authoritative turn_end', async () => { await writeFile(commandLog, '') const ctx = piCtx() const ex = piExecutor(spec, ctx) @@ -325,6 +357,8 @@ describe('piExecutor — pi wrapped, not forked', () => { expect(String((artifact.out as { content: string }).content)).toContain('edited wrong.ts') expect(artifact.spent.tokens).toEqual({ input: 51, output: 16 }) expect(artifact.spent.usd).toBe(0.003) + expect(artifact.spent).not.toHaveProperty('tokensKnown') + expect(artifact.spent).not.toHaveProperty('usdKnown') await ex.teardown('brutalKill') }) diff --git a/tests/runtime/resume-aware-driver.test.ts b/tests/runtime/resume-aware-driver.test.ts index ac345119..dc67c6ef 100644 --- a/tests/runtime/resume-aware-driver.test.ts +++ b/tests/runtime/resume-aware-driver.test.ts @@ -135,12 +135,13 @@ describe('resume-aware built-in driver — a killed coordinator resumes without expect(resumed.kind).toBe('winner') expect(resumed.settledNodes).toEqual(['w1', 'w2', 'w3', 'w4', 'w5']) - // ── The real bar: same output, same worker spend as never having crashed ────────────── + // The output matches control. Spend does not pretend the two killed executions were free: + // their missing receipts are charged at both declared ceilings and marked unknown. expect(resumed.out).toBe(controlReport.out) - expect(resumed.spentBreakdown?.childWork).toEqual(controlReport.spentBreakdown?.childWork) - // Stated absolutely, not just relatively: five workers' worth of paid work, once each. - expect(resumed.spentBreakdown?.childWork.iterations).toBe(5) - expect(resumed.spentBreakdown?.childWork.tokens).toEqual({ input: 50, output: 50 }) + expect(resumed.spentBreakdown?.childWork.iterations).toBe(15) + expect(resumed.spentBreakdown?.childWork.tokens).toEqual({ input: 20_050, output: 50 }) + expect(resumed.spentBreakdown?.childWork.tokensKnown).toBe(false) + expect(resumed.spentBreakdown?.childWork.usdKnown).toBe(false) expect(resumed.spentBreakdown?.childWork.usd).toBeCloseTo(0.05, 10) // The ONLY channel that differs is the coordinator's own inference: a restarted coordinator diff --git a/tests/runtime/spawn-journal-replay-identity.test.ts b/tests/runtime/spawn-journal-replay-identity.test.ts new file mode 100644 index 00000000..ed48f891 --- /dev/null +++ b/tests/runtime/spawn-journal-replay-identity.test.ts @@ -0,0 +1,299 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { canonicalCandidateDigest } from '@tangle-network/agent-interface' +import { describe, expect, it } from 'vitest' +import { + contentAddress, + FileResultBlobStore, + FileSpawnJournal, + InMemoryResultBlobStore, + InMemorySpawnJournal, + materializeTreeView, + replaySpawnTree, +} from '../../src/durable/spawn-journal' +import { createBudgetPool } from '../../src/runtime/supervise/budget' +import { createExecutorRegistry } from '../../src/runtime/supervise/runtime' +import { createScope } from '../../src/runtime/supervise/scope' +import type { + Agent, + AgentSpec, + Executor, + NodeExecutionIdentity, + SpawnEvent, +} from '../../src/runtime/supervise/types' + +const spent = { + iterations: 1, + tokens: { input: 2, output: 3 }, + usd: 0.01, + ms: 4, +} + +describe('spawn journal replay identity', () => { + it('keeps a profile materialization receipt informational when rebuilding node status', () => { + const profileDigest = canonicalCandidateDigest({ profile: 'scientist' }) + const view = materializeTreeView([ + { + kind: 'spawned', + id: 'receipt-only', + label: 'root', + budget: { maxIterations: 1, maxTokens: 100 }, + runtime: 'cli', + seq: 0, + at: new Date(0).toISOString(), + }, + { + kind: 'materialized', + id: 'receipt-only', + receipt: { + status: 'known', + authoredProfileDigest: profileDigest, + effectiveProfileDigest: profileDigest, + materializationPlanDigest: canonicalCandidateDigest({ kind: 'test-plan' }), + runtime: 'cli', + backend: 'bridge', + model: { status: 'known', id: 'test/model' }, + execution: { kind: 'session', id: 'test-session' }, + materializer: 'test', + }, + seq: 0, + at: new Date(0).toISOString(), + }, + { + kind: 'execution-bound', + id: 'receipt-only', + binding: { + status: 'known', + attemptId: 'attempt-1', + materializationReceiptDigest: canonicalCandidateDigest({ receipt: 'test' }), + bindingDigest: canonicalCandidateDigest({ endpoint: 'hidden' }), + descriptor: { kind: 'bridge-session', transport: 'http' }, + }, + seq: 0, + at: new Date(0).toISOString(), + }, + ]) + + expect(view.nodes).toEqual([expect.objectContaining({ id: 'receipt-only', status: 'pending' })]) + }) + + it('copies every spawned identity field onto done, down, and cancelled terminal handles', async () => { + const journal = new InMemorySpawnJournal() + const blobs = new InMemoryResultBlobStore() + const root = 'identity-replay' + const at = new Date(0).toISOString() + const correlation = { pursuitId: 'pursuit-1', experimentId: 'experiment-1' } + const identity: NodeExecutionIdentity = { + profileDigest: canonicalCandidateDigest({ profile: 'scientist' }), + taskDigest: canonicalCandidateDigest({ task: 'test the mechanism' }), + candidateDigest: canonicalCandidateDigest({ candidate: 'candidate-1' }), + correlation, + } + const expectedIdentity = { + profileDigest: identity.profileDigest, + taskDigest: identity.taskDigest, + candidateDigest: identity.candidateDigest, + correlation: { ...correlation }, + } + + await journal.beginTree(root, at) + const spawns: SpawnEvent[] = [ + { + kind: 'spawned', + id: `${root}:done`, + parent: root, + label: 'done', + key: 'done-key', + budget: { maxIterations: 1, maxTokens: 100 }, + runtime: 'router', + identity, + seq: 0, + at, + }, + { + kind: 'spawned', + id: `${root}:down`, + parent: root, + label: 'down', + key: 'down-key', + budget: { maxIterations: 1, maxTokens: 100 }, + runtime: 'router', + identity, + seq: 1, + at, + }, + { + kind: 'spawned', + id: `${root}:cancelled`, + parent: root, + label: 'cancelled', + key: 'cancelled-key', + budget: { maxIterations: 1, maxTokens: 100 }, + runtime: 'router', + identity, + seq: 2, + at, + }, + ] + for (const event of spawns) await journal.appendEvent(root, event) + + const out = { answer: 42 } + const outRef = contentAddress(out) + await blobs.put(outRef, out) + await journal.appendEvent(root, { + kind: 'settled', + id: `${root}:done`, + status: 'done', + outRef, + spent, + seq: 3, + at, + }) + await journal.appendEvent(root, { + kind: 'settled', + id: `${root}:down`, + status: 'down', + spent, + seq: 4, + at, + }) + await journal.appendEvent(root, { + kind: 'cancelled', + id: `${root}:cancelled`, + reason: 'stopped', + seq: 5, + at, + }) + + const replayed = await replaySpawnTree(journal, blobs, root) + expect(replayed).toHaveLength(3) + for (const terminal of replayed) { + expect(terminal.handle.identity).toEqual(expectedIdentity) + expect(terminal.handle.identity).not.toBe(identity) + expect(terminal.handle.identity?.correlation).not.toBe(correlation) + expect(Object.isFrozen(terminal.handle)).toBe(true) + expect(Object.isFrozen(terminal.handle.identity)).toBe(true) + expect(Object.isFrozen(terminal.handle.identity?.correlation)).toBe(true) + } + + correlation.experimentId = 'mutated-after-replay' + expect(replayed.map((terminal) => terminal.handle.identity)).toEqual([ + expectedIdentity, + expectedIdentity, + expectedIdentity, + ]) + }) + + it('preserves an exact child failure across a durable restart and still reads legacy records', async () => { + const dir = await mkdtemp(join(tmpdir(), 'spawn-down-replay-')) + try { + const root = 'down-restart' + const exactReason = 'provider lost session at turn 7' + const journalPath = join(dir, 'spawn-journal.jsonl') + const blobPath = join(dir, 'blobs') + const journal = new FileSpawnJournal(journalPath) + const blobs = new FileResultBlobStore(blobPath) + await journal.beginTree(root, new Date(0).toISOString()) + + const executor: Executor = { + runtime: 'router', + async execute() { + throw new Error(exactReason) + }, + async teardown() { + return { destroyed: true } + }, + resultArtifact() { + return { + outRef: 'unreachable', + out: undefined, + spent: { + iterations: 0, + tokens: { input: 0, output: 0 }, + usd: 0, + ms: 0, + }, + } + }, + } + const spec: AgentSpec = { + profile: { name: 'failing worker' }, + harness: null, + executor, + } + const agent = { + name: 'failing worker', + act: async () => undefined, + executorSpec: spec, + } as Agent & { executorSpec: AgentSpec } + const scope = createScope({ + parentId: root, + root, + pool: createBudgetPool({ maxIterations: 1, maxTokens: 10 }, () => 0), + journal, + blobs, + executors: createExecutorRegistry(), + seams: {}, + depth: 0, + maxDepth: 4, + signal: new AbortController().signal, + now: () => 0, + }) + + const spawned = scope.spawn(agent, 'fail exactly once', { + budget: { maxIterations: 1, maxTokens: 10 }, + label: 'failure', + }) + expect(spawned.ok).toBe(true) + const live = await scope.next() + expect(live).toMatchObject({ kind: 'down', reason: exactReason }) + + // A new store instance has no in-memory state from the writer: this is the restart boundary. + const restartedJournal = new FileSpawnJournal(journalPath) + const durableEvents = await restartedJournal.loadTree(root) + expect( + durableEvents?.find((event) => event.kind === 'settled' && event.status === 'down'), + ).toMatchObject({ reason: exactReason }) + const replayed = await replaySpawnTree( + restartedJournal, + new FileResultBlobStore(blobPath), + root, + ) + expect(replayed).toEqual([expect.objectContaining({ kind: 'down', reason: exactReason })]) + + // Before the reason field existed, some down records carried only verdict.notes and some + // carried neither. Both remain readable; no migration is required to resume an old run. + const legacy = new InMemorySpawnJournal() + await legacy.beginTree('legacy-down', new Date(0).toISOString()) + await legacy.appendEvent('legacy-down', { + kind: 'spawned', + id: 'legacy-down:s0', + parent: 'legacy-down', + label: 'legacy', + budget: { maxIterations: 1, maxTokens: 10 }, + runtime: 'router', + seq: 0, + at: new Date(0).toISOString(), + }) + await legacy.appendEvent('legacy-down', { + kind: 'settled', + id: 'legacy-down:s0', + status: 'down', + spent, + seq: 0, + at: new Date(0).toISOString(), + }) + const legacyReplay = await replaySpawnTree( + legacy, + new InMemoryResultBlobStore(), + 'legacy-down', + ) + expect(legacyReplay).toEqual([ + expect.objectContaining({ kind: 'down', reason: 'child down' }), + ]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/runtime/spawn-keys.test.ts b/tests/runtime/spawn-keys.test.ts index 0b35b37b..9707fcf2 100644 --- a/tests/runtime/spawn-keys.test.ts +++ b/tests/runtime/spawn-keys.test.ts @@ -136,7 +136,34 @@ describe('semantic spawn keys', () => { }) expect(result.kind).toBe('winner') expect(seen.reason).toBe('duplicate-key') - expect(seen.runsAtRefusal).toBe(1) + // The first execution waits for its identity append, so the duplicate can be refused before + // either worker body starts. The admitted original still runs exactly once afterward. + expect(seen.runsAtRefusal).toBe(0) + expect(runs.n).toBe(1) + }) + + it('refuses a completed key when the requested profile or task changes', async () => { + const runs = { n: 0 } + const seen: Record = {} + await runRoot(async (_task, scope) => { + const first = scope.spawn(countingLeaf('original', 'A', runs), 'task A', { + budget: childBudget, + label: 'assignment', + key: 'same-key', + }) + expect(first.ok).toBe(true) + await scope.next() + const freeBefore = scope.budget.tokensLeft + const changed = scope.spawn(countingLeaf('changed', 'B', runs), 'task B', { + budget: childBudget, + label: 'assignment', + key: 'same-key', + }) + seen.reason = changed.ok ? 'accepted' : changed.reason + seen.freeUnchanged = scope.budget.tokensLeft === freeBefore + return 'done' + }) + expect(seen).toEqual({ reason: 'key-conflict', freeUnchanged: true }) expect(runs.n).toBe(1) }) diff --git a/tests/runtime/stop-rules.test.ts b/tests/runtime/stop-rules.test.ts index 0263fe50..2cc61037 100644 --- a/tests/runtime/stop-rules.test.ts +++ b/tests/runtime/stop-rules.test.ts @@ -353,7 +353,10 @@ describe('driverAgent stopRule — evaluated after the hard ceilings, never inst const chat = scriptedBrain([ { toolCalls: [ - { name: 'spawn_agent', arguments: { profile: { kind: 'worker' }, task: 'go' } }, + { + name: 'spawn_agent', + arguments: { profile: { metadata: { kind: 'worker' } }, task: 'go' }, + }, { name: 'await_event', arguments: {} }, ], }, @@ -424,7 +427,10 @@ describe('driverAgent stopRule — evaluated after the hard ceilings, never inst brain: scriptedBrain([ { toolCalls: [ - { name: 'spawn_agent', arguments: { profile: { kind: 'worker' }, task: 'go' } }, + { + name: 'spawn_agent', + arguments: { profile: { metadata: { kind: 'worker' } }, task: 'go' }, + }, { name: 'await_event', arguments: {} }, ], }, diff --git a/tests/runtime/supervisor-finalizer.test.ts b/tests/runtime/supervisor-finalizer.test.ts index 4f4506e1..8fb42728 100644 --- a/tests/runtime/supervisor-finalizer.test.ts +++ b/tests/runtime/supervisor-finalizer.test.ts @@ -253,7 +253,7 @@ const makeWorker = (profile: unknown) => { describe('SupervisorFinalizer — end to end through supervise()', () => { it('the default keeps the delivered answer over a higher-scoring unchecked one', async () => { - const result = await supervise({ name: 'root', harness: null }, 'task', { + const result = await supervise({ name: 'root', harness: 'cli-base' }, 'task', { budget, perWorker: { maxIterations: 5, maxTokens: 10_000 }, makeWorkerAgent: makeWorker, @@ -264,7 +264,7 @@ describe('SupervisorFinalizer — end to end through supervise()', () => { }) it('an opted-in collectDelivered changes the SHAPE without ever widening eligibility', async () => { - const result = await supervise({ name: 'root', harness: null }, 'task', { + const result = await supervise({ name: 'root', harness: 'cli-base' }, 'task', { budget, perWorker: { maxIterations: 5, maxTokens: 10_000 }, makeWorkerAgent: makeWorker, @@ -278,7 +278,7 @@ describe('SupervisorFinalizer — end to end through supervise()', () => { }) it('a run whose only high scorer is unchecked is a no-winner, not a rescued output', async () => { - const result = await supervise({ name: 'root', harness: null }, 'task', { + const result = await supervise({ name: 'root', harness: 'cli-base' }, 'task', { budget, perWorker: { maxIterations: 5, maxTokens: 10_000 }, makeWorkerAgent: () => leaf('unchecked', 'UNCHECKED-PROSE', 0.99, false), diff --git a/tests/runtime/supervisor-resume.test.ts b/tests/runtime/supervisor-resume.test.ts index 56711284..fc51197c 100644 --- a/tests/runtime/supervisor-resume.test.ts +++ b/tests/runtime/supervisor-resume.test.ts @@ -158,12 +158,12 @@ describe('supervisor durable resume across a real process kill', () => { expect(finalLabels[2]).toBe('c') }) - it('without `resume`, the same durable stores start a FRESH tree (opt-in, not a default)', { + it('refuses a reused durable runId without `resume` before executing or mutating its tree', { timeout: 120_000, }, async () => { - // The default-safety claim: durability is a store choice, resume is a separate opt-in. A - // supervisor run that does not pass `resume` re-runs everything even against a journal that - // already holds committed work. + // A durable run id is one execution identity. Starting fresh under an existing id would mix + // two executions into one journal, so continuing requires an explicit resume and starting over + // requires a new id. const { createSupervisor } = await import('../../src/runtime/supervise/supervisor') const { InMemoryResultBlobStore, InMemorySpawnJournal } = await import( '../../src/durable/spawn-journal' @@ -191,10 +191,12 @@ describe('supervisor durable resume across a real process kill', () => { } const a = await createSupervisor().run(root, 't', opts) expect(a.kind).toBe('winner') - // A second run on the SAME journal + runId still takes the fresh path (`beginTree` is - // idempotent for an identical `at`), and `Scope.resume` was never populated. - const b = await createSupervisor().run(root, 't', opts) - expect(b.kind).toBe('winner') - expect(acts).toBe(2) + const committed = await journal.loadTree(opts.runId) + + await expect(createSupervisor().run(root, 't', opts)).rejects.toThrow( + /runId 'no-resume' already exists.*resume: true.*new runId/, + ) + expect(acts).toBe(1) + expect(await journal.loadTree(opts.runId)).toEqual(committed) }) }) diff --git a/tests/runtime/wait-states.test.ts b/tests/runtime/wait-states.test.ts index a0b97e2e..cf371a8a 100644 --- a/tests/runtime/wait-states.test.ts +++ b/tests/runtime/wait-states.test.ts @@ -18,7 +18,13 @@ import { } from '../../src/durable/spawn-journal' import { createExecutorRegistry } from '../../src/runtime/supervise/runtime' import { createSupervisor } from '../../src/runtime/supervise/supervisor' -import type { Agent, Scope, Settled, SpawnEvent } from '../../src/runtime/supervise/types' +import type { + Agent, + Scope, + Settled, + SpawnEvent, + SpawnJournal, +} from '../../src/runtime/supervise/types' import { createWaitProbes, isWaitOutcome, @@ -227,7 +233,7 @@ describe('wait-states', () => { expect(scope.view.nodes).toHaveLength(0) return 'refused' }, - { probes: { known: () => true }, deadlineMs: Date.now() + 1_000 }, + { probes: { known: () => true }, deadlineMs: 1_000 }, ) }) @@ -270,4 +276,101 @@ describe('wait-states', () => { const cursors = events.filter((e) => e.kind === 'woken').map((e) => e.seq) expect(new Set(cursors).size).toBe(cursors.length) }) + + it('commits a fresh wait before its timer can wake', async () => { + const base = new InMemorySpawnJournal() + const appendStarted = deferred() + const releaseWaiting = deferred() + const appendAttempts: SpawnEvent['kind'][] = [] + const journal: SpawnJournal = { + loadTree: (root) => base.loadTree(root), + beginTree: (root, at) => base.beginTree(root, at), + async appendEvent(root, event): Promise { + appendAttempts.push(event.kind) + if (event.kind === 'waiting') { + appendStarted.resolve() + await releaseWaiting.promise + } + await base.appendEvent(root, event) + }, + } + const root: Agent = { + name: 'commit-wait-first', + async act(_task, scope): Promise { + const armed = scope.wait({ kind: 'timer', untilMs: Date.now() }, { label: 'now' }) + expect(armed.ok).toBe(true) + expect((await scope.next())?.kind).toBe('done') + return 'woke' + }, + } + + const running = createSupervisor().run(root, 'task', { + budget: { maxIterations: 1, maxTokens: 1 }, + runId: 'commit-wait-first', + journal, + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + }) + await appendStarted.promise + await new Promise((resolve) => setImmediate(resolve)) + const attemptsBeforeCommit = [...appendAttempts] + releaseWaiting.resolve() + const result = await running + + expect(attemptsBeforeCommit.filter((kind) => kind === 'waiting' || kind === 'woken')).toEqual([ + 'waiting', + ]) + expect(result.kind).toBe('winner') + expect(appendAttempts.filter((kind) => kind === 'waiting' || kind === 'woken')).toEqual([ + 'waiting', + 'woken', + ]) + }) + + it('does not journal a wake when the wait arm itself was never committed', async () => { + const base = new InMemorySpawnJournal() + const appendAttempts: SpawnEvent['kind'][] = [] + const journal: SpawnJournal = { + loadTree: (root) => base.loadTree(root), + beginTree: (root, at) => base.beginTree(root, at), + async appendEvent(root, event): Promise { + appendAttempts.push(event.kind) + if (event.kind === 'waiting') throw new Error('journal unavailable') + await base.appendEvent(root, event) + }, + } + + const result = await createSupervisor().run( + { + name: 'failed-wait-arm', + async act(_task, scope): Promise { + const armed = scope.wait({ kind: 'timer', untilMs: Date.now() }, { label: 'now' }) + expect(armed.ok).toBe(true) + const settled = await scope.next() + expect(settled?.kind).toBe('down') + return settled?.kind === 'down' ? settled.reason : 'unexpected' + }, + }, + 'task', + { + budget: { maxIterations: 1, maxTokens: 1 }, + runId: 'failed-wait-arm', + journal, + blobs: new InMemoryResultBlobStore(), + executors: createExecutorRegistry(), + }, + ) + + expect(result.kind).toBe('winner') + if (result.kind === 'winner') expect(result.out).toContain('journal unavailable') + expect(appendAttempts).not.toContain('woken') + }) }) + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} diff --git a/tests/runtime/worker-trace-evidence.test.ts b/tests/runtime/worker-trace-evidence.test.ts new file mode 100644 index 00000000..a3738da9 --- /dev/null +++ b/tests/runtime/worker-trace-evidence.test.ts @@ -0,0 +1,220 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + FileResultBlobStore, + FileSpawnJournal, + InMemoryResultBlobStore, + InMemorySpawnJournal, + replaySpawnTree, +} from '../../src/durable/spawn-journal' +import { createBudgetPool } from '../../src/runtime/supervise/budget' +import { createExecutorRegistry } from '../../src/runtime/supervise/runtime' +import { createScope } from '../../src/runtime/supervise/scope' +import { workerTraceAnalysisStore } from '../../src/runtime/supervise/trace-evidence' +import { createPushTraceSource } from '../../src/runtime/supervise/trace-source' +import type { + Agent, + AgentSpec, + Executor, + ExecutorResult, + ResultBlobStore, + SpawnJournal, +} from '../../src/runtime/supervise/types' + +const spent = { + iterations: 1, + tokens: { input: 2, output: 3 }, + usd: 0.01, + ms: 4, +} + +function makeAgent( + executor: Executor, +): Agent & { executorSpec: AgentSpec } { + return { + name: 'trace worker', + act: async () => undefined, + executorSpec: { profile: { name: 'trace worker' }, harness: null, executor }, + } +} + +function makeScope(root: string, journal: SpawnJournal, blobs: ResultBlobStore) { + return createScope({ + parentId: root, + root, + pool: createBudgetPool({ maxIterations: 2, maxTokens: 100 }, () => 0), + journal, + blobs, + executors: createExecutorRegistry(), + seams: {}, + depth: 0, + maxDepth: 4, + signal: new AbortController().signal, + now: () => 100, + }) +} + +describe('durable worker trace evidence', () => { + it('persists exact tool spans and the result blob before journaling a live settlement', async () => { + const root = 'live-trace' + const innerJournal = new InMemorySpawnJournal() + const blobs = new InMemoryResultBlobStore() + let checkedTerminalReferences = false + const journal: SpawnJournal = { + loadTree: (id) => innerJournal.loadTree(id), + beginTree: (id, at) => innerJournal.beginTree(id, at), + async appendEvent(id, event) { + if (event.kind === 'settled') { + expect(event.trace?.status).toBe('available') + if (event.trace?.status === 'available') { + expect(await blobs.get(event.trace.traceRef)).toBeDefined() + } + if (event.status === 'done') expect(await blobs.get(event.outRef!)).toBeDefined() + checkedTerminalReferences = true + } + await innerJournal.appendEvent(id, event) + }, + } + await journal.beginTree(root, new Date(100).toISOString()) + + const trace = createPushTraceSource({ runId: 'live-worker', now: () => 101 }) + const output = { final: 'worker prose is a result, not a trace' } + const executor: Executor = { + runtime: 'router-tools', + async execute(): Promise> { + trace.record({ + toolName: 'read_file', + args: { path: 'src/index.ts' }, + result: { bytes: 42 }, + }) + return { outRef: 'executor-local-ref', out: output, spent } + }, + traceSource: () => trace.source, + teardown: async () => ({ destroyed: true }), + resultArtifact: () => ({ outRef: 'executor-local-ref', out: output, spent }), + } + const scope = makeScope(root, journal, blobs) + expect( + scope.spawn(makeAgent(executor), 'inspect', { + budget: { maxIterations: 1, maxTokens: 50 }, + label: 'live trace', + }).ok, + ).toBe(true) + + const settled = await scope.next() + expect(settled).toMatchObject({ + kind: 'done', + out: output, + trace: { status: 'available', spanCount: 1 }, + }) + expect(checkedTerminalReferences).toBe(true) + if (settled?.trace.status !== 'available') throw new Error('expected trace evidence') + const store = await workerTraceAnalysisStore(settled.trace, blobs) + await expect(store.getOverview()).resolves.toMatchObject({ + total_traces: 1, + sample_trace_ids: ['live-worker'], + tool_names: ['read_file'], + }) + }) + + it('marks an empty Codex bridge source unavailable and never treats final prose as evidence', async () => { + const root = 'codex-no-trace' + const journal = new InMemorySpawnJournal() + const blobs = new InMemoryResultBlobStore() + await journal.beginTree(root, new Date(100).toISOString()) + const trace = createPushTraceSource({ runId: 'codex-worker' }) + const output = { final: 'I used many tools, trust me' } + const executor: Executor = { + runtime: 'codex', + execute: async () => ({ outRef: 'executor-local-ref', out: output, spent }), + traceSource: () => trace.source, + teardown: async () => ({ destroyed: true }), + resultArtifact: () => ({ outRef: 'executor-local-ref', out: output, spent }), + } + const scope = makeScope(root, journal, blobs) + expect( + scope.spawn(makeAgent(executor), 'inspect', { + budget: { maxIterations: 1, maxTokens: 50 }, + label: 'codex no trace', + }).ok, + ).toBe(true) + + const settled = await scope.next() + expect(settled).toMatchObject({ + kind: 'done', + out: output, + trace: { status: 'unavailable', reason: 'no-tool-spans-captured' }, + }) + if (!settled) throw new Error('expected settlement') + await expect(workerTraceAnalysisStore(settled.trace, blobs)).rejects.toThrow( + 'trace evidence is missing', + ) + }) + + it('retains partial structured trace evidence across a worker crash and process restart', async () => { + const dir = await mkdtemp(join(tmpdir(), 'worker-trace-restart-')) + try { + const root = 'crash-restart-trace' + const journalPath = join(dir, 'spawn.jsonl') + const blobPath = join(dir, 'blobs') + const journal = new FileSpawnJournal(journalPath) + const blobs = new FileResultBlobStore(blobPath) + await journal.beginTree(root, new Date(100).toISOString()) + const trace = createPushTraceSource({ runId: 'crashed-worker', now: () => 102 }) + const executor: Executor = { + runtime: 'router-tools', + async execute() { + trace.record({ + toolName: 'write_file', + args: { path: 'partial.ts' }, + status: 'error', + result: { error: 'disk full' }, + }) + throw new Error('provider session crashed') + }, + traceSource: () => trace.source, + teardown: async () => ({ destroyed: true }), + resultArtifact() { + throw new Error('result unavailable after crash') + }, + } + const scope = makeScope(root, journal, blobs) + expect( + scope.spawn(makeAgent(executor), 'mutate', { + budget: { maxIterations: 1, maxTokens: 50 }, + label: 'crashed trace', + }).ok, + ).toBe(true) + await expect(scope.next()).resolves.toMatchObject({ + kind: 'down', + reason: 'provider session crashed', + trace: { status: 'available', spanCount: 1 }, + }) + + const restartedJournal = new FileSpawnJournal(journalPath) + const restartedBlobs = new FileResultBlobStore(blobPath) + const replayed = await replaySpawnTree(restartedJournal, restartedBlobs, root) + expect(replayed).toHaveLength(1) + expect(replayed[0]).toMatchObject({ + kind: 'down', + reason: 'provider session crashed', + trace: { status: 'available', spanCount: 1 }, + }) + const resumed = replayed[0] + if (resumed?.trace.status !== 'available') { + throw new Error('expected replayed trace evidence') + } + const store = await workerTraceAnalysisStore(resumed.trace, restartedBlobs) + await expect(store.getOverview()).resolves.toMatchObject({ + total_traces: 1, + sample_trace_ids: ['crashed-worker'], + tool_names: ['write_file'], + errors: { trace_count: 1, span_count: 1 }, + }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/runtime/worktree-cli-executor.test.ts b/tests/runtime/worktree-cli-executor.test.ts index e5d4435d..094193ed 100644 --- a/tests/runtime/worktree-cli-executor.test.ts +++ b/tests/runtime/worktree-cli-executor.test.ts @@ -28,8 +28,15 @@ vi.mock('node:http', async () => { end: () => { const payload = JSON.parse(body || '{}') as Record if (!bridgeHttpHandler) throw new Error('bridgeHttpHandler not set') - const res = bridgeHttpHandler(payload) as Readable & { statusCode?: number } + const res = bridgeHttpHandler(payload) as Readable & { + statusCode?: number + headers?: Record + } res.statusCode = res.statusCode ?? 200 + res.headers = { + 'x-run-id': String(payload.run_id), + 'x-run-request-digest': `sha256:${'d'.repeat(64)}`, + } cb(res) }, on: () => {}, @@ -105,6 +112,7 @@ const reproducibleCodexProfile: AgentProfile = { function bridgeSseResponse(content: string): Readable { const payload = [ + 'id: 1', `data: ${JSON.stringify({ choices: [{ delta: { content } }], usage: { prompt_tokens: 3, completion_tokens: 5, cost: 0.02 }, @@ -273,6 +281,25 @@ describe('createWorktreeCliExecutor', () => { ).toThrow(/requires codexReproducible/) }) + it('refuses an unsupported profile before creating a worktree', async () => { + const state = freshGitState() + expect(() => + createWorktreeCliExecutor({ + repoRoot: '/workspace', + profile: { + ...authoredProfile, + connections: [{ connectionId: 'github', capabilities: ['issues:read'] }], + }, + harness: 'claude', + taskPrompt: 'x', + runGit: makeFakeGit(state), + runHarness: vi.fn(), + }), + ).toThrow(/profile materialization would drop axis changes.*connections/s) + expect(state.worktreesCreated).toEqual([]) + expect(state.worktreesRemoved).toEqual([]) + }) + it('meters reproducible Codex usage and surfaces isolation evidence', async () => { const state = freshGitState() let seen: RunLocalHarnessOptions | undefined @@ -475,6 +502,34 @@ describe('createWorktreeCliExecutor', () => { expect(exec.budgetExempt).toBe(false) }) + it('runs the execute task without requiring a fixed configured prompt', async () => { + const state = freshGitState() + let seen: RunLocalHarnessOptions | undefined + const executor = createWorktreeCliExecutor({ + repoRoot: '/workspace', + profile: authoredProfile, + harness: 'claude', + runGit: makeFakeGit(state), + runHarness: vi.fn(async (options) => { + seen = options + return { + exitCode: 0, + stdout: 'done', + stderr: '', + killedBySignal: null, + durationMs: 1, + timedOut: false, + } + }), + }) + const authorizedTask = { experimentId: 'exp-7', instruction: 'fix the authorized defect' } + + await executor.execute(authorizedTask, new AbortController().signal) + + const prompt = seen?.invocation?.args.find((arg) => arg.includes('exp-7')) + expect(prompt).toContain(JSON.stringify(authorizedTask)) + }) + it('resultArtifact() before execute() resolves throws (fail loud, no fabricated artifact)', () => { const exec = createWorktreeCliExecutor({ repoRoot: '/workspace', @@ -594,7 +649,6 @@ describe('createWorktreeCliExecutor', () => { const factory = createExecutor({ backend: 'cli-worktree', repoRoot: '/workspace', - taskPrompt: 'implement the feature', runId: 'run-live', bridge: { bridgeUrl: 'http://bridge.test', @@ -611,9 +665,13 @@ describe('createWorktreeCliExecutor', () => { }) const spec: AgentSpec = { profile: authoredProfile, harness: null } const exec = factory(spec, { signal: new AbortController().signal, seams: {} }) + const authorizedTask = { + experimentId: 'bridge-exp-9', + instruction: 'implement the authorized feature', + } exec.deliver?.({ steer: 'also update docs' }) - const run = exec.execute(undefined, new AbortController().signal) + const run = exec.execute(authorizedTask, new AbortController().signal) await drain(run as AsyncIterable) const worktreePath = state.worktreesCreated[0] @@ -621,9 +679,9 @@ describe('createWorktreeCliExecutor', () => { expect(requests).toHaveLength(1) expect(requests[0]?.cwd).toBe(worktreePath) expect(requests[0]?.session_id).toBe('session-live') - expect(requests[0]?.messages?.some((m) => m.content.includes('implement the feature'))).toBe( - true, - ) + expect( + requests[0]?.messages?.some((m) => m.content.includes(JSON.stringify(authorizedTask))), + ).toBe(true) expect(requests[0]?.messages?.some((m) => m.content.includes('also update docs'))).toBe(true) expect(checks).toEqual([{ command: 'pnpm test', cwd: worktreePath }]) @@ -642,7 +700,7 @@ describe('createWorktreeCliExecutor', () => { expect(state.worktreesRemoved).toEqual(state.worktreesCreated) }) - it('fails loud on a missing repoRoot / harness / taskPrompt', () => { + it('fails loud on a missing repoRoot, harness, or both task sources', async () => { expect(() => createWorktreeCliExecutor({ repoRoot: '', @@ -659,5 +717,14 @@ describe('createWorktreeCliExecutor', () => { taskPrompt: '', }), ).toThrow(/taskPrompt required/) + + const noTask = createWorktreeCliExecutor({ + repoRoot: '/workspace', + profile: authoredProfile, + harness: 'claude', + }) + await expect(noTask.execute(undefined, new AbortController().signal)).rejects.toThrow( + /execute task required/, + ) }) }) diff --git a/tests/supervisor-loop-example.test.ts b/tests/supervisor-loop-example.test.ts index dc5c083f..c7fe3b1d 100644 --- a/tests/supervisor-loop-example.test.ts +++ b/tests/supervisor-loop-example.test.ts @@ -65,8 +65,10 @@ describe('supervisor-loop example — supervise() on the scripted brain (offline const result = await supervise( { name: 'supervisor', - harness: null, - systemPrompt: 'You are a supervisor. Spawn a worker, await it, and stop on delivery.', + harness: 'cli-base', + prompt: { + systemPrompt: 'You are a supervisor. Spawn a worker, await it, and stop on delivery.', + }, }, demoGoal, {