From 8848ec4c2e34ffc55f62888e6d7a5297924252f9 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sat, 5 Sep 2026 10:23:28 -0700 Subject: [PATCH 1/3] fix(supervise): make recursive authority profile-owned --- api-surface.json | 13 +- bench/src/atom-mcp-e2e.mts | 1 + .../src/coordination-mcp-container-reach.mts | 1 + bench/src/mcp-mount-probe.mts | 1 + bench/src/tb-supervisor-sidecar.mts | 1 + docs/api/durable.md | 35 +- docs/api/mcp.md | 5 +- docs/api/primitive-catalog.md | 5 +- docs/api/runtime.md | 150 +++--- docs/api/testing.md | 122 +++-- docs/architecture.md | 2 +- docs/canonical-api.md | 6 +- examples/graphs/analyst-agent-review.ts | 11 +- examples/graphs/best-of-n.ts | 11 +- examples/graphs/collaborates-review-loop.ts | 11 +- examples/graphs/shot-loop-resumed.ts | 11 +- examples/graphs/shot-loop.ts | 11 +- examples/graphs/user-sim-conversation.ts | 14 +- examples/graphs/watchdog-steer.ts | 12 +- examples/p1-parity/parity.test.ts | 4 + examples/p1-parity/run-parity.ts | 4 + examples/supervise/supervise.ts | 9 +- .../supervisor-loop/run-supervisor-mcp.ts | 1 + skills/codemode/SKILL.md | 4 +- skills/supervise/SKILL.md | 30 +- src/mcp/in-process-executor.ts | 7 +- src/mcp/local-harness.ts | 8 +- src/profiles/researcher.ts | 2 +- src/runtime/index.ts | 1 - src/runtime/strategy.ts | 66 +-- src/runtime/supervise/authoring.ts | 16 +- src/runtime/supervise/bridge-executor.test.ts | 2 +- src/runtime/supervise/code-mode.ts | 8 +- src/runtime/supervise/coordination-driver.ts | 79 ++- src/runtime/supervise/coordination-mcp.ts | 44 +- src/runtime/supervise/driver-executor.ts | 23 +- src/runtime/supervise/graph.ts | 25 +- .../supervise/in-place-cli-executor.ts | 2 +- .../supervise/interactive-worker.test.ts | 4 + src/runtime/supervise/materialization.ts | 13 + src/runtime/supervise/prompt-registry.ts | 51 +- src/runtime/supervise/run-context.ts | 10 +- src/runtime/supervise/scope.ts | 20 +- src/runtime/supervise/supervise.ts | 246 +++++---- src/runtime/supervise/supervisor-agent.ts | 125 +++-- src/runtime/supervise/types.ts | 5 +- .../supervise/worktree-cli-executor.ts | 2 +- tests/helpers/resume-driver-child.ts | 40 +- tests/improvement-surfaces.test.ts | 2 +- .../pursuit-usage-provenance.test.ts | 3 +- .../runtime-eval-pursuit-evidence.test.ts | 3 +- tests/kernel/code-mode.test.ts | 12 +- tests/kernel/completion-gate.test.ts | 12 +- tests/kernel/composition-families.test.ts | 3 +- tests/kernel/coordination-driver.test.ts | 40 +- tests/kernel/coordination-mcp.test.ts | 44 +- .../kernel/driver-inference-metering.test.ts | 16 +- tests/kernel/graph.test.ts | 197 ++++--- tests/kernel/in-place-cli-executor.test.ts | 1 + tests/kernel/loop-dispatch.test.ts | 7 + tests/kernel/materialization-evidence.test.ts | 6 +- .../nested-coordination-durability.test.ts | 16 +- tests/kernel/peer-mail.test.ts | 1 + tests/kernel/refusal-reasons.test.ts | 1 + tests/kernel/reprompt-owner-attempt.test.ts | 3 +- tests/kernel/run-cancellation.test.ts | 9 +- tests/kernel/skill-tool-names.test.ts | 15 + tests/kernel/supervise-convenience.test.ts | 504 +++++++++++------- .../supervise-full-profile-bridge.test.ts | 227 ++++---- .../supervise-global-concurrency.test.ts | 17 +- .../supervise-live-box-validator.test.ts | 3 +- tests/kernel/supervise-option-keys.test.ts | 9 +- tests/kernel/supervise-otel-spans.test.ts | 3 +- .../kernel/supervise-peer-mail-mount.test.ts | 24 +- .../supervise-restart-resource-safety.test.ts | 3 - tests/kernel/supervise-worker-trace.test.ts | 3 +- tests/kernel/supervisor-agent.test.ts | 353 ++++++++---- tests/kernel/supervisor-authoring.test.ts | 23 +- tests/kernel/test-agent-profile.ts | 8 + tests/kernel/worker-cancellation.test.ts | 1 + tests/kernel/worker-spawn-retry.test.ts | 12 +- tests/kernel/worker-steer.test.ts | 1 + tests/mcp/in-process-executor.test.ts | 18 +- tests/mcp/local-harness.test.ts | 21 + tests/profile-materialization.test.ts | 2 +- tests/researcher-profile.test.ts | 2 +- tests/runtime/bridge-executor.test.ts | 3 +- tests/runtime/mid-flight-steering.test.ts | 14 +- tests/runtime/stop-rules.test.ts | 7 +- tests/runtime/supervisor-finalizer.test.ts | 16 +- tests/supervisor-loop-example.test.ts | 4 + 91 files changed, 1826 insertions(+), 1117 deletions(-) diff --git a/api-surface.json b/api-surface.json index f341f1e30..287c389c0 100644 --- a/api-surface.json +++ b/api-surface.json @@ -1216,7 +1216,7 @@ "DownMessageDeliveryAttempt": "type 5bdd1f632583", "DownMessageDeliveryOutcome": "type bca367e62a6a", "DownMessageEvent": "type 9dd808200f9d", - "DriveHarness": "type 3ef6bda5ba08", + "DriveHarness": "type e81197face8f", "DriveHarnessOwnerContext": "type d0689ff0eb6e", "Driver": "type 4a747076b1a3", "DriverAttemptRecord": "type f9ce2f046564", @@ -1259,7 +1259,7 @@ "ExecutorContext": "type 98d2e228ea1b", "ExecutorExecutionBinding": "type cbd52e7e2eb8", "ExecutorFactory": "type 0d6f475ad3d4", - "ExecutorMaterialization": "type 4d1e9a2101ed", + "ExecutorMaterialization": "type ceafe44da26b", "ExecutorNodeContext": "type 7f86daa88edc", "ExecutorProgress": "type c91983468166", "ExecutorProgressEvent": "type 19b8d5b7224e", @@ -1573,7 +1573,7 @@ "StructuralRolloutPolicy": "type d372912ef050", "StructuralRolloutResult": "type 9248b72cae04", "SuperviseDispatchOptions": "type 55e71c9c1e6b", - "SuperviseOptions": "type 51029d9e2b96", + "SuperviseOptions": "type 5ec2e38b4671", "SuperviseOptionsForDispatch": "type 7d89526c1040", "SuperviseRegistry": "type 4fd60c297f74", "SuperviseRegistryTable": "type cc1468cd50c1", @@ -1581,7 +1581,7 @@ "SuperviseSurfaceResult": "type 2c378dbc3193", "SupervisedResult": "type d2fabb828e07", "Supervisor": "type 7d9aff9cd744", - "SupervisorAgentDeps": "type 7ecec59281fc", + "SupervisorAgentDeps": "type 43201c6909f8", "SupervisorCleanupReceipt": "type d862eb60266d", "SupervisorFinalizer": "type f8628e65536f", "SupervisorNodeContext": "type b8e545bbb355", @@ -1924,7 +1924,7 @@ "selectChampion": "value db305c9b1701", "selectValidWinner": "value ff507a8be978", "sentinelCompletion": "value 4b96e3c2c63d", - "serveCoordinationMcp": "value 78094b37ccc3", + "serveCoordinationMcp": "value b704fe1b4eef", "settledToIteration": "value 6603cd508de9", "settledWorkerOut": "value 6f3bae481b15", "spendFromUsageEvents": "value f4bec1c9bfc6", @@ -1942,7 +1942,6 @@ "superviseSurface": "value 730f753cfb58", "supervisorAgent": "value 880407b0dab3", "supervisorInstructions": "value a20603be0ba8", - "supervisorPolicyPrompt": "value 373728f5643d", "supervisorRunDir": "value 3925fc5b5ced", "supervisorRunsRoot": "value f50183307ce8", "supervisorWorkersDir": "value 229aecaab891", @@ -2339,7 +2338,7 @@ "./testing": { "AgentProfileImprovementFixture": "value 8a79d0f5c646", "AgentProfileImprovementProposalFixture": "value 95f42c91bc08", - "DriverAgentOptions": "type 56064f9cc0f7", + "DriverAgentOptions": "type 87c25847a608", "RunGraphTestOptions": "type c5b5730ba4f0", "SuperviseTestOptions": "type 1fd4a3f892e4", "SupervisorAgentTestDeps": "type f39aa3b16149", diff --git a/bench/src/atom-mcp-e2e.mts b/bench/src/atom-mcp-e2e.mts index 686f7e82d..7cf1f1b9c 100644 --- a/bench/src/atom-mcp-e2e.mts +++ b/bench/src/atom-mcp-e2e.mts @@ -176,6 +176,7 @@ async function main(): Promise { blobs, makeWorkerAgent: (raw) => makeWorker(raw, ws, n++), perWorker: { maxIterations: 2, maxTokens: 200_000 }, + toolNames: ['spawn_worker', 'await_event', 'stop'], }) // The supervisor's cwd carries the REAL skill file (opencode loads it from the cwd skill dirs). const supCwd = mkdtempSync(join(tmpdir(), 'e2e-sup-')) diff --git a/bench/src/coordination-mcp-container-reach.mts b/bench/src/coordination-mcp-container-reach.mts index 066de44d7..3685daef3 100644 --- a/bench/src/coordination-mcp-container-reach.mts +++ b/bench/src/coordination-mcp-container-reach.mts @@ -118,6 +118,7 @@ async function main(): Promise { blobs, makeWorkerAgent: () => trivialWorker('w'), perWorker: { maxIterations: 4, maxTokens: 2000 }, + toolNames: ['spawn_worker', 'await_event'], host: HOST_BIND, }) // Docker containers reach the host through the bridge gateway, not the 0.0.0.0 bind URL. diff --git a/bench/src/mcp-mount-probe.mts b/bench/src/mcp-mount-probe.mts index cdeff0070..052e57ad1 100644 --- a/bench/src/mcp-mount-probe.mts +++ b/bench/src/mcp-mount-probe.mts @@ -90,6 +90,7 @@ async function main(): Promise { blobs, makeWorkerAgent: () => deliveringLeaf('w', { ok: true }), perWorker: { maxIterations: 4, maxTokens: 2000 }, + toolNames: ['spawn_worker', 'await_event', 'stop'], }) console.error(`[probe] coordination MCP live at ${mcp.url}`) try { diff --git a/bench/src/tb-supervisor-sidecar.mts b/bench/src/tb-supervisor-sidecar.mts index 823dd5b54..1e941dfd9 100644 --- a/bench/src/tb-supervisor-sidecar.mts +++ b/bench/src/tb-supervisor-sidecar.mts @@ -113,6 +113,7 @@ async function main(): Promise { blobs, makeWorkerAgent, perWorker: { maxIterations: 40, maxTokens: 200_000 }, + toolNames: ['spawn_worker', 'observe_agent', 'await_event', 'stop'], host: '0.0.0.0', onEvent: (event) => logEvent('bus', event), }) diff --git a/docs/api/durable.md b/docs/api/durable.md index 7359f6c39..08ef80ae8 100644 --- a/docs/api/durable.md +++ b/docs/api/durable.md @@ -1077,9 +1077,9 @@ The independent completion check for backend-derived workers and direct supervis > `readonly` `optional` **resolveDeliverable?**: (`input`) => [`DeliverableSpec`](runtime.md#deliverablespec)\<`unknown`\> \| `undefined` -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. +Resolve the completion check for one exact authorized backend-derived child. The callback runs +after spawn authorization and receives a detached immutable context. It may return `undefined` +to use the run-wide `deliverable`; a managed child receives its selected check for direct work. ###### Parameters @@ -1298,29 +1298,6 @@ authorized task. The exact worker identity and detached bytes are recorded befor [`SuperviseOptions`](runtime.md#superviseoptions).[`authorizeMessage`](runtime.md#authorizemessage-1) -##### isDriverProfile? - -> `readonly` `optional` **isDriverProfile?**: (`input`) => `boolean` - -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. - -###### Parameters - -###### input - -[`AuthorizedSpawnContext`](runtime.md#authorizedspawncontext) - -###### Returns - -`boolean` - -###### Inherited from - -[`SuperviseOptions`](runtime.md#superviseoptions).[`isDriverProfile`](runtime.md#isdriverprofile-1) - ##### router? > `readonly` `optional` **router?**: [`RouterTransportConfig`](runtime.md#routertransportconfig) @@ -1485,9 +1462,9 @@ A re-prompt is the retry path, not a second loop: same scope, same coordination live children, and the same budget, deadline, abort, and `driverRetry.maxAttempts` bounds. A run the coordination server already stopped is never re-prompted — that stop was a decision. -Requires `deliverable`, and applies to the ROOT manager — the one that declares the run's -completion check. A recursive manager declares none of its own, so it is left unchanged. -Refused for a router-brained root, which runs its turn loop in process. Omit/`0` = never. +Requires `deliverable`, and applies to every external manager with a completion check. A +recursive manager receives the check selected for its exact assignment. Refused for a +router-brained manager, which runs its turn loop in process. Omit/`0` = never. ###### Inherited from diff --git a/docs/api/mcp.md b/docs/api/mcp.md index 89d81ceab..c9d4045b4 100644 --- a/docs/api/mcp.md +++ b/docs/api/mcp.md @@ -1886,7 +1886,7 @@ Optional per-delegation typecheck command. Same shape as `testCmd`. **`Experimental`** -Wall-clock cap per harness subprocess (ms). Default 5min. +Optional wall-clock cap per harness subprocess (ms). Omit it for no timer. ##### postCheckTimeoutMs? @@ -2295,7 +2295,8 @@ Absolute host paths that reproducible Codex must not read. The normalized set is **`Experimental`** -Wall-clock kill deadline (ms). Default 5 min. Subprocess SIGTERMed on expiry. +Optional wall-clock kill deadline (ms). Omit it for no timer. A positive value sends + SIGTERM on expiry. ##### maxOutputBytes? diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index 5d4198018..f23f755a3 100644 --- a/docs/api/primitive-catalog.md +++ b/docs/api/primitive-catalog.md @@ -571,7 +571,7 @@ Import from `@tangle-network/agent-runtime/intelligence` — 171 exports. ### Execution kernel — recursive atom, supervision, executors, round-synchronous loop -Import from `@tangle-network/agent-runtime/kernel` — 921 exports. +Import from `@tangle-network/agent-runtime/kernel` — 920 exports. | Symbol | Kind | Summary | |---|---|---| @@ -807,7 +807,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 921 exports. | `superviseDispatch` | function | Run one recursive supervised tree inside Eval's pre-execution paid-call lifecycle. | | `superviseSurface` | function | Drive a team of agents (spawned + steered by `profile`) to solve a graded `AgenticSurface` task, and | | `supervisorAgent` | function | Build a supervisor `Agent` from its profile: the brain resolves from `profile.harness` | -| `supervisorInstructions` | function | The supervisor SKILL — the how-to the supervisor reads (its system prompt). THE optimizable | +| `supervisorInstructions` | function | The supervisor skill: an explicit profile-authoring instruction, never an implicit Runtime | | `supervisorRunDir` | function | The run directory every artifact of one supervisor run lives under. | | `supervisorRunsRoot` | function | The root every supervisor run of one workspace lives under. | | `supervisorWorkersDir` | function | The directory holding every per-worker file of one run (inboxes and control-event logs). | @@ -877,7 +877,6 @@ Import from `@tangle-network/agent-runtime/kernel` — 921 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. | | `strategyAuthorSystemPrompt` | const | Standing behavior callers put in the strategy-author AgentProfile. | -| `supervisorPolicyPrompt` | const | THE supervisor policy — one stance, both front doors. The work-vs-delegate rule is conditional | | `TERMINAL_DECISIONS` | const | Decision values the kernel treats as terminal. Every other value returned by | | `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. | diff --git a/docs/api/runtime.md b/docs/api/runtime.md index b0052ca18..7572f29d0 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -13037,8 +13037,9 @@ documentation from `SuperviseOptions`, which is the one owner of both. > `readonly` `optional` **backend?**: [`ExecutorConfig`](#executorconfig) WHERE worker nodes run — the executor backend. Provide this OR `makeLeafAgent`. Forwarded to - `supervise()`, which derives every authorized LEAF from it; a node declared `role: 'driver'` - becomes a nested supervisor instead, whose own leaves are derived the same way. + `supervise()`, which derives every authorized leaf from it. A node that declares + `agent_runtime_coordination_spawn_worker` becomes a nested supervisor instead, whose own + leaves are derived the same way. ##### driverBackend? @@ -13058,8 +13059,8 @@ WHERE the ROOT node's harness brain runs — forwarded to `supervise()` verbatim Leaf-execution override (offline tests / advanced). `runGraph` still owns node pinning, directive delivery, and the edge ledger AROUND this seam — only the leaf `act` is yours. - Slots INSIDE the kernel's authorized path (`SuperviseOptions.makeLeafAgent`), so a node - declared `role: 'driver'` still becomes a nested supervisor even under an offline leaf. + Slots INSIDE the kernel's authorized path (`SuperviseOptions.makeLeafAgent`), so a node that + declares the spawn tool still becomes a nested supervisor even under an offline leaf. ##### brain? @@ -13171,9 +13172,9 @@ digests itself from the exact detached values it executes. > `readonly` `optional` **resolveDeliverable?**: (`input`) => [`DeliverableSpec`](#deliverablespec)\<`unknown`\> \| `undefined` -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. +Resolve the completion check for one exact authorized backend-derived child. The callback runs +after spawn authorization and receives a detached immutable context. It may return `undefined` +to use the run-wide `deliverable`; a managed child receives its selected check for direct work. ###### Parameters @@ -13318,29 +13319,6 @@ The EFFECTIVE continuity of this spawn, resolved by the coordination layer. [`SuperviseOptions`](#superviseoptions).[`authorizeSpawn`](#authorizespawn-1) -##### isDriverProfile? - -> `readonly` `optional` **isDriverProfile?**: (`input`) => `boolean` - -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. - -###### Parameters - -###### input - -[`AuthorizedSpawnContext`](#authorizedspawncontext) - -###### Returns - -`boolean` - -###### Inherited from - -[`SuperviseOptions`](#superviseoptions).[`isDriverProfile`](#isdriverprofile-1) - ##### router? > `readonly` `optional` **router?**: [`RouterTransportConfig`](#routertransportconfig) @@ -13468,9 +13446,9 @@ A re-prompt is the retry path, not a second loop: same scope, same coordination live children, and the same budget, deadline, abort, and `driverRetry.maxAttempts` bounds. A run the coordination server already stopped is never re-prompted — that stop was a decision. -Requires `deliverable`, and applies to the ROOT manager — the one that declares the run's -completion check. A recursive manager declares none of its own, so it is left unchanged. -Refused for a router-brained root, which runs its turn loop in process. Omit/`0` = never. +Requires `deliverable`, and applies to every external manager with a completion check. A +recursive manager receives the check selected for its exact assignment. Refused for a +router-brained manager, which runs its turn loop in process. Omit/`0` = never. ###### Inherited from @@ -13909,7 +13887,7 @@ Default instruction for direct `execute(undefined, signal)` calls. An execution- **`Experimental`** -Wall-clock cap per harness subprocess (ms). Default 5 min (the `runLocalHarness` default). +Optional wall-clock cap per harness subprocess (ms). Omit it for no timer. ##### runHarness? @@ -15407,8 +15385,8 @@ Options for a supervised run context. > `readonly` `optional` **withDriver?**: `boolean` -Wrap the executor registry with `withDriverExecutor` so a spawned child marked -`role: 'driver'` resolves to the recursive driver-executor (agents driving agents +Wrap the executor registry with `withDriverExecutor` so a child constructed by `driverChild` +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`. @@ -17440,9 +17418,9 @@ The independent completion check for backend-derived workers and direct supervis > `readonly` `optional` **resolveDeliverable?**: (`input`) => [`DeliverableSpec`](#deliverablespec)\<`unknown`\> \| `undefined` -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. +Resolve the completion check for one exact authorized backend-derived child. The callback runs +after spawn authorization and receives a detached immutable context. It may return `undefined` +to use the run-wide `deliverable`; a managed child receives its selected check for direct work. ###### Parameters @@ -17621,25 +17599,6 @@ authorized task. The exact worker identity and detached bytes are recorded befor [`AuthorizedDownMessage`](#authorizeddownmessage) -##### isDriverProfile? - -> `readonly` `optional` **isDriverProfile?**: (`input`) => `boolean` - -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. - -###### Parameters - -###### input - -[`AuthorizedSpawnContext`](#authorizedspawncontext) - -###### Returns - -`boolean` - ##### router? > `readonly` `optional` **router?**: [`RouterTransportConfig`](#routertransportconfig) @@ -17772,9 +17731,9 @@ A re-prompt is the retry path, not a second loop: same scope, same coordination live children, and the same budget, deadline, abort, and `driverRetry.maxAttempts` bounds. A run the coordination server already stopped is never re-prompted — that stop was a decision. -Requires `deliverable`, and applies to the ROOT manager — the one that declares the run's -completion check. A recursive manager declares none of its own, so it is left unchanged. -Refused for a router-brained root, which runs its turn loop in process. Omit/`0` = never. +Requires `deliverable`, and applies to every external manager with a completion check. A +recursive manager receives the check selected for its exact assignment. Refused for a +router-brained manager, which runs its turn loop in process. Omit/`0` = never. ##### onUnmetContract? @@ -18658,9 +18617,15 @@ How to run an external harness as the DRIVER, with the coordination verbs mounte `AgentProfile` -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). +The exact provider-visible projection. Runtime-owned coordination tool declarations are + removed only when their descriptors are actually mounted; send this profile to the provider. + +###### authoredProfile + +`AgentProfile` + +The immutable canonical profile Runtime admitted. Use it only to bind receipts or audit + authority; never send it to a provider, because it contains Runtime-owned declarations. ###### systemPrompt? @@ -18773,6 +18738,22 @@ Runtime-owned sink for provider identity observed by this manager's own turns. Independent completion check for direct driver work (`submit_result`). +##### onAcceptedSubmission? + +> `readonly` `optional` **onAcceptedSubmission?**: (`result`) => `void` + +Receives a result only after this manager's completion check accepted it. + +###### Parameters + +###### result + +`unknown` + +###### Returns + +`void` + ##### maxLiveWorkers? > `readonly` `optional` **maxLiveWorkers?**: `number` @@ -19614,7 +19595,14 @@ Scope snapshots this value and computes the durable receipt; callers never provi > `readonly` **effectiveProfile**: `AgentProfile` -Complete profile after trusted runtime-owned attachments or backend overlays were applied. +Complete profile the provider actually receives after Runtime consumes its own declarations. + +##### authoredProfile? + +> `readonly` `optional` **authoredProfile?**: `AgentProfile` + +Canonical profile admitted by Runtime when it differs from the provider-visible profile. + Its digest must equal the kernel-owned authored profile digest for the node. ##### backend @@ -21057,7 +21045,7 @@ Override the base ref the worktree is cut from (default `HEAD`). **`Experimental`** -Wall-clock cap per harness subprocess (ms). Default 5 min (the `runLocalHarness` default). +Optional wall-clock cap per harness subprocess (ms). Omit it for no timer. ##### codexReproducible? @@ -26412,24 +26400,12 @@ How long a worker may produce no metered activity before a `progress()` read cal *** -### supervisorPolicyPrompt - -> `const` **supervisorPolicyPrompt**: [`RegisteredPrompt`](#registeredprompt) - -THE supervisor policy — one stance, both front doors. The work-vs-delegate rule is conditional -on capability (work tools present or not), which is what dissolves the old contradiction: "do -small work yourself" was written for a supervisor WITH work tools, "you do not do the work" for -one WITHOUT — one policy states both branches explicitly. - -*** - ### delegatesWorkerBriefPrompt > `const` **delegatesWorkerBriefPrompt**: [`RegisteredPrompt`](#registeredprompt) Default DELEGATES-edge directive: the standing instruction a worker receives with every -traversal of a delegates edge that names this surface. Seeded from the bounded-brief knowledge -in the supervisor policy, phrased for the RECEIVING side of the edge. +traversal of a delegates edge that names this surface. *** @@ -29908,13 +29884,8 @@ Narrow an untyped `spawn_worker` profile argument to an `AuthoredProfile`, or nu > **supervisorInstructions**(`opts?`): `string` -The supervisor SKILL — the how-to the supervisor reads (its system prompt). THE optimizable - surface: editing this changes how the supervisor designs every agent it spawns. - - The POLICY paragraph is the registry's one `supervisor/policy` entry — the same stance - `defaultSupervisorPrompt` carries — so both front doors run the same work-vs-delegate rule; - this function ADDS the profile-authoring skill (how to WRITE the workers it spawns), which is - additive craft, not a different policy. +The supervisor skill: an explicit profile-authoring instruction, never an implicit Runtime +policy. Editing this text changes how a profile designs the descendants it spawns. #### Parameters @@ -30396,6 +30367,13 @@ 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. +###### toolNames + +readonly `string`[] + +Exact bare tool names to expose from the coordination and node-tool set. Runtime never + grants an implicit complete tool set. An unknown name fails before the listener opens. + ###### peerMail? `boolean` \| \{ `limits?`: `Partial`\<[`PeerMailLimits`](#peermaillimits)\>; \} diff --git a/docs/api/testing.md b/docs/api/testing.md index e5341a674..dbe955ecb 100644 --- a/docs/api/testing.md +++ b/docs/api/testing.md @@ -76,6 +76,22 @@ Per-child budget reserved from the conserved pool on each spawn. 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. +##### onAcceptedSubmission? + +> `readonly` `optional` **onAcceptedSubmission?**: (`result`) => `void` + +Receives a result only after this manager's completion check accepted it. + +###### Parameters + +###### result + +`unknown` + +###### Returns + +`void` + ##### maxLiveWorkers? > `readonly` `optional` **maxLiveWorkers?**: `number` @@ -166,6 +182,13 @@ The driver's stance — a string, or built from the task (the worker-driver prom 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. +##### toolNames + +> `readonly` **toolNames**: readonly `string`[] + +Exact bare names to expose from the coordination, node-tool, and direct-work-tool set. + Runtime never grants an implicit complete tool set. + ##### extraTools? > `readonly` `optional` **extraTools?**: readonly `object`[] @@ -389,8 +412,9 @@ and a run-scoped request stays unanswered. > `readonly` `optional` **backend?**: [`ExecutorConfig`](runtime.md#executorconfig) WHERE worker nodes run — the executor backend. Provide this OR `makeLeafAgent`. Forwarded to - `supervise()`, which derives every authorized LEAF from it; a node declared `role: 'driver'` - becomes a nested supervisor instead, whose own leaves are derived the same way. + `supervise()`, which derives every authorized leaf from it. A node that declares + `agent_runtime_coordination_spawn_worker` becomes a nested supervisor instead, whose own + leaves are derived the same way. ###### Inherited from @@ -418,8 +442,8 @@ WHERE the ROOT node's harness brain runs — forwarded to `supervise()` verbatim Leaf-execution override (offline tests / advanced). `runGraph` still owns node pinning, directive delivery, and the edge ledger AROUND this seam — only the leaf `act` is yours. - Slots INSIDE the kernel's authorized path (`SuperviseOptions.makeLeafAgent`), so a node - declared `role: 'driver'` still becomes a nested supervisor even under an offline leaf. + Slots INSIDE the kernel's authorized path (`SuperviseOptions.makeLeafAgent`), so a node that + declares the spawn tool still becomes a nested supervisor even under an offline leaf. ###### Inherited from @@ -567,9 +591,9 @@ digests itself from the exact detached values it executes. > `readonly` `optional` **resolveDeliverable?**: (`input`) => [`DeliverableSpec`](runtime.md#deliverablespec)\<`unknown`\> \| `undefined` -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. +Resolve the completion check for one exact authorized backend-derived child. The callback runs +after spawn authorization and receives a detached immutable context. It may return `undefined` +to use the run-wide `deliverable`; a managed child receives its selected check for direct work. ###### Parameters @@ -714,29 +738,6 @@ The EFFECTIVE continuity of this spawn, resolved by the coordination layer. [`SuperviseOptions`](runtime.md#superviseoptions).[`authorizeSpawn`](runtime.md#authorizespawn-1) -##### isDriverProfile? - -> `readonly` `optional` **isDriverProfile?**: (`input`) => `boolean` - -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. - -###### Parameters - -###### input - -[`AuthorizedSpawnContext`](runtime.md#authorizedspawncontext) - -###### Returns - -`boolean` - -###### Inherited from - -[`SuperviseOptions`](runtime.md#superviseoptions).[`isDriverProfile`](runtime.md#isdriverprofile-1) - ##### router? > `readonly` `optional` **router?**: [`RouterTransportConfig`](runtime.md#routertransportconfig) @@ -864,9 +865,9 @@ A re-prompt is the retry path, not a second loop: same scope, same coordination live children, and the same budget, deadline, abort, and `driverRetry.maxAttempts` bounds. A run the coordination server already stopped is never re-prompted — that stop was a decision. -Requires `deliverable`, and applies to the ROOT manager — the one that declares the run's -completion check. A recursive manager declares none of its own, so it is left unchanged. -Refused for a router-brained root, which runs its turn loop in process. Omit/`0` = never. +Requires `deliverable`, and applies to every external manager with a completion check. A +recursive manager receives the check selected for its exact assignment. Refused for a +router-brained manager, which runs its turn loop in process. Omit/`0` = never. ###### Inherited from @@ -1307,9 +1308,9 @@ The independent completion check for backend-derived workers and direct supervis > `readonly` `optional` **resolveDeliverable?**: (`input`) => [`DeliverableSpec`](runtime.md#deliverablespec)\<`unknown`\> \| `undefined` -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. +Resolve the completion check for one exact authorized backend-derived child. The callback runs +after spawn authorization and receives a detached immutable context. It may return `undefined` +to use the run-wide `deliverable`; a managed child receives its selected check for direct work. ###### Parameters @@ -1528,29 +1529,6 @@ authorized task. The exact worker identity and detached bytes are recorded befor [`SuperviseOptions`](runtime.md#superviseoptions).[`authorizeMessage`](runtime.md#authorizemessage-1) -##### isDriverProfile? - -> `readonly` `optional` **isDriverProfile?**: (`input`) => `boolean` - -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. - -###### Parameters - -###### input - -[`AuthorizedSpawnContext`](runtime.md#authorizedspawncontext) - -###### Returns - -`boolean` - -###### Inherited from - -[`SuperviseOptions`](runtime.md#superviseoptions).[`isDriverProfile`](runtime.md#isdriverprofile-1) - ##### router? > `readonly` `optional` **router?**: [`RouterTransportConfig`](runtime.md#routertransportconfig) @@ -1715,9 +1693,9 @@ A re-prompt is the retry path, not a second loop: same scope, same coordination live children, and the same budget, deadline, abort, and `driverRetry.maxAttempts` bounds. A run the coordination server already stopped is never re-prompted — that stop was a decision. -Requires `deliverable`, and applies to the ROOT manager — the one that declares the run's -completion check. A recursive manager declares none of its own, so it is left unchanged. -Refused for a router-brained root, which runs its turn loop in process. Omit/`0` = never. +Requires `deliverable`, and applies to every external manager with a completion check. A +recursive manager receives the check selected for its exact assignment. Refused for a +router-brained manager, which runs its turn loop in process. Omit/`0` = never. ###### Inherited from @@ -2272,6 +2250,26 @@ Independent completion check for direct driver work (`submit_result`). [`SupervisorAgentDeps`](runtime.md#supervisoragentdeps).[`deliverable`](runtime.md#deliverable-5) +##### onAcceptedSubmission? + +> `readonly` `optional` **onAcceptedSubmission?**: (`result`) => `void` + +Receives a result only after this manager's completion check accepted it. + +###### Parameters + +###### result + +`unknown` + +###### Returns + +`void` + +###### Inherited from + +[`SupervisorAgentDeps`](runtime.md#supervisoragentdeps).[`onAcceptedSubmission`](runtime.md#onacceptedsubmission) + ##### maxLiveWorkers? > `readonly` `optional` **maxLiveWorkers?**: `number` diff --git a/docs/architecture.md b/docs/architecture.md index b1d1fa377..d40b5e952 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -467,7 +467,7 @@ Salience filtering and the cross-box durable mailbox are not built; see **§13.6 - 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. + A child that declares `tools.agent_runtime_coordination_spawn_worker: true` 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` that mounts the live coordination MCP under one reserved alias. The endpoint travels as a runtime attachment beside the AgentProfile (`runtime_attachments.mcp` on the cli-bridge request), never inside it, so a rebound ephemeral port cannot move the profile digest a durable bridge session is bound to. cli-bridge must advertise `capabilities.runtimeAttachments.mcp`; a bridge that does not is refused before the first paid turn. diff --git a/docs/canonical-api.md b/docs/canonical-api.md index 775c64b43..e424d6d98 100644 --- a/docs/canonical-api.md +++ b/docs/canonical-api.md @@ -39,7 +39,7 @@ The system is four steps, each with a named entry point: It returns every unique candidate as an exact profile plus Interface diffs, or as an explicit refusal. Official GEPA graph nodes retain parent indices and selection scores. -Two standing rules: the model that picks the best attempt is never the model that grades it, and observation attaches to the *loop* via `RuntimeHooks`, never to the portable profile. One known limit: the current `Supervisor` records completed settlements but does not resume a live tree after coordinator restart. +Two standing rules: the model that picks the best attempt is never the model that grades it, and observation attaches to the *loop* via `RuntimeHooks`, never to the portable profile. A durable `Supervisor` replays a crashed tree from its journal after coordinator restart; committed work is reused while in-flight keyed work remains `in-doubt` until its exact prior execution is recovered. (The original one-sentence compressed form of this spine is preserved in [design.md](./design.md).) @@ -112,7 +112,7 @@ A general "loop" primitive is the single most common modelling error in this rep | I want to… | Use (import) | Do NOT build | |---|---|---| | Run one product chat turn with streamed events, ordered persistence hooks, and stable execution/turn identity | `handleChatTurn(...)` + `deriveExecutionId(...)`: `/durable`; pass the derived id as both `executionId` and `turnId` on initial dispatch | importing the broad package entry from an edge worker, treating `executionId` alone as dispatch idempotency, or rebuilding framing and persistence ordering in the product | -| Run a supervisor toward a goal with default setup | `supervise(profile, task, { budget, backend? })`: `/kernel` | hand-wiring `createSupervisor().run` + `blobs`/`perWorker`/`journal`/`executors`; reaching for lower-level calls before you need a specific counterparty | +| Run a profile-authored supervisor toward a goal | `supervise(profile, task, { budget, backend? })`: `/kernel` — the profile supplies the standing prompt and capability grants; Runtime adds no default supervisor policy | hand-wiring `createSupervisor().run` + `blobs`/`perWorker`/`journal`/`executors`; reaching for lower-level calls before you need a specific counterparty | | Score a supervised sandbox worker by an executable check **against its live box** | `supervise(..., { backend: { backend: 'sandbox', sandboxClient, validator } })`: `/kernel` — the leaf forwards it to the composed `runAgentRounds`, which calls `validate(output, ctx)` with `ctx.box` still alive, and the verdict lands on the worker's settle | a post-settle hook (the box is destroyed by then), a second scoring loop beside `depthStrategy`, or pairing `validator` with `steering` (refused: a steerable session composes no loop to score) | | Send an external-harness driver back when it FINISHED without delivering | `supervise(..., { deliverable, repromptOnUnmet: 1, onUnmetContract? })`: `/kernel` — a harness owns its own turn loop, so it ends when IT decides it is finished, which can be while the run has produced nothing (measured on discovery-lab, 2026-09-01, n = 1,422 settled runs: 376 of 376 winning runs ended on the driver's own completion, and the completion gate could only label the undelivered result). A re-prompt re-enters the SAME session, coordination server, and live children with the unmet items, under the same budget, deadline, abort, and attempt bounds a retry crosses; a run the coordination server already stopped is never re-prompted | a second `supervise()` call over the same task, a caller-side while-loop around the run, or reading a completed driver as a delivered run | | Run a static root, workers, and analysts as reviewable `AgentProfile` nodes with versioned edge directives | `runGraph(graph, options)`: `/kernel` | a second graph executor, prompt-only roles, or pretending a static graph can discover new nodes while running | @@ -192,7 +192,7 @@ A general "loop" primitive is the single most common modelling error in this rep | State any benchmark/A-B claim | `pairedLift(...)` (bench) over `pairedBootstrap`/`heldoutSignificance` (substrate) | your own bootstrap loop/PRNG per gate; a point lift without `low/high/pairs` | | Let an agent **delegate ONE generic INTENT** (no fixed coder/researcher type) and get the result + real spend SYNCHRONOUSLY | the **`delegate` tool**: `createDelegateHandler` via `createMcpServer({ delegateSupervisor })`; mount it over the `agent-runtime mcp` bin with `MCP_ENABLE_DELEGATE=1` (the bin authors a supervisor over a `sandbox` backend): `/mcp` | a hardcoded coder/researcher profile, or task-specific `delegate_code`/`delegate_research` verbs (RETIRED): `delegate` is the ONE delegation path and the only one with a cost channel | | Run a coding task INSIDE the agent's OWN sandbox session (a sibling box, fresh branch, validated patch) | `detachedSessionDelegate({ sandboxClient \| executor, workerProfile, fanoutProfiles? })`: `/mcp` (the required exact worker profile owns harness, provider, model, prompt, and tools; optional exact profiles make fanout heterogeneous) | a hardcoded coder profile baked into the delegate; `delegate()` (that spawns workers in a *chosen* backend, not the agent's own session) | -| Have a **supervisor spawn + live-drive workers in a backend you choose** and observe or steer them while the coordinator is alive | the **coordination MCP** via `createCoordinationTools` / `serveCoordinationMcp` over a live `Scope`; each worker's leaf is `createExecutor({ backend })`. The spawn verb is **`spawn_worker`** — say that exact word in any prompt, skill, or authored profile | `detachedSessionDelegate`, which is own-sandbox-session only and one-shot. Supervised-tree restart recovery is not implemented. | +| Have a **supervisor spawn + live-drive workers in a backend you choose** and observe or steer them while the coordinator is alive | the **coordination MCP** via `createCoordinationTools` / `serveCoordinationMcp` over a live `Scope`; each worker's leaf is `createExecutor({ backend })`. The spawn verb is **`spawn_worker`** — say that exact word in any prompt, skill, or authored profile. With `runDir` and a stable `runId`, the recorded tree resumes after coordinator restart. | `detachedSessionDelegate`, which is own-sandbox-session only and one-shot; a second restart protocol or replacement dispatch for `in-doubt` work. | | Name a NEW tool this package publishes to a model (or check a prompt names the runtime's tool, not the harness's) | `collidesWithHarnessNativeTool(name)` / `harnessNativeToolNames(harness)`: `/mcp` — the sourced sub-agent tools each harness publishes to its own model. `tests/kernel/harness-native-tools.test.ts` sweeps every name the coordination, peer-mail, delegation, memory, and code-mode builders publish; `tests/kernel/skill-tool-names.test.ts` sweeps the shipped `skills/**/SKILL.md`, which are prompt text and ship in the tarball | a bare word a harness also publishes (codex publishes `spawn_agent`, claude-code `Agent`/`Task`, opencode `task`): the MCP prefix keeps the wire clear, but a prompt's bare word reaches the HARNESS's tool, and that worker gets no journal row, no reservation from the conserved pool, and no grade | | Let a LEAD compose its own children in code — fan out, chain, join, retry — inside one tool call | `SuperviseOptions.resolveSupervisorTools`, then `context.verbs` (`CoordinationVerbs`: `spawnAgent`/`awaitEvent`/`steerAgent`/`observeAgent`/`listQuestions`/`answerQuestion`/`runAnalyst`) in the tool handler: `/kernel` — each verb dispatches to this manager's live coordination handler, so a coded spawn crosses the same authorizeSpawn, allowedModels, pool reservation, `maxLiveWorkers`, journal, and bus as the MCP verb, on both arms and at every depth | a product-side script host driving the run's own coordination MCP over a pinned port, `runGraph` from inside a tool handler (it opens a second conserved pool), or a compose tool that only mounts on the root manager | | Stop a child BEFORE it spends when the backend already knows it will fail — an unrouted model wire id, a bridge already at admission capacity, a coordination tool no verb names | **spawn pre-flight**: `supervise(profile, task, { backend: { backend: 'bridge', … } })` installs one automatically, or `createCoordinationTools({ preflightSpawn })` / `serveCoordinationMcp({ preflightSpawn })` for a custom gate; it runs before the assignment, the reservation and the journal write, returns `{ error: 'preflight-refused', cause, detail }` to the lead, and counts every refusal on `stats().preflight` | a check inside `makeWorkerAgent` or `authorizeSpawn` (both SYNCHRONOUS — they cannot ask the backend anything), reading the failure back off the settled child, or a gate that skips itself when the bridge does not answer | diff --git a/examples/graphs/analyst-agent-review.ts b/examples/graphs/analyst-agent-review.ts index 19f68b102..c61f6cfcb 100644 --- a/examples/graphs/analyst-agent-review.ts +++ b/examples/graphs/analyst-agent-review.ts @@ -27,7 +27,16 @@ export function analystAgentReview(): { graph: AgentGraph; opts: RunGraphTestOpt // ── The topology: plain data (the analyst is the 'reviewer' NODE, not a registry lens) ── const graph: AgentGraph = { nodes: [ - { id: 'driver', profile: offlineProfile('driver', 'Drive the build.') }, + { + id: 'driver', + profile: { + ...offlineProfile('driver', 'Drive the build.'), + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + }, + }, + }, { id: 'implementer', profile: offlineProfile('implementer', 'Build.') }, { id: 'reviewer', diff --git a/examples/graphs/best-of-n.ts b/examples/graphs/best-of-n.ts index c04a10cd6..12e255513 100644 --- a/examples/graphs/best-of-n.ts +++ b/examples/graphs/best-of-n.ts @@ -21,7 +21,16 @@ export function bestOfN(): { graph: AgentGraph; opts: RunGraphTestOptions } { // ── The topology: plain data ── const graph: AgentGraph = { nodes: [ - { id: 'lead', profile: offlineProfile('lead', 'Keep the best.') }, + { + id: 'lead', + profile: { + ...offlineProfile('lead', 'Keep the best.'), + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + }, + }, + }, { id: 'coder-a', profile: offlineProfile('coder-a', 'Minimal diff.') }, { id: 'coder-b', profile: offlineProfile('coder-b', 'Full rewrite.') }, ], diff --git a/examples/graphs/collaborates-review-loop.ts b/examples/graphs/collaborates-review-loop.ts index b034259e3..1a2fa5f74 100644 --- a/examples/graphs/collaborates-review-loop.ts +++ b/examples/graphs/collaborates-review-loop.ts @@ -48,7 +48,16 @@ export function collaboratesReviewLoop(): { graph: AgentGraph; opts: RunGraphTes // ── The topology: plain data ── const graph: AgentGraph = { nodes: [ - { id: 'driver', profile: offlineProfile('driver', 'Drive the loop.') }, + { + id: 'driver', + profile: { + ...offlineProfile('driver', 'Drive the loop.'), + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + }, + }, + }, { id: 'implementer', profile: offlineProfile('implementer', 'Build.') }, { id: 'reviewer', profile: offlineProfile('reviewer', 'Review.') }, ], diff --git a/examples/graphs/shot-loop-resumed.ts b/examples/graphs/shot-loop-resumed.ts index 8019efffb..71102733e 100644 --- a/examples/graphs/shot-loop-resumed.ts +++ b/examples/graphs/shot-loop-resumed.ts @@ -33,7 +33,16 @@ export function shotLoopResumed(): { // ── The topology: plain data — continuity is one field on the edge ── const graph: AgentGraph = { nodes: [ - { id: 'reviewer', profile: offlineProfile('reviewer', 'Verify.') }, + { + id: 'reviewer', + profile: { + ...offlineProfile('reviewer', 'Verify.'), + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + }, + }, + }, { id: 'coder', profile: offlineProfile('coder', 'Make tests pass.') }, ], edges: [ diff --git a/examples/graphs/shot-loop.ts b/examples/graphs/shot-loop.ts index 27258fc83..6c7e98330 100644 --- a/examples/graphs/shot-loop.ts +++ b/examples/graphs/shot-loop.ts @@ -36,7 +36,16 @@ export function shotLoop(): { graph: AgentGraph; opts: RunGraphTestOptions } { // ── The topology: plain data ── const graph: AgentGraph = { nodes: [ - { id: 'reviewer', profile: offlineProfile('reviewer', 'Verify.') }, + { + id: 'reviewer', + profile: { + ...offlineProfile('reviewer', 'Verify.'), + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + }, + }, + }, { id: 'coder', profile: offlineProfile('coder', 'Make tests pass.') }, ], edges: [ diff --git a/examples/graphs/user-sim-conversation.ts b/examples/graphs/user-sim-conversation.ts index e97358ac6..4b44b44ae 100644 --- a/examples/graphs/user-sim-conversation.ts +++ b/examples/graphs/user-sim-conversation.ts @@ -58,10 +58,16 @@ export function userSimConversation(): { nodes: [ { id: 'user-sim', - profile: offlineProfile( - 'user-sim', - 'You are Ada, a busy founder buying a team plan. Terse. SSO is non-negotiable.', - ), + profile: { + ...offlineProfile( + 'user-sim', + 'You are Ada, a busy founder buying a team plan. Terse. SSO is non-negotiable.', + ), + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + }, + }, }, { id: 'product-agent', diff --git a/examples/graphs/watchdog-steer.ts b/examples/graphs/watchdog-steer.ts index 018fd479d..5935645d0 100644 --- a/examples/graphs/watchdog-steer.ts +++ b/examples/graphs/watchdog-steer.ts @@ -50,7 +50,17 @@ export function watchdogSteer(): { graph: AgentGraph; opts: RunGraphTestOptions // ── The topology: plain data ── const graph: AgentGraph = { nodes: [ - { id: 'driver', profile: offlineProfile('driver', 'Watch and steer.') }, + { + id: 'driver', + profile: { + ...offlineProfile('driver', 'Watch and steer.'), + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + agent_runtime_coordination_steer_agent: true, + }, + }, + }, { id: 'builder', profile: offlineProfile('builder', 'Build.') }, ], edges: [{ kind: 'delegates', from: 'driver', to: 'builder', directive: brief }], diff --git a/examples/p1-parity/parity.test.ts b/examples/p1-parity/parity.test.ts index f7eaaf38a..d59e49d4d 100644 --- a/examples/p1-parity/parity.test.ts +++ b/examples/p1-parity/parity.test.ts @@ -56,6 +56,10 @@ const parityCell = (shots: number): CellSpec => ({ reviewerProfile: { name: 'reviewer', harness: 'cli-base', + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + }, model: { provider: 'scripted', default: 'scripted/parity-reviewer', diff --git a/examples/p1-parity/run-parity.ts b/examples/p1-parity/run-parity.ts index f33ec9d1c..50aabf9d6 100644 --- a/examples/p1-parity/run-parity.ts +++ b/examples/p1-parity/run-parity.ts @@ -97,6 +97,10 @@ function parityCell(index: number, shots: number): CellSpec { reviewerProfile: { name: 'reviewer', harness: 'cli-base', + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + }, model: { provider: 'scripted', default: 'scripted/parity-reviewer', diff --git a/examples/supervise/supervise.ts b/examples/supervise/supervise.ts index 6bfb0df87..4b19d9221 100644 --- a/examples/supervise/supervise.ts +++ b/examples/supervise/supervise.ts @@ -32,10 +32,11 @@ async function main(): Promise { name: 'supervisor', harness: 'cli-base', // in-process router brain (the supervisor calls spawn/await/stop) model: { provider: 'tangle-router', default: model }, - // 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). + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + agent_runtime_coordination_stop: true, + }, prompt: { systemPrompt: 'You are a supervisor. Produce the deliverable by delegating:\n' + diff --git a/examples/supervisor-loop/run-supervisor-mcp.ts b/examples/supervisor-loop/run-supervisor-mcp.ts index b36624f92..f7559c819 100644 --- a/examples/supervisor-loop/run-supervisor-mcp.ts +++ b/examples/supervisor-loop/run-supervisor-mcp.ts @@ -141,6 +141,7 @@ async function main(): Promise { describe: `worker output contains ${expectedAnswer}`, }), perWorker: { maxIterations: 2, maxTokens: 200_000 }, + toolNames: ['spawn_worker', 'await_event', 'stop'], }) try { console.log(`[mcp] coordination server at ${mcp.url}`) diff --git a/skills/codemode/SKILL.md b/skills/codemode/SKILL.md index 345a1e1c5..bb71b710b 100644 --- a/skills/codemode/SKILL.md +++ b/skills/codemode/SKILL.md @@ -23,8 +23,8 @@ In a coding harness you already have the whole capability: a shell, a filesystem ## Boundaries That Are Not Yours To Move -Spawning, steering, and settling agents go through your coordination tools, never through a script. -A script that reaches those verbs over HTTP bypasses the budget pool and the journal, so the run's spend and record lie. +Code may spawn or steer only through Runtime-provided API bindings such as `api.spawn_worker`. +Never reach coordination verbs over HTTP or create a second scheduler; that bypasses the budget pool and journal. An operation that costs money must run where the runtime meters it; do not wrap metered work in a script that hides the spend. The lint on authored code refuses imports, `process`, and network access; it is a lint, not a sandbox, so treat generated code you did not review as untrusted. diff --git a/skills/supervise/SKILL.md b/skills/supervise/SKILL.md index 14038153b..6783f2ce2 100644 --- a/skills/supervise/SKILL.md +++ b/skills/supervise/SKILL.md @@ -28,21 +28,39 @@ Use only fields the selected backend can materialize. "name": "source-skeptic-v1", "description": "Challenge one candidate claim against primary evidence.", "prompt": { - "appendSystemPrompt": "Return a claim table with source locations, contradictions, unknowns, and a reproducible rejection check." + "systemPrompt": "Return a claim table with source locations, contradictions, unknowns, and a reproducible rejection check." }, "model": { "default": "", "reasoningEffort": "xhigh" }, - "metadata": { - "role": "driver" + "tools": { + "agent_runtime_coordination_spawn_worker": true, + "agent_runtime_coordination_await_event": true + }, + "resources": { + "failOnError": true, + "skills": [ + { + "kind": "inline", + "name": "profile-authoring/SKILL.md", + "content": "" + } + ] } } ``` The example shows placement, not required values. -Use `metadata.role: 'driver'` only when this child should author and supervise descendants. -Omit that role for a leaf. +Every agent is the same `AgentProfile` shape. +An agent becomes a recursive lead only by declaring `agent_runtime_coordination_spawn_worker: true`. +Declare each other Runtime verb it will use, such as `await_event`, `steer_agent`, or `read_journal`. +Runtime mounts only the declared bare verbs through its coordination surface; the provider receives a profile projection without Runtime-owned declarations. +Metadata can describe the work, but it never grants execution authority. +Every profile that can spawn workers carries the complete profile-authoring skill in `resources.skills`. +Make that resource an immutable inline snapshot or a pinned reference, and set `resources.failOnError: true`. +This is taught through the profile, not injected or enforced by Runtime: the authored profile remains the complete record of why it can delegate. +Omit Runtime coordination tools for a leaf. The task argument names the concrete artifact and a check that can fail. The profile names how the agent works and which capabilities it receives. @@ -74,7 +92,7 @@ Inspect the artifact and its independent completion result. Preserve exact profile identities, assignment keys, parent-child links, continuations, costs, failures, and unknown accounting. Worker prose cannot promote its own result. -Use `submit_result` only when the attached completion check can validate the root's own artifact. +Use `submit_result` only when the attached completion check can validate this agent's own artifact. Calling `stop` ends coordination; it does not turn missing evidence into success. ## Exact contracts diff --git a/src/mcp/in-process-executor.ts b/src/mcp/in-process-executor.ts index b4f5364b6..0ff3f548c 100644 --- a/src/mcp/in-process-executor.ts +++ b/src/mcp/in-process-executor.ts @@ -37,7 +37,7 @@ export interface InProcessExecutorOptions { testCmd?: string /** Optional per-delegation typecheck command. Same shape as `testCmd`. */ typecheckCmd?: string - /** Wall-clock cap per harness subprocess (ms). Default 5min. */ + /** Optional wall-clock cap per harness subprocess (ms). Omit it for no timer. */ harnessTimeoutMs?: number /** Wall-clock cap per test/typecheck subprocess (ms). Default 2min. */ postCheckTimeoutMs?: number @@ -70,7 +70,6 @@ interface VirtualSandbox extends SandboxInstance { } } -const DEFAULT_HARNESS_TIMEOUT_MS = 5 * 60 * 1000 const DEFAULT_POSTCHECK_TIMEOUT_MS = 2 * 60 * 1000 /** @@ -162,7 +161,9 @@ export function createInProcessExecutor(options: InProcessExecutorOptions): Dele harness: localHarness, taskPrompt, runId, - harnessTimeoutMs: options.harnessTimeoutMs ?? DEFAULT_HARNESS_TIMEOUT_MS, + ...(options.harnessTimeoutMs !== undefined + ? { harnessTimeoutMs: options.harnessTimeoutMs } + : {}), checkTimeoutMs: options.postCheckTimeoutMs ?? DEFAULT_POSTCHECK_TIMEOUT_MS, ...(options.testCmd !== undefined ? { testCmd: options.testCmd } : {}), ...(options.typecheckCmd !== undefined ? { typecheckCmd: options.typecheckCmd } : {}), diff --git a/src/mcp/local-harness.ts b/src/mcp/local-harness.ts index 2b34b2c40..52e64958a 100644 --- a/src/mcp/local-harness.ts +++ b/src/mcp/local-harness.ts @@ -426,7 +426,8 @@ export interface RunLocalHarnessOptions { /** Absolute host paths that reproducible Codex must not read. The normalized set is compiled * into the controlled permission profile and its digest is returned in execution evidence. */ codexReadDeniedPaths?: ReadonlyArray - /** Wall-clock kill deadline (ms). Default 5 min. Subprocess SIGTERMed on expiry. */ + /** Optional wall-clock kill deadline (ms). Omit it for no timer. A positive value sends + * SIGTERM on expiry. */ timeoutMs?: number /** Newest stdout/stderr bytes retained per stream. Default 64 MiB. */ maxOutputBytes?: number @@ -539,7 +540,6 @@ export interface LocalHarnessResult { evidence?: CodexExecutionEvidence } -const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000 const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024 * 1024 const processKillGraceMs = 250 const processGroupExitConfirmMs = 1_000 @@ -620,7 +620,7 @@ export async function runLocalHarness( if (options.codexReadDeniedPaths !== undefined && !options.codexReproducible) { throw new Error('runLocalHarness: codexReadDeniedPaths requires codexReproducible') } - const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const timeoutMs = options.timeoutMs const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 0) { throw new Error('runLocalHarness: maxOutputBytes must be a non-negative safe integer') @@ -747,7 +747,7 @@ export async function runLocalHarness( } timer = - timeoutMs > 0 + timeoutMs !== undefined && timeoutMs > 0 ? setTimeout(() => { timedOut = true void terminate() diff --git a/src/profiles/researcher.ts b/src/profiles/researcher.ts index cccbf5d02..fbbe81803 100644 --- a/src/profiles/researcher.ts +++ b/src/profiles/researcher.ts @@ -143,7 +143,7 @@ export function researcherProfile(options: ResearcherProfileOptions & { task?: R description: base.description ?? "Source-grounded research agent. Propose-don't-apply.", prompt: { ...base.prompt, systemPrompt }, tools: { web_search: true, fs: true, shell: true, ...base.tools }, - metadata: { ...base.metadata, role: 'researcher' }, + metadata: { ...base.metadata, specialty: 'researcher' }, } const output: OutputAdapter = { parse: parseResearcherEvents } const validator: Validator = options.task diff --git a/src/runtime/index.ts b/src/runtime/index.ts index cc075334a..53bb22fbc 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -857,7 +857,6 @@ export { type PromptRegistry, promptHandle, type RegisteredPrompt, - supervisorPolicyPrompt, } from './supervise/prompt-registry' // One-call durable Runtime owner for external clients that need a real root, one interactive worker, // the worker control acknowledger, and an exact cleanup receipt. Braid consumes this surface and diff --git a/src/runtime/strategy.ts b/src/runtime/strategy.ts index df57c1365..1fc4acab1 100644 --- a/src/runtime/strategy.ts +++ b/src/runtime/strategy.ts @@ -17,8 +17,8 @@ * Both are an `Agent` whose `act` spawns leaf shots through `scope.spawn` and reacts via * `scope.next()` — so the conserved budget pool meters them (equal-k by construction), the journal * records the tree, and the same primitive nests. `runAgentic` runs the chosen driver through - * `createSupervisor().run`. The leaf (one shot over a handle) is resolved per-spawn from a - * surface-closed registry — the open `Executor` seam, not bespoke per-benchmark glue. + * `createSupervisor().run`. Each leaf carries its own per-spawn executor factory over the open + * `Executor` seam, rather than using profile metadata to select behavior. */ import type { ChatClient } from '@tangle-network/agent-eval' @@ -36,16 +36,13 @@ import { concreteModelId, profileModelExecutionSettings, } from './supervise/model-policy' -import { createExecutor } from './supervise/runtime' +import { createExecutor, createExecutorRegistry } from './supervise/runtime' import { createSupervisor } from './supervise/supervisor' import type { Agent, AgentSpec, Budget, Executor, - ExecutorContext, - ExecutorFactory, - ExecutorRegistry, ExecutorResult, ExecutorToolCall, Scope, @@ -459,7 +456,7 @@ async function renderCorpusReadback(opts: AgenticOptions): Promise { return `Relevant learned facts from prior attempts:\n${rendered.join('\n')}` } -// ── Leaf executors (one shot / one analyst), resolved per-spawn from the surface ── +// ── Leaf executors (one shot / one analyst), constructed per spawn from the surface ── /** Measured result of one strategy shot. */ export interface StrategyShotResult { @@ -587,49 +584,32 @@ function analystExecutor(opts: AgenticOptions): Executor { } } -/** - * Registry dispatching on the child's role tag — fresh executor per spawn (no - * shared-instance race). `withDriverExecutor` wraps it so a `role:'driver'` child resolves - * to the recursive driver-executor (a child that drives its OWN children — agents drive - * agents) before this leaf dispatch; `shot`/`analyst` children resolve to their leaf - * executors here unchanged. - */ -function agenticRegistry(surface: AgenticSurface, opts: AgenticOptions): ExecutorRegistry { - const leaves: ExecutorRegistry = { - register() { - throw new Error('agenticRegistry: register unsupported') - }, - resolve(spec: AgentSpec) { - const role = (spec.profile.metadata as { role?: string } | undefined)?.role - const factory: ExecutorFactory = (_s: AgentSpec, _ctx: ExecutorContext) => - (role === 'analyst' ? analystExecutor(opts) : shotExecutor(surface, opts)) as Executor - return { succeeded: true as const, value: factory } - }, - } - return withDriverExecutor(leaves) -} - function leaf( name: string, - role: 'shot' | 'analyst', + kind: 'shot' | 'analyst', profile: AgentProfile, + surface: AgenticSurface, + opts: AgenticOptions, ): Agent> { - const exactProfile = exactAgenticProfile(profile, `agentic ${role}`) + const exactProfile = exactAgenticProfile(profile, `agentic ${kind}`) const agent = { name, executorSpec: { profile: { ...exactProfile, name, - metadata: { ...exactProfile.metadata, role }, }, harness: null, + // The execution behavior is a trusted, per-spawn factory. It is not profile metadata, so + // changing a descriptive profile field can never select a different Runtime authority path. + executorFactory: () => + (kind === 'analyst' + ? analystExecutor(opts) + : shotExecutor(surface, opts)) as Executor, } as AgentSpec, act(): Promise> { - // SPAWNED, not run: its `executorSpec` (role shot/analyst) resolves a leaf executor - // the scope drives. `act` is never called for a spawned child; it fails loud if - // mis-used as a root. A `role:'driver'` child instead resolves to the recursive - // driver-executor (agents drive agents) — see `withDriverExecutor`. + // SPAWNED, not run: its `executorSpec` resolves its per-spawn leaf executor. `act` is never + // called for a spawned child; it fails loud if mis-used as a root. throw new Error(`agentic: spawned child "${name}" was run directly (the executor drives it)`) }, } @@ -692,7 +672,7 @@ export function depthStrategy( let shots = 0 try { for (shots = 0; shots < cfg.maxShots; shots += 1) { - const child = leaf(`shot:${shots}`, 'shot', opts.workerProfile) + const child = leaf(`shot:${shots}`, 'shot', opts.workerProfile, surface, opts) const memorySteer = await renderCorpusReadback(opts) const steer = [shots === 0 ? undefined : pendingSteer, memorySteer] .filter((part): part is string => typeof part === 'string' && part.trim().length > 0) @@ -714,6 +694,8 @@ export function depthStrategy( `analyst:${shots}`, 'analyst', opts.analystProfile ?? opts.workerProfile, + surface, + opts, ) const aRes = scope.spawn( aChild, @@ -762,7 +744,7 @@ export function breadthStrategy( let opened = 0 for (let k = 0; k < cfg.width; k += 1) { const res = scope.spawn( - leaf(`rollout:${k}`, 'shot', opts.workerProfile), + leaf(`rollout:${k}`, 'shot', opts.workerProfile, _surface, opts), { task } as ShotTask, { budget: perChild(innerTurns), @@ -938,7 +920,7 @@ export function defineStrategy( async shot(spec) { const profile = spec?.profile ?? opts.workerProfile const innerTurns = profileTurnLimit(profile, 'authored strategy shot') - const child = leaf(`shot:${seq}`, 'shot', profile) + const child = leaf(`shot:${seq}`, 'shot', profile, surface, opts) seq += 1 const res = scope.spawn( child, @@ -972,6 +954,8 @@ export function defineStrategy( `analyst:${seq}`, 'analyst', opts.analystProfile ?? opts.workerProfile, + surface, + opts, ) seq += 1 const res = scope.spawn( @@ -990,6 +974,8 @@ export function defineStrategy( `analyst:${seq}`, 'analyst', opts.analystProfile ?? opts.workerProfile, + surface, + opts, ) seq += 1 const res = scope.spawn( @@ -1176,7 +1162,7 @@ export async function runAgentic runId: `agentic:${strategy.name}:${opts.task.id}`, journal: new InMemorySpawnJournal(), blobs: new InMemoryResultBlobStore(), - executors: agenticRegistry(opts.surface, exactOpts), + executors: withDriverExecutor(createExecutorRegistry()), maxDepth: 3, ...(opts.hooks ? { hooks: opts.hooks } : {}), }) diff --git a/src/runtime/supervise/authoring.ts b/src/runtime/supervise/authoring.ts index 92ebf8014..0fc05e9d7 100644 --- a/src/runtime/supervise/authoring.ts +++ b/src/runtime/supervise/authoring.ts @@ -19,7 +19,6 @@ import { type AgentProfilePrompt, agentProfileSchema, } from '@tangle-network/agent-interface' -import { supervisorPolicyPrompt } from './prompt-registry' /** 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. */ @@ -45,17 +44,10 @@ export function asAuthoredProfile(raw: unknown): AuthoredProfile | null { } } -/** The supervisor SKILL — the how-to the supervisor reads (its system prompt). THE optimizable - * surface: editing this changes how the supervisor designs every agent it spawns. - * - * The POLICY paragraph is the registry's one `supervisor/policy` entry — the same stance - * `defaultSupervisorPrompt` carries — so both front doors run the same work-vs-delegate rule; - * this function ADDS the profile-authoring skill (how to WRITE the workers it spawns), which is - * additive craft, not a different policy. */ +/** The supervisor skill: an explicit profile-authoring instruction, never an implicit Runtime + * policy. Editing this text changes how a profile designs the descendants it spawns. */ export function supervisorInstructions(opts?: { goal?: string }): string { return [ - supervisorPolicyPrompt.text, - '', 'Your delegation craft is AUTHORING: a spawned worker is exactly as good as the profile you write.', '', 'For the task you are given:', @@ -65,7 +57,9 @@ export function supervisorInstructions(opts?: { goal?: string }): string { ' • 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.', + ' • tools.agent_runtime_coordination_spawn_worker: true ONLY when this child should author and drive descendants. Add only the other agent_runtime_coordination_ tools it will call, such as await_event or steer_agent.', + ' • A child with spawn_worker MUST carry this complete profile-authoring instruction as an immutable resources.skills entry with resources.failOnError: true, so it can author its own descendants from the same contract.', + ' • metadata may describe the work, but it never grants recursion or selects a Runtime execution path.', ' 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 profile whose prompt.systemPrompt names the SPECIFIC failure and how to fix it — never just retry the same profile.', diff --git a/src/runtime/supervise/bridge-executor.test.ts b/src/runtime/supervise/bridge-executor.test.ts index 89d586592..f9e28c15c 100644 --- a/src/runtime/supervise/bridge-executor.test.ts +++ b/src/runtime/supervise/bridge-executor.test.ts @@ -1422,7 +1422,7 @@ describe('bridgeExecutor upstream-error propagation', () => { }, hooks: { afterTool: [{ command: './record-result', blocking: true }] }, modes: { adversarial: { prompt: 'Try to falsify the leading claim.' } }, - metadata: { role: 'driver', source: 'test-fixture' }, + metadata: { source: 'test-fixture' }, } const executor = bridgeExecutor( { profile, harness: null }, diff --git a/src/runtime/supervise/code-mode.ts b/src/runtime/supervise/code-mode.ts index 0122e59b1..8b4806860 100644 --- a/src/runtime/supervise/code-mode.ts +++ b/src/runtime/supervise/code-mode.ts @@ -339,12 +339,14 @@ export function codeModeSupervisorTools( else context.signal.addEventListener('abort', onScopeAbort, { once: true }) const bindings: Record Promise> = {} - for (const [wire, member] of Object.entries(CODE_CALLABLE_VERBS)) { - bindings[wire] = async (args: unknown) => { + for (const face of faces(context)) { + const member = CODE_CALLABLE_VERBS[face.name as keyof typeof CODE_CALLABLE_VERBS] + if (member === undefined) continue + bindings[face.name] = async (args: unknown) => { if (execution.signal.aborted) { if (!timedOut) throw abortReason(execution.signal) throw new ValidationError( - `code mode: the execute deadline passed; api.${wire} is refused so no work outlives the call`, + `code mode: the execute deadline passed; api.${face.name} is refused so no work outlives the call`, ) } return detach(await context.verbs[member](args)) diff --git a/src/runtime/supervise/coordination-driver.ts b/src/runtime/supervise/coordination-driver.ts index 1b4f41073..1e025283a 100644 --- a/src/runtime/supervise/coordination-driver.ts +++ b/src/runtime/supervise/coordination-driver.ts @@ -120,6 +120,8 @@ export interface DriverAgentOptions { /** 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. */ readonly deliverable?: DeliverableSpec + /** Receives a result only after this manager's completion check accepted it. */ + readonly onAcceptedSubmission?: (result: unknown) => void /** Hard cap on simultaneously-LIVE workers — `spawn_worker` fails closed once this many are in * flight (a concurrency fence on top of the conserved-pool fence). Omit/`<= 0` = no cap. */ readonly maxLiveWorkers?: number @@ -156,6 +158,9 @@ export interface DriverAgentOptions { /** 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 + /** Exact bare names to expose from the coordination, node-tool, and direct-work-tool set. + * Runtime never grants an implicit complete tool set. */ + readonly toolNames: 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 @@ -827,6 +832,12 @@ export function driverAgent(opts: DriverAgentOptions): Agent { if (typeof opts.brain !== 'function') { throw new ValidationError('driverAgent: opts.brain must be a function') } + if (!Array.isArray(opts.toolNames)) { + throw new ValidationError('driverAgent: toolNames must name every granted tool explicitly') + } + if (new Set(opts.toolNames).size !== opts.toolNames.length) { + throw new ValidationError('driverAgent: toolNames contains a duplicate name') + } // Fail loud on a half-wired work-tool seam: extra tool specs with no executor (or an executor // with no specs the model can see) is a silent no-op the house rules forbid. if ((opts.extraTools?.length ?? 0) > 0 && typeof opts.executeExtraTool !== 'function') { @@ -918,9 +929,32 @@ export function driverAgent(opts: DriverAgentOptions): Agent { : {}), }) await coord.ready() + const availableTools = [...coord.tools, ...(opts.nodeTools ?? [])] + const availableByName = new Map(availableTools.map((tool) => [tool.name, tool])) + const extraByName = new Map((opts.extraTools ?? []).map((tool) => [tool.name, tool])) + const selectedNames = opts.toolNames + const selectedTools = selectedNames.map((name) => { + const descriptor = availableByName.get(name) + if (descriptor !== undefined) return { kind: 'descriptor' as const, descriptor } + const extra = extraByName.get(name) + if (extra === undefined) { + throw new ValidationError( + `driverAgent: requested tool ${JSON.stringify(name)} is unavailable`, + ) + } + return { kind: 'extra' as const, extra } + }) + const modelTools = selectedTools.flatMap((selected) => + selected.kind === 'descriptor' ? [selected.descriptor] : [], + ) + const selectedExtraNames = new Set( + selectedTools.flatMap((selected) => + selected.kind === 'extra' ? [selected.extra.name] : [], + ), + ) // Before the first brain turn: a node tool invoked on turn one must already be able to call // these verbs. - opts.onCoordinationTools?.(coord.tools) + opts.onCoordinationTools?.(modelTools) // The worker-cancel acknowledger, mounted only for a durable run that named its layout dir. // It runs inside this existing turn loop — the one place that already runs every turn and // already holds the child handles — so external cancellation needs no second lifetime. @@ -957,24 +991,26 @@ export function driverAgent(opts: DriverAgentOptions): Agent { ) } } - const byName = new Map( - [...coord.tools, ...(opts.nodeTools ?? [])].map((t) => [t.name, t]), + const byName = new Map(modelTools.map((tool) => [tool.name, tool])) + const toolSpecs: ToolSpec[] = selectedTools.map((selected) => + selected.kind === 'descriptor' + ? { + type: 'function' as const, + function: { + name: selected.descriptor.name, + description: selected.descriptor.description, + parameters: selected.descriptor.inputSchema, + }, + } + : { + type: 'function' as const, + function: { + name: selected.extra.name, + description: selected.extra.description, + parameters: selected.extra.parameters, + }, + }, ) - 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, - function: { name: t.name, description: t.description, parameters: t.parameters }, - })), - ] const system = typeof opts.systemPrompt === 'function' ? opts.systemPrompt(task) : opts.systemPrompt @@ -1144,7 +1180,7 @@ export function driverAgent(opts: DriverAgentOptions): Agent { execute: async (name, args) => { // WORK FIRST: a work tool the driver runs itself (act). A non-null return is handled here; // null/undefined means "not mine" → fall through to the coordination dispatch (spawn/await/…). - if (opts.executeExtraTool) { + if (opts.executeExtraTool && selectedExtraNames.has(name)) { const worked = await runExtraTool(opts.executeExtraTool, name, args) if (worked !== null && worked !== undefined) return worked } @@ -1230,7 +1266,10 @@ export function driverAgent(opts: DriverAgentOptions): Agent { // check workers face. Raw driver prose remains ineligible. The first passing submission wins; // otherwise finalize over delivered children as before. const submitted = coord.submittedResult() - if (submitted) return submitted.result + if (submitted) { + opts.onAcceptedSubmission?.(submitted.result) + return submitted.result + } return runFinalizer(opts.finalizer ?? bestDelivered, { settled: coord.settled(), blobs: opts.blobs, diff --git a/src/runtime/supervise/coordination-mcp.ts b/src/runtime/supervise/coordination-mcp.ts index ff12923d3..136f3c8bd 100644 --- a/src/runtime/supervise/coordination-mcp.ts +++ b/src/runtime/supervise/coordination-mcp.ts @@ -21,9 +21,9 @@ import { createServer, type Server } from 'node:http' import type { AgentProfile } from '@tangle-network/agent-interface' -import { ConfigError } from '../../errors' +import { ConfigError, ValidationError } from '../../errors' import type { JsonRpcMessage } from '../../mcp/protocol' -import { createMcpServer, type McpToolDescriptor } from '../../mcp/server' +import type { McpToolDescriptor } from '../../mcp/server' import { createStdioToolServer, type StdioToolServer } from '../../mcp/tool-server' import { type AnalystRegistry, @@ -146,6 +146,9 @@ export async function serveCoordinationMcp(opts: { /** 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 + /** Exact bare tool names to expose from the coordination and node-tool set. Runtime never + * grants an implicit complete tool set. An unknown name fails before the listener opens. */ + toolNames: ReadonlyArray /** * OPT-IN peer mail: let this manager's workers message each other directly, bounded and audited * (`runtime/supervise/peer-mail`). Each spawn receives a capability URL on @@ -228,14 +231,43 @@ export async function serveCoordinationMcp(opts: { : {}), }) await coord.ready() - const servedTools = [...coord.tools, ...(opts.nodeTools ?? [])] - const mcp = createMcpServer({ - extraTools: servedTools, + const reservedNames = new Set(coord.tools.map((tool) => tool.name)) + for (const tool of opts.nodeTools ?? []) { + if (reservedNames.has(tool.name)) { + throw new ValidationError( + `serveCoordinationMcp: node tool ${JSON.stringify(tool.name)} shadows a coordination verb or another node tool`, + ) + } + reservedNames.add(tool.name) + } + const availableTools = [...coord.tools, ...(opts.nodeTools ?? [])] + const availableByName = new Map(availableTools.map((tool) => [tool.name, tool])) + if (!Array.isArray(opts.toolNames)) { + throw new ValidationError( + 'serveCoordinationMcp: toolNames must name every granted tool explicitly', + ) + } + const selectedNames = opts.toolNames + if (new Set(selectedNames).size !== selectedNames.length) { + throw new ValidationError('serveCoordinationMcp: toolNames contains a duplicate name') + } + const servedTools = selectedNames.map((name) => { + const tool = availableByName.get(name) + if (tool === undefined) { + throw new ValidationError( + `serveCoordinationMcp: requested tool ${JSON.stringify(name)} is unavailable`, + ) + } + return tool + }) + const mcp = createStdioToolServer({ serverName: 'coordination', + serverVersion: '1', + tools: servedTools, }) // Before the listener opens: a node tool invoked on the first request must already be able to // call these verbs. Read back the server's ordered set so every consumer records the exact MCP - // surface, including the shared tools that `createMcpServer` mounts before coordination tools. + // surface. opts.onCoordinationTools?.([...mcp.tools.values()]) const server: Server = createServer((req, res) => { diff --git a/src/runtime/supervise/driver-executor.ts b/src/runtime/supervise/driver-executor.ts index 912eba7ad..37908bef2 100644 --- a/src/runtime/supervise/driver-executor.ts +++ b/src/runtime/supervise/driver-executor.ts @@ -78,6 +78,9 @@ interface DriverSpec extends AgentSpec { /** 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). */ readonly journal: SpawnJournal + /** Reads whether this manager accepted a direct result through its assignment's completion + * check. The check itself runs in the manager, exactly once, before this executor settles. */ + readonly acceptedSubmission?: () => boolean } /** @@ -92,6 +95,7 @@ export function driverChild( driver: Agent, journal: SpawnJournal, execution?: AgentExecutionRef, + acceptedSubmission?: () => boolean, ): Agent { const name = profile.name ?? driver.name const rawSpec: DriverSpec = { @@ -101,6 +105,7 @@ export function driverChild( driverRuntime, driver: driver as Agent, journal, + ...(acceptedSubmission ? { acceptedSubmission } : {}), } const spec = executableAgentSpecSnapshot(rawSpec, 'driverChild') as DriverSpec const deliver = driver.deliver?.bind(driver) @@ -227,11 +232,10 @@ export const driverExecutorFactory: ExecutorFactory = (rawSpec, ctx) => 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) + // A manager may finish work itself through an assignment-selected completion check. That + // accepted submission is already independent evidence, so carry it to this settlement + // instead of requiring an unrelated nested child or running the check again. + const verdict = deriveDeliveryVerdict(settled, out, spec.acceptedSubmission?.() === true) artifact = { outRef: `${driverRuntime}:${nestedRoot}`, out, @@ -405,16 +409,19 @@ async function safeRollup( } } -/** 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; +/** Derive the driver child's delivery verdict from an accepted direct submission, or from its + * DIRECT children's settlements and the manager's actual finalized output: `valid` iff the + * manager's own assignment check accepted a submission, or 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, + acceptedSubmission: boolean, ): DefaultVerdict | undefined { + if (acceptedSubmission) return { valid: true, score: 1 } let sawChild = false let anyValid = false let bestValidScore: number | undefined diff --git a/src/runtime/supervise/graph.ts b/src/runtime/supervise/graph.ts index 19e9122ac..006ad6b97 100644 --- a/src/runtime/supervise/graph.ts +++ b/src/runtime/supervise/graph.ts @@ -71,6 +71,7 @@ import { type PromptRegistry, } from './prompt-registry' import { type SuperviseOptions, supervise, superviseWithTestBrain } from './supervise' +import { coordinationProfileToolPrefix } from './supervisor-agent' import type { Budget, NodeId, ResultBlobStore, SpawnJournal, SupervisedResult } from './types' // ── The algebra ──────────────────────────────────────────────────────────────── @@ -264,7 +265,6 @@ const GRAPH_FORWARDED_SUPERVISE_OPTIONS = [ 'driverBackend', 'profileSecurity', 'authorizeSpawn', - 'isDriverProfile', 'router', 'driveHarness', 'driverRetry', @@ -402,8 +402,9 @@ function forwardedSuperviseOptions( */ export interface RunGraphOptions extends Pick { /** WHERE worker nodes run — the executor backend. Provide this OR `makeLeafAgent`. Forwarded to - * `supervise()`, which derives every authorized LEAF from it; a node declared `role: 'driver'` - * becomes a nested supervisor instead, whose own leaves are derived the same way. */ + * `supervise()`, which derives every authorized leaf from it. A node that declares + * `agent_runtime_coordination_spawn_worker` becomes a nested supervisor instead, whose own + * leaves are derived the same way. */ readonly backend?: Exclude /** WHERE the ROOT node's harness brain runs — forwarded to `supervise()` verbatim (see * `SuperviseOptions.driverBackend`). Needed when the root node's profile declares an external @@ -415,8 +416,8 @@ export interface RunGraphOptions extends Pick /** Leaf-execution override (offline tests / advanced). `runGraph` still owns node pinning, * directive delivery, and the edge ledger AROUND this seam — only the leaf `act` is yours. - * Slots INSIDE the kernel's authorized path (`SuperviseOptions.makeLeafAgent`), so a node - * declared `role: 'driver'` still becomes a nested supervisor even under an offline leaf. */ + * Slots INSIDE the kernel's authorized path (`SuperviseOptions.makeLeafAgent`), so a node that + * declares the spawn tool still becomes a nested supervisor even under an offline leaf. */ readonly makeLeafAgent?: MakeWorkerAgent /** The ROOT driver's inference seam — a caller-owned `ToolLoopChat` that makes every root * model call. Use it when the root's decisions must be caller-owned orchestration (a @@ -586,6 +587,13 @@ function validateGraph( ) } const root = requireNode(roots[0] as string, 'root resolution') + if (root.profile.tools?.[`${coordinationProfileToolPrefix}spawn_worker`] !== true) { + throw new ValidationError( + `runGraph: root node '${root.id}' has delegates edges but does not declare ` + + `${JSON.stringify(`${coordinationProfileToolPrefix}spawn_worker`)} — the graph cannot ` + + 'spawn any declared worker', + ) + } for (const edge of delegates) { if (edge.from !== root.id) { throw new ValidationError( @@ -848,10 +856,9 @@ export function superviseAgentGraph( // ── Node pinning + delegates spawn traversals (the authorizeSpawn wrapper) ── // Pinning lives in `authorizeSpawn`, which the kernel runs BEFORE it decides whether a child is - // a leaf or a nested supervisor. That is what lets a node declared `role: 'driver'` become a real - // supervisor carrying its canonical profile: the kernel's `isDriver` reads the PINNED profile, - // not the driver-authored `{ name }` stub. (It used to live in `makeWorkerAgent`, a leaf-only - // seam, which made every node a leaf no matter what its profile declared — #965.) + // a leaf or a nested supervisor. That is what lets a node declaring the spawn tool become a real + // supervisor carrying its canonical profile: the kernel reads the PINNED profile, not the + // driver-authored `{ name }` stub. const nodeByWorkerId = new Map() const pendingByAssignment = new Map() type SpawnAuthorizationInput = Parameters>[0] diff --git a/src/runtime/supervise/in-place-cli-executor.ts b/src/runtime/supervise/in-place-cli-executor.ts index 84954333c..8cb79a7da 100644 --- a/src/runtime/supervise/in-place-cli-executor.ts +++ b/src/runtime/supervise/in-place-cli-executor.ts @@ -58,7 +58,7 @@ export interface InPlaceCliExecutorOptions { /** 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 - /** Wall-clock cap per harness subprocess (ms). Default 5 min (the `runLocalHarness` default). */ + /** Optional wall-clock cap per harness subprocess (ms). Omit it for no timer. */ harnessTimeoutMs?: number /** Test seam — inject the harness runner so unit tests script a `LocalHarnessResult`. */ runHarness?: typeof runLocalHarness diff --git a/src/runtime/supervise/interactive-worker.test.ts b/src/runtime/supervise/interactive-worker.test.ts index b28b5488d..1b5ba245f 100644 --- a/src/runtime/supervise/interactive-worker.test.ts +++ b/src/runtime/supervise/interactive-worker.test.ts @@ -252,6 +252,10 @@ describe('workerFromInteractiveProvider', () => { name: 'interactive supervisor', harness: 'cli-base', model: { provider: 'openai', default: 'openai/gpt-5' }, + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + }, }, 'coordinate one interactive worker', { diff --git a/src/runtime/supervise/materialization.ts b/src/runtime/supervise/materialization.ts index 220f9ebad..6a189578d 100644 --- a/src/runtime/supervise/materialization.ts +++ b/src/runtime/supervise/materialization.ts @@ -75,6 +75,7 @@ interface UnknownReceiptInput { const declarationKeys = new Set([ 'effectiveProfile', + 'authoredProfile', 'backend', 'model', 'execution', @@ -379,6 +380,13 @@ export function knownMaterializationReceipt( if (!effectiveProfile.success) { throw new ValidationError('executor materialization: effectiveProfile must be an AgentProfile') } + const authoredProfile = + declaration.authoredProfile === undefined + ? effectiveProfile + : agentProfileSchema.safeParse(declaration.authoredProfile) + if (!authoredProfile.success) { + throw new ValidationError('executor materialization: authoredProfile must be an AgentProfile') + } assertNonEmpty(declaration.backend, 'backend') assertNonEmpty(declaration.materializer, 'materializer') assertNonEmpty(declaration.execution?.kind, 'execution.kind') @@ -390,6 +398,11 @@ export function knownMaterializationReceipt( let platformAttachmentsDigest: Sha256Digest | undefined try { effectiveProfileDigest = canonicalAgentProfileDigest(effectiveProfile.data) + if (canonicalAgentProfileDigest(authoredProfile.data) !== input.authoredProfileDigest) { + throw new ValidationError( + 'executor materialization: authoredProfile conflicts with the admitted AgentProfile', + ) + } materializationPlanDigest = canonicalCandidateDigest(jsonWireSnapshot(declaration.plan)) platformAttachmentsDigest = declaration.platformAttachments === undefined diff --git a/src/runtime/supervise/prompt-registry.ts b/src/runtime/supervise/prompt-registry.ts index b96e5c06d..fff602843 100644 --- a/src/runtime/supervise/prompt-registry.ts +++ b/src/runtime/supervise/prompt-registry.ts @@ -9,11 +9,8 @@ * optimizer names a handle and the TEXT is swappable, sweepable, and diffable without a code * change. Graph edges (`runGraph`) carry handles, never inline prose. * - * ONE policy per role, whichever front door builds it: the seeded `supervisor/policy` entry is the - * single supervisor stance. The package previously shipped two contradictory defaults — the router - * arm's "do small work YOURSELF" (`defaultSupervisorPrompt`) versus the delegate front door's "you - * do NOT do the work yourself" (`supervisorInstructions`) — selected by entry point. Both now - * derive from the one entry here; which door you enter no longer decides the policy. + * A profile or graph may opt into one of these versioned texts. Runtime does not select a standing + * supervisor policy when the profile omits one. * * @experimental */ @@ -119,50 +116,9 @@ export function createPromptRegistry(seed?: ReadonlyArray): Pr // ── Seeded kernel surfaces ───────────────────────────────────────────────────── // -// The knowledge below was previously hardcoded inside builder functions -// (`defaultSupervisorPrompt`, `supervisorInstructions`, the steering-driver continuation -// parameters). Seeding it here makes each surface a versioned optimization target; the builders -// now DERIVE from these entries instead of owning the text. - -/** - * THE supervisor policy — one stance, both front doors. The work-vs-delegate rule is conditional - * on capability (work tools present or not), which is what dissolves the old contradiction: "do - * small work yourself" was written for a supervisor WITH work tools, "you do not do the work" for - * one WITHOUT — one policy states both branches explicitly. - */ -export const supervisorPolicyPrompt: RegisteredPrompt = Object.freeze({ - surface: 'supervisor/policy', - version: 1, - description: - 'The single supervisor stance: accountability, work-vs-delegate rule, context lifecycle, stop condition.', - text: [ - 'You are a supervisor accountable for DELIVERING the task — not for looking busy. You succeed', - 'only when the deliverable is actually produced and verified, never on a worker reporting "done".', - '', - 'Work-vs-delegate — one rule, conditional on your capability:', - '- Do small, sequential work YOURSELF only when you hold WORK tools for it (tools beyond the', - ' coordination verbs). Without work tools you cannot do the work — author and delegate it.', - '- Spawn a worker when a sub-task is large, independent (parallelizable), or needs a clean', - ' context the current one has filled.', - '- Spawning spends the shared, conserved budget — delegate with intent, not by reflex, and', - ' prefer the FEWEST workers that deliver.', - '', - 'Manage the context lifecycle on long work: give each spawned worker a BOUNDED brief — the', - 'specific sub-task plus only the interfaces/state it needs — never your whole history. When one', - 'chapter is done, distill what the next chapter needs and spawn fresh, rather than steering one', - 'worker until its context fills and degrades.', - '', - 'Wait on real signals (await a settle, answer a blocking question), integrate the result, and', - 'stop as soon as the deliverable is met. You cannot declare done by fiat — only a verified', - 'deliverable counts: a delivered (valid:true) worker, or your own submission passing the same', - 'independent check.', - ].join('\n'), -}) - /** * Default DELEGATES-edge directive: the standing instruction a worker receives with every - * traversal of a delegates edge that names this surface. Seeded from the bounded-brief knowledge - * in the supervisor policy, phrased for the RECEIVING side of the edge. + * traversal of a delegates edge that names this surface. */ export const delegatesWorkerBriefPrompt: RegisteredPrompt = Object.freeze({ surface: 'delegates/worker-brief', @@ -229,7 +185,6 @@ export const dumbContinuationPassPrompt: RegisteredPrompt = Object.freeze({ * may register additional surfaces/versions on the returned registry. */ export function kernelPromptRegistry(): PromptRegistry { return createPromptRegistry([ - supervisorPolicyPrompt, delegatesWorkerBriefPrompt, analyzesFindingsReportPrompt, naiveContinuationPrompt, diff --git a/src/runtime/supervise/run-context.ts b/src/runtime/supervise/run-context.ts index e86ea1ecf..c75ba87d0 100644 --- a/src/runtime/supervise/run-context.ts +++ b/src/runtime/supervise/run-context.ts @@ -10,9 +10,9 @@ * It exists to kill the boilerplate every offline/local supervised run repeats by * hand — three constructors threaded into `SupervisorOpts` — and to single-source the * ONE wiring invariant that is easy to get wrong: when the root is the recursive - * `driverAgent` LLM-driver brain AND it may spawn DRIVER children (agents - * driving agents), the registry MUST be wrapped with `withDriverExecutor` so a - * `role: 'driver'` child resolves to the nested-scope executor — and that SAME blob + * `driverAgent` LLM-driver brain AND it may spawn nested managed children, the registry MUST be + * wrapped with `withDriverExecutor` so a child constructed by `driverChild` resolves to the + * nested-scope executor — and that SAME blob * store MUST be the one passed to `driverAgent({ blobs })`, or the driver * reads from a different store than the scope writes to. Pass `{ withDriver: true }` * and reuse the returned `blobs` for both. @@ -38,8 +38,8 @@ import type { ExecutorRegistry, ResultBlobStore, SpawnJournal } from './types' /** Options for a supervised run context. */ export interface InMemoryRunContextOptions { /** - * Wrap the executor registry with `withDriverExecutor` so a spawned child marked - * `role: 'driver'` resolves to the recursive driver-executor (agents driving agents + * Wrap the executor registry with `withDriverExecutor` so a child constructed by `driverChild` + * 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`. */ diff --git a/src/runtime/supervise/scope.ts b/src/runtime/supervise/scope.ts index 6f263efb3..c5a6c72eb 100644 --- a/src/runtime/supervise/scope.ts +++ b/src/runtime/supervise/scope.ts @@ -26,7 +26,6 @@ */ import { - canonicalAgentProfileDigest, canonicalCandidateDigest, type Sha256Digest, sha256DigestSchema, @@ -864,13 +863,8 @@ export function createScope(args: ScopeArgs): Scope { runtime: executor.runtime, declaration: pending.declaration, }) - if ( - plannedReceipt.status !== 'known' || - plannedReceipt.effectiveProfileDigest !== plannedReceipt.authoredProfileDigest - ) { - throw new ValidationError( - 'scope.spawn: pending executor changed the authored AgentProfile before execution', - ) + if (plannedReceipt.status !== 'known') { + throw new ValidationError('scope.spawn: pending executor returned an unknown receipt') } let recorded = false pendingEvidence = { @@ -891,11 +885,6 @@ export function createScope(args: ScopeArgs): Scope { if (finalReceipt.status !== 'known') { throw new ValidationError('scope.spawn: terminal materialization remained unknown') } - if (finalReceipt.effectiveProfileDigest !== finalReceipt.authoredProfileDigest) { - throw new ValidationError( - 'scope.spawn: external executor changed the authored AgentProfile', - ) - } const finalBinding = knownExecutionBindingReceipt(finalReceipt, acknowledgedBinding) await appendNodeMaterialization(args, id, ordinal, finalReceipt, finalBinding, now) live.materialization = finalReceipt @@ -1642,11 +1631,6 @@ export async function recordScopeOwnerMaterialization( 'scope owner execution binding does not use the kernel-minted attempt id', ) } - if (canonicalAgentProfileDigest(declaration.effectiveProfile) !== state.authoredProfileDigest) { - throw new ValidationError( - 'scope owner stable effective profile conflicts with its admitted authored profile', - ) - } receipt = knownMaterializationReceipt({ authoredProfileDigest: state.authoredProfileDigest, runtime, diff --git a/src/runtime/supervise/supervise.ts b/src/runtime/supervise/supervise.ts index 74c36ade8..845150aef 100644 --- a/src/runtime/supervise/supervise.ts +++ b/src/runtime/supervise/supervise.ts @@ -55,7 +55,6 @@ import type { WorkerSpawnContext, WorkerWatchOptions, } from '../../mcp/tools/coordination' -import { coordinationVerbNames } from '../../mcp/tools/coordination' import { composeRuntimeHooks, type RuntimeHooks } from '../../runtime-hooks' import { agentHarness, harnessRunsAgent } from '../harness-role' import type { RouterTransportConfig } from '../router-client' @@ -117,11 +116,16 @@ import type { StopRule } from './stop-rules' import { createRootHandle, createSupervisor } from './supervisor' import { assertCoordinationBinding, + assertNoReservedCoordinationMcpAlias, type CoordinationBinding, + coordinationMcpAlias, type DriveHarness, type DriveHarnessOwnerContext, + declaredRuntimeToolNames, + providerVisibleProfile, type ResolveDriveHarness, type ResolveSupervisorTools, + runtimeToolDeclarationError, type SupervisorAgentDeps, type SupervisorNodeContext, type SupervisorProfile, @@ -447,14 +451,27 @@ function assertProfileContract( profile: AgentProfile, contract: ProfileMaterializationContract, context: string, + runtimeConsumesCoordinationTools = false, ): void { + const materializedProfile = runtimeConsumesCoordinationTools + ? profileWithoutDeclaredRuntimeCoordinationTools(profile) + : profile assertProfileMaterialization({ contract, - changedAxes: profileMaterializationAxes(profile), + changedAxes: profileMaterializationAxes(materializedProfile), context, }) } +/** + * Materialization contracts need the profile axes the provider owns, before an individual manager + * has asynchronously resolved its exact product-tool descriptors. Runtime-owned declarations are + * not provider tools; unsupported declarations still fail when the coordination surface resolves. + */ +function profileWithoutDeclaredRuntimeCoordinationTools(profile: AgentProfile): AgentProfile { + return providerVisibleProfile(profile) +} + function assertBackendProfileMaterialization( profile: AgentProfile, backend: ExecutorConfig, @@ -550,36 +567,12 @@ const routerSupervisorProfileMaterialization = defineProfileMaterializationContr ], }) -const coordinationMcpAlias = 'agent-runtime-coordination' - -/** How a harness sees a coordination verb once the MCP is mounted under its reserved alias. */ -const coordinationToolPrefix = `${coordinationMcpAlias.replaceAll('-', '_')}_` - -/** - * Tools a child REQUIRES that name the coordination MCP but no coordination verb. - * - * A profile can only receive a coordination tool this run actually serves, and the served set is - * closed (`coordinationVerbNames`). A required name inside the reserved namespace that is not one - * of them can never mount on any harness, for any backend, at any depth — the harness discovers it - * only when it starts and exits (`pi exit 78: requested tool "…" is unavailable`), after the child - * is spawned, journaled and metered. - */ -function unmountedCoordinationTools(profile: AgentProfile): readonly string[] { - const served = new Set(coordinationVerbNames.map((verb) => `${coordinationToolPrefix}${verb}`)) - return Object.entries(profile.tools ?? {}) - .filter(([name, required]) => required === true && name.startsWith(coordinationToolPrefix)) - .map(([name]) => name) - .filter((name) => !served.has(name)) -} - /** * The pre-flight `supervise` installs for a bridge backend. No new knob: the backend already says * where the bridge is, and these are the questions only the bridge can answer. * - * Three causes, in cost order — the pure one first, so a deterministic refusal never pays for a - * round trip: + * Two bridge causes, after the profile-owned tool preflight: * - * - `unmountable-tool` — pure; see {@link unmountedCoordinationTools}. * - `model-route` — `GET /v1/capabilities?model=`. The bridge answers exactly this * question and 404s `no backend matches model "…"`. FAIL CLOSED: any answer that is not a route * refuses, including a transport error or an unexpected status, because a pre-flight that skips @@ -590,13 +583,6 @@ function unmountedCoordinationTools(profile: AgentProfile): readonly string[] { */ function bridgeSpawnPreflight(seam: BridgeSeam): SpawnPreflight { return async (profile) => { - const unmounted = unmountedCoordinationTools(profile) - if (unmounted.length > 0) { - return { - cause: 'unmountable-tool', - detail: `no coordination verb is named by ${unmounted.map((name) => JSON.stringify(name)).join(', ')}; this run serves ${coordinationVerbNames.join(', ')}`, - } - } const wireModel = profileBridgeWireModel(profile) if (wireModel === undefined) { return { @@ -612,6 +598,43 @@ function bridgeSpawnPreflight(seam: BridgeSeam): SpawnPreflight { } } +/** Refuse a Runtime-managed child whose declared tools cannot exist on its execution path. */ +function profileToolSpawnPreflight( + runtimeOwnsManager: boolean, + canResolveProductTools: boolean, +): SpawnPreflight { + return async (profile) => { + // A caller-owned worker factory is the execution port. It receives the exact profile and owns + // its own native tool mount, just as it owns retry and completion behavior; Runtime cannot + // truthfully infer that mount from a function value. + if (!runtimeOwnsManager) return undefined + const declarationError = runtimeToolDeclarationError(profile, canResolveProductTools) + if (declarationError !== undefined) { + return { + cause: 'unmountable-tool', + detail: declarationError, + } + } + return undefined + } +} + +function composeSpawnPreflights( + ...preflights: ReadonlyArray +): SpawnPreflight | undefined { + const active = preflights.filter( + (preflight): preflight is SpawnPreflight => preflight !== undefined, + ) + if (active.length === 0) return undefined + return async (profile, context) => { + for (const preflight of active) { + const refusal = await preflight(profile, context) + if (refusal !== undefined) return refusal + } + return undefined + } +} + const defaultAllowedMcpHosts: string[] = [] Object.freeze(defaultAllowedMcpHosts) @@ -657,6 +680,7 @@ function driveHarnessFromBackend( let activeExecutor: Executor | undefined const drive: DriveHarness = async ({ profile, + authoredProfile, task, scope, coordinationMcpUrl, @@ -674,25 +698,31 @@ function driveHarnessFromBackend( ) { 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`, - ) - } + // `supervise` passes a canonical profile plus its provider projection. Parse both at this + // boundary and prove the projection still matches the exact descriptors mounted for this turn. + const canonicalDriverProfile = agentProfileSchema.parse(authoredProfile) + assertNoReservedCoordinationMcpAlias(canonicalDriverProfile, 'driveHarnessFromBackend') const stableCoordinationTools = detachedSnapshot( coordinationTools, 'driveHarnessFromBackend coordination tools', ) + const expectedProviderProfile = providerVisibleProfile(canonicalDriverProfile) + const providerDriverProfile = agentProfileSchema.parse(profile) + if ( + canonicalAgentProfileDigest(providerDriverProfile) !== + canonicalAgentProfileDigest(expectedProviderProfile) + ) { + throw new ValidationError( + 'driveHarnessFromBackend: supervisor passed a provider profile that does not match its canonical profile and mounted coordination tools', + ) + } // The authored profile travels unchanged. The coordination server is a Runtime-owned // attachment: it rides the executor's attachment seam, so a resumed run that rebinds the // port keeps the profile digest a durable bridge session is bound to. const spec: AgentSpec = { - profile: canonicalDriverProfile, + profile: providerDriverProfile, harness: - boundBackend.backend === 'sandbox' ? (canonicalDriverProfile.harness as BackendType) : null, + boundBackend.backend === 'sandbox' ? (providerDriverProfile.harness as BackendType) : null, } // The turn cap rides the SAME stop lever the coordination stop uses: the harness runs its own // loop, so the only honest bound is "stop at the next turn boundary". Composing the two @@ -816,8 +846,10 @@ function driveHarnessFromBackend( exactDeclaration: ExecutorMaterialization, ): ExecutorMaterialization => ({ ...exactDeclaration, - // The coordination MCP is a Runtime-owned platform attachment, not authored behavior. - effectiveProfile: canonicalDriverProfile, + // The provider sees the projected profile while the receipt remains bound to the canonical + // profile that authorized Runtime coordination. + authoredProfile: canonicalDriverProfile, + effectiveProfile: providerDriverProfile, platformAttachments: { [coordinationMcpAlias]: { kind: 'coordination-mcp', @@ -873,10 +905,10 @@ function driveHarnessFromBackend( } if ( canonicalAgentProfileDigest(pending.declaration.effectiveProfile) !== - canonicalAgentProfileDigest(canonicalDriverProfile) + canonicalAgentProfileDigest(providerDriverProfile) ) { throw new ValidationError( - 'driveHarnessFromBackend: pending executor changed the authored AgentProfile before execution', + 'driveHarnessFromBackend: pending executor changed the provider-visible AgentProfile before execution', ) } } else { @@ -1136,9 +1168,9 @@ 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. */ + /** Resolve the completion check for one exact authorized backend-derived child. The callback runs + * after spawn authorization and receives a detached immutable context. It may return `undefined` + * to use the run-wide `deliverable`; a managed child receives its selected check for direct work. */ readonly resolveDeliverable?: ( input: DeliverableResolutionInput, ) => DeliverableSpec | undefined @@ -1220,11 +1252,6 @@ export interface SuperviseOptions { 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?: RouterTransportConfig @@ -1296,9 +1323,9 @@ export interface SuperviseOptions { * live children, and the same budget, deadline, abort, and `driverRetry.maxAttempts` bounds. A * run the coordination server already stopped is never re-prompted — that stop was a decision. * - * Requires `deliverable`, and applies to the ROOT manager — the one that declares the run's - * completion check. A recursive manager declares none of its own, so it is left unchanged. - * Refused for a router-brained root, which runs its turn loop in process. Omit/`0` = never. + * Requires `deliverable`, and applies to every external manager with a completion check. A + * recursive manager receives the check selected for its exact assignment. Refused for a + * router-brained manager, which runs its turn loop in process. Omit/`0` = never. */ readonly repromptOnUnmet?: number /** Compose the re-entry instruction for an unmet contract, or return `'stop'` to end the run. @@ -1536,7 +1563,6 @@ const superviseOptionKeys = [ 'extraTools', 'finalizer', 'hooks', - 'isDriverProfile', 'journal', 'makeLeafAgent', 'makeWorkerAgent', @@ -1678,7 +1704,6 @@ const superviseExecutableOptionKeys = [ 'escalateQuestion', 'executeExtraTool', 'finalizer', - 'isDriverProfile', 'makeLeafAgent', 'makeWorkerAgent', 'now', @@ -1779,7 +1804,6 @@ export function captureSuperviseOptions(opts: SuperviseOptions): SuperviseOption otel, authorizeSpawn, authorizeMessage, - isDriverProfile, driveHarness, resolveDriveHarness, resolveSupervisorTools, @@ -1904,7 +1928,6 @@ export function captureSuperviseOptions(opts: SuperviseOptions): SuperviseOption ...(probes === undefined ? {} : { probes }), ...(authorizeSpawn === undefined ? {} : { authorizeSpawn }), ...(authorizeMessage === undefined ? {} : { authorizeMessage }), - ...(isDriverProfile === undefined ? {} : { isDriverProfile }), ...(driveHarness === undefined ? {} : { driveHarness }), ...(resolveDriveHarness === undefined ? {} : { resolveDriveHarness }), ...(resolveSupervisorTools === undefined ? {} : { resolveSupervisorTools }), @@ -2241,8 +2264,8 @@ function superviseInternal( 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. + // `withDriver: true` is the wiring invariant: a child constructed by `driverChild` must resolve + // to the nested-scope executor; `runDir` only changes where the journal and blobs live. const ctx = options.runDir !== undefined ? createFileRunContext(options.runDir, { withDriver: true }) @@ -2274,17 +2297,23 @@ function superviseInternal( : undefined const managerBackend = options.driverBackend ?? (options.rootDriverFromBackend === false ? undefined : options.backend) - // Derived from the backend the run already declares — no new knob. Only a bridge can answer the - // route and admission questions, so only a bridge backend installs one. - const spawnPreflight: SpawnPreflight | undefined = - options.backend?.backend === 'bridge' ? bridgeSpawnPreflight(options.backend) : undefined + // Runtime-managed tool declarations are checked before reservation or journaling. A bridge adds + // its own route/admission check after that purely local validation. A caller-owned worker port + // owns its own mount and receives the exact profile unchanged. + const spawnPreflight = composeSpawnPreflights( + profileToolSpawnPreflight( + options.makeWorkerAgent === undefined, + options.resolveSupervisorTools !== undefined, + ), + options.backend?.backend === 'bridge' ? bridgeSpawnPreflight(options.backend) : undefined, + ) 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. + // A custom harness receives the provider-visible profile plus the immutable canonical profile + // for receipt binding. An undeclared materialization therefore defaults to the full canonical + // leaf set, apart from Runtime-owned coordination declarations consumed before dispatch. // Declaring `driveHarnessMaterialization` narrows that claim and turns dropped axes into // pre-spawn faults. const driverMaterialization = hasCustomDriveHarness @@ -2379,6 +2408,7 @@ function superviseInternal( ? promptControlProfileMaterialization : routerSupervisorProfileMaterialization, 'supervise root', + true, ) const now = options.now ?? Date.now @@ -2486,34 +2516,28 @@ function superviseInternal( 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)}`, - ) - if (leafDeliverable !== deliverable && !options.backend) { + const selectedDeliverable = options.resolveDeliverable?.(postAuthorizationContext) + const childDeliverable = + selectedDeliverable === undefined + ? deliverable + : captureDeliverable( + selectedDeliverable, + `supervise deliverable for ${JSON.stringify(spawnContext.label)}`, + ) + // A Runtime tool grant makes this a managed persistent node. `spawn_worker` is one + // capability on that node, not a hidden role bit: an IC can submit a result, ask a parent, + // or call a product tool without also being allowed to delegate. + const usesRuntimeCoordination = declaredRuntimeToolNames(authorized).length > 0 + if (!usesRuntimeCoordination) { + if (childDeliverable !== deliverable && !options.backend) { throw new ValidationError( 'supervise: resolveDeliverable selected a per-spawn deliverable but there is no backend to derive that leaf from; makeLeafAgent owns its own completion check', ) } const makeSelectedLeaf = - leafDeliverable === deliverable + childDeliverable === deliverable ? makeLeaf - : withRetry(workerFromBackend(options.backend as ExecutorConfig, leafDeliverable)) + : withRetry(workerFromBackend(options.backend as ExecutorConfig, childDeliverable)) return makeSelectedLeaf( authorized, Object.freeze({ @@ -2557,10 +2581,11 @@ function superviseInternal( ? (driverMaterialization as ProfileMaterializationContract) : promptModelProfileMaterialization, `supervise driver ${JSON.stringify(spawnContext.label)}`, + true, ) if (managerBackend) { assertBridgeProfileMaterializes( - authorized, + profileWithoutDeclaredRuntimeCoordinationTools(authorized), managerBackend, `supervise driver ${JSON.stringify(spawnContext.label)}`, ) @@ -2574,6 +2599,7 @@ function superviseInternal( ) const nestedPerWorker = defaultPerWorker(spawnContext.budget) const authorizeNestedMessage = authorizeDownFor(authorized, depth + 1) + let acceptedSubmission = false const nested = supervisorAgent(authorized, { blobs, makeWorkerAgent: childFactory, @@ -2601,9 +2627,6 @@ function superviseInternal( ? { continuityByProfile: options.continuityByProfile } : {}), ...(spawnPreflight ? { preflightSpawn: spawnPreflight } : {}), - ...(options.resolveSpawnProfile - ? { resolveSpawnProfile: options.resolveSpawnProfile } - : {}), ...(options.resolveSpawnProfile ? { resolveSpawnProfile: options.resolveSpawnProfile } : {}), @@ -2614,11 +2637,20 @@ function superviseInternal( ...(options.compaction ? { compaction: options.compaction } : {}), ...(options.driverRetry ? { driverRetry: options.driverRetry } : {}), ...(options.onDriverAttempt ? { onDriverAttempt: options.onDriverAttempt } : {}), - // `repromptOnUnmet` is deliberately NOT forwarded here. A nested manager declares no - // completion check of its own — the run's `deliverable` gates the LEAVES, and this - // manager receives no `submit_result` — so it has no contract that could be unmet, and - // forwarding the option would refuse every recursive spawn at construction. The run's - // contract belongs to the manager that declared it. + // A managed child receives its assignment's independent completion check. It can submit + // work itself, delegate, or do both under the same profile contract. + ...(childDeliverable ? { deliverable: childDeliverable } : {}), + ...(childDeliverable + ? { + onAcceptedSubmission: () => { + acceptedSubmission = true + }, + } + : {}), + ...(options.repromptOnUnmet !== undefined + ? { repromptOnUnmet: options.repromptOnUnmet } + : {}), + ...(options.onUnmetContract ? { onUnmetContract: options.onUnmetContract } : {}), ...(log ? { onEvent: (_event, record) => log.append(runId, record, ownerId), @@ -2633,7 +2665,13 @@ function superviseInternal( ? {} : { controlDir: resolve(options.runDir), controlScope: 'subtree' as const }), }) - return driverChild(authorized, nested, journal, childExecution.ref) + return driverChild( + authorized, + nested, + journal, + childExecution.ref, + () => acceptedSubmission, + ) } return makeRecursiveWorker } diff --git a/src/runtime/supervise/supervisor-agent.ts b/src/runtime/supervise/supervisor-agent.ts index 7de26bf5b..3e6a97ded 100644 --- a/src/runtime/supervise/supervisor-agent.ts +++ b/src/runtime/supervise/supervisor-agent.ts @@ -60,7 +60,6 @@ import { profileModelExecutionSettings, } from './model-policy' import type { PeerMailLimits } from './peer-mail' -import { supervisorPolicyPrompt } from './prompt-registry' import { beginScopeOwnerAttempt } from './scope' import { detachedSnapshot } from './snapshot' import { @@ -71,16 +70,66 @@ import { } from './stop-rules' 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 - * verbs but no policy for WHEN to use them, and either over-spawns or stalls. A profile may override - * it for a specific topology. +/** Runtime-owned coordination is mounted under this MCP alias. */ +export const coordinationMcpAlias = 'agent-runtime-coordination' + +/** A profile declares Runtime-owned tools with this provider-neutral prefix. */ +export const coordinationProfileToolPrefix = `${coordinationMcpAlias.replaceAll('-', '_')}_` + +const coordinationVerbNameSet = new Set(coordinationVerbNames) + +/** Bare Runtime tool names explicitly enabled by one exact profile. */ +export function declaredRuntimeToolNames(profile: AgentProfile): ReadonlyArray { + const names = Object.entries(profile.tools ?? {}) + .filter(([name, enabled]) => enabled === true && name.startsWith(coordinationProfileToolPrefix)) + .map(([name]) => name.slice(coordinationProfileToolPrefix.length)) + // Tool maps are sets in AgentProfile. Normalize their order before a provider sees a tool list or + // Runtime records an attachment, so equivalent authored maps cannot change model behavior. + return Object.freeze([...new Set(names)].sort()) +} + +/** Describe Runtime declarations that cannot resolve without a product tool provider. */ +export function runtimeToolDeclarationError( + profile: AgentProfile, + hasProductToolResolver: boolean, +): string | undefined { + const unresolved = declaredRuntimeToolNames(profile).filter( + (name) => !coordinationVerbNameSet.has(name), + ) + if (unresolved.length === 0 || hasProductToolResolver) return undefined + return `the profile declares ${unresolved + .map((name) => JSON.stringify(`${coordinationProfileToolPrefix}${name}`)) + .join(', ')}, but this run has no resolveSupervisorTools provider for those tools` +} + +/** Runtime owns this attachment alias. An authored entry would make the provider mount ambiguous. */ +export function assertNoReservedCoordinationMcpAlias(profile: AgentProfile, context: string): void { + if (profile.mcp?.[coordinationMcpAlias] === undefined) return + throw new ValidationError( + `${context}: profile MCP alias ${JSON.stringify(coordinationMcpAlias)} is reserved for Runtime coordination`, + ) +} + +/** + * Project one canonical profile to the profile a provider may receive. * - * This is the registry's ONE supervisor policy (`supervisor/policy`), not this module's own text: - * the delegate front door (`supervisorInstructions`) derives from the same entry, so which front - * door built the supervisor no longer decides its work-vs-delegate policy — the package used to - * ship two contradictory defaults selected by entry point. */ -export const defaultSupervisorPrompt = supervisorPolicyPrompt.text + * The reserved prefix is Runtime-owned in its entirety. A `true` declaration must resolve to a + * mounted Runtime or product descriptor before execution; a `false` declaration grants nothing. + * Neither is a provider-native tool. Stripping the whole namespace keeps a false or refused grant + * from becoming an invented harness capability during strict materialization. + */ +export function providerVisibleProfile(profile: AgentProfile): AgentProfile { + if (profile.tools === undefined) return profile + const providerTools = Object.fromEntries( + Object.entries(profile.tools).filter( + ([name]) => !name.startsWith(coordinationProfileToolPrefix), + ), + ) + if (Object.keys(providerTools).length === Object.keys(profile.tools).length) return profile + if (Object.keys(providerTools).length > 0) return { ...profile, tools: providerTools } + const { tools: _runtimeTools, ...withoutTools } = profile + return withoutTools +} /** A supervisor is an exact canonical AgentProfile; no looser model/prompt shape exists. */ export type SupervisorProfile = AgentProfile @@ -137,22 +186,15 @@ function assertRouterArmResourcePolicy(profile: SupervisorProfile): void { * The standing instruction both arms run under: `prompt.systemPrompt`, then canonical prompt and * resource instruction lines. * `undefined` only when the profile names none at all. - * */ -function resolveSupervisorSystemPrompt( - profile: SupervisorProfile, - activePrompt?: string, -): string | undefined { +function resolveSupervisorSystemPrompt(profile: SupervisorProfile): string | undefined { const promptSystem = profile.prompt?.systemPrompt - // Instruction lines are APPENDED to the active prompt, so a profile that names only - // instructions keeps whatever prompt the arm would otherwise run — never replaces it. - const base = promptSystem ?? activePrompt const lines = [ ...(profile.prompt?.instructions ?? []), ...resourceInstructionLines(profile.resources?.instructions), ] - if (lines.length === 0) return base - return (base !== undefined ? [base, ...lines] : lines).join('\n') + if (lines.length === 0) return promptSystem + return (promptSystem !== undefined ? [promptSystem, ...lines] : lines).join('\n') } /** Resolve the model after refusing any incomplete execution identity. */ @@ -367,10 +409,12 @@ export type ObserveSupervisorNodeEvent = ( * so the harness calls spawn_worker / 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). */ + /** The exact provider-visible projection. Runtime-owned coordination tool declarations are + * removed only when their descriptors are actually mounted; send this profile to the provider. */ readonly profile: SupervisorProfile + /** The immutable canonical profile Runtime admitted. Use it only to bind receipts or audit + * authority; never send it to a provider, because it contains Runtime-owned declarations. */ + readonly authoredProfile: 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 @@ -409,6 +453,8 @@ export interface SupervisorAgentDeps { readonly onProviderModel?: (model: string | undefined) => void /** Independent completion check for direct driver work (`submit_result`). */ readonly deliverable?: DeliverableSpec + /** Receives a result only after this manager's completion check accepted it. */ + readonly onAcceptedSubmission?: (result: unknown) => void /** Hard cap on simultaneously-LIVE workers across both arms — `spawn_worker` fails closed once * 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. */ @@ -618,6 +664,11 @@ function buildSupervisorAgent( const stableRouter = deps.router === undefined ? undefined : snapshotRouterTransportConfig(deps.router) const resolveTools = deps.resolveSupervisorTools + assertNoReservedCoordinationMcpAlias(stableProfile, 'supervisorAgent') + const runtimeToolError = runtimeToolDeclarationError(stableProfile, resolveTools !== undefined) + if (runtimeToolError !== undefined) { + throw new ValidationError(`supervisorAgent: ${runtimeToolError}`) + } const observeNodeEvent = deps.observeNodeEvent const nodeContextSeed = deps.nodeContext === undefined @@ -632,9 +683,10 @@ function buildSupervisorAgent( const harness = agentHarness(stableProfile.harness) ?? null // 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. + // No fallback at this site. Both arms receive only the prompt and instruction bytes declared by + // the profile. const profilePrompt = resolveSupervisorSystemPrompt(stableProfile) + const runtimeToolNames = declaredRuntimeToolNames(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 @@ -725,12 +777,12 @@ function buildSupervisorAgent( 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, + // An omitted prompt means no standing system text. Runtime executes the exact profile and + // never chooses a research policy for it. + systemPrompt: resolveSupervisorSystemPrompt(stableProfile) ?? '', ...(deps.deliverable ? { deliverable: deps.deliverable } : {}), + ...(deps.onAcceptedSubmission ? { onAcceptedSubmission: deps.onAcceptedSubmission } : {}), + toolNames: runtimeToolNames, ...(nodeTools?.length ? { nodeTools } : {}), ...(deps.maxLiveWorkers !== undefined ? { maxLiveWorkers: deps.maxLiveWorkers } : {}), ...(deps.extraTools ? { extraTools: deps.extraTools } : {}), @@ -895,10 +947,15 @@ function buildSupervisorAgent( ? { priorAnalystDefinitions: priorCoordination.analystDefinitions } : {}), ...(nodeTools?.length ? { nodeTools } : {}), + toolNames: runtimeToolNames, onCoordinationTools: (tools) => slot.bind(tools), }) ledger = mcp const coordinationTools = slot.descriptors() + const providerProfile = detachedSnapshot( + providerVisibleProfile(stableProfile), + 'supervisorAgent provider-visible profile', + ) try { // The retry's progress mark. `tokensLeft` only falls, so the difference from the first // reading is everything this run has spent from the shared pool — the driver's own turns @@ -933,7 +990,8 @@ function buildSupervisorAgent( beginScopeOwnerAttempt(scope, attempt) try { await driveHarness({ - profile: stableProfile, + profile: providerProfile, + authoredProfile: stableProfile, ...(profilePrompt !== undefined ? { systemPrompt: profilePrompt } : {}), // A re-prompt re-enters the SAME session, so the unmet items ARE the turn. The // bridge backend reattaches by durable execution id, exactly as a retry does. @@ -985,7 +1043,10 @@ function buildSupervisorAgent( // Direct work is eligible only through `submit_result`, after the injected independent // check passes. Raw harness prose remains ineligible. const submitted = mcp.submittedResult() - if (submitted) return submitted.result + if (submitted) { + deps.onAcceptedSubmission?.(submitted.result) + 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, { diff --git a/src/runtime/supervise/types.ts b/src/runtime/supervise/types.ts index 1049c2efc..91b9b21c9 100644 --- a/src/runtime/supervise/types.ts +++ b/src/runtime/supervise/types.ts @@ -545,8 +545,11 @@ export interface MaterializedExecutionIdentity { * 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. */ + /** Complete profile the provider actually receives after Runtime consumes its own declarations. */ readonly effectiveProfile: AgentProfile + /** Canonical profile admitted by Runtime when it differs from the provider-visible profile. + * Its digest must equal the kernel-owned authored profile digest for the node. */ + readonly authoredProfile?: AgentProfile /** Concrete backend or harness selected for this run. */ readonly backend: string /** Exact selected model, or an explicit unknown reason. */ diff --git a/src/runtime/supervise/worktree-cli-executor.ts b/src/runtime/supervise/worktree-cli-executor.ts index cb97c385d..3db91c6e0 100644 --- a/src/runtime/supervise/worktree-cli-executor.ts +++ b/src/runtime/supervise/worktree-cli-executor.ts @@ -65,7 +65,7 @@ export interface WorktreeCliExecutorOptions { runId?: string /** Override the base ref the worktree is cut from (default `HEAD`). */ baseRef?: string - /** Wall-clock cap per harness subprocess (ms). Default 5 min (the `runLocalHarness` default). */ + /** Optional wall-clock cap per harness subprocess (ms). Omit it for no timer. */ harnessTimeoutMs?: number /** Run Codex with an ephemeral session, isolated config/instructions, network disabled, and * JSONL usage capture. Requires `profile.harness: 'codex'`; metered by default. */ diff --git a/tests/helpers/resume-driver-child.ts b/tests/helpers/resume-driver-child.ts index b2166afd7..bb3b621ec 100644 --- a/tests/helpers/resume-driver-child.ts +++ b/tests/helpers/resume-driver-child.ts @@ -151,22 +151,32 @@ const brain: ToolLoopChat = async (messages) => { } } -const result = await supervise(offlineProfile('root'), '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. - perWorker: { maxIterations: 5, maxTokens: 10_000 }, - makeWorkerAgent: (profile) => { - const name = String((profile as { name?: unknown } | undefined)?.name ?? '') - const w = workers.find((x) => x.key === name) - if (w === undefined) throw new Error(`unknown worker profile ${JSON.stringify(profile)}`) - return leafAgent(w.key, w.out, w.score, phase === '1' && hangsInPhase1.has(w.key)) +const result = await supervise( + { + ...offlineProfile('root'), + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + }, + }, + '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. + perWorker: { maxIterations: 5, maxTokens: 10_000 }, + makeWorkerAgent: (profile) => { + const name = String((profile as { name?: unknown } | undefined)?.name ?? '') + const w = workers.find((x) => x.key === name) + if (w === undefined) throw new Error(`unknown worker profile ${JSON.stringify(profile)}`) + return leafAgent(w.key, w.out, w.score, phase === '1' && hangsInPhase1.has(w.key)) + }, + brain, + runId, + runDir: dir, + now: () => (phase === '2' ? 2_000 : 1_000), }, - brain, - runId, - runDir: dir, - now: () => (phase === '2' ? 2_000 : 1_000), -}) +) process.stdout.write( `${JSON.stringify({ diff --git a/tests/improvement-surfaces.test.ts b/tests/improvement-surfaces.test.ts index 010b4a3e1..92d3b8cac 100644 --- a/tests/improvement-surfaces.test.ts +++ b/tests/improvement-surfaces.test.ts @@ -399,7 +399,7 @@ describe('agent improvement profile delivery', () => { tools: { Read: true }, permissions: { shell: 'deny' }, maxSteps: 4, - metadata: { role: 'critic' }, + metadata: { specialty: 'critic' }, }, }, resources: { diff --git a/tests/integration/pursuit-usage-provenance.test.ts b/tests/integration/pursuit-usage-provenance.test.ts index d9bbce40a..88bb8cd41 100644 --- a/tests/integration/pursuit-usage-provenance.test.ts +++ b/tests/integration/pursuit-usage-provenance.test.ts @@ -15,7 +15,7 @@ import type { Spend, UsageEvent, } from '../../src/runtime/supervise/types' -import { testAgentProfile } from '../kernel/test-agent-profile' +import { runtimeToolDeclarations, testAgentProfile } from '../kernel/test-agent-profile' const budget: Budget = { maxIterations: 100, maxTokens: 100_000 } const perWorker: Budget = { maxIterations: 4, maxTokens: 1_000 } @@ -97,6 +97,7 @@ describe('pursuit projection usage and totals', () => { const executed = await supervisePursuit( testAgentProfile('usage-root', { prompt: { systemPrompt: 'Delegate twice, wait for both, then stop.' }, + tools: runtimeToolDeclarations('spawn_worker', 'await_event', 'stop'), }), 'measure two workers exactly once', { diff --git a/tests/integration/runtime-eval-pursuit-evidence.test.ts b/tests/integration/runtime-eval-pursuit-evidence.test.ts index 1e6b3ca1d..0c662465e 100644 --- a/tests/integration/runtime-eval-pursuit-evidence.test.ts +++ b/tests/integration/runtime-eval-pursuit-evidence.test.ts @@ -18,7 +18,7 @@ import type { ExecutorResult, UsageEvent, } from '../../src/runtime/supervise/types' -import { testAgentProfile } from '../kernel/test-agent-profile' +import { runtimeToolDeclarations, testAgentProfile } from '../kernel/test-agent-profile' const budget: Budget = { maxIterations: 100, maxTokens: 100_000 } const perWorker: Budget = { maxIterations: 4, maxTokens: 1_000 } @@ -92,6 +92,7 @@ describe('Runtime pursuit -> Eval evidence', () => { testAgentProfile('evidence-root', { harness: 'opencode', prompt: { systemPrompt: 'Delegate once, wait for the result, then stop.' }, + tools: runtimeToolDeclarations('spawn_worker', 'await_event', 'stop'), }), 'produce independently measurable output', { diff --git a/tests/kernel/code-mode.test.ts b/tests/kernel/code-mode.test.ts index 6990fbe16..866d9911f 100644 --- a/tests/kernel/code-mode.test.ts +++ b/tests/kernel/code-mode.test.ts @@ -24,7 +24,7 @@ import type { Spend, } from '../../src/runtime/supervise/types' import { scriptedBrain } from './scripted-brain' -import { testAgentProfile } from './test-agent-profile' +import { runtimeToolDeclarations, testAgentProfile } from './test-agent-profile' const SPEND: Spend = { iterations: 1, tokens: { input: 1, output: 1 }, usd: 0, ms: 0 } @@ -300,7 +300,10 @@ describe('code mode over a REAL supervise() — the dynamic workflow, kernel-met return { workers: spawned.length, outputs: settled.map((event) => event.status) } ` const res = await superviseWithTestBrain( - testAgentProfile('root', { harness: 'cli-base' }), + testAgentProfile('root', { + harness: 'cli-base', + tools: runtimeToolDeclarations('spawn_worker', 'await_event', 'search', 'execute'), + }), 'coordinate the build', { budget: { maxIterations: 30, maxTokens: 100_000 }, @@ -333,7 +336,10 @@ describe('code mode over a REAL supervise() — the dynamic workflow, kernel-met it('search answers the LIVE grant: the rendered API is the spawn_worker the verbs actually serve', async () => { let rendered = '' const res = await superviseWithTestBrain( - testAgentProfile('root', { harness: 'cli-base' }), + testAgentProfile('root', { + harness: 'cli-base', + tools: runtimeToolDeclarations('spawn_worker', 'search'), + }), 'look around', { budget: { maxIterations: 10, maxTokens: 50_000 }, diff --git a/tests/kernel/completion-gate.test.ts b/tests/kernel/completion-gate.test.ts index 94d7ca61e..b8c4a5892 100644 --- a/tests/kernel/completion-gate.test.ts +++ b/tests/kernel/completion-gate.test.ts @@ -123,6 +123,7 @@ describe('gateOnDeliverable — the leaf completion-oracle (valid ⟺ the delive // ── End-to-end: the honest settle through a real driver + the recursion ─────────────────── const perWorker: Budget = { maxIterations: 4, maxTokens: 1000 } +const spawnAndAwait = ['spawn_worker', 'await_event'] as const let blobs = new InMemoryResultBlobStore() function driverOpts( @@ -130,7 +131,16 @@ function driverOpts( brain: ToolLoopChat, makeWorkerAgent: (p: AgentProfile) => Agent, ): DriverAgentOptions { - return { name, brain, blobs, makeWorkerAgent, perWorker, systemPrompt: 'drive', maxTurns: 8 } + return { + name, + brain, + blobs, + makeWorkerAgent, + perWorker, + toolNames: spawnAndAwait, + systemPrompt: 'drive', + maxTurns: 8, + } } /** A leaf worker whose executor is gated on a deliverable — `out` is delivered ONLY if `check` passes. */ diff --git a/tests/kernel/composition-families.test.ts b/tests/kernel/composition-families.test.ts index 3afd2fb5c..b8205c45e 100644 --- a/tests/kernel/composition-families.test.ts +++ b/tests/kernel/composition-families.test.ts @@ -44,7 +44,7 @@ import type { } from '../../src/runtime/supervise/types' import { runGraph } from '../helpers/runtime-with-test-brain' import { scriptedBrain } from './scripted-brain' -import { testAgentProfile } from './test-agent-profile' +import { runtimeToolDeclarations, testAgentProfile } from './test-agent-profile' // ── Offline leaf: records the order stages actually ran in ──────────────────────── @@ -155,6 +155,7 @@ describe('model-decided composition — the graph family', () => { profile: testAgentProfile('driver', { harness: 'cli-base', prompt: { systemPrompt: 'Drive the worker until it delivers.' }, + tools: runtimeToolDeclarations('spawn_worker', 'await_event'), }), }, { diff --git a/tests/kernel/coordination-driver.test.ts b/tests/kernel/coordination-driver.test.ts index 536473c11..b3e632afb 100644 --- a/tests/kernel/coordination-driver.test.ts +++ b/tests/kernel/coordination-driver.test.ts @@ -106,6 +106,10 @@ function hangingWorkerLeaf(name: string): Agent { const perWorker: Budget = { maxIterations: 4, maxTokens: 1000 } +const spawnAndAwait = ['spawn_worker', 'await_event'] as const +const spawnOnly = ['spawn_worker'] as const +const listQuestionsOnly = ['list_questions'] as const + function driverOpts( name: string, brain: ToolLoopChat, @@ -117,6 +121,7 @@ function driverOpts( blobs: SHARED_BLOBS, makeWorkerAgent, perWorker, + toolNames: spawnAndAwait, systemPrompt: `drive the worker to do: `, maxTurns: 8, } @@ -420,6 +425,7 @@ describe('driverAgent — the driver BRAIN (LLM tool-loop drives real spawns)', const root = driverAgent({ ...driverOpts('root', chat, () => worker), + toolNames: spawnOnly, compaction: { thresholdTokens: 1 }, }) const result = await createSupervisor().run(root, 'keep track of work', { @@ -474,6 +480,7 @@ function bounds0Opts(name: string, brain: ToolLoopChat): DriverAgentOptions { blobs: SHARED_BLOBS, makeWorkerAgent: dummyWorker, perWorker, + toolNames: listQuestionsOnly, systemPrompt: 'drive', maxTurns: 0, } @@ -498,6 +505,7 @@ describe('driverAgent — maxTurns=0 lifts the turn cap; the conserved pool + de makeWorkerAgent: dummyWorker, // A worker needs more tokens than the whole run pool holds → no worker is ever affordable. perWorker: { maxIterations: 4, maxTokens: 5000 }, + toolNames: listQuestionsOnly, systemPrompt: 'drive', maxTurns: 0, } @@ -598,6 +606,7 @@ describe('driverAgent — the driver can ACT (call work tools itself), not only ) const opts: DriverAgentOptions = { ...driverOpts('root', chat, dummyWorker), + toolNames: ['echo'], extraTools: [echoTool], executeExtraTool: async (name, args) => { workCalls.push({ name, args }) @@ -642,6 +651,7 @@ describe('driverAgent — the driver can ACT (call work tools itself), not only ) const root = driverAgent({ ...driverOpts('root', chat, dummyWorker), + toolNames: ['submit_result'], deliverable: { describe: 'an object whose answer is 42', check: (result) => (result as { answer?: unknown }).answer === 42, @@ -675,6 +685,7 @@ describe('driverAgent — the driver can ACT (call work tools itself), not only const chat = scriptedBrain([benignTurn, { content: 'done' }], seen) const opts: DriverAgentOptions = { ...driverOpts('root', chat, dummyWorker), + toolNames: ['list_questions', 'echo'], extraTools: [echoTool], executeExtraTool: async (name) => { if (name === 'list_questions') extraSawCoordVerb = true @@ -692,9 +703,9 @@ describe('driverAgent — the driver can ACT (call work tools itself), not only now: () => 0, }) - // The executor was consulted first (saw the verb name) but returned null, so the coordination - // tool actually ran — its result (a questions list, never the string "echoed") came back. - expect(extraSawCoordVerb).toBe(true) + // An ungranted work handler is never invoked for a coordination verb. The coordination tool + // still runs and returns its questions list, rather than leaking an unselected product action. + expect(extraSawCoordVerb).toBe(false) const lastConvo = seen[seen.length - 1]! expect(lastConvo.some((m) => m.role === 'tool' && String(m.content) === 'echoed')).toBe(false) }) @@ -717,6 +728,29 @@ describe('driverAgent — the driver can ACT (call work tools itself), not only expect(() => driverAgent(opts)).toThrow(/collides with a coordination verb/) }) + it('refuses a product node tool that shadows spawn_worker before any brain turn', () => { + const opts: DriverAgentOptions = { + ...driverOpts('root', scriptedBrain([{ content: 'x' }], []), dummyWorker), + nodeTools: [ + { + name: 'spawn_worker', + description: 'must not shadow coordination', + inputSchema: { type: 'object' }, + handler: async () => ({}), + }, + ], + } + expect(() => driverAgent(opts)).toThrow(/node tool "spawn_worker" collides/) + }) + + it('refuses duplicate explicit tool grants at construction', () => { + const opts: DriverAgentOptions = { + ...driverOpts('root', scriptedBrain([{ content: 'x' }], []), dummyWorker), + toolNames: ['list_questions', 'list_questions'], + } + expect(() => driverAgent(opts)).toThrow(/toolNames contains a duplicate name/) + }) + it('reserves submit_result even when no independent check is configured', () => { const opts: DriverAgentOptions = { ...driverOpts('root', scriptedBrain([{ content: 'x' }], []), dummyWorker), diff --git a/tests/kernel/coordination-mcp.test.ts b/tests/kernel/coordination-mcp.test.ts index 8be881d25..6b47fd1f3 100644 --- a/tests/kernel/coordination-mcp.test.ts +++ b/tests/kernel/coordination-mcp.test.ts @@ -14,7 +14,7 @@ import type { UsageEvent, } from '../../src/runtime/supervise/types' import { supervisorAgent } from '../helpers/runtime-with-test-brain' -import { testAgentProfile } from './test-agent-profile' +import { runtimeToolDeclarations, testAgentProfile } from './test-agent-profile' // A real (simple) delivering leaf — NOT a mock of the MCP path; the HTTP→MCP→Scope.spawn is real. function deliveringLeaf(name: string, out: unknown): Agent { @@ -68,6 +68,7 @@ describe('coordination MCP over a live Scope — the real keystone (HTTP → MCP blobs, makeWorkerAgent: () => deliveringLeaf('w', { answer: 42 }), perWorker: { maxIterations: 4, maxTokens: 1000 } as Budget, + toolNames: ['spawn_worker', 'await_event'], }) try { const toolsList = await jsonRpc(mcp.url, 'tools/list', {}) @@ -118,6 +119,7 @@ describe('coordination MCP over a live Scope — the real keystone (HTTP → MCP blobs: new InMemoryResultBlobStore(), makeWorkerAgent: () => deliveringLeaf('unused', {}), perWorker: { maxIterations: 1, maxTokens: 1 }, + toolNames: ['spawn_worker', 'read_binding'], onCoordinationTools: (tools) => { boundNames = tools.map((tool) => tool.name) }, @@ -141,7 +143,6 @@ describe('coordination MCP over a live Scope — the real keystone (HTTP → MCP expect(called.error).toBeUndefined() expect(boundBeforeFirstCall).toBe(true) expect(boundNames).toContain('spawn_worker') - expect(boundNames).toContain('await_event') } finally { await mcp.close() } @@ -155,6 +156,7 @@ describe('coordination MCP over a live Scope — the real keystone (HTTP → MCP blobs: new InMemoryResultBlobStore(), makeWorkerAgent: () => deliveringLeaf('unused', {}), perWorker: { maxIterations: 1, maxTokens: 1 }, + toolNames: ['spawn_worker', 'lookup_evidence'], nodeTools: [ { name: 'lookup_evidence', @@ -198,6 +200,7 @@ describe('coordination MCP over a live Scope — the real keystone (HTTP → MCP blobs: new InMemoryResultBlobStore(), makeWorkerAgent: () => deliveringLeaf('unused', {}), perWorker: { maxIterations: 1, maxTokens: 1 }, + toolNames: [], nodeTools: [ { name: 'spawn_worker', @@ -209,6 +212,18 @@ describe('coordination MCP over a live Scope — the real keystone (HTTP → MCP }), ).rejects.toThrow(/spawn_worker.*shadows/) }) + + it('refuses duplicate explicit tool grants before opening a listener', async () => { + await expect( + serveCoordinationMcp({ + scope: {} as Scope, + blobs: new InMemoryResultBlobStore(), + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker: { maxIterations: 1, maxTokens: 1 }, + toolNames: ['spawn_worker', 'spawn_worker'], + }), + ).rejects.toThrow(/toolNames contains a duplicate name/) + }) }) /** Run `body` against a REAL live scope — the same path the sandbox supervisor arm uses — and @@ -255,6 +270,7 @@ describe('serveCoordinationMcp itself fails closed on a non-loopback bind', () = blobs: new InMemoryResultBlobStore(), makeWorkerAgent: () => deliveringLeaf('w', {}), perWorker: { maxIterations: 1, maxTokens: 10 } as Budget, + toolNames: ['spawn_worker'], ...extra, }) @@ -366,16 +382,22 @@ describe('serveCoordinationMcp receives the peerMail the supervisor forwards', ( } await jsonRpc(coordinationMcpUrl, 'tools/call', { name: 'stop', arguments: {} }) } - const root = supervisorAgent(testAgentProfile('sup', { harness: 'opencode' }), { - blobs, - makeWorkerAgent: (_profile, context) => { - mailUrls.push(context?.peerMailUrl) - return deliveringLeaf('w', { answer: 1 }) + const root = supervisorAgent( + testAgentProfile('sup', { + harness: 'opencode', + tools: runtimeToolDeclarations('spawn_worker', 'await_event', 'stop'), + }), + { + blobs, + makeWorkerAgent: (_profile, context) => { + mailUrls.push(context?.peerMailUrl) + return deliveringLeaf('w', { answer: 1 }) + }, + perWorker: { maxIterations: 4, maxTokens: 1000 } as Budget, + driveHarness, + peerMail: true, }, - perWorker: { maxIterations: 4, maxTokens: 1000 } as Budget, - driveHarness, - peerMail: true, - }) + ) const result = await createSupervisor().run(root, 'solve', { budget: { maxIterations: 100, maxTokens: 100_000 }, runId: 'mail-mcp', diff --git a/tests/kernel/driver-inference-metering.test.ts b/tests/kernel/driver-inference-metering.test.ts index f2a95aecc..296516f8e 100644 --- a/tests/kernel/driver-inference-metering.test.ts +++ b/tests/kernel/driver-inference-metering.test.ts @@ -96,6 +96,9 @@ function meteredChat(turns: ScriptedTurn[]): ToolLoopChat { } const perWorker: Budget = { maxIterations: 4, maxTokens: 1000 } +const spawnAndAwait = ['spawn_worker', 'await_event'] as const +const spawnOnly = ['spawn_worker'] as const +const listQuestionsOnly = ['list_questions'] as const 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 () => { @@ -116,7 +119,6 @@ describe("driver inference metering — the driver's own tokens count against th const nested = driverChild( testAgentProfile('nested', { harness: 'cli-base', - metadata: { role: 'driver' }, }), nestedDriver, journal, @@ -184,6 +186,7 @@ describe("driver inference metering — the driver's own tokens count against th blobs, makeWorkerAgent: () => worker, perWorker, + toolNames: spawnAndAwait, systemPrompt: 'drive', maxTurns: 8, } @@ -243,6 +246,7 @@ describe("driver inference metering — the driver's own tokens count against th blobs, makeWorkerAgent: makeAgent, perWorker: workerBudget, + toolNames: spawnAndAwait, systemPrompt: 'drive', maxTurns: 8, }) @@ -366,6 +370,7 @@ describe("driver inference metering — the driver's own tokens count against th blobs, makeWorkerAgent: makeAgent, perWorker: workerBudget, + toolNames: spawnAndAwait, systemPrompt: 'drive', maxTurns: 8, }) @@ -483,6 +488,7 @@ describe("driver inference metering — the driver's own tokens count against th blobs, makeWorkerAgent: makeAgent, perWorker, + toolNames: listQuestionsOnly, systemPrompt: 'drive', maxTurns: 8, }), @@ -515,6 +521,7 @@ describe("driver inference metering — the driver's own tokens count against th blobs, makeWorkerAgent: makeAgent, perWorker, + toolNames: spawnAndAwait, systemPrompt: 'drive', maxTurns: 8, }), @@ -569,6 +576,7 @@ describe("driver inference metering — the driver's own tokens count against th blobs, makeWorkerAgent: () => workerLeaf('w', { input: 1, output: 1 }), perWorker: { maxIterations: 4, maxTokens: 500 }, + toolNames: listQuestionsOnly, systemPrompt: 'drive', maxTurns: 0, // unlimited turn count — the pool is the only bound } @@ -623,6 +631,7 @@ describe("driver inference metering — the driver's own tokens count against th blobs, makeWorkerAgent: () => workerLeaf('unused', { input: 1, output: 1 }), perWorker: workerBudget, + toolNames: spawnOnly, systemPrompt: 'drive', maxTurns: 0, }), @@ -670,6 +679,7 @@ describe("driver inference metering — the driver's own tokens count against th blobs, makeWorkerAgent: () => workerLeaf('w', { input: 10, output: 5 }), perWorker, + toolNames: spawnAndAwait, systemPrompt: 'drive', maxTurns: 8, } @@ -741,6 +751,7 @@ describe("driver inference metering — the driver's own tokens count against th blobs, makeWorkerAgent: () => workerLeaf('w', { input: 1, output: 1 }), perWorker: { maxIterations: 4, maxTokens: 100 }, + toolNames: listQuestionsOnly, systemPrompt: 'drive', maxTurns: 0, } @@ -928,6 +939,7 @@ describe('unmetered turns are impossible — a turn with unknown usage is record blobs, makeWorkerAgent: () => workerLeaf('w', { input: 10, output: 5 }), perWorker, + toolNames: spawnAndAwait, systemPrompt: 'drive', maxTurns: 8, } @@ -1000,6 +1012,7 @@ describe('unmetered turns are impossible — a turn with unknown usage is record blobs, makeWorkerAgent: () => workerLeaf('w', { input: 1, output: 1 }), perWorker, + toolNames: [], systemPrompt: 'drive', maxTurns: 4, }), @@ -1047,6 +1060,7 @@ describe('unmetered turns are impossible — a turn with unknown usage is record blobs, makeWorkerAgent: () => workerLeaf('w', { input: 1, output: 1 }), perWorker, + toolNames: listQuestionsOnly, systemPrompt: 'drive', maxTurns: 8, }), diff --git a/tests/kernel/graph.test.ts b/tests/kernel/graph.test.ts index 35407f4ba..bb7d4ff68 100644 --- a/tests/kernel/graph.test.ts +++ b/tests/kernel/graph.test.ts @@ -35,9 +35,9 @@ * directions, and the refusals fail loud: resume-with-no-prior, resume-while-live (steer is * the live channel), resume-under-a-key, and nonsense values or analyzes edges carrying * continuity refused at validation. - * 11. Pinning is spawn AUTHORIZATION (#965): the kernel classifies the PINNED profile, so a node - * declared `role: 'driver'` becomes a supervisor instead of silently running as a leaf; a - * caller's authorizeSpawn sees the canonical profile; steers stay live without a filter. + * 11. Pinning is spawn AUTHORIZATION (#965): the kernel reads the PINNED profile's explicit + * `spawn_worker` grant, not a hidden role field; a caller's authorizeSpawn sees the canonical + * profile; steers stay live without a filter. * 12. resolveSupervisorTools passthrough: RunGraphOptions forwards the product-tool resolver to * supervise(), so a declared graph's root mounts the SAME product tools a supervise() run * mounts and its handler receives the trusted node context; omitted = coordination only. @@ -79,7 +79,7 @@ import type { import type { ToolLoopChat } from '../../src/runtime/tool-loop' import { runGraph } from '../helpers/runtime-with-test-brain' import { scriptedBrain } from './scripted-brain' -import { testAgentProfile } from './test-agent-profile' +import { runtimeToolDeclarations, testAgentProfile } from './test-agent-profile' // ── Leaf fixtures (the offline execution seam; the graph machinery around them is real) ──────── @@ -95,6 +95,8 @@ const toolSpan = (runId: string): ToolSpan => ({ endedAt: 105, }) +const graphDriverTools = runtimeToolDeclarations('spawn_worker', 'await_event', 'steer_agent') + interface LeafOptions { /** Block settlement until a deliver() arrives (so a steer can reach a LIVE worker). */ awaitSteer?: boolean @@ -197,6 +199,7 @@ const twoNodeGraph = (over?: Partial): AgentGraph => ({ id: 'driver', profile: testAgentProfile('driver', { harness: 'cli-base', + tools: graphDriverTools, prompt: { systemPrompt: 'Drive the worker until it delivers.' }, }), }, @@ -229,6 +232,7 @@ describe('runGraph — the 2-node cyclic case over supervise()', () => { id: 'driver', profile: testAgentProfile('driver', { harness: 'cli-base', + tools: graphDriverTools, prompt: { systemPrompt: 'Drive.' }, }), }, @@ -593,6 +597,7 @@ describe('runGraph — analyzes edges (analysts are environment, findings get a id: 'driver', profile: testAgentProfile('driver', { harness: 'cli-base', + tools: graphDriverTools, prompt: { systemPrompt: 'Drive both.' }, }), }, @@ -855,6 +860,7 @@ describe('runGraph — analyst NODES (the analyzes lens as a tool-equipped agent id: 'driver', profile: testAgentProfile('driver', { harness: 'cli-base', + tools: graphDriverTools, prompt: { systemPrompt: 'Drive.' }, }), }, @@ -1133,17 +1139,39 @@ describe('runGraph — every supervise option a graph does not own reaches super } return { content: 'done', toolCalls: [] } } - const res = await runGraph(twoNodeGraph(), { - runId: 'gx', - makeLeafAgent: leafSeam([]), - extraTools: [ - { name: 'measure_rung', description: 'Measure one rung', parameters: { type: 'object' } }, - ], - // Returning null for anything but the extra tool is the contract: a non-null answer - // swallows the coordination verb and the root can never spawn. - executeExtraTool: async (name) => (name === 'measure_rung' ? '{"measured":true}' : null), - brain, - }) + const graph = twoNodeGraph() + const res = await runGraph( + { + ...graph, + nodes: graph.nodes.map((node) => + node.id === 'driver' + ? { + ...node, + profile: { + ...node.profile, + tools: runtimeToolDeclarations( + 'spawn_worker', + 'await_event', + 'steer_agent', + 'measure_rung', + ), + }, + } + : node, + ), + }, + { + runId: 'gx', + makeLeafAgent: leafSeam([]), + extraTools: [ + { name: 'measure_rung', description: 'Measure one rung', parameters: { type: 'object' } }, + ], + // Returning null for anything but the extra tool is the contract: a non-null answer + // swallows the coordination verb and the root can never spawn. + executeExtraTool: async (name) => (name === 'measure_rung' ? '{"measured":true}' : null), + brain, + }, + ) expect(res.result.kind).toBe('winner') expect(mounted[0]).toContain('measure_rung') expect(mounted[0]).toContain('spawn_worker') @@ -1168,26 +1196,25 @@ describe('runGraph — every supervise option a graph does not own reaches super }) describe('runGraph — pinning is spawn AUTHORIZATION, so a node can be a supervisor (#965)', () => { - // The kernel decides leaf-vs-supervisor from the profile it has AFTER `authorizeSpawn` and - // BEFORE the leaf seam. Pinning used to live in the leaf seam, so every node was a leaf no - // matter what its canonical profile declared. Now the kernel classifies the PINNED profile. - - it("the kernel's driver decision reads the node's pinned metadata, not the driver's stub", async () => { - // `isDriverProfile` receives the post-authorization context. If pinning had not happened yet - // it would see `{ name: 'lead' }` with no metadata; it sees the canonical node profile. - const seenByClassifier: Array<{ name?: string; role?: unknown; systemPrompt?: string }> = [] + // The kernel decides whether a node needs Runtime management after graph pinning and spawn + // authorization, before the leaf seam can run it. Explicit tool grants are the only authority. + + it('uses the pinned spawn_worker grant, not the driver stub, to select Runtime management', async () => { const graph = twoNodeGraph({ nodes: [ { id: 'driver', - profile: testAgentProfile('driver', { harness: 'cli-base' }), + profile: testAgentProfile('driver', { + harness: 'cli-base', + tools: graphDriverTools, + }), }, { id: 'lead', profile: testAgentProfile('lead', { harness: 'cli-base', prompt: { systemPrompt: 'You run a sub-team.' }, - metadata: { role: 'driver' }, + tools: runtimeToolDeclarations('spawn_worker'), }), }, ], @@ -1200,18 +1227,10 @@ describe('runGraph — pinning is spawn AUTHORIZATION, so a node can be a superv }, ], }) - await runGraph(graph, { + const received: AgentProfile[] = [] + const res = await runGraph(graph, { runId: 'gsup', - makeLeafAgent: leafSeam([]), - isDriverProfile: (ctx) => { - seenByClassifier.push({ - name: ctx.profile.name, - role: ctx.profile.metadata?.role, - systemPrompt: ctx.profile.prompt?.systemPrompt, - }) - // Answer "leaf" so the run completes offline: a nested supervisor needs a router brain. - return false - }, + makeLeafAgent: leafSeam(received), brain: scriptedBrain([ { toolCalls: [ @@ -1222,24 +1241,25 @@ describe('runGraph — pinning is spawn AUTHORIZATION, so a node can be a superv { content: 'done' }, ]), }) - expect(seenByClassifier).toEqual([ - { name: 'lead', role: 'driver', systemPrompt: 'You run a sub-team.' }, - ]) + // The graph pinned the full lead profile before the model's `{ name: 'lead' }` stub arrived. + // Without a nested router brain it cannot finish, which proves the leaf seam did not run it. + expect(received).toHaveLength(0) + expect(res.result.kind).not.toBe('winner') }) - it('a node declared role:driver is classified a SUPERVISOR by default — it no longer runs as a leaf', async () => { - // Default classification (`metadata.role === 'driver'`) over the pinned profile. Offline, the - // nested supervisor is then refused for lack of a router brain — and that refusal is the proof: - // before this fix the same node silently ran as a leaf and the run completed `winner`. + it('keeps a node without a Runtime grant on the ordinary leaf path', async () => { const received: AgentProfile[] = [] const graph = twoNodeGraph({ nodes: [ - { id: 'driver', profile: testAgentProfile('driver', { harness: 'cli-base' }) }, + { + id: 'driver', + profile: testAgentProfile('driver', { harness: 'cli-base', tools: graphDriverTools }), + }, { id: 'lead', profile: testAgentProfile('lead', { harness: 'cli-base', - metadata: { role: 'driver' }, + metadata: { phase: 'review' }, }), }, ], @@ -1265,10 +1285,8 @@ describe('runGraph — pinning is spawn AUTHORIZATION, so a node can be a superv { content: 'done' }, ]), }) - // The leaf seam never saw the lead: the kernel took the supervisor branch for it. - expect(received).toHaveLength(0) - // And the run did not silently succeed on a mis-classified node. - expect(res.result.kind).not.toBe('winner') + expect(received.map((profile) => profile.name)).toEqual(['lead']) + expect(res.result.kind).toBe('winner') }) it("a caller's authorizeSpawn sees the CANONICAL node profile, never the driver's stub", async () => { @@ -1381,26 +1399,46 @@ describe('runGraph — resolveSupervisorTools passthrough (product tools on a de } return { content: 'done', toolCalls: [] } } - const res = await runGraph(twoNodeGraph(), { - runId: 'gt', - makeLeafAgent: leafSeam([]), - resolveSupervisorTools: async () => [ - { - name: 'kb_record', - description: 'Record one claim in the product ledger', - inputSchema: { - type: 'object', - properties: { claim: { type: 'string' } }, - required: ['claim'], + const res = await runGraph( + twoNodeGraph({ + nodes: [ + { + id: 'driver', + profile: testAgentProfile('driver', { + harness: 'cli-base', + tools: { ...graphDriverTools, ...runtimeToolDeclarations('kb_record') }, + prompt: { systemPrompt: 'Drive the worker until it delivers.' }, + }), }, - handler: async (raw, context) => { - handled.push({ raw, runId: context.runId, nodeId: context.nodeId }) - return { recorded: true } + { + id: 'worker', + profile: testAgentProfile('worker', { + prompt: { systemPrompt: 'You build what the driver asks.' }, + }), }, - }, - ], - brain, - }) + ], + }), + { + runId: 'gt', + makeLeafAgent: leafSeam([]), + resolveSupervisorTools: async () => [ + { + name: 'kb_record', + description: 'Record one claim in the product ledger', + inputSchema: { + type: 'object', + properties: { claim: { type: 'string' } }, + required: ['claim'], + }, + handler: async (raw, context) => { + handled.push({ raw, runId: context.runId, nodeId: context.nodeId }) + return { recorded: true } + }, + }, + ], + brain, + }, + ) expect(res.result.kind).toBe('winner') // Mounted alongside the coordination verbs, not instead of them. @@ -1559,6 +1597,7 @@ describe('runGraph — driverBackend selects WHERE the root harness brain runs', id: 'driver', profile: testAgentProfile('driver', { harness: 'codex', + tools: graphDriverTools, prompt: { systemPrompt: 'Drive the worker until it delivers.' }, }), }, @@ -1728,6 +1767,7 @@ describe('runGraph — the caller-brain seam on the production surface (#694 opt id: 'driver', profile: testAgentProfile('driver', { harness: 'codex', + tools: graphDriverTools, prompt: { systemPrompt: 'Drive.' }, }), }, @@ -2152,7 +2192,10 @@ describe('runGraph — validation fails loud before any compute', () => { // would make every analyzes edge over/to this node silently never match. const graph = twoNodeGraph({ nodes: [ - { id: 'driver', profile: { name: 'driver', prompt: { systemPrompt: 'Drive.' } } }, + { + id: 'driver', + profile: { name: 'driver', tools: graphDriverTools, prompt: { systemPrompt: 'Drive.' } }, + }, { id: 'worker', profile: { name: 'builder', prompt: { systemPrompt: 'Build.' } } }, ], }) @@ -2162,7 +2205,10 @@ describe('runGraph — validation fails loud before any compute', () => { // An ABSENT profile name diverges the same way (undefined ≠ the node id) and fails the same. const unnamed = twoNodeGraph({ nodes: [ - { id: 'driver', profile: { name: 'driver', prompt: { systemPrompt: 'Drive.' } } }, + { + id: 'driver', + profile: { name: 'driver', tools: graphDriverTools, prompt: { systemPrompt: 'Drive.' } }, + }, { id: 'worker', profile: { prompt: { systemPrompt: 'Build.' } } }, ], }) @@ -2178,7 +2224,10 @@ describe('runGraph — validation fails loud before any compute', () => { } const graph = twoNodeGraph({ nodes: [ - { id: 'driver', profile: { name: 'driver', prompt: { systemPrompt: 'Drive.' } } }, + { + id: 'driver', + profile: { name: 'driver', tools: graphDriverTools, prompt: { systemPrompt: 'Drive.' } }, + }, { id: 'builder', profile: { name: 'builder', prompt: { systemPrompt: 'Build.' } } }, { id: 'fixer', profile: { name: 'fixer', prompt: { systemPrompt: 'Fix.' } } }, ], @@ -2292,12 +2341,12 @@ describe('prompt registry — versioned directives as data', () => { expect(() => promptHandle('no-version')).toThrow(ValidationError) }) - it('resolves seeded kernel surfaces and fails loud on unknown handles', () => { + it('resolves seeded edge directives and fails loud on unknown handles', () => { const registry = kernelPromptRegistry() - expect(registry.resolve(promptHandle('supervisor/policy/v1')).text).toContain( - 'accountable for DELIVERING', + expect(registry.resolve(promptHandle('delegates/worker-brief/v1')).text).toContain( + 'delegated sub-task', ) - expect(() => registry.resolve(promptHandle('supervisor/policy/v99'))).toThrow(/no entry/) + expect(() => registry.resolve(promptHandle('delegates/worker-brief/v99'))).toThrow(/no entry/) }) it('treats versions as immutable — re-registering an existing version fails loud', () => { diff --git a/tests/kernel/in-place-cli-executor.test.ts b/tests/kernel/in-place-cli-executor.test.ts index daf01455e..ba3bfdceb 100644 --- a/tests/kernel/in-place-cli-executor.test.ts +++ b/tests/kernel/in-place-cli-executor.test.ts @@ -348,6 +348,7 @@ describe('createExecutor({ backend: "cli-in-place" })', () => { expect(seen[0]?.taskPrompt).toBe('TASK_PROMPT_4d10') expect(argv).toContain('TASK_PROMPT_4d10') expect(argv).not.toContain('{"prompt"') + expect(seen[0]).not.toHaveProperty('timeoutMs') expect(readFileSync(join(workspacePath, 'program.mjs'), 'utf8')).toBe( 'export const version = 1\n', ) diff --git a/tests/kernel/loop-dispatch.test.ts b/tests/kernel/loop-dispatch.test.ts index 10792edad..1ca6e8f80 100644 --- a/tests/kernel/loop-dispatch.test.ts +++ b/tests/kernel/loop-dispatch.test.ts @@ -27,6 +27,7 @@ import type { SupervisedResult, } from '../../src/runtime/supervise/types' import { refineDriver } from './refine-driver' +import { runtimeToolDeclarations } from './test-agent-profile' interface Task { goal: string @@ -466,6 +467,7 @@ describe('superviseDispatch', () => { name: 'pi-moving', harness: 'pi', model: { provider: 'tangle-router', default: 'deepseek-v4-flash' }, + tools: runtimeToolDeclarations('submit_result'), } function movingPiDispatch(bridgeUrl: string) { @@ -829,6 +831,7 @@ describe('superviseDispatch', () => { name: 'recursive-root', harness: 'cli-base', model: { provider: 'offline', default: 'test-model@2026-08-11' }, + tools: runtimeToolDeclarations('submit_result'), }, { id: 'recursive', kind: 'task' }, fake.ctx, @@ -895,6 +898,7 @@ describe('superviseDispatch', () => { name: 'partial-cache-root', harness: 'cli-base', model: { provider: 'offline', default: 'test-model@2026-08-11' }, + tools: runtimeToolDeclarations('submit_result'), }, { id: 'partial-cache', kind: 'task' }, fake.ctx, @@ -958,6 +962,7 @@ describe('superviseDispatch', () => { name: 'mixed-cache-root', harness: 'cli-base', model: { provider: 'offline', default: 'test-model@2026-08-11' }, + tools: runtimeToolDeclarations('submit_result'), }, { id: 'mixed-cache', kind: 'task' }, fake.ctx, @@ -1011,6 +1016,7 @@ describe('superviseDispatch', () => { name: 'unknown-usage-root', harness: 'cli-base', model: { provider: 'offline', default: 'test-model@2026-08-11' }, + tools: runtimeToolDeclarations('submit_result'), }, { id: 'unknown-usage', kind: 'task' }, fake.ctx, @@ -1171,6 +1177,7 @@ describe('superviseDispatch', () => { name: 'glm-root', harness: 'cli-base', model: { provider: 'zai', default: 'glm-root@2026-08-11' }, + tools: runtimeToolDeclarations('spawn_worker', 'await_event', 'submit_result'), }, { id: 'mixed-model', kind: 'task' }, fake.ctx, diff --git a/tests/kernel/materialization-evidence.test.ts b/tests/kernel/materialization-evidence.test.ts index d509ef899..d72efdea1 100644 --- a/tests/kernel/materialization-evidence.test.ts +++ b/tests/kernel/materialization-evidence.test.ts @@ -354,11 +354,7 @@ describe('kernel-owned materialization evidence', () => { { name: 'silent-manager', act: async () => ({ shouldNotWin: true }) }, 'cli', ) - const child = driverChild( - testAgentProfile('manager', { metadata: { role: 'driver' } }), - silentManager, - journal, - ) + const child = driverChild(testAgentProfile('manager'), silentManager, journal) const executors = withDriverExecutor(createExecutorRegistry()) await runOneChild(child, journal, 'missing-deferred', executors) diff --git a/tests/kernel/nested-coordination-durability.test.ts b/tests/kernel/nested-coordination-durability.test.ts index d1336379a..1f8014208 100644 --- a/tests/kernel/nested-coordination-durability.test.ts +++ b/tests/kernel/nested-coordination-durability.test.ts @@ -11,7 +11,10 @@ import type { import type { ToolLoopChat } from '../../src/runtime/tool-loop' import { supervise } from '../helpers/runtime-with-test-brain' import { scriptedBrain } from './scripted-brain' -import { testAgentProfile } from './test-agent-profile' +import { runtimeToolDeclarations, testAgentProfile } from './test-agent-profile' + +const rootTools = runtimeToolDeclarations('spawn_worker', 'await_event', 'steer_agent') +const managerTools = runtimeToolDeclarations('list_questions', 'ask_parent') async function callTool( url: string, @@ -44,7 +47,7 @@ async function callTool( function rootBrain() { const manager = testAgentProfile('identical-manager', { harness: 'codex', - metadata: { role: 'driver' }, + tools: managerTools, }) return scriptedBrain([ { @@ -108,6 +111,7 @@ describe('nested supervisor coordination durability', () => { } const profile = testAgentProfile('root', { harness: 'cli-base', + tools: rootTools, prompt: { systemPrompt: 'Run both managers.' }, }) @@ -172,7 +176,7 @@ describe('nested supervisor coordination durability', () => { if (turn === 1) { const manager = testAgentProfile('identical-manager', { harness: 'codex', - metadata: { role: 'driver' }, + tools: managerTools, }) return { toolCalls: [ @@ -223,6 +227,7 @@ describe('nested supervisor coordination durability', () => { await supervise( testAgentProfile('root', { harness: 'cli-base', + tools: rootTools, prompt: { systemPrompt: 'Run both managers.' }, }), 'root task', @@ -270,6 +275,7 @@ describe('nested supervisor coordination durability', () => { await supervise( testAgentProfile('root', { harness: 'cli-base', + tools: rootTools, prompt: { systemPrompt: 'Run both managers.' }, }), 'root task', @@ -294,7 +300,7 @@ describe('nested supervisor coordination durability', () => { arguments: { profile: testAgentProfile('identical-manager', { harness: 'codex', - metadata: { role: 'driver' }, + tools: managerTools, }), task: 'same task', key: 'manager-a', @@ -305,7 +311,7 @@ describe('nested supervisor coordination durability', () => { arguments: { profile: testAgentProfile('identical-manager', { harness: 'codex', - metadata: { role: 'driver' }, + tools: managerTools, }), task: 'same task', key: 'manager-b', diff --git a/tests/kernel/peer-mail.test.ts b/tests/kernel/peer-mail.test.ts index 6bbc46b1b..e33630720 100644 --- a/tests/kernel/peer-mail.test.ts +++ b/tests/kernel/peer-mail.test.ts @@ -140,6 +140,7 @@ async function runTwoSiblings( return scriptedWorker(name, context, script) }, perWorker: { maxIterations: 4, maxTokens: 1000 }, + toolNames: ['spawn_worker', 'await_event'], peerMail: limits ? { limits } : true, onEvent: (event) => { events.push(event) diff --git a/tests/kernel/refusal-reasons.test.ts b/tests/kernel/refusal-reasons.test.ts index 6951775e5..d4185fff0 100644 --- a/tests/kernel/refusal-reasons.test.ts +++ b/tests/kernel/refusal-reasons.test.ts @@ -244,6 +244,7 @@ describe('ask_parent at the top of the chain', () => { blobs, makeWorkerAgent, perWorker, + toolNames: ['ask_parent'], host: '127.0.0.1', escalateQuestion: (question: { question: string }) => { received.push(question.question) diff --git a/tests/kernel/reprompt-owner-attempt.test.ts b/tests/kernel/reprompt-owner-attempt.test.ts index 8d0c858a4..3329373fd 100644 --- a/tests/kernel/reprompt-owner-attempt.test.ts +++ b/tests/kernel/reprompt-owner-attempt.test.ts @@ -13,7 +13,7 @@ import { createSupervisor } from '../../src/runtime/supervise/supervisor' import type { DriveHarness } from '../../src/runtime/supervise/supervisor-agent' import type { Agent, Budget, ExecutorExecutionBinding } from '../../src/runtime/supervise/types' import { supervisorAgent } from '../helpers/runtime-with-test-brain' -import { testAgentProfile } from './test-agent-profile' +import { runtimeToolDeclarations, testAgentProfile } from './test-agent-profile' const perWorker: Budget = { maxIterations: 4, maxTokens: 1000 } @@ -67,6 +67,7 @@ describe('a re-prompted root is a new execution attempt (#1085)', () => { harness: 'pi', model: { provider: 'offline', default: 'test/model' }, prompt: { systemPrompt: 'solve or delegate' }, + tools: runtimeToolDeclarations('submit_result'), }) const reported: string[] = [] const driveHarness: DriveHarness = async ({ coordinationMcpUrl, scope }) => { diff --git a/tests/kernel/run-cancellation.test.ts b/tests/kernel/run-cancellation.test.ts index bcc34c79b..8239e2bfb 100644 --- a/tests/kernel/run-cancellation.test.ts +++ b/tests/kernel/run-cancellation.test.ts @@ -27,7 +27,14 @@ import { scriptedBrain } from './scripted-brain' import { testAgentProfile } from './test-agent-profile' const budget: Budget = { maxIterations: 100, maxTokens: 100_000 } -const rootProfile = (): AgentProfile => testAgentProfile('root', { harness: 'cli-base' }) +const rootProfile = (): AgentProfile => + testAgentProfile('root', { + harness: 'cli-base', + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + }, + }) const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) /** A worker that runs until its per-child signal aborts — so the run is genuinely live when the diff --git a/tests/kernel/skill-tool-names.test.ts b/tests/kernel/skill-tool-names.test.ts index 0270d81f3..ce621bc68 100644 --- a/tests/kernel/skill-tool-names.test.ts +++ b/tests/kernel/skill-tool-names.test.ts @@ -74,3 +74,18 @@ describe('shipped skills name no harness-native tool', () => { ).toEqual([]) }) }) + +describe('supervise skill recursive authority', () => { + const supervise = readFileSync(join(skillsRoot, 'supervise', 'SKILL.md'), 'utf8') + + it('teaches profile-owned recursion and profile-authoring skill propagation', () => { + expect(supervise).toContain('agent_runtime_coordination_spawn_worker: true') + expect(supervise).toContain('profile-authoring/SKILL.md') + expect(supervise).toContain('resources.failOnError: true') + expect(supervise).toContain('Every profile that can spawn workers carries') + }) + + it('does not grant recursion through role metadata', () => { + expect(supervise).not.toMatch(/metadata\.role|role\s*:\s*['"]driver['"]|"role"\s*:\s*"driver"/) + }) +}) diff --git a/tests/kernel/supervise-convenience.test.ts b/tests/kernel/supervise-convenience.test.ts index a6d47b713..920571c76 100644 --- a/tests/kernel/supervise-convenience.test.ts +++ b/tests/kernel/supervise-convenience.test.ts @@ -14,6 +14,7 @@ import { type SuperviseRegistryTable, workerFromBackend, } from '../../src/runtime/supervise/supervise' +import type { DriveHarness } from '../../src/runtime/supervise/supervisor-agent' import type { Agent, AgentSpec, @@ -24,7 +25,7 @@ import type { } from '../../src/runtime/supervise/types' import { supervise } from '../helpers/runtime-with-test-brain' import { scriptedBrain } from './scripted-brain' -import { testAgentProfile } from './test-agent-profile' +import { runtimeToolDeclarations, testAgentProfile } from './test-agent-profile' const budget: Budget = { maxIterations: 100, maxTokens: 100_000 } @@ -97,11 +98,14 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ { content: 'done' }, ]) const result = await supervise( - rootProfile({ prompt: { systemPrompt: 'drive the worker' } }), + rootProfile({ + prompt: { systemPrompt: 'drive the worker' }, + tools: runtimeToolDeclarations('spawn_worker', 'await_event'), + }), 'solve it', { budget, makeWorkerAgent: () => deliveringLeaf('w', { answer: 42 }), brain }, ) - expect(result.kind).toBe('winner') + expect(result.kind, JSON.stringify(result)).toBe('winner') }) it('cascades the caller abort signal through the root and every live child', async () => { @@ -141,19 +145,23 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ executorSpec: spec, } as Agent & { executorSpec: AgentSpec } } - const running = supervise(rootProfile(), 'solve it', { - budget, - signal: controller.signal, - makeWorkerAgent: blockedLeaf, - brain: scriptedBrain([ - { - toolCalls: [ - { name: 'spawn_worker', arguments: { profile: workerProfile(), task: 'go' } }, - ], - }, - { toolCalls: [{ name: 'await_event', arguments: {} }] }, - ]), - }) + const running = supervise( + rootProfile({ tools: runtimeToolDeclarations('spawn_worker', 'await_event') }), + 'solve it', + { + budget, + signal: controller.signal, + makeWorkerAgent: blockedLeaf, + brain: scriptedBrain([ + { + toolCalls: [ + { name: 'spawn_worker', arguments: { profile: workerProfile(), task: 'go' } }, + ], + }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + ]), + }, + ) await childStarted controller.abort() @@ -175,23 +183,27 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ }) const seen: Array>> = [] let turn = 0 - const running = supervise(rootProfile(), '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({}) }], + const running = supervise( + rootProfile({ tools: runtimeToolDeclarations('list_questions') }), + '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: [] } + return { content: 'stopped after reading the steer', toolCalls: [] } + }, }, - }) + ) await firstTurnEntered expect(handle.deliver({ junk: true })).toBe(false) @@ -217,7 +229,10 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ { content: 'must not need another turn' }, ]) const result = await supervise( - rootProfile({ prompt: { systemPrompt: 'solve or delegate' } }), + rootProfile({ + prompt: { systemPrompt: 'solve or delegate' }, + tools: runtimeToolDeclarations('submit_result'), + }), 'solve it directly', { budget, @@ -234,6 +249,69 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ if (result.kind === 'winner') expect(result.out).toEqual({ answer: 42 }) }) + it('uses a child-specific completion check when a managed child submits its own result', async () => { + let childCheckCalls = 0 + const child = testAgentProfile('specialist', { + harness: 'opencode', + tools: runtimeToolDeclarations('submit_result'), + }) + const call = async (url: string, name: string, args: Record) => { + const response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: name, + method: 'tools/call', + params: { name, arguments: args }, + }), + }) + return response.json() + } + const driveHarness: DriveHarness = async ({ profile, coordinationMcpUrl }) => { + if (profile.name === 'root') { + await call(coordinationMcpUrl, 'spawn_worker', { profile: child, task: 'solve' }) + await call(coordinationMcpUrl, 'await_event', {}) + return + } + await call(coordinationMcpUrl, 'submit_result', { result: { answer: 42 } }) + } + + const result = await supervise( + testAgentProfile('root', { + harness: 'opencode', + tools: runtimeToolDeclarations('spawn_worker', 'await_event'), + }), + 'delegate', + { + budget, + perWorker: { maxIterations: 4, maxTokens: 10_000 }, + makeLeafAgent: () => deliveringLeaf('unused', {}), + driveHarness, + // The run-wide check deliberately rejects the child's output. A profile-managed child + // must instead receive the check selected for its exact authorized assignment. + deliverable: { check: () => false }, + resolveDeliverable: (input) => + input.profile.name === 'specialist' + ? { + check: (value) => { + childCheckCalls += 1 + return ( + typeof value === 'object' && + value !== null && + (value as { answer?: unknown }).answer === 42 + ) + }, + } + : undefined, + }, + ) + + expect(result.kind).toBe('winner') + if (result.kind === 'winner') expect(result.out).toEqual({ answer: 42 }) + expect(childCheckCalls).toBe(1) + }) + it('runDir makes the run durable and resumable; unset stays in-memory', async () => { const dir = await mkdtemp(join(tmpdir(), 'supervise-rundir-')) try { @@ -254,10 +332,14 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ runDir: dir, } - const first = await supervise(rootProfile(), 'solve it', { - ...opts, - brain: script(), - }) + const first = await supervise( + rootProfile({ tools: runtimeToolDeclarations('spawn_worker', 'await_event') }), + 'solve it', + { + ...opts, + brain: script(), + }, + ) expect(first.kind).toBe('winner') // The journal really landed on disk with the settled child, not in a process-lifetime map. @@ -272,10 +354,14 @@ 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(rootProfile(), 'solve it', { - ...opts, - brain: script(), - }) + const second = await supervise( + rootProfile({ tools: runtimeToolDeclarations('spawn_worker', 'await_event') }), + 'solve it', + { + ...opts, + brain: script(), + }, + ) expect(second.kind).toBe('winner') } finally { await rm(dir, { recursive: true, force: true }) @@ -321,18 +407,22 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ runDir: dir, onCoordinationEvent, } - const first = await supervise(rootProfile(), 'solve it', { - ...common, - brain: scriptedBrain([ - { - toolCalls: [ - { name: 'spawn_worker', arguments: { profile: workerProfile(), task: 'go' } }, - ], - }, - { toolCalls: [{ name: 'await_event', arguments: {} }] }, - { content: 'stop after the observer error' }, - ]), - }) + const first = await supervise( + rootProfile({ tools: runtimeToolDeclarations('spawn_worker', 'await_event') }), + 'solve it', + { + ...common, + brain: scriptedBrain([ + { + toolCalls: [ + { name: 'spawn_worker', arguments: { profile: workerProfile(), 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) @@ -347,10 +437,14 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ expect(replayCommitted).toBe(true) return replayScript(...args) } - const second = await supervise(rootProfile(), 'solve it', { - ...common, - brain: replayBrain, - }) + const second = await supervise( + rootProfile({ tools: runtimeToolDeclarations('spawn_worker', 'await_event') }), + 'solve it', + { + ...common, + brain: replayBrain, + }, + ) expect(second.kind).toBe('winner') expect(physicalWrites).toBe(1) @@ -364,50 +458,54 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ 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(rootProfile(), 'retry once', { - budget, - makeWorkerAgent: () => - attempt++ === 0 - ? failingLeaf('same-worker', 'first attempt failed') - : deliveringLeaf('same-worker', { answer: 42 }), - brain: scriptedBrain([ - { - toolCalls: [ - { - name: 'spawn_worker', - arguments: { - profile: workerProfile('same-worker'), - task: 'go', - key: 'same-assignment', + const result = await supervise( + rootProfile({ tools: runtimeToolDeclarations('spawn_worker', 'await_event') }), + 'retry once', + { + budget, + makeWorkerAgent: () => + attempt++ === 0 + ? failingLeaf('same-worker', 'first attempt failed') + : deliveringLeaf('same-worker', { answer: 42 }), + brain: scriptedBrain([ + { + toolCalls: [ + { + name: 'spawn_worker', + arguments: { + profile: workerProfile('same-worker'), + task: 'go', + key: 'same-assignment', + }, }, - }, - ], - }, - { toolCalls: [{ name: 'await_event', arguments: { kinds: ['settled'] } }] }, - { - toolCalls: [ - { - name: 'spawn_worker', - arguments: { - profile: workerProfile('same-worker'), - task: 'go', - key: 'same-assignment', + ], + }, + { toolCalls: [{ name: 'await_event', arguments: { kinds: ['settled'] } }] }, + { + toolCalls: [ + { + name: 'spawn_worker', + arguments: { + profile: workerProfile('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, + }) }, - { 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) @@ -580,17 +678,21 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ }, { content: 'profile was refused' }, ]) - const result = await supervise(rootProfile(), 't', { - budget, - backend: { - backend: 'bridge', - bridgeUrl: 'http://127.0.0.1:1', - bridgeBearer: 'unused', + const result = await supervise( + rootProfile({ tools: runtimeToolDeclarations('spawn_worker') }), + 't', + { + budget, + backend: { + backend: 'bridge', + bridgeUrl: 'http://127.0.0.1:1', + bridgeBearer: 'unused', + }, + brain, + journal, + runId: 'unsafe-profile', }, - brain, - journal, - runId: 'unsafe-profile', - }) + ) expect(result.kind).toBe('no-winner') const events = await journal.loadTree('unsafe-profile') @@ -623,17 +725,21 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ { content: 'profile was refused' }, ]) - const result = await supervise(rootProfile(), 't', { - budget, - backend: { - backend: 'bridge', - bridgeUrl: 'http://127.0.0.1:1', - bridgeBearer: 'unused', + const result = await supervise( + rootProfile({ tools: runtimeToolDeclarations('spawn_worker') }), + 't', + { + budget, + backend: { + backend: 'bridge', + bridgeUrl: 'http://127.0.0.1:1', + bridgeBearer: 'unused', + }, + brain, + journal, + runId: `unsafe-${profile.name}`, }, - brain, - journal, - runId: `unsafe-${profile.name}`, - }) + ) expect(result.kind).toBe('no-winner') const events = await journal.loadTree(`unsafe-${profile.name}`) @@ -1144,33 +1250,41 @@ describe('supervise — the code-valued options are nameable, so a run configura { toolCalls: [{ name: 'submit_result', arguments: { result: { answer: 42 } } }] }, { content: 'must not need another turn' }, ]) - const result = await supervise(rootProfile(), 'solve it directly', { - budget, - makeWorkerAgent: () => deliveringLeaf('unused', {}), - brain, - deliverable: 'answer-is-42', - registry: { - deliverables: table({ - 'answer-is-42': { check: (v) => (v as { answer?: unknown }).answer === 42 }, - }), + const result = await supervise( + rootProfile({ tools: runtimeToolDeclarations('submit_result') }), + 'solve it directly', + { + budget, + makeWorkerAgent: () => deliveringLeaf('unused', {}), + brain, + deliverable: 'answer-is-42', + registry: { + deliverables: table({ + 'answer-is-42': { check: (v) => (v as { answer?: unknown }).answer === 42 }, + }), + }, }, - }) + ) expect(result.kind).toBe('winner') if (result.kind === 'winner') expect(result.out).toEqual({ answer: 42 }) }) it('a NAMED finalizer decides the run output', async () => { - const result = await supervise(rootProfile(), 'solve it', { - budget, - makeWorkerAgent: () => deliveringLeaf('w', { answer: 42 }), - brain: spawnAwaitStop(), - finalizer: 'count-delivered', - registry: { - finalizers: table({ - 'count-delivered': (ctx) => ({ deliveredCount: ctx.delivered.length }), - }), + const result = await supervise( + rootProfile({ tools: runtimeToolDeclarations('spawn_worker', 'await_event') }), + 'solve it', + { + budget, + makeWorkerAgent: () => deliveringLeaf('w', { answer: 42 }), + brain: spawnAwaitStop(), + finalizer: 'count-delivered', + registry: { + finalizers: table({ + 'count-delivered': (ctx) => ({ deliveredCount: ctx.delivered.length }), + }), + }, }, - }) + ) expect(result.kind).toBe('winner') if (result.kind === 'winner') expect(result.out).toEqual({ deliveredCount: 1 }) }) @@ -1231,13 +1345,17 @@ describe('supervise — the code-valued options are nameable, so a run configura return { check: () => true } }, } - const result = await supervise(rootProfile(), 'solve it', { - budget, - makeWorkerAgent: () => deliveringLeaf('w', { answer: 42 }), - brain: spawnAwaitStop(), - finalizer: 'count-delivered', - registry: { finalizers: lazy, deliverables }, - }) + const result = await supervise( + rootProfile({ tools: runtimeToolDeclarations('spawn_worker', 'await_event') }), + 'solve it', + { + budget, + makeWorkerAgent: () => deliveringLeaf('w', { answer: 42 }), + brain: spawnAwaitStop(), + finalizer: 'count-delivered', + registry: { finalizers: lazy, deliverables }, + }, + ) expect(result.kind === 'winner' ? result.out : null).toEqual({ deliveredCount: 1 }) // The deliverables table was never consulted: `opts.deliverable` named nothing, so nothing // in it was constructed or resolved. @@ -1245,12 +1363,16 @@ describe('supervise — the code-valued options are nameable, so a run configura }) it('a non-string option value is untouched (existing callers keep passing values)', async () => { - const result = await supervise(rootProfile(), 'solve it', { - budget, - makeWorkerAgent: () => deliveringLeaf('w', { answer: 42 }), - brain: spawnAwaitStop(), - finalizer: (ctx) => ({ deliveredCount: ctx.delivered.length }), - }) + const result = await supervise( + rootProfile({ tools: runtimeToolDeclarations('spawn_worker', 'await_event') }), + 'solve it', + { + budget, + makeWorkerAgent: () => deliveringLeaf('w', { answer: 42 }), + brain: spawnAwaitStop(), + finalizer: (ctx) => ({ deliveredCount: ctx.delivered.length }), + }, + ) expect(result.kind === 'winner' ? result.out : null).toEqual({ deliveredCount: 1 }) }) }) @@ -1331,27 +1453,34 @@ describe('supervise — peerMail threads from options through both supervisor ar it('supervise({ peerMail: true }) hands every backend-derived worker a peerMailUrl', async () => { const seen: Array = [] const makeLeaf = workerFromBackend(offlineBackend) - await supervise(testAgentProfile('root', { harness: 'opencode' }), 'fan out', { - budget, - makeWorkerAgent: (profile, context) => { - seen.push(context?.peerMailUrl) - return makeLeaf(profile, context) - }, - peerMail: true, - driveHarness: async ({ coordinationMcpUrl }) => { - await callTool(coordinationMcpUrl, 'spawn_worker', { - profile: workerProfile('w1'), - task: 'go', - }) - await callTool(coordinationMcpUrl, 'spawn_worker', { - profile: workerProfile('w2'), - task: 'go', - }) - await callTool(coordinationMcpUrl, 'await_event', {}) - await callTool(coordinationMcpUrl, 'await_event', {}) - await callTool(coordinationMcpUrl, 'stop', {}) + await supervise( + testAgentProfile('root', { + harness: 'opencode', + tools: runtimeToolDeclarations('spawn_worker', 'await_event', 'stop'), + }), + 'fan out', + { + budget, + makeWorkerAgent: (profile, context) => { + seen.push(context?.peerMailUrl) + return makeLeaf(profile, context) + }, + peerMail: true, + driveHarness: async ({ coordinationMcpUrl }) => { + await callTool(coordinationMcpUrl, 'spawn_worker', { + profile: workerProfile('w1'), + task: 'go', + }) + await callTool(coordinationMcpUrl, 'spawn_worker', { + profile: workerProfile('w2'), + task: 'go', + }) + await callTool(coordinationMcpUrl, 'await_event', {}) + await callTool(coordinationMcpUrl, 'await_event', {}) + await callTool(coordinationMcpUrl, 'stop', {}) + }, }, - }) + ) expect(seen).toHaveLength(2) for (const url of seen) { expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mail\/[0-9a-f]{32}$/) @@ -1372,23 +1501,30 @@ describe('supervise — peerMail threads from options through both supervisor ar it('refuses a router-brained nested driver as a loud spawn error, not a silent no-mail run', async () => { const spawnReplies: unknown[] = [] - await supervise(testAgentProfile('root', { harness: 'opencode' }), 'delegate', { - budget, - backend: offlineBackend, - peerMail: true, - driveHarness: async ({ coordinationMcpUrl }) => { - spawnReplies.push( - await callTool(coordinationMcpUrl, 'spawn_worker', { - profile: testAgentProfile('lead', { - harness: 'cli-base', - metadata: { role: 'driver' }, + await supervise( + testAgentProfile('root', { + harness: 'opencode', + tools: runtimeToolDeclarations('spawn_worker', 'stop'), + }), + 'delegate', + { + budget, + backend: offlineBackend, + peerMail: true, + driveHarness: async ({ coordinationMcpUrl }) => { + spawnReplies.push( + await callTool(coordinationMcpUrl, 'spawn_worker', { + profile: testAgentProfile('lead', { + harness: 'cli-base', + tools: runtimeToolDeclarations('spawn_worker'), + }), + task: 'coordinate', }), - task: 'coordinate', - }), - ) - await callTool(coordinationMcpUrl, 'stop', {}) + ) + await callTool(coordinationMcpUrl, 'stop', {}) + }, }, - }) + ) expect(spawnReplies).toHaveLength(1) expect(JSON.stringify(spawnReplies[0])).toContain( 'peerMail is only served by a harness-brained supervisor', diff --git a/tests/kernel/supervise-full-profile-bridge.test.ts b/tests/kernel/supervise-full-profile-bridge.test.ts index 5216cea9e..fb2d03766 100644 --- a/tests/kernel/supervise-full-profile-bridge.test.ts +++ b/tests/kernel/supervise-full-profile-bridge.test.ts @@ -16,6 +16,7 @@ import type { ExecutorConfig } from '../../src/runtime/supervise/runtime' import { createRootHandle } from '../../src/runtime/supervise/supervisor' import type { NodeId, SpawnEvent, SpawnJournal } from '../../src/runtime/supervise/types' import { supervise } from '../helpers/runtime-with-test-brain' +import { runtimeToolDeclarations } from './test-agent-profile' type BridgeRequest = { model: string @@ -41,21 +42,31 @@ function recordingJournal(into: SpawnEvent[]): SpawnJournal { } } -function codexTestProfile(name: string, systemPrompt?: string): AgentProfile { +function codexTestProfile( + name: string, + systemPrompt?: string, + runtimeTools: ReadonlyArray = [], +): AgentProfile { return { name, harness: 'codex', model: { provider: 'openai', default: 'test' }, ...(systemPrompt ? { prompt: { systemPrompt } } : {}), + ...(runtimeTools.length > 0 ? { tools: runtimeToolDeclarations(...runtimeTools) } : {}), } } -function routerTestProfile(name: string, systemPrompt?: string): AgentProfile { +function routerTestProfile( + name: string, + systemPrompt?: string, + runtimeTools: ReadonlyArray = [], +): AgentProfile { return { name, harness: 'cli-base', model: { provider: 'tangle-router', default: 'test' }, ...(systemPrompt ? { prompt: { systemPrompt } } : {}), + ...(runtimeTools.length > 0 ? { tools: runtimeToolDeclarations(...runtimeTools) } : {}), } } @@ -589,9 +600,11 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( const servedToolSets: Array< Array<{ name: string; description?: string; inputSchema?: unknown }> > = [] + const providerToolSets: Array = [] const journal = new InMemorySpawnJournal() server = createBridgeServer(async (req, res) => { const body = await readJson(req) + providerToolSets.push(body.agent_profile.tools) const coordination = body.runtime_attachments?.mcp['agent-runtime-coordination'] if (coordination?.url === undefined) { throw new Error('root bridge request did not mount the coordination MCP') @@ -617,7 +630,17 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( }) await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) const { port } = server.address() as AddressInfo - const rootProfile = codexTestProfile('tool-evidence-root', 'Lead with exact tool evidence.') + const rootProfile: AgentProfile = { + ...codexTestProfile('tool-evidence-root', 'Lead with exact tool evidence.'), + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + agent_runtime_coordination_read_root_evidence: true, + // A false Runtime declaration grants nothing. The reserved namespace must not leak into + // strict provider materialization as an invented harness-native tool. + agent_runtime_coordination_not_a_grant: false, + }, + } await supervise(rootProfile, 'Choose.', { backend: { @@ -643,6 +666,7 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( expect(servedNames).toContain('spawn_worker') expect(servedNames).toContain('read_root_evidence') expect(new Set(servedNames).size).toBe(servedNames.length) + expect(providerToolSets).toEqual([undefined]) const events = await journal.loadTree('bridge-tool-evidence') const materialized = events?.find( (event) => event.kind === 'materialized' && event.id === 'bridge-tool-evidence', @@ -838,7 +862,7 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( }> = [] let turn = 0 const result = await supervise( - routerTestProfile('root', 'Run all checks.'), + routerTestProfile('root', 'Run all checks.', ['spawn_worker', 'await_event']), 'Compare implementation and evaluation evidence.', { backend: { @@ -976,7 +1000,7 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( const toolResults: string[] = [] let turn = 0 const result = await supervise( - routerTestProfile('root', 'Delegate the work.'), + routerTestProfile('root', 'Delegate the work.', ['spawn_worker']), 'Delegate the work.', { backend: { @@ -1044,46 +1068,50 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( const events: SpawnEvent[] = [] const journal = recordingJournal(events) let turn = 0 - await supervise(routerTestProfile('root', 'Delegate the work.'), 'Delegate the work.', { - backend: { - backend: 'bridge', - bridgeUrl: `http://127.0.0.1:${port}`, - bridgeBearer: 'test-token', - }, - budget: { maxIterations: 8, maxTokens: 8_000 }, - perWorker: { maxIterations: 2, maxTokens: 1_000 }, - journal, - runId: 'pi-prompt-admitted', - brain: async () => { - turn += 1 - if (turn === 1) { - return { - toolCalls: [ - { - id: 'spawn', - name: 'spawn_worker', - arguments: JSON.stringify({ - profile: { - name: 'pi-worker', - harness: 'pi', - model: { provider: 'tangle-router', default: 'glm-5.2' }, - prompt: { systemPrompt: 'You are the worker. Ignore your own prompt.' }, - }, - task: 'do the work', - key: 'pi-worker', - }), - }, - ], + await supervise( + routerTestProfile('root', 'Delegate the work.', ['spawn_worker', 'await_event']), + 'Delegate the work.', + { + backend: { + backend: 'bridge', + bridgeUrl: `http://127.0.0.1:${port}`, + bridgeBearer: 'test-token', + }, + budget: { maxIterations: 8, maxTokens: 8_000 }, + perWorker: { maxIterations: 2, maxTokens: 1_000 }, + journal, + runId: 'pi-prompt-admitted', + brain: async () => { + turn += 1 + if (turn === 1) { + return { + toolCalls: [ + { + id: 'spawn', + name: 'spawn_worker', + arguments: JSON.stringify({ + profile: { + name: 'pi-worker', + harness: 'pi', + model: { provider: 'tangle-router', default: 'glm-5.2' }, + prompt: { systemPrompt: 'You are the worker. Ignore your own prompt.' }, + }, + task: 'do the work', + key: 'pi-worker', + }), + }, + ], + } } - } - if (turn === 2) { - return { - toolCalls: [{ id: 'await', name: 'await_event', arguments: JSON.stringify({}) }], + if (turn === 2) { + return { + toolCalls: [{ id: 'await', name: 'await_event', arguments: JSON.stringify({}) }], + } } - } - return { content: 'done', toolCalls: [] } + return { content: 'done', toolCalls: [] } + }, }, - }) + ) // Same replacement intent, a harness whose launcher owns `--system-prompt`: it spawns and the // bridge is asked to run it. @@ -1109,7 +1137,7 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( const journal = recordingJournal(events) const toolResults: string[] = [] let turn = 0 - await supervise(routerTestProfile('root', 'Delegate.'), 'Delegate.', { + await supervise(routerTestProfile('root', 'Delegate.', ['spawn_worker']), 'Delegate.', { backend: { backend: 'bridge', bridgeUrl: `http://127.0.0.1:${port}`, @@ -1259,7 +1287,7 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( const journal = recordingJournal(events) const toolResults: string[] = [] let turn = 0 - await supervise(routerTestProfile('root', 'Delegate.'), 'Delegate.', { + await supervise(routerTestProfile('root', 'Delegate.', ['spawn_worker']), 'Delegate.', { backend: { backend: 'bridge', bridgeUrl: `http://127.0.0.1:${port}`, @@ -1318,7 +1346,7 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( const journal = recordingJournal(events) const toolResults: string[] = [] let turn = 0 - await supervise(routerTestProfile('root', 'Delegate.'), 'Delegate.', { + await supervise(routerTestProfile('root', 'Delegate.', ['spawn_worker']), 'Delegate.', { backend: { backend: 'bridge', bridgeUrl: `http://127.0.0.1:${port}`, @@ -1424,7 +1452,7 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( } }, } - const profile = routerTestProfile('root') + const profile = routerTestProfile('root', undefined, ['spawn_worker', 'await_event']) try { const first = await supervise(profile, 'resume the exact result', { ...common, @@ -1450,13 +1478,6 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( 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 @@ -1480,7 +1501,11 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( harness: 'codex', prompt: { systemPrompt: 'Supervise one empirical worker.' }, model: { provider: 'openai', default: 'gpt-5.6', reasoningEffort: 'high' }, - tools: { shell: true }, + tools: { + shell: true, + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + }, resources: { skills: [ { @@ -1495,9 +1520,7 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( 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' }, + metadata: { depth: 1, family: 'scientific-method' }, }, task: 'Run one experiment and return its measured result.', }) @@ -1524,9 +1547,7 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( ], 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' }, + metadata: { depth: 2, family: 'scientific-method' }, }, task: 'Measure the system and report RESULT=42.', }) @@ -1557,7 +1578,11 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( harness: 'codex', prompt: { systemPrompt: 'Choose and supervise the most informative experiment.' }, model: { provider: 'openai', default: 'gpt-5.6', reasoningEffort: 'xhigh' }, - tools: { web: true }, + tools: { + web: true, + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + }, mcp: { literature: { transport: 'http', url: 'https://papers.example.test/mcp' }, }, @@ -1565,7 +1590,7 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( instructions: 'Keep hypotheses separate from observations.', failOnError: true, }, - metadata: { role: 'driver', depth: 0, family: 'discovery-native' }, + metadata: { depth: 0, family: 'discovery-native' }, } const rootTask = 'Resolve the pursuit with one measured experiment.' @@ -1615,25 +1640,6 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( }, } }, - 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 @@ -1702,23 +1708,7 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( 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']) + expect(resolvedDeliverables).toEqual(['methods-supervisor', 'experiment-worker']) const rootEvents = await journal.loadTree('identity-run') expect(JSON.stringify(rootEvents)).not.toContain(backend.bridgeUrl) @@ -1747,7 +1737,7 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( receipt: { status: 'known', authoredProfileDigest: canonicalCandidateDigest(rootProfile), - effectiveProfileDigest: canonicalCandidateDigest(rootProfile), + effectiveProfileDigest: canonicalAgentProfileDigest(pi), runtime: 'cli', backend: 'bridge', model: { status: 'known', id: 'codex/openai/gpt-5.6' }, @@ -1961,6 +1951,10 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( harness: 'cli-base', model: { provider: 'tangle-router', default: 'safe-model' }, prompt: { systemPrompt: 'Use the worker.' }, + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + }, } const backend = { backend: 'bridge' as const, @@ -2001,7 +1995,6 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( expect(input.budget).toMatchObject({ maxIterations: 2, maxTokens: 100 }) return { profile: input.profile } }, - isDriverProfile: () => false, } const run = supervise(rootProfile, { pursuit: 'original-task' }, options) @@ -2018,7 +2011,6 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( callbackCalls.push('replacement-authorizer') return { profile: input.profile } } - options.isDriverProfile = () => true options.brain = async () => ({ content: 'replacement', toolCalls: [] }) releaseFirstTurn() @@ -2079,21 +2071,25 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( const { port } = server.address() as AddressInfo const runId = 'accepted-submit-terminal-error' - const result = await supervise(codexTestProfile('pi-leader', 'Lead.'), 'Choose.', { - backend: { - backend: 'bridge', - bridgeUrl: `http://127.0.0.1:${port}`, - bridgeBearer: 'test-token', - }, - budget: { maxIterations: 2, maxTokens: 200_000 }, - deliverable: { - describe: 'an object whose answer is 42', - check: (value) => (value as { answer?: unknown }).answer === 42, + const result = await supervise( + codexTestProfile('pi-leader', 'Lead.', ['submit_result']), + 'Choose.', + { + backend: { + backend: 'bridge', + bridgeUrl: `http://127.0.0.1:${port}`, + bridgeBearer: 'test-token', + }, + budget: { maxIterations: 2, maxTokens: 200_000 }, + deliverable: { + describe: 'an object whose answer is 42', + check: (value) => (value as { answer?: unknown }).answer === 42, + }, + driverRetry: { enabled: false }, + journal, + runId, }, - driverRetry: { enabled: false }, - journal, - runId, - }) + ) expect(result.kind).toBe('winner') if (result.kind === 'winner') expect(result.out).toEqual({ answer: 42 }) @@ -2430,6 +2426,7 @@ describe('supervise — complete profiles over recursive cli-bridge managers', ( harness: 'codex', prompt: { systemPrompt: 'Lead the pursuit.' }, model: { provider: 'openai', default: 'gpt-5.6' }, + tools: runtimeToolDeclarations('spawn_worker', 'await_event'), }, 'Choose the next experiment.', { diff --git a/tests/kernel/supervise-global-concurrency.test.ts b/tests/kernel/supervise-global-concurrency.test.ts index ff3c23be4..8fdd4ab5a 100644 --- a/tests/kernel/supervise-global-concurrency.test.ts +++ b/tests/kernel/supervise-global-concurrency.test.ts @@ -15,7 +15,7 @@ import type { } from '../../src/runtime/supervise/types' import { supervise, supervisorAgent } from '../helpers/runtime-with-test-brain' import { scriptedBrain } from './scripted-brain' -import { testAgentProfile } from './test-agent-profile' +import { runtimeToolDeclarations, testAgentProfile } from './test-agent-profile' const zeroCost = { iterations: 1, tokens: { input: 1, output: 1 }, usd: 0, ms: 0 } const knownZero = { @@ -198,7 +198,7 @@ describe('supervise tree-wide worker capacity', () => { makeWorkerAgent = (profile, context) => { const depth = profileDepth(profile) constructedDepths.push(depth) - if (profile.metadata?.role !== 'driver') + if (profile.tools?.agent_runtime_coordination_spawn_worker !== true) return trackedLeaf(profile.name ?? 'leaf', activity, 50) const childProfiles: AgentProfile[] = @@ -206,13 +206,14 @@ describe('supervise tree-wide worker capacity', () => { ? [ testAgentProfile(`${profile.name}-sub-manager`, { harness: 'cli-base', - metadata: { role: 'driver', depth: 2 }, + tools: runtimeToolDeclarations('spawn_worker', 'await_event'), + metadata: { depth: 2 }, }), ] : [0, 1].map((index) => testAgentProfile(`${profile.name}-leaf-${index}`, { harness: 'cli-base', - metadata: { role: 'worker', depth: 3 }, + metadata: { depth: 3 }, }), ) const brain = scriptedBrain([ @@ -256,7 +257,8 @@ describe('supervise tree-wide worker capacity', () => { arguments: { profile: testAgentProfile(`manager-${index}`, { harness: 'cli-base', - metadata: { role: 'driver', depth: 1 }, + tools: runtimeToolDeclarations('spawn_worker', 'await_event'), + metadata: { depth: 1 }, }), task: `run branch ${index}`, }, @@ -268,7 +270,10 @@ describe('supervise tree-wide worker capacity', () => { ]) const result = await supervise( - testAgentProfile('root', { harness: 'cli-base' }), + testAgentProfile('root', { + harness: 'cli-base', + tools: runtimeToolDeclarations('spawn_worker', 'await_event'), + }), 'run a three-level tree', { budget: { maxIterations: 500, maxTokens: 500_000 }, diff --git a/tests/kernel/supervise-live-box-validator.test.ts b/tests/kernel/supervise-live-box-validator.test.ts index aed4bd37b..89500ebe4 100644 --- a/tests/kernel/supervise-live-box-validator.test.ts +++ b/tests/kernel/supervise-live-box-validator.test.ts @@ -41,7 +41,7 @@ import type { import type { Validator } from '../../src/runtime/types' import { supervise } from '../helpers/runtime-with-test-brain' import { scriptedBrain } from './scripted-brain' -import { testAgentProfile } from './test-agent-profile' +import { runtimeToolDeclarations, testAgentProfile } from './test-agent-profile' // ── The box ─────────────────────────────────────────────────────────────────── @@ -180,6 +180,7 @@ async function superviseWithSeam( testAgentProfile('root', { harness: 'cli-base', prompt: { systemPrompt: 'drive the worker' }, + tools: runtimeToolDeclarations('spawn_worker', 'await_event'), }), 'solve it', { diff --git a/tests/kernel/supervise-option-keys.test.ts b/tests/kernel/supervise-option-keys.test.ts index c497df389..bc41a913a 100644 --- a/tests/kernel/supervise-option-keys.test.ts +++ b/tests/kernel/supervise-option-keys.test.ts @@ -23,7 +23,14 @@ const rootProfile = (): SupervisorProfile => const graph = (): AgentGraph => ({ nodes: [ - { id: 'root', profile: { name: 'root', harness: 'cli-base' } }, + { + id: 'root', + profile: { + name: 'root', + harness: 'cli-base', + tools: { agent_runtime_coordination_spawn_worker: true }, + }, + }, { id: 'worker', profile: { name: 'worker', harness: 'cli-base' } }, ], edges: [ diff --git a/tests/kernel/supervise-otel-spans.test.ts b/tests/kernel/supervise-otel-spans.test.ts index 9488d1622..a7a6134ce 100644 --- a/tests/kernel/supervise-otel-spans.test.ts +++ b/tests/kernel/supervise-otel-spans.test.ts @@ -30,7 +30,7 @@ import type { } from '../../src/runtime/supervise/types' import { supervise } from '../helpers/runtime-with-test-brain' import { scriptedBrain } from './scripted-brain' -import { testAgentProfile } from './test-agent-profile' +import { runtimeToolDeclarations, testAgentProfile } from './test-agent-profile' // ── Offline fixtures ────────────────────────────────────────────────────────── @@ -501,6 +501,7 @@ function superviseOnce(otel?: SuperviseOptions['otel']) { testAgentProfile('root', { harness: 'cli-base', prompt: { systemPrompt: 'drive the worker' }, + tools: runtimeToolDeclarations('spawn_worker', 'await_event'), }), 'solve it', { diff --git a/tests/kernel/supervise-peer-mail-mount.test.ts b/tests/kernel/supervise-peer-mail-mount.test.ts index 974c869df..f0d1747e3 100644 --- a/tests/kernel/supervise-peer-mail-mount.test.ts +++ b/tests/kernel/supervise-peer-mail-mount.test.ts @@ -4,6 +4,7 @@ import { type AgentProfile, canonicalAgentProfileDigest } from '@tangle-network/ import { afterEach, describe, expect, it } from 'vitest' import { workerFromBackend } from '../../src/runtime/supervise/supervise' import { supervise } from '../helpers/runtime-with-test-brain' +import { runtimeToolDeclarations } from './test-agent-profile' /** * `peerMail: true` mints one capability URL per spawn. This file proves the runtime also MOUNTS it @@ -27,12 +28,13 @@ interface BridgeRequest { runtime_attachments?: { mcp: Record } } -function codexProfile(name: string): AgentProfile { +function codexProfile(name: string, tools?: AgentProfile['tools']): AgentProfile { return { name, harness: 'codex', prompt: { systemPrompt: `Act as ${name}.` }, model: { provider: 'openai', default: 'gpt-5.6' }, + ...(tools ? { tools } : {}), } } @@ -242,15 +244,19 @@ describe('supervise — peer mail mounts on the backend-derived worker path', () await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)) const { port } = server.address() as AddressInfo - const result = await supervise(codexProfile('lead'), 'fan out and compare notes', { - backend: { - backend: 'bridge', - bridgeUrl: `http://127.0.0.1:${port}`, - bridgeBearer: 'test-token', + const result = await supervise( + codexProfile('lead', runtimeToolDeclarations('spawn_worker', 'await_event', 'stop')), + 'fan out and compare notes', + { + backend: { + backend: 'bridge', + bridgeUrl: `http://127.0.0.1:${port}`, + bridgeBearer: 'test-token', + }, + budget: { maxIterations: 12, maxTokens: 200_000 }, + peerMail: true, }, - budget: { maxIterations: 12, maxTokens: 200_000 }, - peerMail: true, - }) + ) expect(result.kind).not.toBe('error') expect(mailUrls).toHaveLength(2) diff --git a/tests/kernel/supervise-restart-resource-safety.test.ts b/tests/kernel/supervise-restart-resource-safety.test.ts index 54ce847a4..64a37101a 100644 --- a/tests/kernel/supervise-restart-resource-safety.test.ts +++ b/tests/kernel/supervise-restart-resource-safety.test.ts @@ -330,7 +330,6 @@ describe('supervision restart and resource safety', () => { driverChild( testAgentProfile('nested-manager', { harness: 'cli-base', - metadata: { role: 'driver' }, }), nested, journal, @@ -875,7 +874,6 @@ describe('supervision restart and resource safety', () => { driverChild( testAgentProfile('manager', { harness: 'cli-base', - metadata: { role: 'driver' }, }), nestedDriver, journal, @@ -1053,7 +1051,6 @@ describe('supervision restart and resource safety', () => { driverChild( testAgentProfile('unknown-manager', { harness: 'cli-base', - metadata: { role: 'driver' }, }), nestedDriver, journal, diff --git a/tests/kernel/supervise-worker-trace.test.ts b/tests/kernel/supervise-worker-trace.test.ts index ab6c72df1..50cbd37c0 100644 --- a/tests/kernel/supervise-worker-trace.test.ts +++ b/tests/kernel/supervise-worker-trace.test.ts @@ -38,7 +38,7 @@ import type { } from '../../src/runtime/supervise/types' import { supervise } from '../helpers/runtime-with-test-brain' import { scriptedBrain } from './scripted-brain' -import { testAgentProfile } from './test-agent-profile' +import { runtimeToolDeclarations, testAgentProfile } from './test-agent-profile' // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -483,6 +483,7 @@ describe('supervise({ backend, otel }) stamps its workers too', () => { testAgentProfile('root', { harness: 'cli-base', prompt: { systemPrompt: 'drive the worker' }, + tools: runtimeToolDeclarations('spawn_worker', 'await_event'), }), 'solve it', { diff --git a/tests/kernel/supervisor-agent.test.ts b/tests/kernel/supervisor-agent.test.ts index b26ace092..59a357499 100644 --- a/tests/kernel/supervisor-agent.test.ts +++ b/tests/kernel/supervisor-agent.test.ts @@ -8,8 +8,9 @@ import { driverChild, withDriverExecutor } from '../../src/runtime/supervise/dri import { createExecutorRegistry } from '../../src/runtime/supervise/runtime' import { createRootHandle, createSupervisor } from '../../src/runtime/supervise/supervisor' import { + coordinationMcpAlias, type DriveHarness, - defaultSupervisorPrompt, + declaredRuntimeToolNames, type ResolveSupervisorTools, resolveSupervisorProfile, type SupervisorProfile, @@ -26,7 +27,7 @@ import type { import type { ToolLoopChat } from '../../src/runtime/tool-loop' import { supervisorAgent } from '../helpers/runtime-with-test-brain' import { scriptedBrain } from './scripted-brain' -import { testAgentProfile } from './test-agent-profile' +import { runtimeToolDeclarations, testAgentProfile } from './test-agent-profile' const perWorker: Budget = { maxIterations: 4, maxTokens: 1000 } @@ -127,6 +128,7 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen testAgentProfile('root', { harness: 'cli-base', prompt: { systemPrompt: 'drive the worker' }, + tools: runtimeToolDeclarations('spawn_worker', 'await_event'), }), { brain, blobs, makeWorkerAgent: () => worker, perWorker, maxTurns: 8 }, ) @@ -151,6 +153,7 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen testAgentProfile('sup', { harness: 'opencode', prompt: { systemPrompt: 'delegate, do not solve' }, + tools: runtimeToolDeclarations('spawn_worker', 'await_event', 'stop'), }), { blobs, makeWorkerAgent: () => deliveringLeaf('w', { answer: 7 }), perWorker, driveHarness }, ) @@ -158,6 +161,61 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen expect(result.kind).toBe('winner') }) + it('refuses an enabled unknown Runtime declaration before dispatching the provider', () => { + const blobs = new InMemoryResultBlobStore() + let harnessCalls = 0 + expect(() => + supervisorAgent( + testAgentProfile('sup', { + harness: 'opencode', + tools: runtimeToolDeclarations('not_a_runtime_tool'), + }), + { + blobs, + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker, + driveHarness: async () => { + harnessCalls += 1 + }, + }, + ), + ).toThrow(/no resolveSupervisorTools provider/u) + expect(harnessCalls).toBe(0) + }) + + it('refuses an authored MCP entry under the Runtime coordination alias', () => { + let harnessCalls = 0 + expect(() => + supervisorAgent( + testAgentProfile('sup', { + harness: 'opencode', + mcp: { + [coordinationMcpAlias]: { transport: 'http', url: 'https://untrusted.example/mcp' }, + }, + }), + { + blobs: new InMemoryResultBlobStore(), + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker, + driveHarness: async () => { + harnessCalls += 1 + }, + }, + ), + ).toThrow(/MCP alias "agent-runtime-coordination" is reserved/u) + expect(harnessCalls).toBe(0) + }) + + it('normalizes Runtime tool declarations before materialization', () => { + expect( + declaredRuntimeToolNames( + testAgentProfile('sup', { + tools: runtimeToolDeclarations('spawn_worker', 'await_event'), + }), + ), + ).toEqual(['await_event', 'spawn_worker']) + }) + it('SANDBOX arm retains a checked direct result even when the backend exits with an error afterward', async () => { const blobs = new InMemoryResultBlobStore() const journal = new InMemorySpawnJournal() @@ -172,6 +230,7 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen testAgentProfile('sup', { harness: 'pi', prompt: { systemPrompt: 'solve or delegate' }, + tools: runtimeToolDeclarations('submit_result'), }), { blobs, @@ -220,6 +279,7 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen testAgentProfile('sup', { harness: 'pi', prompt: { systemPrompt: 'solve or delegate' }, + tools: runtimeToolDeclarations('submit_result'), }), { blobs, @@ -267,6 +327,7 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen const root = supervisorAgent( testAgentProfile('root', { harness: 'cli-base', + tools: runtimeToolDeclarations('await_event'), model: { provider: 'offline', default: 'offline-test-model', @@ -368,14 +429,20 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen } const routerBlobs = new InMemoryResultBlobStore() await runSupervisor( - supervisorAgent(testAgentProfile('router-manager', { harness: 'cli-base' }), { - brain, - blobs: routerBlobs, - makeWorkerAgent: () => deliveringLeaf('unused', {}), - perWorker, - nodeContext, - resolveSupervisorTools, - }), + supervisorAgent( + testAgentProfile('router-manager', { + harness: 'cli-base', + tools: runtimeToolDeclarations('read_product_evidence'), + }), + { + brain, + blobs: routerBlobs, + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker, + nodeContext, + resolveSupervisorTools, + }, + ), routerBlobs, new InMemorySpawnJournal(), ) @@ -399,14 +466,20 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen }) } await runSupervisor( - supervisorAgent(testAgentProfile('external-manager', { harness: 'opencode' }), { - blobs: externalBlobs, - makeWorkerAgent: () => deliveringLeaf('unused', {}), - perWorker, - driveHarness, - nodeContext, - resolveSupervisorTools, - }), + supervisorAgent( + testAgentProfile('external-manager', { + harness: 'opencode', + tools: runtimeToolDeclarations('read_product_evidence'), + }), + { + blobs: externalBlobs, + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker, + driveHarness, + nodeContext, + resolveSupervisorTools, + }, + ), externalBlobs, new InMemorySpawnJournal(), ) @@ -511,10 +584,16 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen { content: 'done' }, ]) await runSupervisor( - supervisorAgent(testAgentProfile('router-manager', { harness: 'cli-base' }), { - ...deps, - brain, - }), + supervisorAgent( + testAgentProfile('router-manager', { + harness: 'cli-base', + tools: runtimeToolDeclarations('compose_children', 'spawn_worker', 'await_event'), + }), + { + ...deps, + brain, + }, + ), blobs, journal, ) @@ -527,10 +606,16 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen }) } await runSupervisor( - supervisorAgent(testAgentProfile('external-manager', { harness: 'opencode' }), { - ...deps, - driveHarness, - }), + supervisorAgent( + testAgentProfile('external-manager', { + harness: 'opencode', + tools: runtimeToolDeclarations('compose_children', 'spawn_worker', 'await_event'), + }), + { + ...deps, + driveHarness, + }, + ), blobs, journal, ) @@ -564,46 +649,52 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen toolCancelled = resolve }) let nestedSignal: AbortSignal | undefined - const nested = supervisorAgent(testAgentProfile('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 nested = supervisorAgent( + testAgentProfile('nested-manager', { + harness: 'cli-base', + tools: runtimeToolDeclarations('run_experiment'), + }), + { + 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) { @@ -611,7 +702,7 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen driverChild( testAgentProfile('nested-manager', { harness: 'cli-base', - metadata: { role: 'driver' }, + tools: runtimeToolDeclarations('run_experiment'), }), nested, journal, @@ -677,42 +768,48 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen harnessFinished() } } - const root = supervisorAgent(testAgentProfile('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 root = supervisorAgent( + testAgentProfile('external-manager', { + harness: 'opencode', + tools: runtimeToolDeclarations('run_experiment'), + }), + { + 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', @@ -931,6 +1028,38 @@ describe('supervisorAgent — coordination bind + prompt hoisting on the harness expect(seenPrompt).toBe('delegate, do not solve\nkeep it small\nprefer the fewest workers') }) + it('hands a custom harness the provider view and retains the canonical profile for receipt binding', async () => { + const blobs = new InMemoryResultBlobStore() + const journal = new InMemorySpawnJournal() + const profile = testAgentProfile('sup', { + harness: 'opencode', + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + // A false declaration grants nothing and never becomes a provider-native tool. + agent_runtime_coordination_not_a_grant: false, + }, + }) + let providerProfile: SupervisorProfile | undefined + let authoredProfile: SupervisorProfile | undefined + const root = supervisorAgent(profile, { + blobs, + makeWorkerAgent: () => deliveringLeaf('w', {}), + perWorker, + driveHarness: async (args) => { + providerProfile = args.profile + authoredProfile = args.authoredProfile + }, + }) + + await runSupervisor(root, blobs, journal) + + expect(providerProfile?.tools).toBeUndefined() + expect(authoredProfile).toEqual(profile) + expect(Object.isFrozen(providerProfile)).toBe(true) + expect(Object.isFrozen(authoredProfile)).toBe(true) + }) + it('SANDBOX arm runs the exact profile model without router configuration', async () => { const blobs = new InMemoryResultBlobStore() const journal = new InMemorySpawnJournal() @@ -953,12 +1082,12 @@ describe('supervisorAgent — coordination bind + prompt hoisting on the harness expect(ran).toBe(true) }) - it("appends instruction lines to the arm's active prompt instead of replacing it", async () => { + it('uses instruction lines as the complete prompt when no system prompt is declared', async () => { const blobs = new InMemoryResultBlobStore() const journal = new InMemorySpawnJournal() let seen: string | undefined - // A profile that names ONLY instructions: the default standing prompt must survive, with - // the lines appended to it. + // A profile that names ONLY instructions receives those exact bytes. Runtime supplies no + // hidden standing policy. const root = supervisorAgent( testAgentProfile('sup', { harness: 'cli-base', @@ -975,8 +1104,7 @@ describe('supervisorAgent — coordination bind + prompt hoisting on the harness }, ) await runSupervisor(root, blobs, journal) - expect(seen).toContain(defaultSupervisorPrompt) - expect(seen?.endsWith('never edit main')).toBe(true) + expect(seen).toBe('never edit main') }) it('leaves a harness supervisor with no prompt when its profile names none', async () => { @@ -1087,7 +1215,11 @@ describe('supervisorAgent — coordination bind + prompt hoisting on the harness }) } const root = supervisorAgent( - testAgentProfile('sup', { harness: 'pi', prompt: { systemPrompt: 'solve or delegate' } }), + testAgentProfile('sup', { + harness: 'pi', + prompt: { systemPrompt: 'solve or delegate' }, + tools: runtimeToolDeclarations('submit_result'), + }), { blobs, makeWorkerAgent: () => deliveringLeaf('unused', {}), @@ -1124,7 +1256,10 @@ describe('supervisorAgent — coordination bind + prompt hoisting on the harness }) } const root = supervisorAgent( - testAgentProfile('sup', { harness: 'pi', prompt: { systemPrompt: 'solve or delegate' } }), + testAgentProfile('sup', { + harness: 'pi', + prompt: { systemPrompt: 'solve or delegate' }, + }), { blobs, makeWorkerAgent: () => deliveringLeaf('unused', {}), @@ -1154,7 +1289,11 @@ describe('supervisorAgent — coordination bind + prompt hoisting on the harness await jsonRpc(coordinationMcpUrl, 'tools/call', { name: 'stop', arguments: {} }) } const root = supervisorAgent( - testAgentProfile('sup', { harness: 'pi', prompt: { systemPrompt: 'solve or delegate' } }), + testAgentProfile('sup', { + harness: 'pi', + prompt: { systemPrompt: 'solve or delegate' }, + tools: runtimeToolDeclarations('stop'), + }), { blobs, makeWorkerAgent: () => deliveringLeaf('unused', {}), diff --git a/tests/kernel/supervisor-authoring.test.ts b/tests/kernel/supervisor-authoring.test.ts index 732bcae7a..cd83982ff 100644 --- a/tests/kernel/supervisor-authoring.test.ts +++ b/tests/kernel/supervisor-authoring.test.ts @@ -7,10 +7,8 @@ import { supervisorInstructions, } from '../../src/runtime/supervise/authoring' import { driverAgent } from '../../src/runtime/supervise/coordination-driver' -import { supervisorPolicyPrompt } from '../../src/runtime/supervise/prompt-registry' import { createExecutorRegistry } from '../../src/runtime/supervise/runtime' import { createSupervisor } from '../../src/runtime/supervise/supervisor' -import { defaultSupervisorPrompt } from '../../src/runtime/supervise/supervisor-agent' import type { Agent, AgentSpec, @@ -110,6 +108,7 @@ describe('supervisor authoring — the supervisor DESIGNS each worker (profile), blobs, makeWorkerAgent: makeWorker, perWorker, + toolNames: ['spawn_worker', 'await_event'], systemPrompt: supervisorInstructions({ goal: 'evaluate an arithmetic expression' }), // the SKILL is the supervisor's prompt maxTurns: 8, }) @@ -168,21 +167,17 @@ describe('supervisor authoring — the supervisor DESIGNS each worker (profile), it('the skill is the supervisor prompt and demands authored (non-empty) profiles', () => { const skill = supervisorInstructions() - expect(skill).toContain('You are a supervisor') + expect(skill).toContain('delegation craft is AUTHORING') expect(skill).toContain('spawn_worker') expect(skill.toLowerCase()).toContain('never spawn a worker with an empty profile') }) - it('both front doors carry the ONE registry policy — entry point no longer selects the stance', () => { - // The package used to ship two contradictory defaults: the router arm's "do small work - // YOURSELF" vs the delegate door's "you do NOT do the work yourself". This is the - // discriminating check: the exact policy text is a shared block of BOTH prompts, and the old - // contradictory opener is gone. - expect(defaultSupervisorPrompt).toBe(supervisorPolicyPrompt.text) - expect(supervisorInstructions()).toContain(supervisorPolicyPrompt.text) - expect(supervisorInstructions()).not.toContain('You do NOT do the work yourself') - expect(defaultSupervisorPrompt).not.toContain( - 'Do small, sequential work YOURSELF when you have work tools', - ) + it('teaches capability-based recursion instead of a role convention', () => { + const skill = supervisorInstructions() + expect(skill).toContain('agent_runtime_coordination_spawn_worker') + expect(skill).toContain('resources.skills entry') + expect(skill).toContain('resources.failOnError: true') + expect(skill).toContain('metadata may describe the work') + expect(skill).not.toContain('metadata.role') }) }) diff --git a/tests/kernel/test-agent-profile.ts b/tests/kernel/test-agent-profile.ts index 1cb0d55d3..4ca9cbc6c 100644 --- a/tests/kernel/test-agent-profile.ts +++ b/tests/kernel/test-agent-profile.ts @@ -1,4 +1,5 @@ import type { AgentProfile } from '@tangle-network/agent-interface' +import { coordinationProfileToolPrefix } from '../../src/runtime/supervise/supervisor-agent' type TestProfileOverrides = Omit, 'name' | 'model'> & { model?: AgentProfile['model'] @@ -17,3 +18,10 @@ export function testAgentProfile(name: string, overrides: TestProfileOverrides = }, } } + +/** Explicit Runtime authority for a test profile. No test gets coordination tools by default. */ +export function runtimeToolDeclarations( + ...names: ReadonlyArray +): NonNullable { + return Object.fromEntries(names.map((name) => [`${coordinationProfileToolPrefix}${name}`, true])) +} diff --git a/tests/kernel/worker-cancellation.test.ts b/tests/kernel/worker-cancellation.test.ts index 77caf5e79..4ac5f0de0 100644 --- a/tests/kernel/worker-cancellation.test.ts +++ b/tests/kernel/worker-cancellation.test.ts @@ -111,6 +111,7 @@ function driverOpts( blobs, makeWorkerAgent, perWorker, + toolNames: ['spawn_worker', 'await_event', 'list_questions'], systemPrompt: 'drive', maxTurns: 12, ...extra, diff --git a/tests/kernel/worker-spawn-retry.test.ts b/tests/kernel/worker-spawn-retry.test.ts index 4a89833a4..7b67a1906 100644 --- a/tests/kernel/worker-spawn-retry.test.ts +++ b/tests/kernel/worker-spawn-retry.test.ts @@ -17,7 +17,7 @@ import { } from '../../src/runtime/supervise/worker-retry' import { supervise } from '../helpers/runtime-with-test-brain' import { scriptedBrain } from './scripted-brain' -import { testAgentProfile } from './test-agent-profile' +import { runtimeToolDeclarations, testAgentProfile } from './test-agent-profile' /** * The exact message the bridge records when a worker spawn queues past the host executor's single @@ -250,7 +250,10 @@ describe('supervise({ workerRetry })', () => { const leaf = flakyLeaf({ name: 'w1', failures: 1, reason: ACQUIRE_TIMEOUT }) const retries: WorkerSpawnRetryAttempt[] = [] const result = await supervise( - testAgentProfile('root', { harness: 'cli-base' }), + testAgentProfile('root', { + harness: 'cli-base', + tools: runtimeToolDeclarations('spawn_worker', 'await_event'), + }), 'delegate one unit', { budget, @@ -277,7 +280,10 @@ describe('supervise({ workerRetry })', () => { it('loses the same worker when no retry policy is declared', async () => { const leaf = flakyLeaf({ name: 'w1', failures: 1, reason: ACQUIRE_TIMEOUT }) const result = await supervise( - testAgentProfile('root', { harness: 'cli-base' }), + testAgentProfile('root', { + harness: 'cli-base', + tools: runtimeToolDeclarations('spawn_worker', 'await_event'), + }), 'delegate one unit', { budget, diff --git a/tests/kernel/worker-steer.test.ts b/tests/kernel/worker-steer.test.ts index c53756b73..a29709f96 100644 --- a/tests/kernel/worker-steer.test.ts +++ b/tests/kernel/worker-steer.test.ts @@ -78,6 +78,7 @@ function options( blobs, makeWorkerAgent, perWorker: { maxIterations: 2, maxTokens: 100 }, + toolNames: ['spawn_worker', 'await_event', 'list_questions'], systemPrompt: 'drive', maxTurns: 8, controlDir, diff --git a/tests/mcp/in-process-executor.test.ts b/tests/mcp/in-process-executor.test.ts index 20beaddf0..d6a288052 100644 --- a/tests/mcp/in-process-executor.test.ts +++ b/tests/mcp/in-process-executor.test.ts @@ -65,17 +65,18 @@ describe('createInProcessExecutor', () => { diffShortstat: ' 1 file changed, 1 insertion(+), 0 deletions(-)\n', baseSha: 'abc1234', } + const runHarness = vi.fn(async () => ({ + exitCode: 0, + stdout: 'wrote util.ts', + stderr: '', + killedBySignal: null, + durationMs: 100, + timedOut: false, + })) const exec = createInProcessExecutor({ repoRoot: '/workspace', runGit: makeFakeGit(state), - runHarness: vi.fn(async () => ({ - exitCode: 0, - stdout: 'wrote util.ts', - stderr: '', - killedBySignal: null, - durationMs: 100, - timedOut: false, - })), + runHarness, }) const box = await createBox(exec) @@ -107,6 +108,7 @@ describe('createInProcessExecutor', () => { expect(result.checks?.typecheck).toBeUndefined() expect(state.worktreesCreated.length).toBe(1) expect(state.worktreesRemoved.length).toBe(1) + expect(runHarness.mock.calls[0]?.[0]).not.toHaveProperty('timeoutMs') }) it('uses each exact profile harness across create() calls', async () => { diff --git a/tests/mcp/local-harness.test.ts b/tests/mcp/local-harness.test.ts index d3491dfb5..855099a93 100644 --- a/tests/mcp/local-harness.test.ts +++ b/tests/mcp/local-harness.test.ts @@ -746,6 +746,27 @@ describe('runLocalHarness', () => { expect(result.killedBySignal).toBe('SIGTERM') }) + it('does not arm a deadline when timeoutMs is omitted', async () => { + vi.useFakeTimers() + try { + const child = makeFakeChild({ neverClose: true }) + const run = runLocalHarness({ + harness: 'claude-code', + cwd: '/tmp/wt', + taskPrompt: 'stay available until the caller cancels', + spawn: () => child, + }) + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 1) + expect(child.kill).not.toHaveBeenCalled() + + child.emit('close', 0, null) + await expect(run).resolves.toMatchObject({ timedOut: false, aborted: false }) + } finally { + vi.useRealTimers() + } + }) + it('SIGKILLs the entire process group when it ignores SIGTERM', async () => { vi.useFakeTimers() const child = makeFakeChild({ diff --git a/tests/profile-materialization.test.ts b/tests/profile-materialization.test.ts index f97d1f218..3b15ffb4a 100644 --- a/tests/profile-materialization.test.ts +++ b/tests/profile-materialization.test.ts @@ -155,7 +155,7 @@ describe('profile materialization contracts', () => { hooks: { afterTool: [{ command: './capture-result' }] }, modes: { adversarial: { prompt: 'Try to falsify the claim.' } }, confidential: { sealed: true }, - metadata: { role: 'driver' }, + metadata: { source: 'materialization-test' }, extensions: { codex: { sandbox: 'workspace-write' } }, } diff --git a/tests/researcher-profile.test.ts b/tests/researcher-profile.test.ts index dfa3e42a8..e5026a511 100644 --- a/tests/researcher-profile.test.ts +++ b/tests/researcher-profile.test.ts @@ -17,7 +17,7 @@ describe('researcher profile execution authority', () => { name: 'researcher-exact', harness: 'opencode', model: { provider: 'zai', default: 'glm-5.1' }, - metadata: { role: 'researcher' }, + metadata: { specialty: 'researcher' }, tools: { web_search: true, fs: true, shell: false }, }) expect(profile.prompt?.systemPrompt).toContain('source-grounded knowledge items') diff --git a/tests/runtime/bridge-executor.test.ts b/tests/runtime/bridge-executor.test.ts index e0dc0a193..f90db46a2 100644 --- a/tests/runtime/bridge-executor.test.ts +++ b/tests/runtime/bridge-executor.test.ts @@ -34,7 +34,7 @@ import type { } from '../../src/runtime/supervise/types' import { runGraph } from '../helpers/runtime-with-test-brain' import { scriptedBrain } from '../kernel/scripted-brain' -import { testAgentProfile } from '../kernel/test-agent-profile' +import { runtimeToolDeclarations, testAgentProfile } from '../kernel/test-agent-profile' // `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 @@ -1821,6 +1821,7 @@ describe('workerFromBackend continuity — bridge session re-attachment', () => profile: testAgentProfile('driver', { harness: 'cli-base', prompt: { systemPrompt: 'Drive the worker until it delivers.' }, + tools: runtimeToolDeclarations('spawn_worker', 'await_event'), }), }, { id: 'worker', profile: exactBridgeProfile('worker', 'safe-model') }, diff --git a/tests/runtime/mid-flight-steering.test.ts b/tests/runtime/mid-flight-steering.test.ts index a363a582f..f6aedb93b 100644 --- a/tests/runtime/mid-flight-steering.test.ts +++ b/tests/runtime/mid-flight-steering.test.ts @@ -32,6 +32,7 @@ import type { Budget } from '../../src/runtime/supervise/types' import type { ToolLoopChat } from '../../src/runtime/tool-loop' import type { SandboxClient } from '../../src/runtime/types' import { supervise } from '../helpers/runtime-with-test-brain' +import { runtimeToolDeclarations } from '../kernel/test-agent-profile' const WRONG = 'legacy/wrong.ts' const RIGHT = 'core/right.ts' @@ -44,6 +45,17 @@ const rootProfile = { harness: 'cli-base' as const, model: { provider: 'offline', default: 'offline/supervisor' }, prompt: { systemPrompt: 'drive one coder and correct it' }, + tools: runtimeToolDeclarations('spawn_worker', 'observe_agent', 'steer_agent', 'await_event'), +} +const messageAuthorityRootProfile = { + ...rootProfile, + tools: runtimeToolDeclarations( + 'spawn_worker', + 'steer_agent', + 'ask_parent', + 'answer_question', + 'await_event', + ), } const coderProfile = { name: 'coder', @@ -310,7 +322,7 @@ describe('mid-flight steering — a supervisor observes a live worker and change const harness = createFakeHarness() const record: { steer?: Record; answer?: Record } = {} try { - await supervise(rootProfile, 'change the right module', { + await supervise(messageAuthorityRootProfile, 'change the right module', { budget, backend: backend(harness, true), brain: missingMessageAuthorityBrain(harness, record), diff --git a/tests/runtime/stop-rules.test.ts b/tests/runtime/stop-rules.test.ts index 45a971b2d..7efa5e3cd 100644 --- a/tests/runtime/stop-rules.test.ts +++ b/tests/runtime/stop-rules.test.ts @@ -43,7 +43,9 @@ import type { } from '../../src/runtime/supervise/types' import { supervisorAgent } from '../helpers/runtime-with-test-brain' import { scriptedBrain } from '../kernel/scripted-brain' -import { testAgentProfile } from '../kernel/test-agent-profile' +import { runtimeToolDeclarations, testAgentProfile } from '../kernel/test-agent-profile' + +const spawnAndAwait = ['spawn_worker', 'await_event'] as const async function jsonRpc(url: string, method: string, params: unknown): Promise { const r = await fetch(url, { @@ -379,6 +381,7 @@ describe('driverAgent stopRule — evaluated after the hard ceilings, never inst blobs, makeWorkerAgent, perWorker: perUnit, + toolNames: spawnAndAwait, systemPrompt: 'drive', maxTurns: opts.maxTurns, ...(opts.stopRule ? { stopRule: opts.stopRule } : {}), @@ -452,6 +455,7 @@ describe('driverAgent stopRule — evaluated after the hard ceilings, never inst return scoredLeaf(`w${spawned}`, 0.5) }, perWorker: perUnit, + toolNames: spawnAndAwait, systemPrompt: 'drive', maxTurns: 50, stopRule: never, @@ -561,6 +565,7 @@ describe('external-arm stopRule — the harness arm stops on the settle that pla testAgentProfile('sup', { harness: 'opencode', prompt: { systemPrompt: 'delegate, do not solve' }, + tools: runtimeToolDeclarations(...spawnAndAwait), }), { blobs, diff --git a/tests/runtime/supervisor-finalizer.test.ts b/tests/runtime/supervisor-finalizer.test.ts index 66a70fd41..98f4ce26d 100644 --- a/tests/runtime/supervisor-finalizer.test.ts +++ b/tests/runtime/supervisor-finalizer.test.ts @@ -45,6 +45,16 @@ function offlineProfile(name: string): AgentProfile { } } +function driverProfile(): AgentProfile { + return { + ...offlineProfile('root'), + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + }, + } +} + const emptyTree: TreeView = { root: 'r', nodes: [], inFlight: 0, waiting: 0 } const poolReadout: Scope['budget'] = { tokensLeft: budget.maxTokens, @@ -261,7 +271,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(offlineProfile('root'), 'task', { + const result = await supervise(driverProfile(), 'task', { budget, perWorker: { maxIterations: 5, maxTokens: 10_000 }, makeWorkerAgent: makeWorker, @@ -272,7 +282,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(offlineProfile('root'), 'task', { + const result = await supervise(driverProfile(), 'task', { budget, perWorker: { maxIterations: 5, maxTokens: 10_000 }, makeWorkerAgent: makeWorker, @@ -286,7 +296,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(offlineProfile('root'), 'task', { + const result = await supervise(driverProfile(), 'task', { budget, perWorker: { maxIterations: 5, maxTokens: 10_000 }, makeWorkerAgent: () => leaf('unchecked', 'UNCHECKED-PROSE', 0.99, false), diff --git a/tests/supervisor-loop-example.test.ts b/tests/supervisor-loop-example.test.ts index 2fa020bc8..8d69f3704 100644 --- a/tests/supervisor-loop-example.test.ts +++ b/tests/supervisor-loop-example.test.ts @@ -75,6 +75,10 @@ describe('supervisor-loop example — supervise() on the scripted brain (offline prompt: { systemPrompt: 'You are a supervisor. Spawn a worker, await it, and stop on delivery.', }, + tools: { + agent_runtime_coordination_spawn_worker: true, + agent_runtime_coordination_await_event: true, + }, }, demoGoal, { From 0bac1be1d968d36436e6652c2148df596490e078 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sat, 5 Sep 2026 11:07:35 -0700 Subject: [PATCH 2/3] fix(supervise): persist accepted direct submissions --- api-surface.json | 6 +- docs/api/index.md | 12 +- src/mcp/tools/coordination.ts | 61 +++++++- src/runtime/supervise/coordination-driver.ts | 3 +- src/runtime/supervise/supervise.ts | 3 +- src/runtime/supervise/supervisor-agent.ts | 30 +++- tests/kernel/coordination-driver.test.ts | 91 +++++++++++- tests/kernel/coordination.test.ts | 43 ++++++ tests/kernel/supervisor-agent.test.ts | 147 +++++++++++++++++++ 9 files changed, 377 insertions(+), 19 deletions(-) diff --git a/api-surface.json b/api-surface.json index 287c389c0..2a26c2738 100644 --- a/api-surface.json +++ b/api-surface.json @@ -120,7 +120,7 @@ "ConversationResult": "type f74fcdc4f8f0", "ConversationStreamEvent": "type a39182b3dbfc", "ConversationTurn": "type d8280ca3c636", - "CoordinationEvent": "type c5e4af54ef79", + "CoordinationEvent": "type c618020ecc5e", "CreateAgentCandidateWorkspacePortOptions": "type 02ca21801e67", "CreateKnowledgeImprovementActivationExecutorOptions": "type 4b3e5fe02df7", "CreateProfileImprovementHarnessOptions": "type 36de01aba28e", @@ -1177,7 +1177,7 @@ "ContinuityMode": "type 57d23eae19f8", "CoordinationBinding": "type 195715b60ec5", "CoordinationDeliveryEvidence": "type b9bcf48aeb2e", - "CoordinationEvent": "type c5e4af54ef79", + "CoordinationEvent": "type c618020ecc5e", "CoordinationLog": "type 760d6417189e", "CoordinationMcpHandle": "type 4ebbd68d8400", "CoordinationOwnerId": "type ba5552a40a12", @@ -2028,7 +2028,7 @@ "CodexExecutionPolicy": "type 1c10e614b168", "CodexTokenUsage": "type 5e574348084f", "ContinuationInstruction": "type b8afbf15f4e3", - "CoordinationEvent": "type c5e4af54ef79", + "CoordinationEvent": "type c618020ecc5e", "CoordinationTools": "type e90459fca882", "CoordinationToolsOptions": "type c838cac712c9", "CreateKbGateOptions": "type 9163935b6cf3", diff --git a/docs/api/index.md b/docs/api/index.md index fcbaf26e3..6b5a5435a 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -12350,7 +12350,7 @@ 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); `analyst?`: `string`; \} \| \{ `type`: `"answer"`; `down`: [`DownMessageEvent`](runtime.md#downmessageevent); `questionId`: `string`; \} \| \{ `type`: `"instruction"`; `instruction`: [`ContinuationInstruction`](runtime.md#continuationinstruction); \} \| \{ `type`: `"delivery-attempt"`; `attempt`: [`DownMessageDeliveryAttempt`](runtime.md#downmessagedeliveryattempt); \} \| \{ `type`: `"mail"`; `mail`: [`PeerMailEvent`](runtime.md#peermailevent); \} \| \{ `type`: `"escalation"`; `escalation`: [`QuestionEscalationRecord`](runtime.md#questionescalationrecord); \} \| \{ `type`: `"analyst-defined"`; `analyst`: [`DefinedAnalystRecord`](runtime.md#definedanalystrecord); \} +> **CoordinationEvent** = \{ `type`: `"question"`; `question`: [`QuestionRecord`](mcp.md#questionrecord); \} \| \{ `type`: `"settled"`; `worker`: [`SettledWorker`](mcp.md#settledworker); \} \| \{ `type`: `"finding"`; `finding`: [`AnalystFindingEvent`](runtime.md#analystfindingevent); \} \| \{ `type`: `"submission"`; `result`: `unknown`; \} \| \{ `type`: `"steer"`; `down`: [`DownMessageEvent`](runtime.md#downmessageevent); `analyst?`: `string`; \} \| \{ `type`: `"answer"`; `down`: [`DownMessageEvent`](runtime.md#downmessageevent); `questionId`: `string`; \} \| \{ `type`: `"instruction"`; `instruction`: [`ContinuationInstruction`](runtime.md#continuationinstruction); \} \| \{ `type`: `"delivery-attempt"`; `attempt`: [`DownMessageDeliveryAttempt`](runtime.md#downmessagedeliveryattempt); \} \| \{ `type`: `"mail"`; `mail`: [`PeerMailEvent`](runtime.md#peermailevent); \} \| \{ `type`: `"escalation"`; `escalation`: [`QuestionEscalationRecord`](runtime.md#questionescalationrecord); \} \| \{ `type`: `"analyst-defined"`; `analyst`: [`DefinedAnalystRecord`](runtime.md#definedanalystrecord); \} Every message on the one typed pipe. UP (child→parent): question / settled / finding — queued for the driver to `pull`. An `instruction` is the pre-delivery authorization receipt and is retained @@ -12381,6 +12381,16 @@ Every message on the one typed pipe. UP (child→parent): question / settled / f ##### Type Literal +\{ `type`: `"submission"`; `result`: `unknown`; \} + +A direct manager result that passed its injected completion check. Record-only: the caller + already received the tool response, and a restarted manager restores this exact accepted + result instead of running the check or its harness again. + +*** + +##### Type Literal + \{ `type`: `"steer"`; `down`: [`DownMessageEvent`](runtime.md#downmessageevent); `analyst?`: `string`; \} ###### type diff --git a/src/mcp/tools/coordination.ts b/src/mcp/tools/coordination.ts index e97cc5821..2adcfcddb 100644 --- a/src/mcp/tools/coordination.ts +++ b/src/mcp/tools/coordination.ts @@ -672,6 +672,10 @@ export type CoordinationEvent = | { readonly type: 'question'; readonly question: QuestionRecord } | { readonly type: 'settled'; readonly worker: SettledWorker } | { readonly type: 'finding'; readonly finding: AnalystFindingEvent } + /** A direct manager result that passed its injected completion check. Record-only: the caller + * already received the tool response, and a restarted manager restores this exact accepted + * result instead of running the check or its harness again. */ + | { readonly type: 'submission'; readonly result: unknown } | { readonly type: 'steer' readonly down: DownMessageEvent @@ -1005,6 +1009,7 @@ export const journalEventKinds = [ 'question', 'settled', 'finding', + 'submission', 'steer', 'answer', 'instruction', @@ -1411,10 +1416,26 @@ function spawnProfileArg(): Record { /** Build the driver's MCP tools over a live scope. */ export function createCoordinationTools(opts: CoordinationToolsOptions): CoordinationTools { const deliverable = opts.deliverable - let stopped = false - let reason: string | undefined + // An accepted direct result is a terminal fact, not an in-process callback. A durable observer + // appends the `submission` record before the tool responds, so a fresh manager can restore it + // after a crash in the response-to-driver window. + const priorSubmission = + deliverable === undefined + ? undefined + : opts.priorJournal?.find( + ( + record, + ): record is BusRecord> => + record.event.type === 'submission', + ) + let stopped = priorSubmission !== undefined + let reason: string | undefined = priorSubmission === undefined ? undefined : 'result-accepted' let stopNotified = false - let submitted: { readonly result: unknown } | undefined + let submitted: { readonly result: unknown } | undefined = + priorSubmission === undefined + ? undefined + : detachedFrozen({ result: priorSubmission.event.result }) + let submissionInFlight: Promise | undefined let questionSeq = 0 const ledger: SettledWorker[] = [] const questions: QuestionRecord[] = [...(opts.priorQuestions ?? [])] @@ -2197,6 +2218,7 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin } if (ev.type === 'question') return { type: 'question', question: ev.question } if (ev.type === 'finding') return { type: 'finding', ...ev.finding } + if (ev.type === 'submission') return { type: 'submission', result: ev.result } 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 } @@ -3369,10 +3391,35 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin } } - submitted = Object.freeze({ result }) - stopped = true - reason = 'result-accepted' - notifyStop() + // Persist before acknowledging. The first caller that reaches this point owns the + // result while the append is in flight; a concurrent caller waits for the same + // durable fact instead of appending a second passing result. + if (submissionInFlight) { + await submissionInFlight + return { + accepted: true, + retained: 'earlier-passing-result', + stop: true, + } + } + const acceptedSubmission = detachedFrozen({ result }) + const commit = bus + .publish( + { type: 'submission', result: acceptedSubmission.result }, + { queue: false }, + ) + .then(() => { + submitted = acceptedSubmission + stopped = true + reason = 'result-accepted' + notifyStop() + }) + submissionInFlight = commit + try { + await commit + } finally { + if (submissionInFlight === commit) submissionInFlight = undefined + } return { accepted: true, retained: 'this-result', stop: true } }, } satisfies McpToolDescriptor, diff --git a/src/runtime/supervise/coordination-driver.ts b/src/runtime/supervise/coordination-driver.ts index 1e025283a..f5d68a13b 100644 --- a/src/runtime/supervise/coordination-driver.ts +++ b/src/runtime/supervise/coordination-driver.ts @@ -1359,7 +1359,8 @@ function hasPriorCoordination(prior?: PriorCoordination): boolean { (prior.questions.length > 0 || prior.findings.length > 0 || prior.continuations.length > 0 || - prior.deliveryEvidence.length > 0) + prior.deliveryEvidence.length > 0 || + prior.records.some((record) => record.event.type === 'submission')) ) } diff --git a/src/runtime/supervise/supervise.ts b/src/runtime/supervise/supervise.ts index 845150aef..777f62164 100644 --- a/src/runtime/supervise/supervise.ts +++ b/src/runtime/supervise/supervise.ts @@ -2719,7 +2719,8 @@ function superviseInternal( (priorCoordination.questions.length > 0 || priorCoordination.findings.length > 0 || priorCoordination.continuations.length > 0 || - priorCoordination.deliveryEvidence.length > 0) + priorCoordination.deliveryEvidence.length > 0 || + priorCoordination.records.some((record) => record.event.type === 'submission')) ? { priorCoordination } : {}), ...(finalizer ? { finalizer } : {}), diff --git a/src/runtime/supervise/supervisor-agent.ts b/src/runtime/supervise/supervisor-agent.ts index 3e6a97ded..ade8e84e9 100644 --- a/src/runtime/supervise/supervisor-agent.ts +++ b/src/runtime/supervise/supervisor-agent.ts @@ -92,14 +92,21 @@ export function declaredRuntimeToolNames(profile: AgentProfile): ReadonlyArray = [], ): string | undefined { + const mountedStaticToolNameSet = new Set(mountedStaticToolNames) const unresolved = declaredRuntimeToolNames(profile).filter( - (name) => !coordinationVerbNameSet.has(name), + (name) => + !coordinationVerbNameSet.has(name) && + !hasProductToolResolver && + !mountedStaticToolNameSet.has(name), ) - if (unresolved.length === 0 || hasProductToolResolver) return undefined + if (unresolved.length === 0) return undefined return `the profile declares ${unresolved .map((name) => JSON.stringify(`${coordinationProfileToolPrefix}${name}`)) - .join(', ')}, but this run has no resolveSupervisorTools provider for those tools` + .join( + ', ', + )}, but this run has no resolveSupervisorTools provider or router-mounted static tool for those tools` } /** Runtime owns this attachment alias. An authored entry would make the provider mount ambiguous. */ @@ -665,7 +672,14 @@ function buildSupervisorAgent( deps.router === undefined ? undefined : snapshotRouterTransportConfig(deps.router) const resolveTools = deps.resolveSupervisorTools assertNoReservedCoordinationMcpAlias(stableProfile, 'supervisorAgent') - const runtimeToolError = runtimeToolDeclarationError(stableProfile, resolveTools !== undefined) + const harness = agentHarness(stableProfile.harness) ?? null + // `extraTools` is mounted only by the router tool-loop. An external harness receives product + // tools only through its coordination MCP, so it must have a node-scoped resolver instead. + const runtimeToolError = runtimeToolDeclarationError( + stableProfile, + resolveTools !== undefined, + harness === null ? deps.extraTools?.map((tool) => tool.name) : undefined, + ) if (runtimeToolError !== undefined) { throw new ValidationError(`supervisorAgent: ${runtimeToolError}`) } @@ -680,7 +694,6 @@ function buildSupervisorAgent( ) } const name = stableProfile.name ?? 'supervisor' - const harness = agentHarness(stableProfile.harness) ?? null // 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. Both arms receive only the prompt and instruction bytes declared by @@ -957,6 +970,13 @@ function buildSupervisorAgent( 'supervisorAgent provider-visible profile', ) try { + // A restored `submission` record proves this manager's completion check already accepted + // the value. Return it before starting another harness process. + const recoveredSubmission = mcp.submittedResult() + if (recoveredSubmission) { + deps.onAcceptedSubmission?.(recoveredSubmission.result) + return recoveredSubmission.result + } // The retry's progress mark. `tokensLeft` only falls, so the difference from the first // reading is everything this run has spent from the shared pool — the driver's own turns // and any child's. Those two channels are the BURN rate, not the goal, so they are reported diff --git a/tests/kernel/coordination-driver.test.ts b/tests/kernel/coordination-driver.test.ts index b3e632afb..088acac2e 100644 --- a/tests/kernel/coordination-driver.test.ts +++ b/tests/kernel/coordination-driver.test.ts @@ -1,10 +1,14 @@ -import type { AgentProfile } from '@tangle-network/agent-interface' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { type AgentProfile, canonicalCandidateDigest } from '@tangle-network/agent-interface' import { describe, expect, it } from 'vitest' import { InMemoryResultBlobStore, InMemorySpawnJournal } from '../../src/durable/spawn-journal' import { type DriverAgentOptions, driverAgent, } from '../../src/runtime/supervise/coordination-driver' +import { FileCoordinationLog } from '../../src/runtime/supervise/coordination-log' import { driverChild, withDriverExecutor } from '../../src/runtime/supervise/driver-executor' import { createExecutorRegistry } from '../../src/runtime/supervise/runtime' import { createSupervisor } from '../../src/runtime/supervise/supervisor' @@ -674,6 +678,91 @@ describe('driverAgent — the driver can ACT (call work tools itself), not only expect(tree.filter((e) => e.kind === 'spawned' && e.id !== 'direct-submit')).toEqual([]) }) + it('recovers an accepted direct result after the driver dies before it settles', async () => { + SHARED_BLOBS = new InMemoryResultBlobStore() + const journal = new InMemorySpawnJournal() + const runId = 'direct-submit-recovery' + const rootIdentity = { + profileDigest: canonicalCandidateDigest({ name: 'direct-submit-recovery-root' }), + taskDigest: canonicalCandidateDigest({ task: 'solve it' }), + } + const logDir = await mkdtemp(join(tmpdir(), 'direct-submit-recovery-')) + const log = new FileCoordinationLog(join(logDir, 'coordination.jsonl')) + let checks = 0 + let resumedBrainCalls = 0 + const deliverable = { + check: (result: unknown) => { + checks += 1 + return (result as { answer?: unknown }).answer === 42 + }, + } + try { + const interrupted = driverAgent({ + ...driverOpts( + 'root', + scriptedBrain([ + { toolCalls: [{ name: 'submit_result', arguments: { result: { answer: 42 } } }] }, + ]), + dummyWorker, + ), + toolNames: ['submit_result'], + deliverable, + onEvent: (_event, record) => log.append(runId, record, 'root'), + // The submission acknowledgement has returned to the driver. Throw before the root scope + // settles to model a coordinator process that dies in exactly that window. + onAcceptedSubmission: () => { + throw new Error('simulated coordinator loss after accepted submission') + }, + }) + const first = await createSupervisor().run(interrupted, 'solve it', { + budget: { maxIterations: 100, maxTokens: 100_000 }, + runId, + journal, + blobs: SHARED_BLOBS, + executors: createExecutorRegistry(), + rootIdentity, + maxDepth: 2, + now: () => 0, + }) + expect(first.kind).toBe('no-winner') + + const priorCoordination = await log.load(runId, 'root') + expect(priorCoordination.records.map((record) => record.event.type)).toEqual(['submission']) + + const resumed = driverAgent({ + ...driverOpts( + 'root', + async () => { + resumedBrainCalls += 1 + throw new Error('a recovered accepted result must not call the driver again') + }, + dummyWorker, + ), + toolNames: ['submit_result'], + deliverable, + priorCoordination, + }) + const second = await createSupervisor().run(resumed, 'solve it', { + budget: { maxIterations: 100, maxTokens: 100_000 }, + runId, + journal, + blobs: SHARED_BLOBS, + executors: createExecutorRegistry(), + rootIdentity, + maxDepth: 2, + resume: true, + now: () => 0, + }) + + expect(second.kind).toBe('winner') + if (second.kind === 'winner') expect(second.out).toEqual({ answer: 42 }) + expect(checks).toBe(1) + expect(resumedBrainCalls).toBe(0) + } finally { + await rm(logDir, { recursive: true, force: true }) + } + }) + it('the work tool is tried FIRST; a null return falls through to the coordination dispatch', async () => { SHARED_BLOBS = new InMemoryResultBlobStore() const journal = new InMemorySpawnJournal() diff --git a/tests/kernel/coordination.test.ts b/tests/kernel/coordination.test.ts index 721b7a676..32f18893d 100644 --- a/tests/kernel/coordination.test.ts +++ b/tests/kernel/coordination.test.ts @@ -215,12 +215,14 @@ describe('coordination tools', () => { const checked: unknown[] = [] const stopReasons: Array = [] + const events: CoordinationEvent[] = [] const withCheck = createCoordinationTools({ scope, blobs, makeWorkerAgent, perWorker: { maxIterations: 1, maxTokens: 10 }, onStop: (reason) => stopReasons.push(reason), + onEvent: (event) => events.push(event), deliverable: { describe: 'an object whose answer is 42', check(result) { @@ -270,6 +272,47 @@ describe('coordination tools', () => { expect(checked).toHaveLength(3) expect(stopReasons).toEqual(['result-accepted']) expect(withCheck.submittedResult()).toEqual({ result: { answer: 42 } }) + expect(events).toEqual([{ type: 'submission', result: { answer: 42 } }]) + }) + + it('commits one passing submission when concurrent callers race the durable append', async () => { + const { scope } = mockScope() + const events: CoordinationEvent[] = [] + let appendStarted!: () => void + const appending = new Promise((resolve) => { + appendStarted = resolve + }) + let releaseAppend!: () => void + const appendReleased = new Promise((resolve) => { + releaseAppend = resolve + }) + const tb = createCoordinationTools({ + scope, + blobs, + makeWorkerAgent, + perWorker: { maxIterations: 1, maxTokens: 10 }, + onEvent: async (event) => { + events.push(event) + appendStarted() + await appendReleased + }, + deliverable: { check: () => true }, + }) + const submit = tool(tb, 'submit_result') + + const first = submit.handler({ result: { winner: 'first' } }) + await appending + const second = submit.handler({ result: { winner: 'second' } }) + releaseAppend() + + await expect(first).resolves.toEqual({ accepted: true, retained: 'this-result', stop: true }) + await expect(second).resolves.toEqual({ + accepted: true, + retained: 'earlier-passing-result', + stop: true, + }) + expect(events).toEqual([{ type: 'submission', result: { winner: 'first' } }]) + expect(tb.submittedResult()).toEqual({ result: { winner: 'first' } }) }) it('spawn_worker returns workerId and fails closed when admission fails', async () => { diff --git a/tests/kernel/supervisor-agent.test.ts b/tests/kernel/supervisor-agent.test.ts index 59a357499..2b3e2a6eb 100644 --- a/tests/kernel/supervisor-agent.test.ts +++ b/tests/kernel/supervisor-agent.test.ts @@ -4,6 +4,7 @@ import { InMemoryResultBlobStore, InMemorySpawnJournal } from '../../src/durable import { ConfigError } from '../../src/errors' import type { MakeWorkerAgent } from '../../src/mcp/tools/coordination' import type { RouterTransportConfig } from '../../src/runtime/router-client' +import type { PriorCoordination } from '../../src/runtime/supervise/coordination-log' import { driverChild, withDriverExecutor } from '../../src/runtime/supervise/driver-executor' import { createExecutorRegistry } from '../../src/runtime/supervise/runtime' import { createRootHandle, createSupervisor } from '../../src/runtime/supervise/supervisor' @@ -11,6 +12,7 @@ import { coordinationMcpAlias, type DriveHarness, declaredRuntimeToolNames, + supervisorAgent as publicSupervisorAgent, type ResolveSupervisorTools, resolveSupervisorProfile, type SupervisorProfile, @@ -183,6 +185,99 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen expect(harnessCalls).toBe(0) }) + it('public router supervisors mount a profile-declared static tool, while external supervisors refuse it', async () => { + const measureRung = { + name: 'measure_rung', + description: 'Measure one research rung', + parameters: { + type: 'object', + properties: { subject: { type: 'string' } }, + required: ['subject'], + }, + } + const mounted: string[][] = [] + const calls: Array> = [] + let turns = 0 + const blobs = new InMemoryResultBlobStore() + const router = publicSupervisorAgent( + testAgentProfile('router', { + harness: 'cli-base', + model: { provider: 'offline', default: 'offline-test-model' }, + tools: runtimeToolDeclarations('measure_rung'), + }), + { + blobs, + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker, + extraTools: [measureRung], + executeExtraTool: async (_name, args) => { + calls.push(args) + return '{"rung":4}' + }, + maxTurns: 2, + router: { + routerBaseUrl: 'http://offline.test/v1', + routerKey: 'test', + complete: async (body) => { + mounted.push( + ((body.tools as Array<{ function: { name: string } }> | undefined) ?? []).map( + (tool) => tool.function.name, + ), + ) + turns += 1 + return turns === 1 + ? { + model: 'offline-test-model', + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: 'measure-1', + function: { + name: 'measure_rung', + arguments: '{"subject":"candidate-a"}', + }, + }, + ], + }, + }, + ], + usage: { prompt_tokens: 4, completion_tokens: 2 }, + } + : { + model: 'offline-test-model', + choices: [{ message: { content: 'measured', tool_calls: [] } }], + usage: { prompt_tokens: 4, completion_tokens: 2 }, + } + }, + }, + }, + ) + + await runSupervisor(router, blobs, new InMemorySpawnJournal()) + expect(mounted).toEqual([['measure_rung'], ['measure_rung']]) + expect(calls).toEqual([{ subject: 'candidate-a' }]) + + expect(() => + publicSupervisorAgent( + testAgentProfile('external', { + harness: 'opencode', + tools: runtimeToolDeclarations('measure_rung'), + }), + { + blobs: new InMemoryResultBlobStore(), + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker, + extraTools: [measureRung], + executeExtraTool: async () => '{"rung":4}', + driveHarness: async () => undefined, + }, + ), + ).toThrow(/no resolveSupervisorTools provider or router-mounted static tool/u) + }) + it('refuses an authored MCP entry under the Runtime coordination alias', () => { let harnessCalls = 0 expect(() => @@ -249,6 +344,58 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen if (result.kind === 'winner') expect(result.out).toEqual({ answer: 42 }) }) + it('SANDBOX arm returns a recovered direct result before starting another harness', async () => { + const blobs = new InMemoryResultBlobStore() + const journal = new InMemorySpawnJournal() + let harnessCalls = 0 + let checks = 0 + const priorCoordination: PriorCoordination = { + questions: [], + findings: [], + escalations: [], + analystDefinitions: [], + continuations: [], + deliveryEvidence: [], + mail: [], + records: [ + { + seq: 0, + at: 0, + priority: 0, + event: { type: 'submission', result: { answer: 42 } }, + }, + ], + } + const root = supervisorAgent( + testAgentProfile('sup', { + harness: 'pi', + prompt: { systemPrompt: 'solve or delegate' }, + tools: runtimeToolDeclarations('submit_result'), + }), + { + blobs, + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker, + driveHarness: async () => { + harnessCalls += 1 + }, + deliverable: { + check: (result) => { + checks += 1 + return (result as { answer?: unknown }).answer === 42 + }, + }, + priorCoordination, + }, + ) + + const result = await runSupervisor(root, blobs, journal) + expect(result.kind).toBe('winner') + if (result.kind === 'winner') expect(result.out).toEqual({ answer: 42 }) + expect(checks).toBe(0) + expect(harnessCalls).toBe(0) + }) + it('SANDBOX arm stops the active harness before a provider turn after accepted submission', async () => { const blobs = new InMemoryResultBlobStore() const journal = new InMemorySpawnJournal() From ff2877ee2480494418cda0b4718169231f690511 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sat, 5 Sep 2026 11:10:54 -0700 Subject: [PATCH 3/3] chore(release): prepare 0.194.0 --- CHANGELOG.md | 14 ++++++++++++++ docs/api/primitive-catalog.md | 2 +- docs/canonical-api.md | 2 +- package.json | 2 +- .../fixtures/agent-improvement-proposal.json | 10 +++++----- .../agent-profile-improvement-proposal.json | 6 +++--- 6 files changed, 25 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b90f8e179..b5bddcd7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.194.0 + +### Profiles own recursive authority + +Runtime now derives managed-node authority from each `AgentProfile` tool declaration. +A profile can use a Runtime coordination tool without a fixed role, and it can recurse only when it declares `agent_runtime_coordination_spawn_worker`. +Runtime no longer exports `supervisorPolicyPrompt` or injects a default supervisor policy. +Consumers must put their working instructions in the profile they run. + +### Accepted direct submissions survive restart + +`submit_result` now records an accepted result before it responds. +A resumed manager returns that result without starting another router or harness session, and concurrent passing submissions retain one accepted result. + ## 0.193.1 ### Bridge roots refuse unavailable work before allocation diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index f23f755a3..b2fdf12f8 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.193.1` and `@tangle-network/agent-eval@0.173.0` 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.194.0` and `@tangle-network/agent-eval@0.173.0` 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 diff --git a/docs/canonical-api.md b/docs/canonical-api.md index e424d6d98..c22ac92e9 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.193.1.** +> **Version 0.194.0.** > [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path. > `agent-eval` must satisfy `>=0.173.0 <0.174.0`. > `sandbox` must satisfy `>=0.36.4 <0.38.0`. diff --git a/package.json b/package.json index 5c5c0ac29..512b064db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-runtime", - "version": "0.193.1", + "version": "0.194.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/testing/fixtures/agent-improvement-proposal.json b/src/testing/fixtures/agent-improvement-proposal.json index 2de6d2aad..59bd6c6c8 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:6d74288ce43d6b1e2a73377160d62b7ba4e6b294c8bfc5fdc2eac8ce8aba175e", + "digest": "sha256:554c76136e7c5276d5727eb18f70b6928d5563fe7b0253f090ab17980cd54b9f", "evaluation": { "decision": { "contributingChecks": [ @@ -4882,7 +4882,7 @@ ], "metadata": { "fixture": "agent-improvement-proposal", - "runtimeVersion": "0.193.1" + "runtimeVersion": "0.194.0" }, "objectives": [ { @@ -4993,8 +4993,8 @@ "baselineContentHash": "sha256:5c21ee53e513fc604cb09754e21c392b24a424da0ef37dbf8f1ee4a8a0b08f09", "candidateContentHash": "sha256:60fcbb1c728194bd51d7d19cb732d1c3f1881dce7e0a6266b41c8b98cfd65693", "kind": "agent-eval-loop", - "recordDigest": "sha256:565f134e0589f18ce455cfd7fef2d76cbf5af335bc8e38befc4b587e640768bf", - "runId": "agent-runtime-0.193.1-proposal-fixture", + "recordDigest": "sha256:0ddd9e1fc3b4b6bc030b6f5e2ff633f4226aea2f6d9ee2d28ceb7ea2a06de6e2", + "runId": "agent-runtime-0.194.0-proposal-fixture", "schema": "agent-candidate-experiment" } }, @@ -5021,5 +5021,5 @@ ], "kind": "agent-improvement-proposal", "proposedAt": "2026-07-10T01:00:00.000Z", - "runId": "agent-runtime-0.193.1-proposal-fixture" + "runId": "agent-runtime-0.194.0-proposal-fixture" } diff --git a/src/testing/fixtures/agent-profile-improvement-proposal.json b/src/testing/fixtures/agent-profile-improvement-proposal.json index 49ede6339..823a21e4b 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:4f1f9175b8ae98f66fa860a54f215006e0a8c011db73d26623b2c7d2902ba9fb", + "digest": "sha256:6360878574d6d22ab6a3ac761738f4c991eddd32197ad899861d744180c6e602", "evaluation": { "decision": { "contributingChecks": [ @@ -1715,7 +1715,7 @@ ], "metadata": { "fixture": "agent-profile-improvement-proposal", - "runtimeVersion": "0.193.1" + "runtimeVersion": "0.194.0" }, "objectives": [ { @@ -1826,7 +1826,7 @@ "baselineContentHash": "sha256:21c495a37c418c10bde64fbaa188beddeed31f1f051ea60a6a6582a9ee0db704", "candidateContentHash": "sha256:103f77bc8481601eef1ad5fe6ba84a40dffabc3a44f421f8c8559121edab84e9", "kind": "agent-eval-loop", - "recordDigest": "sha256:d897dd5e49008c6352decb6befa70952e578dff0069351194185b4b202411085", + "recordDigest": "sha256:f19a2fff51e8eaafcbc5c67ea7c7a50d1c4962d5294973d21a662837a9f6340a", "runId": "profile-improvement-1", "schema": "agent-profile-improvement-experiment" }