From 7805aa2f5f9615d35f1005d38a2552cfa871bb91 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 3 Aug 2026 13:31:58 -0600 Subject: [PATCH 1/2] =?UTF-8?q?feat(supervise):=20continuity=20as=20a=20fi?= =?UTF-8?q?rst-class=20axis=20of=20delegates=20traversals=20=E2=80=94=20fr?= =?UTF-8?q?esh=20|=20resume=20|=20steer=20as=20ledgered=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A delegates edge accepts continuity: 'fresh' | 'resume' (edge default; spawn_agent takes a per-call override). A resume traversal targets a node whose prior worker has SETTLED: the kernel spawns a NEW live worker bound to the same node whose spawn context carries resume: { ofWorker, sequence } (WorkerResumeContext) — the executor seam owns the session re-attachment; the kernel keeps identity, ordering, ledger truth, and spend continuity in the one conserved pool. Traversal caps count resumes exactly like fresh spawns. Fail-loud refusals at the tool: resume-no-prior (explicit resume with no settled prior; the DECLARED edge default instead degrades to fresh on the node's first spawn), resume-while-live (steer is the live-worker channel, the error says so), resume-with-key (keys are run-once, resume runs again). Resume lineage is process-local — the same boundary as the analyst-run marker — and stated where it lives. EdgeTraversal and the journal 'edge' event gain continuity: 'fresh' | 'resume' | 'steer' — spawns stamp their effective mode, every mid-run delivery into a live recipient (driver steer legs, every analyzes delivery) stamps 'steer'. validateGraph refuses nonsense values and analyzes edges carrying the field. Threaded as continuityByProfile through SuperviseOptions → SupervisorAgentDeps → DriverAgentOptions / serveCoordinationMcp → CoordinationToolsOptions; runGraph derives it from delegates edges. Kernel exports ContinuityMode, WorkerResumeContext, TraversalContinuity. New example examples/graphs/shot-loop-resumed.ts (the VB shot shape as data: shot 1 fresh, shots 2-3 resume the prior settled session), proven offline in tests/examples/graph-topologies.test.ts; kernel continuity suite in tests/kernel/graph.test.ts. Edges without continuity behave byte-identically to before. Bump 0.127.0. --- CHANGELOG.md | 25 +- docs/api/mcp.md | 13 + docs/api/primitive-catalog.md | 7 +- docs/api/runtime.md | 135 +++++++- docs/canonical-api.md | 2 +- examples/graphs/README.md | 16 +- examples/graphs/shared.ts | 12 +- examples/graphs/shot-loop-resumed.ts | 128 ++++++++ package.json | 2 +- src/mcp/tools/coordination.ts | 167 ++++++++++ src/runtime/index.ts | 3 + src/runtime/supervise/coordination-driver.ts | 7 + src/runtime/supervise/coordination-mcp.ts | 5 + src/runtime/supervise/graph.ts | 72 ++++- src/runtime/supervise/supervise.ts | 13 + src/runtime/supervise/supervisor-agent.ts | 7 + src/runtime/supervise/types.ts | 6 + .../fixtures/agent-improvement-proposal.json | 10 +- .../agent-profile-improvement-proposal.json | 6 +- tests/examples/graph-topologies.test.ts | 42 ++- tests/kernel/coordination.test.ts | 8 + tests/kernel/graph.test.ts | 300 ++++++++++++++++++ 22 files changed, 959 insertions(+), 27 deletions(-) create mode 100644 examples/graphs/shot-loop-resumed.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 797a76d5..956d20bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,29 @@ # Changelog -## Unreleased +## 0.127.0 + +### Continuity is a first-class axis of delegates traversals + +Fresh respawns, session RESUMES, and live steers are now all expressible as plain data, each a ledgered fact. + +A `delegates` edge may declare `continuity: 'fresh' | 'resume'` — the default mode for that edge's spawn traversals (`'fresh'` is today's behavior; an edge without the field behaves byte-identically). +With `'resume'`, every spawn after the node's first re-attaches to the node's most recent SETTLED worker: the kernel spawns a NEW live worker bound to the SAME node whose spawn context carries `resume: { ofWorker, sequence }` (`WorkerSpawnContext.resume`, typed `WorkerResumeContext`), and the executor seam (`makeWorkerAgent`) owns the actual session re-attachment — e.g. mapping `ofWorker` to a backend session id. +The kernel keeps identity, ordering, ledger truth, and spend continuity: the resumed worker reserves from the same conserved pool, the ledger row's `workerId` is the NEW live worker, and the lineage rides the spawn context and the journal. +Traversal caps count resumes exactly like fresh spawns. + +`spawn_agent` accepts a per-call `continuity` override that wins over the edge default in either direction, and resume fails closed with an actionable error at the tool: + +- `resume-no-prior` — an explicit resume of a node with no settled prior worker (spawn it fresh first; the DECLARED edge default instead degrades to `'fresh'` on the node's first spawn). +- `resume-while-live` — a prior worker of the node is still live; steer is the live-worker channel, and the error says so. +- `resume-with-key` — a semantic key makes an assignment run-once; resume explicitly runs the node again. + +`EdgeTraversal` and the journal `edge` event gain `continuity: 'fresh' | 'resume' | 'steer'` — spawn traversals stamp their effective mode, and every mid-run delivery into an already-live recipient (a driver steer leg, every analyzes delivery) stamps `'steer'` — zero ambiguity in the ledger about how each hop continued. +`validateGraph` refuses nonsense continuity values and analyzes edges carrying the field (analysts are spawned by the analyst machinery; every analyst run is a fresh session over settled evidence). + +Threading: `SuperviseOptions` / `SupervisorAgentDeps` / `DriverAgentOptions` / `serveCoordinationMcp` / `CoordinationToolsOptions` gain `continuityByProfile?: Readonly>` (the per-profile-name default `runGraph` derives from delegates edges), and the kernel entry exports `ContinuityMode`, `WorkerResumeContext`, and `TraversalContinuity`. +Known limit, stated where it lives: resume lineage is PROCESS-LOCAL (the same boundary as the analyst-run marker) — workers settled by a prior process of a durable run are not resume targets, and the built-in backend seam (`workerFromBackend`) forwards the lineage without re-attaching sessions itself. + +New example: `examples/graphs/shot-loop-resumed.ts` — the VB shot shape as data (reviewer root, coder node, `continuity: 'resume'`, `maxTraversals: 3`): shot 1 spawns `fresh`, shots 2–3 resume the prior settled session, proven offline in `tests/examples/graph-topologies.test.ts`. ### Python bridge install hints match the required Eval substrate diff --git a/docs/api/mcp.md b/docs/api/mcp.md index 92e72992..0491ef04 100644 --- a/docs/api/mcp.md +++ b/docs/api/mcp.md @@ -4166,6 +4166,19 @@ resuming caller replays). Seeded into the question ledger verbatim — `list_que them, the stop policy counts the still-blocking ones, and `answer_question` can decide them. Omit/empty = fresh ledger (every run that is not a resume). +##### continuityByProfile? + +> `readonly` `optional` **continuityByProfile?**: `Readonly`\<`Record`\<`string`, [`ContinuityMode`](runtime.md#continuitymode)\>\> + +Default continuity per PROFILE NAME (the stable node identity a graph pins). A name mapping +to `'resume'` makes its spawns re-attach to the node's most recent settled worker whenever +one exists — the node's FIRST spawn is effectively `'fresh'`, and a spawn while a prior +worker of the node is still LIVE fails closed (`resume-while-live`; steer is the live-worker +channel). The spawn tool's per-call `continuity` argument overrides the declared default in +either direction. Omit = every spawn is `'fresh'` (status quo). Resume lineage is +PROCESS-LOCAL (the same boundary as the analyst-run marker): workers settled by a prior +process of a durable run are not resume targets. + *** ### CoordinationTools diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index b1e3ac3e..522b848a 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.126.0` and `@tangle-network/agent-eval@0.143.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.127.0` and `@tangle-network/agent-eval@0.143.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 @@ -510,7 +510,7 @@ Import from `@tangle-network/agent-runtime/intelligence` — 166 exports. ### Execution kernel — recursive atom, supervision, executors, round-synchronous loop -Import from `@tangle-network/agent-runtime/kernel` — 707 exports. +Import from `@tangle-network/agent-runtime/kernel` — 710 exports. | Symbol | Kind | Summary | |---|---|---| @@ -975,6 +975,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 707 exports. | `WidenLineage` | interface | A lineage the gate may widen toward — the settled child that looked promising + the findings | | `WidenSpec` | interface | `widen({ gate })` (G5) — the STREAMING spawn-on-completion driver. Unlike the static-fanout | | `WorkerProgress` | interface | The full live view of one worker, as `observe_agent` returns it mid-flight. | +| `WorkerResumeContext` | interface | The resume lineage a `'resume'` spawn hands the executor seam | | `WorkerSpawnContext` | interface | Immutable task, allocation, identity attribution, and semantic key supplied while a manager's | | `WorkerSteerRequest` | interface | One durable down-leg request appended to a worker's inbox file. | | `WorkerToolTraceArtifact` | interface | Bytes stored under `WorkerTraceEvidence.traceRef`. | @@ -992,6 +993,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 707 exports. | `AxisScoresOf` | type | Decompose ONE record into per-axis scores (e.g. judge dimensions). When set, it REPLACES the | | `BudgetReadout` | type | Post-reservation pool readout — the shape `Scope.budget` exposes. `tokensLeft`, | | `CombinatorShape` | type | A combinator is just a `LoopShape`: a factory `(ShapeContext) => Agent` whose `Agent.act` | +| `ContinuityMode` | type | How a spawn CONTINUES a node's prior work: `'fresh'` starts a brand-new session (the default, | | `CoordinationDeliveryEvidence` | type | Durable delivery evidence retained in commit order. An attempt without a later event carrying | | `CoordinationEvent` | type | Every message on the one typed pipe. UP (child→parent): question / settled / finding — queued for | | `CoordinationOwnerId` | type | Stable identity of the supervisor that owns one coordination stream. High-level supervision | @@ -1053,6 +1055,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 707 exports. | `ToolLoopCompactionOptions` | type | Public supervisor-facing compaction config: same knobs as the primitive, but `distill` is optional | | `ToolLoopMessageRecord` | type | Provider-neutral conversation record accepted by a tool-loop brain. | | `TrajectoryReportFn` | type | `trajectoryReport(...)` — the tree+cost reconstructor. Async (reads journal + optionally blobs). | +| `TraversalContinuity` | type | How one ledgered hop CONTINUED: a spawn traversal stamps its effective spawn mode | | `UnknownMaterializationReason` | type | Why exact materialization evidence is unavailable for a node. | | `UsageEvent` | type | Normalized usage event — the single channel every executor reports through, so the | | `Verify` | type | `verify(spec)` — build the 2-node implement→verifier-gate combinator. | diff --git a/docs/api/runtime.md b/docs/api/runtime.md index ca91654f..9257b634 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -1085,6 +1085,30 @@ Restrict which settled workers feed this lens, by profile name or spawn label. O *** +### WorkerResumeContext + +The resume lineage a `'resume'` spawn hands the executor seam + ([WorkerSpawnContext.resume](#resume)). The kernel owns identity, ordering, ledger truth, and + spend continuity (the resumed worker reserves from the same conserved pool); the seam owns the + re-attachment itself — e.g. mapping `ofWorker` to a backend session id. + +#### Properties + +##### ofWorker + +> `readonly` **ofWorker**: `string` + +The prior SETTLED worker whose session the new worker continues. + +##### sequence + +> `readonly` **sequence**: `number` + +1-based position of the NEW worker in the node's continuity chain: a node spawned once and + resumed once hands the resumed worker `sequence: 2`. + +*** + ### DownMessageDeliveryAttempt A durable marker written after authorization and immediately before Runtime calls `Scope.send`. @@ -1303,6 +1327,21 @@ Present (as the analyst id) ONLY when this spawn is an analyst-AGENT run initiat runtime, never accepted from a driver's tool arguments. A node-pinning `makeWorkerAgent` reads it to admit the analyst node it would refuse as a driver-authored spawn. +##### continuity? + +> `readonly` `optional` **continuity?**: [`ContinuityMode`](#continuitymode) + +The EFFECTIVE continuity mode of this spawn — the spawn tool's per-call argument when given, + else the profile name's declared default ([CoordinationToolsOptions.continuityByProfile](mcp.md#continuitybyprofile)), + else `'fresh'`. Absent only from producers that predate continuity — read absence as + `'fresh'`. + +##### resume? + +> `readonly` `optional` **resume?**: [`WorkerResumeContext`](#workerresumecontext) + +Present iff `continuity === 'resume'`: the lineage the executor seam re-attaches with. + *** ### WorkerWatchOptions @@ -9909,6 +9948,15 @@ Run the ONLINE detector panel over each worker's LIVE tool trace and raise a `fi Idle time after which `observe_agent` reports a worker as stalled (a derived read; nothing is killed). Omit = the runtime default. +##### continuityByProfile? + +> `readonly` `optional` **continuityByProfile?**: `Readonly`\<`Record`\<`string`, [`ContinuityMode`](#continuitymode)\>\> + +Default continuity per worker PROFILE NAME — `'resume'` makes spawns of that name re-attach + to the node's latest settled worker (see + `CoordinationToolsOptions.continuityByProfile`); `spawn_agent`'s per-call `continuity` + argument overrides. Omit = every spawn fresh (status quo). + ##### systemPrompt > `readonly` **systemPrompt**: `string` \| ((`task`) => `string`) @@ -10887,6 +10935,12 @@ The resolved directive reference (`/v`). > `readonly` **outcome**: [`EdgeDeliveryOutcome`](#edgedeliveryoutcome) +##### continuity + +> `readonly` **continuity**: [`TraversalContinuity`](#traversalcontinuity) + +How this hop continued — see [TraversalContinuity](#traversalcontinuity). + ##### bytes > `readonly` **bytes**: `number` @@ -13506,6 +13560,18 @@ Omit = off (status quo — no online watching, no extra events). Idle time after which `observe_agent` reports a running worker as `stalled`. A derived read at observation time — nothing is killed or retried. Omit = the runtime default. +##### continuityByProfile? + +> `readonly` `optional` **continuityByProfile?**: `Readonly`\<`Record`\<`string`, [`ContinuityMode`](#continuitymode)\>\> + +Default continuity per worker PROFILE NAME: `'resume'` makes each spawn of that name after + the first re-attach to the node's most recent SETTLED worker — a NEW live worker whose spawn + context carries the prior worker's identity (`WorkerSpawnContext.resume`), which the executor + seam re-attaches with. `spawn_agent`'s per-call `continuity` argument overrides in either + direction; `runGraph` derives this from delegates-edge `continuity`. Omit = every spawn is + `'fresh'` (status quo). See `CoordinationToolsOptions.continuityByProfile` for the + refusal semantics (no-prior / while-live / with-key) and the process-local resume boundary. + ##### blobs? > `readonly` `optional` **blobs?**: [`ResultBlobStore`](#resultblobstore) @@ -14282,6 +14348,14 @@ Run the ONLINE detector panel over each worker's LIVE tool trace (both arms) so Idle time after which `observe_agent` reports a worker as stalled. Omit = runtime default. +##### continuityByProfile? + +> `readonly` `optional` **continuityByProfile?**: `Readonly`\<`Record`\<`string`, [`ContinuityMode`](#continuitymode)\>\> + +Default continuity per worker PROFILE NAME (both arms) — `'resume'` re-attaches spawns of + that name to the node's latest settled worker; `spawn_agent`'s per-call `continuity` + overrides. Omit = every spawn fresh (status quo). + ##### stopRule? > `readonly` `optional` **stopRule?**: [`StopRule`](#stoprule-1) @@ -18011,6 +18085,18 @@ Present when a commit was attempted (valid, or `commitOnInvalid`). ## Type Aliases +### ContinuityMode + +> **ContinuityMode** = `"fresh"` \| `"resume"` + +How a spawn CONTINUES a node's prior work: `'fresh'` starts a brand-new session (the default, + and the only pre-continuity behavior); `'resume'` re-attaches to the node's most recent + SETTLED worker — a NEW live worker is spawned whose spawn context carries the prior worker's + identity ([WorkerResumeContext](#workerresumecontext)), and the executor seam owns the actual session + re-attachment. + +*** + ### DownMessageDeliveryOutcome > **DownMessageDeliveryOutcome** = `"delivered"` \| `"unknown-worker"` \| `"already-settled"` \| `"runtime-has-no-inbox"` \| `"scope-stopped"` \| `"runtime-error"` @@ -18901,13 +18987,13 @@ Why the dispatcher stopped admitting work. `drained` = the queue ran dry (the or ### GraphEdge -> **GraphEdge** = \{ `kind`: `"delegates"`; `from`: [`NodeId`](#nodeid-5); `to`: [`NodeId`](#nodeid-5); `directive`: [`PromptHandle`](#prompthandle); `maxTraversals?`: `number`; \} \| \{ `kind`: `"analyzes"`; `analyst`: `string`; `over`: `ReadonlyArray`\<[`NodeId`](#nodeid-5)\>; `to`: [`NodeId`](#nodeid-5); `directive`: [`PromptHandle`](#prompthandle); `maxTraversals?`: `number`; \} +> **GraphEdge** = \{ `kind`: `"delegates"`; `from`: [`NodeId`](#nodeid-5); `to`: [`NodeId`](#nodeid-5); `directive`: [`PromptHandle`](#prompthandle); `maxTraversals?`: `number`; `continuity?`: [`ContinuityMode`](#continuitymode); \} \| \{ `kind`: `"analyzes"`; `analyst`: `string`; `over`: `ReadonlyArray`\<[`NodeId`](#nodeid-5)\>; `to`: [`NodeId`](#nodeid-5); `directive`: [`PromptHandle`](#prompthandle); `maxTraversals?`: `number`; \} #### Union Members ##### Type Literal -\{ `kind`: `"delegates"`; `from`: [`NodeId`](#nodeid-5); `to`: [`NodeId`](#nodeid-5); `directive`: [`PromptHandle`](#prompthandle); `maxTraversals?`: `number`; \} +\{ `kind`: `"delegates"`; `from`: [`NodeId`](#nodeid-5); `to`: [`NodeId`](#nodeid-5); `directive`: [`PromptHandle`](#prompthandle); `maxTraversals?`: `number`; `continuity?`: [`ContinuityMode`](#continuitymode); \} Work flows down. The delegation directive is DATA → versionable, sweepable, optimizable. Each spawn of `to` by `from` — and each mid-run steer from `from` to a live `to` worker — @@ -18936,6 +19022,19 @@ Work flows down. The delegation directive is DATA → versionable, sweepable, op Cyclic-graph backstop: traversals beyond this REFUSE (fail loud). Default [defaultEdgeTraversalCap](#defaultedgetraversalcap). +###### continuity? + +> `readonly` `optional` **continuity?**: [`ContinuityMode`](#continuitymode) + +Default continuity for this edge's SPAWN traversals. `'resume'` makes every spawn after + the node's first re-attach to its most recent SETTLED worker: a NEW live worker whose + spawn context carries `resume: { ofWorker, sequence }` for the executor seam, spending + from the same conserved pool — the node's first spawn is effectively `'fresh'`, and a + spawn while a prior worker is still live refuses loudly (steer is the live channel). + The driver's per-call `spawn_agent` `continuity` argument overrides either way. Omit = + `'fresh'` (today's behavior, byte-identical). Caps count resumes exactly like fresh + spawns. + *** ##### Type Literal @@ -18988,6 +19087,17 @@ Observability cap: traversals beyond this are LEDGERED as exhausted (`unpropagat *** +### TraversalContinuity + +> **TraversalContinuity** = [`ContinuityMode`](#continuitymode) \| `"steer"` + +How one ledgered hop CONTINUED: a spawn traversal stamps its effective spawn mode + (`'fresh'` | `'resume'`), and every mid-run delivery into an already-live recipient — a + driver steer leg and every analyzes delivery (routed steer or driver-destined finding) — + stamps `'steer'`. Zero ambiguity: every row carries exactly one of the three. + +*** + ### SupervisorSpanAttributes > **SupervisorSpanAttributes** = `Record`\<`string`, `string` \| `number` \| `boolean`\> @@ -19362,7 +19472,7 @@ adoption state; none of the built-ins can today. ### SpawnEvent -> **SpawnEvent** = \{ `kind`: `"spawned"`; `id`: [`NodeId`](#nodeid-5); `parent?`: [`NodeId`](#nodeid-5); `label`: `string`; `key?`: `string`; `assignmentId?`: `string`; `budget`: [`Budget`](index.md#budget-4); `runtime`: [`Runtime`](#runtime-4); `ownedTreeRoot?`: [`NodeId`](#nodeid-5); `identity?`: [`NodeExecutionIdentity`](#nodeexecutionidentity); `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"execution-bound"`; `id`: [`NodeId`](#nodeid-5); `binding`: [`ExecutionBindingReceipt`](#executionbindingreceipt); `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"materialized"`; `id`: [`NodeId`](#nodeid-5); `receipt`: [`ProfileMaterializationReceipt`](#profilematerializationreceipt); `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"settled"`; `id`: [`NodeId`](#nodeid-5); `status`: `"done"` \| `"down"`; `outRef?`: `string`; `verdict?`: `DefaultVerdict`; `spent`: [`Spend`](index.md#spend); `infra?`: `boolean`; `reason?`: `string`; `trace?`: [`WorkerTraceEvidence`](index.md#workertraceevidence); `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"cancelled"`; `id`: [`NodeId`](#nodeid-5); `reason`: `string`; `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"waiting"`; `id`: [`NodeId`](#nodeid-5); `parent?`: [`NodeId`](#nodeid-5); `label`: `string`; `spec`: [`WaitSpec`](#waitspec); `armedAt`: `number`; `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"woken"`; `id`: [`NodeId`](#nodeid-5); `by`: `"fired"` \| `"timeout"` \| `"cancelled"`; `outRef?`: `string`; `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"metered"`; `id`: [`NodeId`](#nodeid-5); `spend`: [`Spend`](index.md#spend); `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"edge"`; `id`: [`NodeId`](#nodeid-5); `edge`: \{ `kind`: `"delegates"` \| `"analyzes"`; `from`: `string`; `to`: `string`; `directive`: `string`; \}; `traversal`: `number`; `outcome`: `"delivered"` \| `"stripped"` \| `"empty"` \| `"unpropagated"`; `bytes`: `number`; `reason?`: `string`; `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"trace-unpropagated"`; `id`: [`NodeId`](#nodeid-5); `expectedTraceId`: `string`; `backend`: `string`; `reason`: `"no-env-channel"` \| `"no-worker-process"` \| `"caller-omitted"`; `seq`: `number`; `at`: `string`; \} +> **SpawnEvent** = \{ `kind`: `"spawned"`; `id`: [`NodeId`](#nodeid-5); `parent?`: [`NodeId`](#nodeid-5); `label`: `string`; `key?`: `string`; `assignmentId?`: `string`; `budget`: [`Budget`](index.md#budget-4); `runtime`: [`Runtime`](#runtime-4); `ownedTreeRoot?`: [`NodeId`](#nodeid-5); `identity?`: [`NodeExecutionIdentity`](#nodeexecutionidentity); `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"execution-bound"`; `id`: [`NodeId`](#nodeid-5); `binding`: [`ExecutionBindingReceipt`](#executionbindingreceipt); `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"materialized"`; `id`: [`NodeId`](#nodeid-5); `receipt`: [`ProfileMaterializationReceipt`](#profilematerializationreceipt); `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"settled"`; `id`: [`NodeId`](#nodeid-5); `status`: `"done"` \| `"down"`; `outRef?`: `string`; `verdict?`: `DefaultVerdict`; `spent`: [`Spend`](index.md#spend); `infra?`: `boolean`; `reason?`: `string`; `trace?`: [`WorkerTraceEvidence`](index.md#workertraceevidence); `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"cancelled"`; `id`: [`NodeId`](#nodeid-5); `reason`: `string`; `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"waiting"`; `id`: [`NodeId`](#nodeid-5); `parent?`: [`NodeId`](#nodeid-5); `label`: `string`; `spec`: [`WaitSpec`](#waitspec); `armedAt`: `number`; `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"woken"`; `id`: [`NodeId`](#nodeid-5); `by`: `"fired"` \| `"timeout"` \| `"cancelled"`; `outRef?`: `string`; `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"metered"`; `id`: [`NodeId`](#nodeid-5); `spend`: [`Spend`](index.md#spend); `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"edge"`; `id`: [`NodeId`](#nodeid-5); `edge`: \{ `kind`: `"delegates"` \| `"analyzes"`; `from`: `string`; `to`: `string`; `directive`: `string`; \}; `traversal`: `number`; `outcome`: `"delivered"` \| `"stripped"` \| `"empty"` \| `"unpropagated"`; `continuity?`: `"fresh"` \| `"resume"` \| `"steer"`; `bytes`: `number`; `reason?`: `string`; `seq`: `number`; `at`: `string`; \} \| \{ `kind`: `"trace-unpropagated"`; `id`: [`NodeId`](#nodeid-5); `expectedTraceId`: `string`; `backend`: `string`; `reason`: `"no-env-channel"` \| `"no-worker-process"` \| `"caller-omitted"`; `seq`: `number`; `at`: `string`; \} Journaled spawn-tree events (B1/B2). `seq` is the cursor order; `at` is an ISO timestamp for human inspection only (NOT a replay input). @@ -19668,7 +19778,7 @@ A driver's OWN inference spend, journaled separately from spawned-child work — ##### Type Literal -\{ `kind`: `"edge"`; `id`: [`NodeId`](#nodeid-5); `edge`: \{ `kind`: `"delegates"` \| `"analyzes"`; `from`: `string`; `to`: `string`; `directive`: `string`; \}; `traversal`: `number`; `outcome`: `"delivered"` \| `"stripped"` \| `"empty"` \| `"unpropagated"`; `bytes`: `number`; `reason?`: `string`; `seq`: `number`; `at`: `string`; \} +\{ `kind`: `"edge"`; `id`: [`NodeId`](#nodeid-5); `edge`: \{ `kind`: `"delegates"` \| `"analyzes"`; `from`: `string`; `to`: `string`; `directive`: `string`; \}; `traversal`: `number`; `outcome`: `"delivered"` \| `"stripped"` \| `"empty"` \| `"unpropagated"`; `continuity?`: `"fresh"` \| `"resume"` \| `"steer"`; `bytes`: `number`; `reason?`: `string`; `seq`: `number`; `at`: `string`; \} ###### kind @@ -19718,6 +19828,16 @@ The resolved directive reference (`/v`), never the directive bytes. > **outcome**: `"delivered"` \| `"stripped"` \| `"empty"` \| `"unpropagated"` +###### continuity? + +> `optional` **continuity?**: `"fresh"` \| `"resume"` \| `"steer"` + +How the hop CONTINUED the node's work: spawn traversals stamp their effective mode + (`'fresh'` = new session, `'resume'` = re-attached to the node's prior settled session), + and every mid-run delivery into an already-live recipient — a driver steer leg and every + analyzes delivery — stamps `'steer'`. Optional only so journals written before + continuity stamping remain replayable; every new event carries it. + ###### bytes > **bytes**: `number` @@ -23624,6 +23744,13 @@ Run the ONLINE detector panel over each worker's live tool trace (raises `findin Idle time after which `observe_agent` reports a worker as stalled. +###### continuityByProfile? + +`Readonly`\<`Record`\<`string`, [`ContinuityMode`](#continuitymode)\>\> + +Default continuity per worker profile name — `'resume'` re-attaches spawns of that name to + the node's latest settled worker; the tool's per-call `continuity` overrides. + ###### onEvent? (`event`, `record`) => `void` \| `Promise`\<`void`\> diff --git a/docs/canonical-api.md b/docs/canonical-api.md index c1c56424..1c9686f5 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.126.0.** +> **Version 0.127.0.** > [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path. > `agent-eval` must satisfy `>=0.143.0 <0.144.0`. > `sandbox` must satisfy `>=0.17.2 <0.18.0`. diff --git a/examples/graphs/README.md b/examples/graphs/README.md index 457cbe6d..958a05cc 100644 --- a/examples/graphs/README.md +++ b/examples/graphs/README.md @@ -1,15 +1,16 @@ # graphs — agent topologies as plain data -Five runnable topologies for `runGraph` (the agent-graph layer over `supervise()`). -Each file's graph is a ≤25-line data literal — nodes are canonical `AgentProfile`s, edges are typed values carrying versioned registry directives — and each `main()` prints the EDGE LEDGER as the proof artifact: every traversal, its outcome (`delivered | stripped | empty | unpropagated`), its byte count, and the concrete worker it reached. +Six runnable topologies for `runGraph` (the agent-graph layer over `supervise()`). +Each file's graph is a ≤25-line data literal — nodes are canonical `AgentProfile`s, edges are typed values carrying versioned registry directives — and each `main()` prints the EDGE LEDGER as the proof artifact: every traversal, its outcome (`delivered | stripped | empty | unpropagated`), its continuity stamp (`fresh | resume | steer`), its byte count, and the concrete worker it reached. -All five run offline at $0 (scripted driver brain + in-process leaf workers, in [`shared.ts`](./shared.ts) — the same seams the kernel's own graph tests use). +All six run offline at $0 (scripted driver brain + in-process leaf workers, in [`shared.ts`](./shared.ts) — the same seams the kernel's own graph tests use). ```bash pnpm tsx examples/graphs/collaborates-review-loop.ts pnpm tsx examples/graphs/best-of-n.ts pnpm tsx examples/graphs/watchdog-steer.ts pnpm tsx examples/graphs/shot-loop.ts +pnpm tsx examples/graphs/shot-loop-resumed.ts pnpm tsx examples/graphs/analyst-agent-review.ts ``` @@ -19,14 +20,19 @@ pnpm tsx examples/graphs/analyst-agent-review.ts | [`best-of-n.ts`](./best-of-n.ts) | root + two candidate coder nodes, one `delegates` edge each, `maxLiveWorkers: 2` | Breadth is two edges in the data: exactly two delivered spawn traversals, winner decided by the deliverable. | | [`watchdog-steer.ts`](./watchdog-steer.ts) | root + one builder with a live trace; `watchWorkers` passthrough runs the shipped online detector panel | Mid-run intervention: the detector's finding reaches the driver over the bus, and the corrective steer lands as the delegates edge's second delivered traversal BEFORE settle. | | [`shot-loop.ts`](./shot-loop.ts) | reviewer(root) ↔ coder; `delegates maxTraversals: 3`, `analyzes` verify → reviewer | The multishot loop as data: each shot and each verify report is one ledgered traversal, the shot budget lives on the edge, and the deliverable gates on the verdict. | +| [`shot-loop-resumed.ts`](./shot-loop-resumed.ts) | reviewer(root) ↔ coder; `delegates maxTraversals: 3, continuity: 'resume'` | Continuity as data: shot 1 spawns `fresh`, shots 2+ RESUME the coder's prior settled session — the executor seam receives `resume: { ofWorker, sequence }`, the ledger stamps every hop's continuity, and all shots spend from the one conserved pool. | | [`analyst-agent-review.ts`](./analyst-agent-review.ts) | root + implementer; `analyzes` whose analyst is the `reviewer` NODE (no delegates edge to it) | The analyst as a tool-equipped AGENT: the reviewer node is spawned on the implementer's settle with directive + trace evidence as its task, its settle output IS the finding, and its spend lands in the one conserved budget. | -The offline proof for all five (exact ledger counts, outcomes, destinations) lives in `tests/examples/graph-topologies.test.ts`. +The offline proof for all six (exact ledger counts, outcomes, destinations) lives in `tests/examples/graph-topologies.test.ts`. -## Two ledger semantics worth knowing +## Three ledger semantics worth knowing - A mid-run steer increments its delegates edge's traversal count but is only CAP-CHECKED at spawn time — each steer consumes future spawn budget on that edge, so `maxTraversals: 3` means "3 shots" only on a steer-free edge. - `workerId` on a ledger row is the DESTINATION for delegates/steer/routed-analyzes rows, but the SOURCE worker for driver-destined finding rows. +- `continuity` on a ledger row is how the hop CONTINUED: spawn traversals stamp their effective + spawn mode (`fresh` = new session, `resume` = re-attached to the node's latest settled + session), and every mid-run delivery into an already-live recipient — a driver steer leg and + every analyzes delivery — stamps `steer`. diff --git a/examples/graphs/shared.ts b/examples/graphs/shared.ts index a4c6aa69..2b8cb124 100644 --- a/examples/graphs/shared.ts +++ b/examples/graphs/shared.ts @@ -25,6 +25,7 @@ import { type MakeWorkerAgent, type ToolLoopChat, type TraceSource, + type WorkerSpawnContext, } from '@tangle-network/agent-runtime/kernel' // ── The scripted driver brain ────────────────────────────────────────────────── @@ -81,6 +82,10 @@ export interface LeafSeamHooks { /** Called with each spawned node's live trace source (when `withTrace`), so an example can * wire `watchTrace` over it — the online-watchdog seam. */ onTraceSource?: (nodeId: string, source: TraceSource) => void + /** Called with each spawn's `WorkerSpawnContext` — the kernel-authored facts the executor seam + * receives (continuity mode, `resume` lineage, the analyst marker), so an example can PROVE + * what actually crossed the seam. */ + onSpawnContext?: (nodeId: string, context: WorkerSpawnContext | undefined) => void } /** A leaf-agent factory keyed by node name. Every spawned profile (what the graph pinned + the @@ -91,8 +96,9 @@ export function leafSeam( hooks: LeafSeamHooks = {}, ): MakeWorkerAgent { const attempts = new Map() - return (profile) => { + return (profile, context) => { received.push(profile) + hooks.onSpawnContext?.(profile.name ?? 'leaf', context) const name = profile.name ?? 'leaf' const opts = optsByNode[name] ?? {} const attempt = (attempts.get(name) ?? 0) + 1 @@ -166,7 +172,9 @@ export function printLedger(tag: string, res: GraphResult): void { for (const row of res.ledger) { const worker = row.workerId !== undefined ? ` -> ${row.workerId}` : '' const reason = row.reason !== undefined ? ` (${row.reason})` : '' - console.log(` #${row.traversal} ${row.edge} [${row.outcome}] ${row.bytes}B${worker}${reason}`) + console.log( + ` #${row.traversal} ${row.edge} [${row.outcome}|${row.continuity}] ${row.bytes}B${worker}${reason}`, + ) } if (res.exhaustedEdges.length > 0) { console.log(`exhausted edges: ${res.exhaustedEdges.join(', ')}`) diff --git a/examples/graphs/shot-loop-resumed.ts b/examples/graphs/shot-loop-resumed.ts new file mode 100644 index 00000000..e28eee4f --- /dev/null +++ b/examples/graphs/shot-loop-resumed.ts @@ -0,0 +1,128 @@ +/** + * shot-loop-resumed — the VB shot shape with session CONTINUITY as data. + * + * A 'reviewer' root drives one 'coder' worker under a 3-shot cap, exactly like `shot-loop` — + * except the delegates edge declares `continuity: 'resume'`, so every shot after the first is a + * RESUME of the coder's prior session instead of a fresh respawn: the kernel spawns a NEW live + * worker bound to the SAME node whose spawn context carries `resume: { ofWorker, sequence }`, + * and the executor seam owns the actual re-attachment (e.g. a backend session id). The kernel + * keeps identity, ordering, ledger truth, and spend continuity — every shot's spend draws from + * the one conserved pool, and the ledger stamps how each hop continued: traversal 1 `fresh` + * (the effective first spawn), traversals 2+ `resume`. Caps count resumes exactly like fresh + * spawns. + * + * Fully offline (scripted brain + leaf seam). Run: pnpm tsx examples/graphs/shot-loop-resumed.ts + */ + +import type { AgentProfile } from '@tangle-network/agent-interface' +import { + type AgentGraph, + promptHandle, + type RunGraphOptions, + runGraph, + type WorkerSpawnContext, +} from '@tangle-network/agent-runtime/kernel' +import { leafSeam, printLedger, scriptedBrain } from './shared' + +const brief = promptHandle('delegates/worker-brief/v1') + +export function shotLoopResumed(): { + graph: AgentGraph + opts: RunGraphOptions + contexts: Array +} { + // ── The topology: plain data — continuity is one field on the edge ── + const graph: AgentGraph = { + nodes: [ + { id: 'reviewer', profile: { name: 'reviewer', prompt: { systemPrompt: 'Verify.' } } }, + { id: 'coder', profile: { name: 'coder', prompt: { systemPrompt: 'Make tests pass.' } } }, + ], + edges: [ + { + kind: 'delegates', + from: 'reviewer', + to: 'coder', + directive: brief, + maxTraversals: 3, + continuity: 'resume', + }, + ], + deliverable: { + describe: 'coder output whose tests pass', + check: (out) => (out as { tests?: string } | undefined)?.tests === 'pass', + }, + budget: { maxIterations: 30, maxTokens: 100_000 }, + } + + const received: AgentProfile[] = [] + const contexts: Array = [] + const opts: RunGraphOptions = { + runId: 'rshots', + makeWorkerAgent: leafSeam( + received, + { + // Shots 1 and 2 fail their tests; shot 3 — resumed twice from the same session — passes. + coder: { + shots: [ + { out: { tests: 'fail' }, valid: false }, + { out: { tests: 'fail' }, valid: false }, + { out: { tests: 'pass' }, valid: true }, + ], + }, + }, + { onSpawnContext: (_nodeId, context) => contexts.push(context) }, + ), + brain: scriptedBrain([ + { + toolCalls: [ + { + name: 'spawn_agent', + arguments: { profile: { name: 'coder' }, task: 'shot 1: make the tests pass' }, + }, + ], + }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { + toolCalls: [ + { + name: 'spawn_agent', + arguments: { profile: { name: 'coder' }, task: 'shot 2: continue — fix the failures' }, + }, + ], + }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { + toolCalls: [ + { + name: 'spawn_agent', + arguments: { profile: { name: 'coder' }, task: 'shot 3: finish the failing suite' }, + }, + ], + }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { content: 'done' }, + ]), + } + return { graph, opts, contexts } +} + +export async function main(): Promise { + const { graph, opts, contexts } = shotLoopResumed() + const res = await runGraph(graph, opts) + printLedger('shot-loop-resumed', res) + console.log('SPAWN CONTINUITY (what the executor seam received):') + for (const context of contexts) { + const lineage = + context?.resume === undefined + ? '' + : ` resume.ofWorker=${context.resume.ofWorker} sequence=${context.resume.sequence}` + console.log(` ${context?.assignmentId}: ${context?.continuity ?? 'fresh'}${lineage}`) + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/package.json b/package.json index e1d069fe..ba228406 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-runtime", - "version": "0.126.0", + "version": "0.127.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/mcp/tools/coordination.ts b/src/mcp/tools/coordination.ts index eb108797..1e0c49c8 100644 --- a/src/mcp/tools/coordination.ts +++ b/src/mcp/tools/coordination.ts @@ -229,6 +229,25 @@ export function normalizeAnalyzeOnSettle( return typeof entry === 'string' ? { kind: entry } : entry } +/** How a spawn CONTINUES a node's prior work: `'fresh'` starts a brand-new session (the default, + * and the only pre-continuity behavior); `'resume'` re-attaches to the node's most recent + * SETTLED worker — a NEW live worker is spawned whose spawn context carries the prior worker's + * identity ({@link WorkerResumeContext}), and the executor seam owns the actual session + * re-attachment. */ +export type ContinuityMode = 'fresh' | 'resume' + +/** The resume lineage a `'resume'` spawn hands the executor seam + * ({@link WorkerSpawnContext.resume}). The kernel owns identity, ordering, ledger truth, and + * spend continuity (the resumed worker reserves from the same conserved pool); the seam owns the + * re-attachment itself — e.g. mapping `ofWorker` to a backend session id. */ +export interface WorkerResumeContext { + /** The prior SETTLED worker whose session the new worker continues. */ + readonly ofWorker: string + /** 1-based position of the NEW worker in the node's continuity chain: a node spawned once and + * resumed once hands the resumed worker `sequence: 2`. */ + readonly sequence: number +} + /** The exact result of one parent→child delivery attempt. */ export type DownMessageDeliveryOutcome = | 'delivered' @@ -338,6 +357,13 @@ export interface WorkerSpawnContext { * runtime, never accepted from a driver's tool arguments. A node-pinning `makeWorkerAgent` * reads it to admit the analyst node it would refuse as a driver-authored spawn. */ readonly analyst?: string + /** The EFFECTIVE continuity mode of this spawn — the spawn tool's per-call argument when given, + * else the profile name's declared default ({@link CoordinationToolsOptions.continuityByProfile}), + * else `'fresh'`. Absent only from producers that predate continuity — read absence as + * `'fresh'`. */ + readonly continuity?: ContinuityMode + /** Present iff `continuity === 'resume'`: the lineage the executor seam re-attaches with. */ + readonly resume?: WorkerResumeContext } export type MakeWorkerAgent = ( @@ -424,6 +450,17 @@ export interface CoordinationToolsOptions { * Omit/empty = fresh ledger (every run that is not a resume). */ readonly priorQuestions?: ReadonlyArray + /** + * Default continuity per PROFILE NAME (the stable node identity a graph pins). A name mapping + * to `'resume'` makes its spawns re-attach to the node's most recent settled worker whenever + * one exists — the node's FIRST spawn is effectively `'fresh'`, and a spawn while a prior + * worker of the node is still LIVE fails closed (`resume-while-live`; steer is the live-worker + * channel). The spawn tool's per-call `continuity` argument overrides the declared default in + * either direction. Omit = every spawn is `'fresh'` (status quo). Resume lineage is + * PROCESS-LOCAL (the same boundary as the analyst-run marker): workers settled by a prior + * process of a durable run are not resume targets. + */ + readonly continuityByProfile?: Readonly> } /** Online-detector wiring for spawned workers (`CoordinationToolsOptions.watchWorkers`). */ @@ -982,6 +1019,8 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin task, label, analyst: route.kind, + // An analyst run is always a brand-new session over settled evidence — never a resume. + continuity: 'fresh' as const, }) let refusal: string | undefined let spawnedId: string | undefined @@ -1031,6 +1070,102 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin ) } + // ── Continuity: how a spawn continues a node's prior work ── + // Node identity is the PROFILE NAME only (never the label): resume re-attaches to a specific + // prior session, and the label is the driver's free-text choice. All three reads are + // process-local by construction — `profileNameByWorker` records only this process's spawns — + // which is the documented resume boundary (a prior process's workers are not resume targets). + const liveWorkerForNode = (name: string): string | undefined => + opts.scope.view.nodes.find( + (node) => isLive(node.status) && profileNameByWorker.get(node.id) === name, + )?.id + const latestSettledWorkerForNode = (name: string): string | undefined => { + for (let i = ledger.length - 1; i >= 0; i -= 1) { + const worker = ledger[i] as SettledWorker + if (profileNameByWorker.get(worker.id) === name) return worker.id + } + return undefined + } + const nodeSpawnCount = (name: string): number => { + let count = 0 + for (const profileName of profileNameByWorker.values()) if (profileName === name) count += 1 + return count + } + const parseContinuity = (v: unknown): ContinuityMode | undefined => { + if (v === undefined) return undefined + if (v === 'fresh' || v === 'resume') return v + throw new Error('coordination tools: "continuity" must be "fresh" or "resume"') + } + type ResolvedContinuity = + | { readonly continuity: 'fresh' } + | { readonly continuity: 'resume'; readonly resume: WorkerResumeContext } + | { readonly error: string; readonly hint: string } + /** + * Resolve the EFFECTIVE continuity of one spawn: the per-call request wins, else the profile + * name's declared default, else `'fresh'`. Every refusal is loud and actionable: + * - an EXPLICIT `'resume'` with no settled prior worker refuses (`resume-no-prior`) — the + * DECLARED default degrades to `'fresh'` instead, so a resume edge's first traversal is + * simply the first spawn; + * - resume while a prior worker of the node is still LIVE refuses (`resume-while-live`) — + * that is what steer is for, and the error says so; + * - resume under a semantic `key` refuses (`resume-with-key`) — a key makes an assignment + * run-once, resume explicitly runs the node again. + */ + const resolveContinuity = ( + requested: ContinuityMode | undefined, + profileName: string | undefined, + key: string | undefined, + ): ResolvedContinuity => { + const declared = + profileName === undefined ? 'fresh' : (opts.continuityByProfile?.[profileName] ?? 'fresh') + const wantsResume = requested === 'resume' || (requested === undefined && declared === 'resume') + if (!wantsResume) return { continuity: 'fresh' } + if (profileName === undefined || profileName.length === 0) { + return { + error: 'resume-unnamed-profile', + hint: + 'Resume targets a node by profile.name — the stable node identity — and this profile ' + + 'has none. Name the profile, or spawn fresh.', + } + } + if (key !== undefined) { + return { + error: 'resume-with-key', + hint: + 'A semantic key makes an assignment run-once (a completed key returns its committed ' + + 'result instead of running again); resume explicitly runs the node AGAIN. Drop the ' + + 'key to resume, or keep the key and spawn fresh.', + } + } + const live = liveWorkerForNode(profileName) + if (live !== undefined) { + return { + error: 'resume-while-live', + hint: + `Worker '${live}' on node '${profileName}' is still LIVE — resume re-attaches to a ` + + 'SETTLED session. To redirect the live worker, use steer_agent (that is the ' + + "live-worker channel); to run a parallel sibling instead, pass continuity: 'fresh'.", + } + } + const prior = latestSettledWorkerForNode(profileName) + if (prior === undefined) { + // The DECLARED default asked for resume-when-possible; with nothing to resume this is the + // node's effective first spawn. Only an explicit per-call demand fails loud here. + if (requested === undefined) return { continuity: 'fresh' } + return { + error: 'resume-no-prior', + hint: + `Node '${profileName}' has no settled prior worker in this process to resume — ` + + 'resume continues a FINISHED session. Spawn the node fresh first (omit continuity or ' + + "pass 'fresh').", + } + } + return { + continuity: 'resume', + resume: { ofWorker: prior, sequence: nodeSpawnCount(profileName) + 1 }, + } + } + /** * Deliver one routed analyst finding to its destination worker through the SAME authorized * steer machinery a driver steer uses, so the delivery is recorded (`steer` event carrying @@ -1661,6 +1796,21 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin 'never runs twice: completed keys return their committed result, even after a ' + 'coordinator restart.', }, + continuity: { + type: 'string', + enum: ['fresh', 'resume'], + description: + 'How this spawn continues the node\'s prior work. "fresh" (the default) starts a ' + + 'brand-new session. "resume" re-attaches to the node\'s most recent SETTLED ' + + 'worker: a NEW live worker is spawned whose session continues where that worker ' + + 'stopped (the backend receives the prior workerId and the resume sequence). ' + + 'Resume fails closed when the node has no settled prior worker ' + + '(`error: "resume-no-prior"` — spawn it fresh first), while a prior worker of the ' + + 'node is still live (`error: "resume-while-live"` — steer_agent is the ' + + 'live-worker channel), and under a `key` (`error: "resume-with-key"` — keys are ' + + "run-once, resume runs again). Omit to use the run's declared default for this " + + 'profile name.', + }, budget: { type: 'object', description: @@ -1709,6 +1859,17 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin }) } const profile = deepFreezeDetached(parsedProfile.data) + // Continuity resolves BEFORE any assignment is minted or budget reserved, so a refused + // resume touches nothing — same fail-closed discipline as the fences above. + const continuity = resolveContinuity(parseContinuity(a.continuity), profile.name, key) + if ('error' in continuity) { + return Promise.resolve({ + error: continuity.error, + hint: continuity.hint, + live: liveWorkerCount(), + freeSlots: freeWorkerSlots(), + }) + } const task = deepFreezeDetached(a.task) const label = typeof a.label === 'string' ? a.label : 'worker' const budget = Object.freeze( @@ -1723,6 +1884,8 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin task, label, ...(key !== undefined ? { key } : {}), + continuity: continuity.continuity, + ...(continuity.continuity === 'resume' ? { resume: continuity.resume } : {}), }) const res = opts.scope.spawn(() => opts.makeWorkerAgent(profile, context), task, { budget, @@ -1781,6 +1944,10 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin ...(res.handle.executionBindings === undefined ? {} : { executionBindings: res.handle.executionBindings }), + // The EFFECTIVE continuity of this spawn, with the resume lineage when it + // re-attached — so the driver's transcript states how the node continued. + continuity: continuity.continuity, + ...(continuity.continuity === 'resume' ? { resume: continuity.resume } : {}), live: liveWorkerCount(), freeSlots: freeWorkerSlots(), ...priorHistory, diff --git a/src/runtime/index.ts b/src/runtime/index.ts index bcadf893..d3e65f5d 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -65,12 +65,14 @@ export type { AuthorizeDownMessage, AuthorizedDownMessage, ContinuationInstruction, + ContinuityMode, CoordinationEvent, DownMessageAuthorizationInput, DownMessageDeliveryAttempt, DownMessageDeliveryOutcome, DownMessageEvent, MakeWorkerAgent, + WorkerResumeContext, WorkerSpawnContext, WorkerWatchOptions, } from './../mcp/tools/coordination' @@ -621,6 +623,7 @@ export { type GraphResult, type RunGraphOptions, runGraph, + type TraversalContinuity, } from './supervise/graph' // The down-leg receive end: a per-worker inbox an executor exposes as `Executor.deliver`; the loop // drains it at the step boundary + before settle (queued) or aborts the turn (forceful interrupt). diff --git a/src/runtime/supervise/coordination-driver.ts b/src/runtime/supervise/coordination-driver.ts index 579d0ea5..80e7f992 100644 --- a/src/runtime/supervise/coordination-driver.ts +++ b/src/runtime/supervise/coordination-driver.ts @@ -32,6 +32,7 @@ import { type AnalystRegistry, type AnalyzeOnSettleRoute, type AuthorizeDownMessage, + type ContinuityMode, type CoordinationEvent, coordinationVerbNames, createCoordinationTools, @@ -108,6 +109,11 @@ export interface DriverAgentOptions { /** Idle time after which `observe_agent` reports a worker as stalled (a derived read; nothing is * killed). Omit = the runtime default. */ readonly stallAfterMs?: number + /** Default continuity per worker PROFILE NAME — `'resume'` makes spawns of that name re-attach + * to the node's latest settled worker (see + * `CoordinationToolsOptions.continuityByProfile`); `spawn_agent`'s per-call `continuity` + * argument overrides. Omit = every spawn fresh (status quo). */ + readonly continuityByProfile?: Readonly> /** The driver's stance — a string, or built from the task (the worker-driver prompt / * the generator). INJECTED so the prompt is a pluggable, optimizable role. */ readonly systemPrompt: string | ((task: unknown) => string) @@ -358,6 +364,7 @@ export function driverAgent(opts: DriverAgentOptions): Agent { ...(opts.analyzeOnSettle ? { analyzeOnSettle: opts.analyzeOnSettle } : {}), ...(opts.watchWorkers ? { watchWorkers: opts.watchWorkers } : {}), ...(opts.stallAfterMs !== undefined ? { stallAfterMs: opts.stallAfterMs } : {}), + ...(opts.continuityByProfile ? { continuityByProfile: opts.continuityByProfile } : {}), ...(opts.onEvent ? { onEvent: opts.onEvent } : {}), ...(opts.replaySettlements ? { replaySettlements: true } : {}), ...(opts.priorCoordination?.questions.length diff --git a/src/runtime/supervise/coordination-mcp.ts b/src/runtime/supervise/coordination-mcp.ts index 66a47c74..249ecd4a 100644 --- a/src/runtime/supervise/coordination-mcp.ts +++ b/src/runtime/supervise/coordination-mcp.ts @@ -26,6 +26,7 @@ import { type AnalystRegistry, type AnalyzeOnSettleRoute, type AuthorizeDownMessage, + type ContinuityMode, type CoordinationEvent, type CoordinationTools, createCoordinationTools, @@ -107,6 +108,9 @@ export async function serveCoordinationMcp(opts: { watchWorkers?: WorkerWatchOptions /** Idle time after which `observe_agent` reports a worker as stalled. */ stallAfterMs?: number + /** Default continuity per worker profile name — `'resume'` re-attaches spawns of that name to + * the node's latest settled worker; the tool's per-call `continuity` overrides. */ + continuityByProfile?: Readonly> /** Pass-through subscriber for every bus event, including pre-delivery instruction receipts and * steer/answer delivery outcomes. */ onEvent?: (event: CoordinationEvent, record: BusRecord) => void | Promise @@ -149,6 +153,7 @@ export async function serveCoordinationMcp(opts: { ...(opts.analyzeOnSettle ? { analyzeOnSettle: opts.analyzeOnSettle } : {}), ...(opts.watchWorkers ? { watchWorkers: opts.watchWorkers } : {}), ...(opts.stallAfterMs !== undefined ? { stallAfterMs: opts.stallAfterMs } : {}), + ...(opts.continuityByProfile ? { continuityByProfile: opts.continuityByProfile } : {}), ...(opts.onEvent ? { onEvent: opts.onEvent } : {}), ...(opts.replaySettlements ? { replaySettlements: true } : {}), ...(opts.questionPolicy ? { questionPolicy: opts.questionPolicy } : {}), diff --git a/src/runtime/supervise/graph.ts b/src/runtime/supervise/graph.ts index a636c122..8b5b78c0 100644 --- a/src/runtime/supervise/graph.ts +++ b/src/runtime/supervise/graph.ts @@ -27,6 +27,12 @@ * edge is a versioned optimization target, never prose hardcoded in a builder function. * 4. **Per-edge traversal caps** — the cyclic-graph backstop. A delegates edge whose cap is * exhausted REFUSES further traversals (fail loud), so a cycle cannot spin the pool dry. + * 5. **Continuity as data** — a delegates edge may declare `continuity: 'resume'`, so each spawn + * after the node's first re-attaches to its latest SETTLED session (the spawn context hands + * the executor seam `resume: { ofWorker, sequence }`; the kernel keeps identity, ordering, + * ledger truth, and the one conserved pool). Every ledger row states how its hop continued: + * `'fresh' | 'resume'` for spawns, `'steer'` for mid-run deliveries — fresh respawns, session + * resumes, and live steers are all plain data, each a ledgered fact. * * ORACLES ARE ENVIRONMENT, NEVER WORKERS. Graders/verifiers must not be spawnable in the graph — * a delegates edge to them leaks the rubric. An `analyzes` edge names its analyst in one of two @@ -49,6 +55,7 @@ import { ValidationError } from '../../errors' import type { AnalystRegistry, AnalyzeOnSettleRoute, + ContinuityMode, CoordinationEvent, MakeWorkerAgent, WorkerWatchOptions, @@ -89,6 +96,15 @@ export type GraphEdge = /** Cyclic-graph backstop: traversals beyond this REFUSE (fail loud). Default * {@link defaultEdgeTraversalCap}. */ readonly maxTraversals?: number + /** Default continuity for this edge's SPAWN traversals. `'resume'` makes every spawn after + * the node's first re-attach to its most recent SETTLED worker: a NEW live worker whose + * spawn context carries `resume: { ofWorker, sequence }` for the executor seam, spending + * from the same conserved pool — the node's first spawn is effectively `'fresh'`, and a + * spawn while a prior worker is still live refuses loudly (steer is the live channel). + * The driver's per-call `spawn_agent` `continuity` argument overrides either way. Omit = + * `'fresh'` (today's behavior, byte-identical). Caps count resumes exactly like fresh + * spawns. */ + readonly continuity?: ContinuityMode } /** Findings flow anywhere: an analyst over N nodes' settled traces, delivered to ONE node. * With a LENS analyst the directive wraps the findings for the recipient; with a NODE analyst @@ -123,6 +139,12 @@ export interface AgentGraph { export type EdgeDeliveryOutcome = 'delivered' | 'stripped' | 'empty' | 'unpropagated' +/** How one ledgered hop CONTINUED: a spawn traversal stamps its effective spawn mode + * (`'fresh'` | `'resume'`), and every mid-run delivery into an already-live recipient — a + * driver steer leg and every analyzes delivery (routed steer or driver-destined finding) — + * stamps `'steer'`. Zero ambiguity: every row carries exactly one of the three. */ +export type TraversalContinuity = ContinuityMode | 'steer' + /** One recorded edge traversal — the in-memory row; the journal twin is the `edge` SpawnEvent. */ export interface EdgeTraversal { /** Stable edge id: `delegates:->` or `analyzes::->`. */ @@ -135,6 +157,8 @@ export interface EdgeTraversal { /** 1-based per-edge ordinal. */ readonly traversal: number readonly outcome: EdgeDeliveryOutcome + /** How this hop continued — see {@link TraversalContinuity}. */ + readonly continuity: TraversalContinuity /** Bytes of directive + payload that actually crossed the edge. */ readonly bytes: number readonly reason?: string @@ -314,6 +338,18 @@ function validateGraph( 'settle-return loop, not a self-edge', ) } + // The type admits only 'fresh' | 'resume', but a graph is plain data that often arrives + // through JSON — refuse a nonsense mode here, never let it reach the spawn tool as a string. + if ( + edge.continuity !== undefined && + edge.continuity !== 'fresh' && + edge.continuity !== 'resume' + ) { + throw new ValidationError( + `runGraph: ${edgeId(edge)} has invalid continuity ${JSON.stringify(edge.continuity)} — ` + + "a delegates edge's continuity is 'fresh' or 'resume'", + ) + } } // Root: the one node that delegates and is never delegated TO. P0 executes the star/2-node // cyclic family (one driver, N workers); nested driver graphs are the recorded P3 absorption. @@ -340,6 +376,16 @@ function validateGraph( const analystIds = new Set() const analystNodes = new Map() for (const edge of analyzes) { + // Continuity is a delegates-edge axis ONLY: analysts (lens or node) are spawned by the + // analyst-on-settle machinery, each run a fresh session over settled evidence — an analyzes + // edge carrying continuity would silently mean nothing, so it is refused as data. + if ((edge as { continuity?: unknown }).continuity !== undefined) { + throw new ValidationError( + `runGraph: ${edgeId(edge)} carries continuity — analysts are spawned by the analyst ` + + 'machinery (every analyst run is a fresh session over settled evidence), so ' + + 'continuity is a delegates-edge axis only', + ) + } // The runner's traversal ledger resolves a finding/steer back to its edge BY ANALYST ID // alone, so a second edge sharing an analyst would silently absorb the first edge's // traversals (last-registered wins). Multi-edge-per-analyst is not yet supported; refuse it @@ -508,6 +554,7 @@ export function runGraph(graph: AgentGraph, opts: RunGraphOptions): Promise= cap) { exhausted.add(id) exhaustedDelegates.add(id) @@ -574,6 +625,7 @@ export function runGraph(graph: AgentGraph, opts: RunGraphOptions): Promise = {} + for (const [nodeId, edge] of delegatesByWorker) { + if (edge.continuity !== undefined) continuityByProfile[nodeId] = edge.continuity + } + // ── The driver graph brief: which nodes it may spawn, by exact name ── const workerLines = [...workers.values()].map((node) => { const edge = delegatesByWorker.get(node.id) as Extract @@ -669,7 +728,12 @@ export function runGraph(graph: AgentGraph, opts: RunGraphOptions): Promise 0 ? ` — ${node.profile.description}` : '' - return `- '${node.id}'${description} (delegation cap: ${cap} traversals)` + const continuityNote = + edge.continuity === 'resume' + ? "; continuity: resume — each spawn after the first re-attaches to this node's latest " + + 'settled session (spawn again to continue it; steer while it is live)' + : '' + return `- '${node.id}'${description} (delegation cap: ${cap} traversals${continuityNote})` }) const graphBrief = [ 'AGENT GRAPH: you are the driver node of a fixed topology. You may spawn ONLY these worker', @@ -739,6 +803,9 @@ export function runGraph(graph: AgentGraph, opts: RunGraphOptions): Promise 0 ? { analyzeOnSettle: routes, ...(opts.analysts ? { analysts: opts.analysts } : {}) } : {}), + ...(Object.keys(continuityByProfile).length > 0 ? { continuityByProfile } : {}), ...(opts.watchWorkers ? { watchWorkers: opts.watchWorkers } : {}), ...(opts.router ? { router: opts.router } : {}), ...(opts.brain ? { brain: opts.brain } : {}), diff --git a/src/runtime/supervise/supervise.ts b/src/runtime/supervise/supervise.ts index abb0c98b..ebab0d6f 100644 --- a/src/runtime/supervise/supervise.ts +++ b/src/runtime/supervise/supervise.ts @@ -35,6 +35,7 @@ import type { AnalyzeOnSettleRoute, AuthorizeDownMessage, AuthorizedDownMessage, + ContinuityMode, CoordinationEvent, DownMessageAuthorizationInput, MakeWorkerAgent, @@ -749,6 +750,14 @@ export interface SuperviseOptions { /** Idle time after which `observe_agent` reports a running worker as `stalled`. A derived read * at observation time — nothing is killed or retried. Omit = the runtime default. */ readonly stallAfterMs?: number + /** Default continuity per worker PROFILE NAME: `'resume'` makes each spawn of that name after + * the first re-attach to the node's most recent SETTLED worker — a NEW live worker whose spawn + * context carries the prior worker's identity (`WorkerSpawnContext.resume`), which the executor + * seam re-attaches with. `spawn_agent`'s per-call `continuity` argument overrides in either + * direction; `runGraph` derives this from delegates-edge `continuity`. Omit = every spawn is + * `'fresh'` (status quo). See `CoordinationToolsOptions.continuityByProfile` for the + * refusal semantics (no-prior / while-live / with-key) and the process-local resume boundary. */ + readonly continuityByProfile?: Readonly> /** Worker output store. Defaults to in-memory. */ readonly blobs?: ResultBlobStore /** @@ -1564,6 +1573,9 @@ export function supervise(profile: SupervisorProfile, task: unknown, opts: Super ...(options.analyzeOnSettle ? { analyzeOnSettle: options.analyzeOnSettle } : {}), ...(options.watchWorkers ? { watchWorkers: options.watchWorkers } : {}), ...(options.stallAfterMs !== undefined ? { stallAfterMs: options.stallAfterMs } : {}), + ...(options.continuityByProfile + ? { continuityByProfile: options.continuityByProfile } + : {}), ...(options.stopRule ? { stopRule: options.stopRule } : {}), ...(options.onProgressStop ? { onProgressStop: options.onProgressStop } : {}), ...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}), @@ -1642,6 +1654,7 @@ export function supervise(profile: SupervisorProfile, task: unknown, opts: Super ...(options.analyzeOnSettle ? { analyzeOnSettle: options.analyzeOnSettle } : {}), ...(options.watchWorkers ? { watchWorkers: options.watchWorkers } : {}), ...(options.stallAfterMs !== undefined ? { stallAfterMs: options.stallAfterMs } : {}), + ...(options.continuityByProfile ? { continuityByProfile: options.continuityByProfile } : {}), ...(options.stopRule ? { stopRule: options.stopRule } : {}), ...(options.onProgressStop ? { onProgressStop: options.onProgressStop } : {}), ...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}), diff --git a/src/runtime/supervise/supervisor-agent.ts b/src/runtime/supervise/supervisor-agent.ts index c5066da0..33401e3d 100644 --- a/src/runtime/supervise/supervisor-agent.ts +++ b/src/runtime/supervise/supervisor-agent.ts @@ -26,6 +26,7 @@ import type { AnalystRegistry, AnalyzeOnSettleRoute, AuthorizeDownMessage, + ContinuityMode, CoordinationEvent, MakeWorkerAgent, WorkerWatchOptions, @@ -387,6 +388,10 @@ export interface SupervisorAgentDeps { readonly watchWorkers?: WorkerWatchOptions /** Idle time after which `observe_agent` reports a worker as stalled. Omit = runtime default. */ readonly stallAfterMs?: number + /** Default continuity per worker PROFILE NAME (both arms) — `'resume'` re-attaches spawns of + * that name to the node's latest settled worker; `spawn_agent`'s per-call `continuity` + * overrides. Omit = every spawn fresh (status quo). */ + readonly continuityByProfile?: Readonly> /** PROGRESS-derived stop rule (router arm). Ends a run that has stopped learning BEFORE it * exhausts a ceiling; it can never keep a run alive past one. Build it with `plateau` / * `noProgressFor` / `allWorkersStalled` from `supervise/stop-rules` — the thresholds are the @@ -501,6 +506,7 @@ export function supervisorAgent( ...(deps.analyzeOnSettle ? { analyzeOnSettle: deps.analyzeOnSettle } : {}), ...(deps.watchWorkers ? { watchWorkers: deps.watchWorkers } : {}), ...(deps.stallAfterMs !== undefined ? { stallAfterMs: deps.stallAfterMs } : {}), + ...(deps.continuityByProfile ? { continuityByProfile: deps.continuityByProfile } : {}), ...(deps.stopRule ? { stopRule: deps.stopRule } : {}), ...(deps.onProgressStop ? { onProgressStop: deps.onProgressStop } : {}), ...(deps.maxTurns !== undefined ? { maxTurns: deps.maxTurns } : {}), @@ -583,6 +589,7 @@ export function supervisorAgent( ...(deps.analyzeOnSettle ? { analyzeOnSettle: deps.analyzeOnSettle } : {}), ...(deps.watchWorkers ? { watchWorkers: deps.watchWorkers } : {}), ...(deps.stallAfterMs !== undefined ? { stallAfterMs: deps.stallAfterMs } : {}), + ...(deps.continuityByProfile ? { continuityByProfile: deps.continuityByProfile } : {}), ...(onEvent ? { onEvent } : {}), ...(deps.replaySettlements ? { replaySettlements: true } : {}), ...(priorCoordination?.questions.length diff --git a/src/runtime/supervise/types.ts b/src/runtime/supervise/types.ts index e5a6ed57..d8dc0d78 100644 --- a/src/runtime/supervise/types.ts +++ b/src/runtime/supervise/types.ts @@ -936,6 +936,12 @@ export type SpawnEvent = /** 1-based traversal ordinal for THIS edge within the run. */ traversal: number outcome: 'delivered' | 'stripped' | 'empty' | 'unpropagated' + /** How the hop CONTINUED the node's work: spawn traversals stamp their effective mode + * (`'fresh'` = new session, `'resume'` = re-attached to the node's prior settled session), + * and every mid-run delivery into an already-live recipient — a driver steer leg and every + * analyzes delivery — stamps `'steer'`. Optional only so journals written before + * continuity stamping remain replayable; every new event carries it. */ + continuity?: 'fresh' | 'resume' | 'steer' /** Bytes of directive + payload that actually crossed the edge (0 for `empty`). */ bytes: number /** Why a non-`delivered` outcome happened, when the runtime knows. */ diff --git a/src/testing/fixtures/agent-improvement-proposal.json b/src/testing/fixtures/agent-improvement-proposal.json index ce50c4f9..c78fa876 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:ea14f031c1913692b1a3613636d18e6ed4f343cdeb5fab71cbda2a9c50b6a42c", + "digest": "sha256:3e71923bf3dedf470bb5aec4333e9fbd0906f27380c73b76adf80dc6d2ef6a07", "evaluation": { "decision": { "contributingChecks": [ @@ -4810,7 +4810,7 @@ ], "metadata": { "fixture": "agent-improvement-proposal", - "runtimeVersion": "0.126.0" + "runtimeVersion": "0.127.0" }, "objectives": [ { @@ -4921,8 +4921,8 @@ "baselineContentHash": "sha256:5c21ee53e513fc604cb09754e21c392b24a424da0ef37dbf8f1ee4a8a0b08f09", "candidateContentHash": "sha256:60fcbb1c728194bd51d7d19cb732d1c3f1881dce7e0a6266b41c8b98cfd65693", "kind": "agent-eval-loop", - "recordDigest": "sha256:cb92053dd298982e8ea2617bf76dd9b2bbcb0cf4ee16cfacac179a77cd4df579", - "runId": "agent-runtime-0.126.0-proposal-fixture", + "recordDigest": "sha256:7111287a19de2c3df46d005f7f96fc45e0d6889e31e5fd63f1bd760d5b4059e0", + "runId": "agent-runtime-0.127.0-proposal-fixture", "schema": "agent-candidate-experiment" } }, @@ -4949,5 +4949,5 @@ ], "kind": "agent-improvement-proposal", "proposedAt": "2026-07-10T01:00:00.000Z", - "runId": "agent-runtime-0.126.0-proposal-fixture" + "runId": "agent-runtime-0.127.0-proposal-fixture" } diff --git a/src/testing/fixtures/agent-profile-improvement-proposal.json b/src/testing/fixtures/agent-profile-improvement-proposal.json index adceb67a..4b44b5e6 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:9de5695694472a479d66682f5110b6886032c15d82fd052f7a111d5ac088ee5c", + "digest": "sha256:7623fd80722dcb85d83bc896e592eaa4aa98e1f0a706f2a95d5218ab53ca0824", "evaluation": { "decision": { "contributingChecks": [ @@ -1715,7 +1715,7 @@ ], "metadata": { "fixture": "agent-profile-improvement-proposal", - "runtimeVersion": "0.126.0" + "runtimeVersion": "0.127.0" }, "objectives": [ { @@ -1826,7 +1826,7 @@ "baselineContentHash": "sha256:21c495a37c418c10bde64fbaa188beddeed31f1f051ea60a6a6582a9ee0db704", "candidateContentHash": "sha256:103f77bc8481601eef1ad5fe6ba84a40dffabc3a44f421f8c8559121edab84e9", "kind": "agent-eval-loop", - "recordDigest": "sha256:7ca9bba7dac226743c2c343d08ee1d485eed1f2ad9f8b2256ba7f74ed44bcded", + "recordDigest": "sha256:6e7d9cbfc9327d5ce1bde8c6de799bb4af0f5e43aef74e4e970f91469651425a", "runId": "profile-improvement-1", "schema": "agent-profile-improvement-experiment" } diff --git a/tests/examples/graph-topologies.test.ts b/tests/examples/graph-topologies.test.ts index e6b8e613..a0add5b6 100644 --- a/tests/examples/graph-topologies.test.ts +++ b/tests/examples/graph-topologies.test.ts @@ -1,5 +1,5 @@ /** - * The five example graph topologies (`examples/graphs/`) run offline end-to-end, and each one's + * The six example graph topologies (`examples/graphs/`) run offline end-to-end, and each one's * DECISIVE ledger facts hold — the counts, outcomes, and destinations that make the example's * claim true, not just "it ran": * @@ -19,6 +19,10 @@ * 5. analyst-agent-review — the analyzes analyst is a NODE: the reviewer agent is spawned on * the implementer's settle and its settle output arrives as the driver's finding, one * delivered analyzes traversal from the implementer's worker id. + * 6. shot-loop-resumed — continuity as data: the delegates edge's `continuity: 'resume'` makes + * shot 1 spawn `fresh` and shots 2-3 RESUME the coder's prior settled session (the executor + * seam receives `resume: { ofWorker, sequence }`), with every hop's continuity stamped in + * the ledger and all three shots' spend in the one conserved pool. */ import { runGraph } from '@tangle-network/agent-runtime/kernel' @@ -27,9 +31,10 @@ import { analystAgentReview } from '../../examples/graphs/analyst-agent-review' import { bestOfN } from '../../examples/graphs/best-of-n' import { collaboratesReviewLoop } from '../../examples/graphs/collaborates-review-loop' import { shotLoop } from '../../examples/graphs/shot-loop' +import { shotLoopResumed } from '../../examples/graphs/shot-loop-resumed' import { watchdogSteer } from '../../examples/graphs/watchdog-steer' -describe('examples/graphs — the five topologies run offline with truthful ledgers', () => { +describe('examples/graphs — the six topologies run offline with truthful ledgers', () => { it('collaborates-review-loop: every peer hop is mediated, ledgered, and addressed', async () => { const { graph, opts } = collaboratesReviewLoop() const res = await runGraph(graph, opts) @@ -112,6 +117,39 @@ describe('examples/graphs — the five topologies run offline with truthful ledg expect(res.exhaustedEdges).toEqual([]) }) + it('shot-loop-resumed: shot 1 fresh, shots 2-3 resume the prior settled session, one conserved pool', async () => { + const { graph, opts, contexts } = shotLoopResumed() + const res = await runGraph(graph, opts) + + expect(res.result.kind).toBe('winner') + if (res.result.kind === 'winner') expect(res.result.out).toEqual({ tests: 'pass' }) + + // The ledger states how every hop continued: the effective first spawn is fresh, each later + // shot re-attaches — and the shot budget (the cap) counts resumes exactly like fresh spawns. + expect( + res.ledger.map((row) => [row.edge, row.traversal, row.outcome, row.continuity, row.workerId]), + ).toEqual([ + ['delegates:reviewer->coder', 1, 'delivered', 'fresh', 'rshots:s0'], + ['delegates:reviewer->coder', 2, 'delivered', 'resume', 'rshots:s1'], + ['delegates:reviewer->coder', 3, 'delivered', 'resume', 'rshots:s2'], + ]) + expect(res.exhaustedEdges).toEqual([]) + + // The executor seam received the resume lineage the kernel authored: shot 2 continues shot + // 1's worker, shot 3 continues shot 2's — a session chain, in plain data. + expect(contexts.map((c) => [c?.continuity, c?.resume?.ofWorker, c?.resume?.sequence])).toEqual([ + ['fresh', undefined, undefined], + ['resume', 'rshots:s0', 2], + ['resume', 'rshots:s1', 3], + ]) + + // Spend continuity: all three shots drew from the graph's ONE conserved Spend (5/5 each). + if (res.result.kind === 'winner') { + expect(res.result.spentTotal.tokens.input).toBe(15) + expect(res.result.spentTotal.tokens.output).toBe(15) + } + }) + it('analyst-agent-review: the reviewer NODE runs as the analyst and its output is the finding', async () => { const { graph, opts } = analystAgentReview() const res = await runGraph(graph, opts) diff --git a/tests/kernel/coordination.test.ts b/tests/kernel/coordination.test.ts index 8b03c7a7..152e0e15 100644 --- a/tests/kernel/coordination.test.ts +++ b/tests/kernel/coordination.test.ts @@ -210,6 +210,7 @@ describe('coordination tools', () => { expect(await tool(tb, 'spawn_agent').handler({ profile: {}, task: 'go' })).toEqual({ workerId: 'w0', assignmentId: 'ordinal:0', + continuity: 'fresh', live: 1, freeSlots: null, }) @@ -323,12 +324,14 @@ describe('coordination tools', () => { expect(await spawn()).toEqual({ workerId: 'w0', assignmentId: 'ordinal:0', + continuity: 'fresh', live: 1, freeSlots: 1, }) expect(await spawn()).toEqual({ workerId: 'w1', assignmentId: 'ordinal:1', + continuity: 'fresh', live: 2, freeSlots: 0, }) @@ -340,6 +343,7 @@ describe('coordination tools', () => { expect(await spawn()).toEqual({ workerId: 'w2', assignmentId: 'ordinal:2', + continuity: 'fresh', live: 2, freeSlots: 0, }) @@ -354,6 +358,7 @@ describe('coordination tools', () => { expect(await tool(uncapped, 'spawn_agent').handler({ profile: {}, task: 'go' })).toEqual({ workerId: 'w3', assignmentId: 'ordinal:0', + continuity: 'fresh', live: 3, freeSlots: null, }) @@ -424,6 +429,7 @@ describe('coordination tools', () => { expect(await spawnKeyed('a')).toEqual({ workerId: 'w0', assignmentId: 'key:a', + continuity: 'fresh', live: 1, freeSlots: 0, }) @@ -438,6 +444,7 @@ describe('coordination tools', () => { expect(await spawnKeyed('b')).toEqual({ workerId: 'w1', assignmentId: 'key:b', + continuity: 'fresh', live: 1, freeSlots: 0, }) @@ -490,6 +497,7 @@ describe('coordination tools', () => { ).toEqual({ workerId: 'w0', assignmentId: 'ordinal:0', + continuity: 'fresh', live: 1, freeSlots: null, }) diff --git a/tests/kernel/graph.test.ts b/tests/kernel/graph.test.ts index 62477ada..efcda6fd 100644 --- a/tests/kernel/graph.test.ts +++ b/tests/kernel/graph.test.ts @@ -28,6 +28,13 @@ * 9. watchWorkers passthrough: RunGraphOptions forwards the online detector panel to * supervise(), so a live worker's stuck-loop finding reaches the driver on the bus with no * leaf-seam wiring; omitted = off. + * 10. Continuity as data: a delegates edge's `continuity: 'resume'` makes every spawn after the + * node's first a RESUME of its latest settled session (lineage handed to the executor seam, + * spend still in the one conserved pool), every ledger row and journal twin stamps how its + * hop continued ('fresh' | 'resume' | 'steer'), the per-call override wins in both + * 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. */ import type { ToolSpan } from '@tangle-network/agent-eval' @@ -1166,6 +1173,299 @@ describe('runGraph — caller hooks compose onto the same event stream', () => { }) }) +describe('runGraph — continuity (fresh | resume | steer as ledgered data)', () => { + /** driver ↔ worker with the delegates edge declaring resume as its default spawn mode. */ + const resumeGraph = (over?: Partial): AgentGraph => + twoNodeGraph({ + edges: [ + { + kind: 'delegates', + from: 'driver', + to: 'worker', + directive: promptHandle('delegates/worker-brief/v1'), + maxTraversals: 3, + continuity: 'resume', + }, + ], + ...over, + }) + const spawnTurn = (task: string, extra: Record = {}) => ({ + toolCalls: [ + { name: 'spawn_agent', arguments: { profile: { name: 'worker' }, task, ...extra } }, + ], + }) + const awaitTurn = { toolCalls: [{ name: 'await_event', arguments: {} }] } + + it("a resume edge: spawn 1 'fresh', spawn 2 'resume' with lineage at the executor seam, spend in ONE pool", async () => { + const contexts: Array = [] + const journal = new InMemorySpawnJournal() + const res = await runGraph(resumeGraph(), { + runId: 'gc1', + journal, + makeWorkerAgent: leafSeam([], {}, contexts), + brain: scriptedBrain([ + spawnTurn('shot 1'), + awaitTurn, + spawnTurn('shot 2'), + awaitTurn, + { content: 'done' }, + ]), + }) + expect(res.result.kind).toBe('winner') + + // The executor seam received the kernel-authored continuity facts: the node's FIRST spawn is + // effectively fresh (nothing to resume), the second re-attaches to shot 1's worker. + expect(contexts).toHaveLength(2) + expect(contexts[0]?.continuity).toBe('fresh') + expect(contexts[0]?.resume).toBeUndefined() + expect(contexts[1]?.continuity).toBe('resume') + expect(contexts[1]?.resume).toEqual({ ofWorker: 'gc1:s0', sequence: 2 }) + + // The ledger stamps how each hop continued — zero ambiguity, and the row's workerId is the + // NEW live worker (the resume lineage lives in the spawn context and the journal twin). + const rows = res.ledger.filter((row) => row.kind === 'delegates') + expect(rows.map((row) => [row.traversal, row.outcome, row.continuity, row.workerId])).toEqual([ + [1, 'delivered', 'fresh', 'gc1:s0'], + [2, 'delivered', 'resume', 'gc1:s1'], + ]) + + // The journal twin carries the same stamps. + const events = (await journal.loadTree('gc1')) ?? [] + const edgeEvents = events.filter( + (ev): ev is Extract => ev.kind === 'edge', + ) + expect(edgeEvents.map((ev) => [ev.traversal, ev.continuity])).toEqual([ + [1, 'fresh'], + [2, 'resume'], + ]) + + // Spend continuity: both legs of the resumed session drew from the SAME conserved pool — + // worker (5/5) + resumed worker (5/5) in one Spend. + if (res.result.kind === 'winner') { + expect(res.result.spentTotal.tokens.input).toBe(10) + expect(res.result.spentTotal.tokens.output).toBe(10) + } + }) + + it('the per-call override wins in BOTH directions', async () => { + // Direction 1: a resume edge, but the driver demands a FRESH second spawn. + const freshOverride: Array = [] + const res1 = await runGraph(resumeGraph(), { + runId: 'gc2a', + makeWorkerAgent: leafSeam([], {}, freshOverride), + brain: scriptedBrain([ + spawnTurn('shot 1'), + awaitTurn, + spawnTurn('shot 2', { continuity: 'fresh' }), + awaitTurn, + { content: 'done' }, + ]), + }) + expect(res1.result.kind).toBe('winner') + expect(freshOverride[1]?.continuity).toBe('fresh') + expect(freshOverride[1]?.resume).toBeUndefined() + expect(res1.ledger.map((row) => row.continuity)).toEqual(['fresh', 'fresh']) + + // Direction 2: a continuity-free edge (today's default), but the driver demands a resume. + const resumeOverride: Array = [] + const res2 = await runGraph(twoNodeGraph(), { + runId: 'gc2b', + makeWorkerAgent: leafSeam([], {}, resumeOverride), + brain: scriptedBrain([ + spawnTurn('shot 1'), + awaitTurn, + spawnTurn('shot 2', { continuity: 'resume' }), + awaitTurn, + { content: 'done' }, + ]), + }) + expect(res2.result.kind).toBe('winner') + expect(resumeOverride[1]?.continuity).toBe('resume') + expect(resumeOverride[1]?.resume).toEqual({ ofWorker: 'gc2b:s0', sequence: 2 }) + expect(res2.ledger.map((row) => row.continuity)).toEqual(['fresh', 'resume']) + }) + + it('an EXPLICIT resume with no prior settled worker fails loud at the tool — nothing spawns, nothing is ledgered', async () => { + const seen: Array>> = [] + const res = await runGraph(twoNodeGraph(), { + runId: 'gc3', + makeWorkerAgent: leafSeam([]), + brain: scriptedBrain( + [spawnTurn('shot 1', { continuity: 'resume' }), { content: 'give up' }], + seen, + ), + }) + // The refusal happened BEFORE the factory: no worker, no traversal, an honest no-winner. + expect(res.result.kind).not.toBe('winner') + expect(res.ledger).toHaveLength(0) + const transcript = JSON.stringify(seen) + expect(transcript).toContain('resume-no-prior') + expect(transcript).toContain('no settled prior worker') + }) + + it('resume while the prior worker is STILL LIVE fails loud and names steer as the live channel', async () => { + const seen: Array>> = [] + const res = await runGraph(resumeGraph(), { + runId: 'gc4', + makeWorkerAgent: leafSeam([], { awaitSteer: true }), + brain: scriptedBrain( + [ + spawnTurn('shot 1'), + spawnTurn('shot 2 while shot 1 is live'), // the edge default asks resume → refused + { + toolCalls: [ + { name: 'steer_agent', arguments: { workerId: 'gc4:s0', instruction: 'deliver' } }, + ], + }, + awaitTurn, + { content: 'done' }, + ], + seen, + ), + }) + expect(res.result.kind).toBe('winner') + const transcript = JSON.stringify(seen) + expect(transcript).toContain('resume-while-live') + expect(transcript).toContain('steer_agent') + // The refused resume never traversed; the ledger holds the fresh spawn + the steer leg only. + expect(res.ledger.map((row) => [row.outcome, row.continuity])).toEqual([ + ['delivered', 'fresh'], + ['delivered', 'steer'], + ]) + }) + + it('resume cannot ride a semantic key — keys are run-once, resume runs again', async () => { + const seen: Array>> = [] + const res = await runGraph(twoNodeGraph(), { + runId: 'gc5', + makeWorkerAgent: leafSeam([]), + brain: scriptedBrain( + [ + spawnTurn('shot 1', { key: 'build' }), + awaitTurn, + spawnTurn('shot 2', { key: 'build', continuity: 'resume' }), + { content: 'done' }, + ], + seen, + ), + }) + expect(res.result.kind).toBe('winner') + expect(JSON.stringify(seen)).toContain('resume-with-key') + // Only the keyed fresh spawn traversed. + expect(res.ledger.map((row) => row.continuity)).toEqual(['fresh']) + }) + + it('a continuity-free graph stamps fresh spawns and steer legs — the back-compat truth', async () => { + const journal = new InMemorySpawnJournal() + const res = await runGraph(twoNodeGraph(), { + runId: 'gc6', + journal, + makeWorkerAgent: leafSeam([], { awaitSteer: true }), + brain: scriptedBrain([ + spawnTurn('build it'), + { + toolCalls: [ + { name: 'steer_agent', arguments: { workerId: 'gc6:s0', instruction: 'deliver now' } }, + ], + }, + awaitTurn, + { content: 'done' }, + ]), + }) + expect(res.result.kind).toBe('winner') + expect(res.ledger.map((row) => [row.traversal, row.continuity])).toEqual([ + [1, 'fresh'], + [2, 'steer'], + ]) + const events = (await journal.loadTree('gc6')) ?? [] + const edgeEvents = events.filter( + (ev): ev is Extract => ev.kind === 'edge', + ) + expect(edgeEvents.map((ev) => ev.continuity)).toEqual(['fresh', 'steer']) + }) + + it('analyzes traversals stamp steer — a delivery into a live recipient, never a spawn', async () => { + const analysts = { + kinds: [{ id: 'convergence', description: 'is the worker converging', area: 'progress' }], + run: async () => [{ claim: 'ok' }], + } + const res = await runGraph( + twoNodeGraph({ + edges: [ + { + kind: 'delegates', + from: 'driver', + to: 'worker', + directive: promptHandle('delegates/worker-brief/v1'), + }, + { + kind: 'analyzes', + analyst: 'convergence', + over: ['worker'], + to: 'driver', + directive: promptHandle('analyzes/findings-report/v1'), + }, + ], + }), + { + runId: 'gc7', + analysts, + makeWorkerAgent: leafSeam([], { withTrace: true }), + brain: scriptedBrain([spawnTurn('build it'), awaitTurn, awaitTurn, { content: 'done' }]), + }, + ) + expect(res.result.kind).toBe('winner') + const analyzed = res.ledger.filter((row) => row.kind === 'analyzes') + expect(analyzed).toHaveLength(1) + expect(analyzed[0]!.continuity).toBe('steer') + }) + + it('refuses a nonsense continuity value on a delegates edge at validation', () => { + const graph = twoNodeGraph({ + edges: [ + { + kind: 'delegates', + from: 'driver', + to: 'worker', + directive: promptHandle('delegates/worker-brief/v1'), + continuity: 'warm' as never, + }, + ], + }) + expect(() => + runGraph(graph, { makeWorkerAgent: leafSeam([]), brain: scriptedBrain([]) }), + ).toThrow(/invalid continuity "warm"/) + }) + + it('refuses continuity on an analyzes edge — analysts are spawned by the analyst machinery', () => { + const analysts = { + kinds: [{ id: 'convergence', description: 'x', area: 'progress' }], + run: async () => [], + } + const graph = twoNodeGraph({ + edges: [ + { + kind: 'delegates', + from: 'driver', + to: 'worker', + directive: promptHandle('delegates/worker-brief/v1'), + }, + { + kind: 'analyzes', + analyst: 'convergence', + over: ['worker'], + to: 'driver', + directive: promptHandle('analyzes/findings-report/v1'), + continuity: 'resume', + } as never, + ], + }) + expect(() => + runGraph(graph, { analysts, makeWorkerAgent: leafSeam([]), brain: scriptedBrain([]) }), + ).toThrow(/continuity is a delegates-edge axis only/) + }) +}) + describe('runGraph — validation fails loud before any compute', () => { const brain = scriptedBrain([]) const seam = leafSeam([]) From d2986340aa3babbe13fc0677a0ce77dffba4b017 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 3 Aug 2026 13:42:28 -0600 Subject: [PATCH 2/2] fix(supervise): workerFromBackend fails loud on unresumable 'resume'; pin resume-after-failed-prior policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A backend seam with no session re-attachment accepting a resume spawn would ledger continuity:'resume' over a brand-new session — a stamp asserting something that never happened. Refuse loud; session-resuming makeWorkerAgent seams are the resume consumers. Failed-prior resume is deliberately allowed (the seam decides salvageability) — now tested and stated. --- CHANGELOG.md | 2 +- src/runtime/supervise/supervise.ts | 11 +++++++ tests/kernel/graph.test.ts | 48 ++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 956d20bb..3ae67129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ Traversal caps count resumes exactly like fresh spawns. `validateGraph` refuses nonsense continuity values and analyzes edges carrying the field (analysts are spawned by the analyst machinery; every analyst run is a fresh session over settled evidence). Threading: `SuperviseOptions` / `SupervisorAgentDeps` / `DriverAgentOptions` / `serveCoordinationMcp` / `CoordinationToolsOptions` gain `continuityByProfile?: Readonly>` (the per-profile-name default `runGraph` derives from delegates edges), and the kernel entry exports `ContinuityMode`, `WorkerResumeContext`, and `TraversalContinuity`. -Known limit, stated where it lives: resume lineage is PROCESS-LOCAL (the same boundary as the analyst-run marker) — workers settled by a prior process of a durable run are not resume targets, and the built-in backend seam (`workerFromBackend`) forwards the lineage without re-attaching sessions itself. +Known limit, stated where it lives: resume lineage is PROCESS-LOCAL (the same boundary as the analyst-run marker) — workers settled by a prior process of a durable run are not resume targets. The built-in backend seam (`workerFromBackend`) cannot re-attach sessions and FAILS LOUD on a `'resume'` spawn rather than ledgering a resume that never happened; session-resuming `makeWorkerAgent` seams are the resume consumers. Resume after a FAILED prior worker is deliberately allowed — a crashed session may still be resumable, and the executor seam decides. New example: `examples/graphs/shot-loop-resumed.ts` — the VB shot shape as data (reviewer root, coder node, `continuity: 'resume'`, `maxTraversals: 3`): shot 1 spawns `fresh`, shots 2–3 resume the prior settled session, proven offline in `tests/examples/graph-topologies.test.ts`. diff --git a/src/runtime/supervise/supervise.ts b/src/runtime/supervise/supervise.ts index ebab0d6f..fc43d23f 100644 --- a/src/runtime/supervise/supervise.ts +++ b/src/runtime/supervise/supervise.ts @@ -139,6 +139,17 @@ export function workerFromBackend( } const profile = parsed.data assertBackendProfileMaterialization(profile, capturedBackend, 'workerFromBackend') + // Fail closed on a resume this seam cannot honor: workerFromBackend creates a NEW executor + // per spawn and has no session re-attachment, so accepting a 'resume' spawn would ledger + // `continuity: 'resume'` over a brand-new session — a stamp asserting something that never + // happened. Custom makeWorkerAgent seams that re-attach sessions are the resume consumers. + if (spawnContext?.continuity === 'resume') { + throw new ValidationError( + 'workerFromBackend: this backend seam does not re-attach sessions and cannot honor ' + + "continuity: 'resume' — provide a makeWorkerAgent that resumes (it receives " + + "spawnContext.resume.ofWorker), or use continuity: 'fresh'", + ) + } const name = profile.name ?? 'worker' // A Scope assignment is stable across reconstruction. Direct callers that omit that context // still get isolation, but only Scope-backed calls claim durable external-session recovery. diff --git a/tests/kernel/graph.test.ts b/tests/kernel/graph.test.ts index efcda6fd..5959214c 100644 --- a/tests/kernel/graph.test.ts +++ b/tests/kernel/graph.test.ts @@ -1334,6 +1334,54 @@ describe('runGraph — continuity (fresh | resume | steer as ledgered data)', () ]) }) + it('resume after a FAILED prior worker is allowed — the seam decides if the session is salvageable', async () => { + const seen: Array<{ continuity?: string; resumeOf?: string }> = [] + const res = await runGraph( + twoNodeGraph({ + edges: [ + { + kind: 'delegates', + from: 'driver', + to: 'worker', + directive: promptHandle('delegates/worker-brief/v1'), + continuity: 'resume', + }, + ], + }), + { + runId: 'grf', + makeWorkerAgent: (profile, ctx) => { + seen.push({ continuity: ctx?.continuity, resumeOf: ctx?.resume?.ofWorker }) + // First spawn fails; second must still receive the failed worker's lineage. + return leafSeam([], seen.length === 1 ? { fail: true } : {})(profile, ctx) + }, + brain: scriptedBrain([ + { + toolCalls: [ + { name: 'spawn_agent', arguments: { profile: { name: 'worker' }, task: 'build it' } }, + ], + }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { + toolCalls: [ + { + name: 'spawn_agent', + arguments: { profile: { name: 'worker' }, task: 'again', continuity: 'resume' }, + }, + ], + }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { content: 'done' }, + ]), + }, + ) + expect(res.result.kind).toBe('winner') + expect(seen[1]!.continuity).toBe('resume') + expect(seen[1]!.resumeOf).toBe('grf:s0') + const rows = res.ledger.filter((r) => r.kind === 'delegates' && r.continuity === 'resume') + expect(rows).toHaveLength(1) + }) + it('resume cannot ride a semantic key — keys are run-once, resume runs again', async () => { const seen: Array>> = [] const res = await runGraph(twoNodeGraph(), {