From 09ce3dda5deecc972c63a2fd99b7461aac683e13 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 14 Aug 2026 14:54:27 +0200 Subject: [PATCH] feat(agent): add AgentHarness R1 runtime shell --- packages/agent/CHANGELOG.md | 2 + packages/agent/docs/harness.md | 391 ++++-- .../src/harness/agent-harness-runtime.ts | 1105 +++++++++++++++++ packages/agent/src/harness/agent-harness.ts | 124 +- packages/agent/src/harness/events.ts | 146 +++ .../agent/src/harness/execution/assistant.ts | 11 +- .../agent/src/harness/execution/breakpoint.ts | 78 ++ .../src/harness/execution/effect-gate.ts | 54 + packages/agent/src/harness/hooks.ts | 447 +++++++ packages/agent/src/harness/restore.ts | 103 ++ packages/agent/src/harness/session/types.ts | 55 +- packages/agent/src/harness/types.ts | 9 + packages/agent/src/index.ts | 1 + .../harness/agent-harness-runtime.test.ts | 552 ++++++++ .../test/harness/execution-assistant.test.ts | 32 + .../test/harness/execution-primitives.test.ts | 444 +++++++ .../test/harness/execution-tools.test.ts | 32 +- packages/agent/test/harness/restore.test.ts | 181 +++ .../test/harness/session-create-lane.test.ts | 3 +- packages/agent/test/harness/tools.test.ts | 71 +- packages/agent/test/harness/types.test.ts | 15 + 21 files changed, 3689 insertions(+), 167 deletions(-) create mode 100644 packages/agent/src/harness/agent-harness-runtime.ts create mode 100644 packages/agent/src/harness/events.ts create mode 100644 packages/agent/src/harness/execution/breakpoint.ts create mode 100644 packages/agent/src/harness/hooks.ts create mode 100644 packages/agent/src/harness/restore.ts create mode 100644 packages/agent/test/harness/agent-harness-runtime.test.ts create mode 100644 packages/agent/test/harness/execution-primitives.test.ts create mode 100644 packages/agent/test/harness/restore.test.ts diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 7bd04be3a..6c1d4f686 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -5,9 +5,11 @@ ### Breaking Changes - Replaced the unfinished harness runtime and record-log session model with the shared entry/register/usage, operation-state, session, search, and public harness contracts specified for the durable harness. +- Added the required replay-stable invocation metadata argument to `AgentHarnessTool.execute()`. ### Added +- Added scheduler-facing harness execution primitives (`accept`, fenced `drive`, `requestAbort`, and `inspectExecution`) plus replay-stable tool invocation identities. - Added isolated harness execution primitives for assistant streaming and durable-boundary tool phases. ### Fixed diff --git a/packages/agent/docs/harness.md b/packages/agent/docs/harness.md index 80858b5ee..82ae45458 100644 --- a/packages/agent/docs/harness.md +++ b/packages/agent/docs/harness.md @@ -101,9 +101,9 @@ A session groups related work and has four parts: ### Harness and operations -The session layer manages durable data and exposes typed tree views. The harness drives lanes: it accepts prompts, runs model and tool steps, manages queues, compacts or navigates the tree, and resumes interrupted work. It also owns harness-wide registries of available tools and prompt resources, hooks that intercept and transform execution, passive events that report activity and durable changes, and runtime configuration. +The session layer manages durable data and exposes typed tree views. The harness drives lanes through four general primitives: `accept` durably creates an operation, `drive` advances an expected operation, `requestAbort` durably requests cancellation, and `inspectExecution` atomically reports current and latest-terminal execution. Convenience methods such as `prompt`, `resume`, and `abort` compose those same primitives with process-local waiting policy. A serving layer may instead schedule `drive` calls through alarms, jobs, or another host runtime. The harness also owns harness-wide registries of available tools and prompt resources, hooks that intercept and transform execution, passive events that report activity and durable changes, and runtime configuration. -An **operation** is one accepted unit of lane work: a run, compaction, or navigation. Its immutable metadata records its identity, intent, and starting point; its total current state records its phase, control, queues, and recovery data. Each durable transition replaces the current state. Completion removes the operation state and records the lane's result. +An **operation** is one accepted unit of lane work: a run, compaction, or navigation. Its immutable metadata records its identity, intent, and starting point; its total current state records its phase, control, queues, and recovery data. Each durable transition replaces the current state. Acceptance and execution ownership are separate: an accepted operation may have no process-local driver. Completion removes the operation state and records the lane's result. ### Storage @@ -211,6 +211,7 @@ Had the tool declared `replay: "safe"` (a read, a query), the harness would have - **Exactly-once external effects.** See above. Hooks with their own side effects must be idempotent, keyed by operation id. - **Provider stream resumption.** Partial streams are process-local, never persisted. A settled response is persisted *completely* before anything classifies it. - **Multiple writers.** One process per session. The serving layer routes accordingly, and the SQLite backend enforces it with a fenced lease (§1.7). Lanes cover the workload that looks like multi-writer. +- **Work scheduling.** The harness never creates platform alarms, scans repositories for abandoned sessions, leases hosted submissions, or promises an HTTP receipt. It reports durable waits and safe yields through `drive`; the serving layer decides when and where to call it again. - **Replication.** A session lives in one place. - **Durable write history.** Registers hold only current values: an overwritten register is gone, and no API or table exposes write history. Order-of-write assertions in tests use an instrumented storage decorator around `commit()` (Part 9); production auditing belongs to the telemetry layer (§5.8). - **Deletion as a runtime feature.** Entries and usage rows are never deleted: compaction changes provider context, not storage, and terminal cleanup deletes only registers. Note that `retainedTail` copies old messages forward into newer compaction entries and summaries derive from old content, so compaction is not erasure either. Compliance-grade "erase this" is the administrative precise rewrite (§2.9), the sole sanctioned exception. @@ -225,7 +226,7 @@ Had the tool declared `replay: "safe"` (a read, a query), the harness would have Source type provenance: - `AgentMessage`, `AgentTool`, `AgentToolResult`, `QueueMode`, and `ThinkingLevel`: `packages/agent/src/types.ts`. -- `Skill`, `PromptTemplate`, `AgentHarnessResources` (`Resources` below), `AgentHarnessTool`, `AgentHarnessStreamOptions`, and `AgentHarnessStreamOptionsPatch`: `packages/agent/src/harness/types.ts`. +- `Skill`, `PromptTemplate`, `AgentHarnessResources` (`Resources` below), `AgentHarnessTool`, `AgentHarnessToolInvocation`, `AgentHarnessStreamOptions`, and `AgentHarnessStreamOptionsPatch`: `packages/agent/src/harness/types.ts`. - `Model`, `Models`, `Usage`, `RetryPolicy`, `StopReason`, `AssistantMessage`, `ImageContent`, provider messages, stream options, and deferred handles: `packages/ai`. - `CompactionSettings`, `CompactionPreparation`, `CompactResult`, `BranchPreparation`, and `BranchSummaryResult`: `packages/agent/src/harness/compaction/`. Existing preparation and split-turn algorithms remain the implementation starting point unless this document explicitly changes them. - `TelemetryContext` and typed schema helpers: `packages/telemetry`; the agent-owned schemas remain in `packages/agent/src/harness/telemetry.ts`. @@ -296,7 +297,7 @@ interface UsageRow { ## 1.2 Identity -Every id — entry, usage, and every reserved id — is a **UUIDv7** from the session's id generator (§2.8); legacy imports re-mint to conform (Appendix B). The first 48 bits are the mint time, so every reference is self-describing and time-sortable. Cost accepted: ids leak creation time. (A future partitioned Postgres backend would build on this prefix — informative Part 6.) +Every id — operation, entry, usage, and every reserved id — is a **UUIDv7** from the session's id generator (§2.8); legacy imports re-mint to conform (Appendix B). `accept` may receive a caller-supplied operation id so a durable host submission and harness operation share one identity; the caller must mint it by the same UUIDv7 contract and never reuse it for another operation. Omission mints internally. The first 48 bits are the mint time, so every reference is self-describing and time-sortable. Cost accepted: ids leak creation time. (A future partitioned Postgres backend would build on this prefix — informative Part 6.) Minting rules: @@ -1033,7 +1034,7 @@ interface Operation { } ``` -Acceptance data lives in the `op.meta/{operationId}` register: written once at acceptance, never overwritten, and deleted by the terminal transaction (§3.13). `sourceLeafId` is the lane's leaf *before* the operation; entries the operation itself appends come after it. `promptEntryIds` name the caller's normalized prompt entries, born placed in the acceptance transaction (§3.6). +Acceptance data lives in the `op.meta/{operationId}` register: written once at acceptance, never overwritten, and deleted by the terminal transaction (§3.13). `sourceLeafId` is the lane's leaf *before* the operation; entries the operation itself appends come after it. `promptEntryIds` name the caller's normalized prompt entries, born placed in the acceptance transaction (§3.6). The id is either supplied to `accept` or minted before pre-acceptance work; it correlates a hosted submission with current operation and `lane.lastResult`, but the harness retains no forever-idempotency index after a later terminal result overwrites that register. ## 3.2 Operation state — the durable restart point @@ -1172,20 +1173,20 @@ type Deferred = configuration: LaneConfiguration; streamOptions: AgentHarnessStreamOptions }; ``` -One `resume()` performs at most one `fetchDeferred(handle, { wait: 0 })`. Suspended `poll` is the number of completed polls; a fresh intent uses `poll + 1`, and that 1-based value is `before_request.attempt` and the poll turn-id suffix. A poll starts from the original generation's copied base stream options, forces `deferred:false`, runs `before_request`, mounts `before_payload`/`after_response`, then commits its fresh intent and dispatches like assistant generation. Current global stream settings do not affect it. There is no polling retry cap, backoff, or internal loop. A `deferred` response has a valid handle only when `message.deferred` exists, its id is non-empty, and its `{ provider, modelId, api }` equals the captured request model identity/API. An invalid initial handle is normalized to a durable `error` response and response-provenance `failure_drain`. A later pending response must carry a handle completely equal to its source handle and becomes the next source; a mismatch is normalized the same way. Response, usage, `latestAssistantEntryId`, and failure state commit atomically. +One `drive({ pollDeferred: true })` performs at most one `fetchDeferred(handle, { wait: 0 })`; `resume()` is the convenience call that supplies that permit. Suspended `poll` is the number of completed polls; a fresh intent uses `poll + 1`, and that 1-based value is `before_request.attempt` and the poll turn-id suffix. A poll starts from the original generation's copied base stream options, forces `deferred:false`, runs `before_request`, mounts `before_payload`/`after_response`, then commits its fresh intent and dispatches like assistant generation. Current global stream settings do not affect it. There is no polling retry cap, backoff, or internal loop. A `deferred` response has a valid handle only when `message.deferred` exists, its id is non-empty, and its `{ provider, modelId, api }` equals the captured request model identity/API. An invalid initial handle is normalized to a durable `error` response and response-provenance `failure_drain`. A later pending response must carry a handle completely equal to its source handle and becomes the next source; a mismatch is normalized the same way. Response, usage, `latestAssistantEntryId`, and failure state commit atomically. The complete transition table — every row is one `commit()`; classification order (§3.7) applies to every poll settlement, cancellation first: | From | Trigger | Transaction | To | |---|---|---|---| | assistant `effect_pending` | settlement classifies `deferred` with a valid handle | §3.7's deferred row | suspended, `poll: 0`, `sourceEntryId: R` | -| suspended, poll *k* | `resume()`: the poll's `before_request` settlement commits its intent, consuming the invocation's single poll permit | mint fresh R′ and U′, then `TX[ S(deferred{effect_pending, poll k+1, responseEntryId R′, usageId U′}) ]` | effect_pending, poll *k*+1 | +| suspended, poll *k* | a drive with `pollDeferred: true`: the poll's `before_request` settlement commits its intent, consuming the pass's single poll permit | mint fresh R′ and U′, then `TX[ S(deferred{effect_pending, poll k+1, responseEntryId R′, usageId U′}) ]` | effect_pending, poll *k*+1 | | effect_pending, poll *k*+1 | fetch returns **pending** with a completely equal handle | `TX[ insert response entry R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, deferred{suspended, sourceEntryId R′, poll k+1}) ]` — the pending response becomes the next source and the operation re-suspends; no second poll this invocation | suspended, poll *k*+1 | | effect_pending | fetch returns **pending** with a mismatched handle | normalize to a durable `error` response explaining the mismatch: `TX[ insert normalized response R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, failure_drain{error, provenance:response R′}) ]` | failure_drain | | effect_pending | fetch returns **ready** with tool calls | `TX[ insert response R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, tools{plan with reserved result ids}) ]` — result ids minted as followers of R′ (§1.2) | tools | | effect_pending | fetch returns **ready** without tool calls | `TX[ insert response R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, checkpoint{may_finish, includeFinalAssistant:true}) ]` | checkpoint | | effect_pending | fetch settles as a provider `error` | `TX[ insert response R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, failure_drain{error, provenance:response R′}) ]` — polls have no retry path | failure_drain | -| effect_pending, restored, running control | crash left the poll's outcome unknown; the next `resume()` replaces it | mint fresh R″/U″ and commit a fresh intent at the **same** poll number — an unknown-outcome poll never completed, so `poll` does not increment; the old reserved id strings are abandoned, never materialized | effect_pending, poll *k*+1 | +| effect_pending, restored, running control | crash left the poll's outcome unknown; the next drive with a poll permit replaces it | mint fresh R″/U″ and commit a fresh intent at the **same** poll number — an unknown-outcome poll never completed, so `poll` does not increment; the old reserved id strings are abandoned, never materialized | effect_pending, poll *k*+1 | | effect_pending, cancelled control | reconciliation, live or restored (§4.5, §4.6) | synthetic settlement under the **existing** reserved ids: `TX[ insert synthetic aborted response R′, upsert lane.leaf = R′, insert zero usage U′, S(latestAssistantEntryId=R′, cancelled checkpoint{may_finish}) ]` | cancelled checkpoint → aborted finish | | suspended, cancelled control | reconciliation | no fetch starts; best-effort `cancel_deferred` targets the newest source (§4.6), and the operation finishes through the aborted terminal transaction | terminal | @@ -1249,8 +1250,9 @@ Restore checks only semantic consistency of the current lane and operation regis - `lane.state/{lane}` holds a `LaneState`; when it names operation O, `op.meta/O` holds an `Operation` for that lane, and `op.state/O` holds an `OperationState` compatible with O's intent kind; - every entry id the current state or `op.meta` names — trigger, latest assistant, batch assistant, deferred source, completed results, prompt entries, a non-null `sourceLeafId`, a navigation intent's non-null `targetId`, the lane leaf — resolves to an existing entry of the expected type; -- every id in `inbox.*`, `control.drained*`, and `pendingNextRun` has its `pending.entry` register; every effect-pending call has its `op.tool_args` register; every structural decision has its `op.preparation` register; +- every id in `inbox.*`, `control.drained*`, and `pendingNextRun` has its `pending.entry` register; `pendingNextRun`, steer, follow-up, and drained queue items are messages; every effect-pending call has its `op.tool_args` register; every structural decision has its `op.preparation` register; - tool source indices are complete, ordered, unique, in range, and use unique result ids; completed result entries match their source calls; +- a cancelled run has empty steer/follow-up inboxes, structural operations have no drained run queues, and a current operation may reference an `aborted` assistant response only under cancelled control; - cancellation, navigation source/target, and structural-source combinations satisfy the state discriminants. `lane.lastResult` is never a recovery input (§3.13). The bounded checks above establish relationships that types alone cannot express, such as referenced-row existence, owner identity, intent/state compatibility, and reserved-id consistency. They do not revalidate object shapes already guaranteed by internal types. @@ -1266,7 +1268,7 @@ A transition rereads the latest operation and lane registers inside the lane mut ```mermaid stateDiagram-v2 [*] --> idle - idle --> checkpoint : prompt() accepted + idle --> checkpoint : accept(run) committed checkpoint --> assistant : continuation = need_assistant checkpoint --> compaction : context threshold @@ -1318,20 +1320,22 @@ A declined summarized navigation moves nothing: the leaf stays at the source, an ## 3.6 Acceptance -| From | Trigger | Transaction | +`accept(request)` is the only operation-acceptance primitive. It performs operation-kind-specific normalization, validation, preparation, and pre-acceptance hooks, then commits exactly one acceptance transaction and returns `OperationAdmission`. It starts no operation task, hook, provider, tool, or timer after that commit. An accepted operation is therefore allowed to be durably open and process-locally unowned until any caller invokes `drive({ operationId })`. + +| From | Request | Transaction | |---|---|---| -| idle lane | `prompt()` after `before_run` | `TX[ insert entries for captured nextRun items (payloads from their pending.entry registers) and the new messages (caller prompt, hook injections) in order, delete the captured pending.entry registers, upsert lane.leaf = newest entry, upsert op.meta/O, S(run{captured settings, checkpoint need_assistant(false), trigger = newest entry, skipInboxOnce, empty inbox}), L({currentOperationId: O, captured ids removed from pendingNextRun}) ]` | -| reserved idle lane | `compact()` with non-empty preparation | `TX[ upsert op.preparation/O:{taskId} = P, upsert op.meta/O, S(compaction{deciding, taskId}), L({currentOperationId: O}) ]` | -| idle lane | unsummarized `navigateTree()` after validation | `TX[ upsert op.meta/O, S(navigation{ready_to_commit}), L ]` | -| reserved idle lane | summarized `navigateTree()` with preparation | `TX[ upsert op.preparation/O:{taskId} = P, upsert op.meta/O, S(navigation{summary.deciding, taskId}), L ]` | +| idle lane | prompt, skill, or prompt-template request after expansion and `before_run` | `TX[ insert entries for captured nextRun items (payloads from their pending.entry registers) and the new messages (caller prompt, hook injections) in order, delete the captured pending.entry registers, upsert lane.leaf = newest entry, upsert op.meta/O, S(run{captured settings, checkpoint need_assistant(false), trigger = newest entry, skipInboxOnce, empty inbox}), L({currentOperationId: O, captured ids removed from pendingNextRun}) ]` | +| reserved idle lane | compaction request with non-empty preparation | `TX[ upsert op.preparation/O:{taskId} = P, upsert op.meta/O, S(compaction{deciding, taskId}), L({currentOperationId: O}) ]` | +| idle lane | unsummarized navigation request after validation | `TX[ upsert op.meta/O, S(navigation{ready_to_commit}), L ]` | +| reserved idle lane | summarized navigation request with preparation | `TX[ upsert op.preparation/O:{taskId} = P, upsert op.meta/O, S(navigation{summary.deciding, taskId}), L ]` | Captured `nextRun` items already have their payloads in `pending.entry` registers; acceptance inserts their entries from those payloads, deletes the registers, and removes the ids from `pendingNextRun` — the placement half of the one deliberate double write (§1.8). A late-captured item keeps its enqueue-minted id (§1.2). -Manual compaction first allocates its operation id and takes a process-local lane admission reservation, then reads preparation. Summarized navigation uses the same reservation while collecting/building branch preparation; unsummarized navigation needs none because validation and acceptance share one lane-line job. While reserved, competing operations receive `LaneBusy` naming that provisional id/kind and idle tree writes wait; `nextRun` and configuration changes may still commit because they do not move the leaf. Empty compaction preparation releases the reservation and returns `NothingToCompact` with no operation write. Non-empty preparation is accepted only against the unchanged reserved source leaf. Process death drops the reservation and leaves the lane idle. +Compaction first chooses its supplied-or-minted operation id and takes a process-local lane admission reservation, then reads preparation. Summarized navigation uses the same reservation while collecting/building branch preparation; unsummarized navigation needs none because validation and acceptance share one lane-line job. While reserved, competing operation requests receive `LaneBusy` naming that provisional id/kind and idle tree writes wait; `nextRun` and configuration changes may still commit because they do not move the leaf. Empty compaction preparation releases the reservation and returns `NothingToCompact` with no operation write. Non-empty preparation is accepted only against the unchanged reserved source leaf. Process death drops the reservation and leaves the lane idle. -Pre-acceptance rejections write **nothing**: `LaneBusy`, `NothingToCompact`, `InvalidNavigation` (target is the current leaf, label on the root target, summarize from root, or a null target with summarize), `UnknownTarget` (non-null target missing), `MissingIdentities` (model, provider, or an active tool name does not resolve), and `InvalidMessage` when acceptance would append zero entries — an empty normalized prompt with no hook injections and no captured `nextRun` items leaves no newest entry to anchor the checkpoint's trigger. Prompt allocates its operation id and takes a process-local lane admission reservation before `before_run`, so hook idempotency keys are stable and the lane has only one pre-acceptance breakpoint. A competing operation receives `LaneBusy` naming that provisional id/kind. Idle tree writes wait; `nextRun` and configuration changes may still commit because they do not move the leaf. After the hook, acceptance still validates current durable state and releases the reservation on rejection. Process death drops the reservation and leaves the lane idle. +Pre-acceptance rejections write **nothing**: `LaneBusy`, `NothingToCompact`, `InvalidNavigation` (target is the current leaf, label on the root target, summarize from root, or a null target with summarize), `UnknownTarget` (non-null target missing), `MissingIdentities` (model, provider, or an active tool name does not resolve), and `InvalidMessage` when acceptance would append zero entries — an empty normalized prompt with no hook injections and no captured `nextRun` items leaves no newest entry to anchor the checkpoint's trigger. A run request chooses its operation id and takes a process-local lane admission reservation before `before_run`, so hook idempotency keys are stable and the lane has only one pre-acceptance breakpoint. A competing request receives `LaneBusy` naming that provisional id/kind. Idle tree writes wait; `nextRun` and configuration changes may still commit because they do not move the leaf. After the hook, acceptance still validates current durable state and releases the reservation on rejection. Process death drops the reservation and leaves the lane idle. -**Acceptance must observe `currentOperationId === null` and no local `ActiveOperation`/admission reservation.** Because acceptance is on the lane mutation line, this is validation, not compare-and-swap. A task remains locally active through its final observation/cleanup even after its terminal transaction clears durable ownership (§4.1). +**Acceptance must observe `currentOperationId === null` and no local `ActiveOperation`/admission reservation.** Because acceptance is on the lane mutation line, this is validation, not compare-and-swap. Acceptance never installs `ActiveOperation`. Convenience calls and hosted schedulers use the same accepted state: `prompt`/`skill`/`compact`/`navigateTree` call `accept`, then `drive`; a serving layer may return the admission, schedule a durable wake, and call `drive` later. A crash after acceptance but before any drive sees the initial ordinary state and starts it without unknown-effect recovery. ## 3.7 Assistant generation @@ -1403,7 +1407,7 @@ Modes: Blocked and invalid calls skip the intent commit and the effect, but still commit a result at their source position. Their `op.tool_args` register is never written. -Calls are tracked internally by `sourceIndex`. Hooks, events, and tool context see the provider `toolCallId` and tool name — never the index. +Calls are tracked internally by `sourceIndex`. Hooks and events see the provider `toolCallId` and tool name — never the index. Tool execution additionally receives `AgentHarnessToolInvocation`: its opaque `invocationId` is the call's reserved `resultEntryId`, so it is session-unique and unchanged during safe replay; `operationId` and `turnId` provide correlation. A hosting/tool layer may key durable substeps by `invocationId` without making substeps a harness concept. ## 3.9 Summary generation — compaction and navigation summaries @@ -1491,7 +1495,9 @@ TX[ insert hook-reported usage row (only for a hook-supplied summary), upsert lane.leaf = summary entry (when summarize), upsert fact.label (when a label is present), delete the operation's op.* registers, - upsert lane.lastResult = { kind: "navigation", outcome: "completed", leafId }, + upsert lane.lastResult = { kind: "navigation", outcome: "completed", + oldLeafId: sourceLeafId, leafId, + summaryEntryId?: generatedSummaryId }, L({ currentOperationId: null }) ] ``` @@ -1545,21 +1551,36 @@ There is no finished state. An operation ends by ceasing to exist: one **termina The result is computed in memory, pre-commit, from the final operation state — the same value the caller's promise resolves with. What lands durably is its register form: ```ts -type LaneLastResult = { - operationId: string; - kind: "run" | "compaction" | "navigation"; - leafId: string | null; - /** Newest settled assistant, when the outcome includes one (runs only). */ - finalAssistantEntryId?: string; -} & ( - | { outcome: "failed"; error: OperationError; runCompletion?: never } - | { outcome: "completed"; error?: never; - runCompletion?: "assistant" | "terminated_tools" } - | { outcome: "declined" | "aborted"; error?: never; runCompletion?: never } -); +type FailedLaneLastResult = + { outcome: "failed"; error: OperationError; runCompletion?: never }; +type AbortedLaneLastResult = + { outcome: "aborted"; error?: never; runCompletion?: never }; +type StructuralLaneLastResultOutcome = + | FailedLaneLastResult | AbortedLaneLastResult + | { outcome: "declined" | "completed"; error?: never; runCompletion?: never }; + +type LaneLastResult = + | ({ operationId: string; kind: "run"; leafId: string; + /** Newest settled assistant, when the outcome includes one. */ + finalAssistantEntryId?: string } & ( + | FailedLaneLastResult | AbortedLaneLastResult + | { outcome: "completed"; error?: never; + runCompletion: "assistant" | "terminated_tools" })) + | ({ operationId: string; kind: "compaction"; leafId: string; + finalAssistantEntryId?: never } & StructuralLaneLastResultOutcome) + | ({ operationId: string; kind: "navigation"; leafId: string | null; + /** The operation's pre-navigation source leaf. */ + oldLeafId: string | null; + finalAssistantEntryId?: never } & ( + | FailedLaneLastResult | AbortedLaneLastResult + | { outcome: "declined"; error?: never; runCompletion?: never; + summaryEntryId?: never } + | { outcome: "completed"; error?: never; runCompletion?: never; + /** Present when completed navigation published a summary. */ + summaryEntryId?: string })); ``` -A normal run finish copies `RunState.latestAssistantEntryId` and records `runCompletion: "assistant"` when `may_finish.includeFinalAssistant` is true. An all-terminating tool batch records `runCompletion: "terminated_tools"` and omits the final assistant. Failed and aborted run outcomes include the newest settled assistant when non-null and omit the field otherwise. Structural operations omit `runCompletion` and the final assistant. Only terminal transitions construct a `LaneLastResult`. +A normal run finish copies `RunState.latestAssistantEntryId` and records `runCompletion: "assistant"` when `may_finish.includeFinalAssistant` is true. An all-terminating tool batch records `runCompletion: "terminated_tools"` and omits the final assistant. Failed and aborted run outcomes include the newest settled assistant when non-null and omit the field otherwise. Structural operations omit `runCompletion` and the final assistant. Navigation records its source leaf and, when it publishes one, the summary entry id, so a later matching `drive` can hydrate the complete public result after `op.meta` has been deleted. Only terminal transitions construct a `LaneLastResult`. Every terminal transaction, for every operation kind and outcome, has one shape: @@ -1606,14 +1627,12 @@ The invariant this section carries (restated in Part 9): `op.*` registers and op An open operation has durable state whether or not this process is executing it. A **live operation task** is only the process-local async continuation currently executing that operation. It is recorded because durable state cannot answer three process-local questions: whether this process already owns execution, whether an `effect_pending` state still has a live continuation, and what `abort()` should signal. -There is at most one process-local record per lane: +There is at most one process-local record per lane. It owns one `drive` pass, not the durable operation's whole lifetime: a pass may settle, reach a durable wait, or yield safely for its caller's budget. ```ts -type OperationResult = RunOutcome | CompactionOutcome | NavigationOutcome; - interface ActiveOperation { operationId: string; - completion: Promise; + completion: Promise; effectGate: EffectGate; } @@ -1649,35 +1668,41 @@ That asymmetry is unavoidable around an external effect. Making the task explici The task runs direct async procedures. There is no generic planner, action interpreter, or effect-plan graph: ```ts -async function executeOperation(operationId: string, - activation: "fresh" | "resume"): Promise { +async function executeDrivePass(operationId: string, + activation: "fresh" | "continue" | "resume", + options: DriveOptions): Promise { try { if (activation === "resume") await recoverAtActivation(operationId); while (true) { const current = await loadCurrent(operationId); - if (!current) return resultFromLastResult(operationId); + if (!current) return settledFromLastResult(operationId); if (current.state.control.status === "cancel_requested") return reconcileCancellation(current); + if (mustWaitForRetry(current, options)) return retryWait(current); + if (isDeferredSuspended(current) && !options.pollDeferred) + return deferredWait(current); + if (atSafeBoundary(current) && deadlineReached(options)) + return { kind: "yielded", operationId }; - const terminal = await continueOrdinaryOperation(current); - if (terminal) return terminal; + const result = await continueOrdinaryOperation(current, options); + if (result) return result; } } catch (error) { if (error instanceof AbortRequested) { await error.cancellation; const current = await loadCurrent(operationId); - if (!current) return resultFromLastResult(operationId); + if (!current) return settledFromLastResult(operationId); return reconcileCancellation(current); } if (error instanceof OperationEnded) - return resultFromLastResult(operationId); + return settledFromLastResult(operationId); throw error; } } ``` -The pseudo-code shows ownership and catch boundaries, not required helper names. `AbortRequested` is the one internal control-flow signal used when abort interrupts a parked breakpoint or wins the effect gate. A scope holding live local results catches it before the outer boundary when those values affect reconciliation. In particular, the parallel-tool procedure retains its promise map and settles started calls in source order; an assistant procedure parked after receiving a response retains that response long enough to settle it under cancelled control. +The pseudo-code shows ownership and catch boundaries, not required helper names. A deadline is cooperative: it prevents the next transition or external effect only at an effect-free safe boundary; an admitted provider, tool, or hook may finish after it. `waitForRetry: false` returns the durable `notBefore`; `true` starts the existing abort-aware retry timer only when the deadline does not require an earlier return. `pollDeferred: true` grants at most the existing one-poll permit. `AbortRequested` is the one internal control-flow signal used when cancellation interrupts a parked breakpoint or wins the effect gate. A scope holding live local results catches it before the outer boundary when those values affect reconciliation. In particular, the parallel-tool procedure retains its promise map and settles started calls in source order; an assistant procedure parked after receiving a response retains that response long enough to settle it under cancelled control. Ordinary procedures are straight-line across their own durable restart points. Assistant generation is representative: @@ -1692,13 +1717,22 @@ The procedure does not return to a dispatcher after its intent commit. Structura Normal execution handles checkpoints, ready/retry states, planned or locally owned tools, structural decisions, deferred suspension, failure drain, and navigation commit. An unowned `effect_pending` state must not reach it. Such a state is handled only by activation recovery (§4.5); reaching it from ordinary dispatch is an invariant defect. -### Task installation and removal +### Task installation, joining, and removal -Before entering the acceptance mutation, the lane creates a deferred completion promise and a fresh gate but starts no task. The mutation commits the first durable state and installs the `ActiveOperation` record with that promise before releasing the lane line. Only after `Session.mutate` returns does ordinary async execution start and wire its result to the deferred promise. No hook/provider/tool/timer runs inside `Session.mutate`, and no second caller can observe a durably accepted but locally unowned operation. +`accept` commits the first durable state and returns without installing `ActiveOperation`. A durably accepted but locally unowned operation is ordinary. `drive({ operationId })` owns activation. On the lane mutation line it reads current ownership and `lane.lastResult`, then applies exactly one rule: -`resume()` similarly creates and installs a deferred task record on the lane line before starting its activation procedure after the mutation returns. A second local activation sees the record and returns `LaneBusy`; it cannot create another continuation for the same operation. `abort()` installs a task before releasing the lane line when cancellation is durable but no task exists, so automatic cancellation reconciliation cannot be stranded. +- current operation absent and `lane.lastResult.operationId === operationId`: return its already-settled outcome; +- current operation absent with another/no last id, or current operation has another id: return `OperationMismatch` without starting work; +- the same operation already has `ActiveOperation`: join that pass's `completion`; +- the same operation has no task: install one deferred completion promise and fresh gate before releasing the lane line, then start the pass after `Session.mutate` returns. -Process-local record changes use the lane line too. Every task has one outer `finally` path: after its last durable commit or suspension decision and after publishing its final local events, it performs a no-write lane job that verifies it still owns the slot and removes its `ActiveOperation`; only then does `completion` resolve/reject. Missing-identity suspension records its process-local descriptor in that same job. Close/fault removes records under the harness admission barrier. This keeps a lane locally busy until the old continuation and observations are actually done, even when its terminal transaction already made the durable lane idle. A suspended open operation then has no `ActiveOperation`, and its next `resume()` is an activation. External finalization uses the same eventual `finally` removal after signalling the task (§4.9). +The first caller to install a pass supplies that pass's deadline/wait policy. Same-operation joiners receive the same `DriveOutcome`; a convenience caller whose policy requires more progress may call `drive` again after that pass returns. This arbitration lets `prompt` literally compose `accept` and `drive` even if an alarm reaches the accepted operation first, while preserving one process-local driver. Every drive is fenced by its expected operation id, so a stale wake for A never drives later operation B. + +The harness remembers process-locally whether an operation was freshly accepted or already attached in this process. Its first same-process drive uses fresh execution and skips `before_resume`; an operation restored by `create()` uses resume activation and recovery. A safe yield or hosted retry wait may continue without pretending a crash occurred while this harness remains alive. Loss of that marker through process death naturally turns the next drive into resume activation. Explicit deferred polling through `resume()`/`pollDeferred` retains the `before_resume` contract. + +Every task has one outer `finally` path: after its last durable commit, wait/yield decision, or terminal observation and after publishing its final local events, it performs a no-write lane job that verifies it still owns the slot and removes its `ActiveOperation`; only then does `completion` resolve/reject. Missing-identity suspension records its process-local descriptor in that same job. Close/fault removes records under the harness admission barrier. This keeps a lane locally busy until the old continuation and observations are actually done, even when its terminal transaction already made the durable lane idle. External finalization uses the same eventual `finally` removal after signalling the task (§4.9). + +Convenience methods add policy, not another execution path. `prompt`/`skill`/`compact`/`navigateTree` call `accept` and then drive the returned id; they wait locally through retry waits and return on terminal or semantic suspension. `resume()` inspects the current id and drives it with one deferred-poll permit. `abort()` calls `requestAbort`, then ensures cancellation reconciliation has a drive pass; it still returns once cancellation is durable. A scheduler calls the primitives directly and may persist the admission, set an alarm from `waiting.notBefore`, or resume immediately after `yielded`. `AgentHarness.create()` restores and reports open operations but installs no tasks and starts no hooks, providers, tools, or timers. @@ -1750,7 +1784,7 @@ breakpoint → operation code ``` -Public lane calls—acceptance, `steer`, `followUp`, `nextRun`, `cancelQueued`, `abort`, configuration and fact setters, and tree writes—do not wait at this barrier. They remain able to race a parked operation through `Session.mutate`. In manual mode a registered `before_run` hook parks before acceptance; without one, acceptance commits immediately and the first operation breakpoint follows it. +Public lane calls—acceptance, `steer`, `followUp`, `nextRun`, `cancelQueued`, `requestAbort`/`abort`, configuration and fact setters, and tree writes—do not wait at this barrier. They remain able to race a parked drive pass through `Session.mutate`. In manual mode a registered `before_run` hook parks before acceptance; without one, acceptance commits immediately and the first operation breakpoint follows when `drive` claims it. Abort rejects an ordinary parked breakpoint with `AbortRequested` instead of requiring another `executeAction()`. The signal carries the abort-mutation promise; the task waits for it, reloads durable ownership/control, and either enters cancellation reconciliation or observes that the operation already ended, without executing the ordinary statement after the interrupted barrier. Reconciliation's own commits and cleanup effects still park at their documented breakpoints. Close/fault rejects a parked barrier without executing the following code. An operation performs zero procedure writes and starts zero hooks/providers/tools/timers while parked. @@ -1779,7 +1813,7 @@ interface EffectGate { } ``` -The gate privately owns the corresponding `AbortController`. `abort()` creates a deferred abort-mutation promise, calls `beginAbort(promise)` synchronously, and performs the lane mutation. If the marker commits, it resolves the promise and calls `signalAbort()`. If a terminal transaction already won, it resolves the promise, closes the gate with `OperationEnded`, and returns `NoActiveOperation`. A commit fault rejects the promise and closes the gate with `HarnessFault`. `assertOpen()` is a synchronous check: +The gate privately owns the corresponding `AbortController`. `requestAbort(operationId)` is the cancellation primitive. When the expected operation has a live pass, it creates a deferred abort-mutation promise and calls `beginAbort(promise)` synchronously before performing the lane mutation. If the marker commits, it resolves the promise and calls `signalAbort()`. If the expected operation no longer owns the lane, it resolves the promise, closes the gate with `OperationEnded`, and returns `OperationMismatch`. With no live pass it commits or observes the same durable marker but installs no task; a later `drive` performs reconciliation. A commit fault rejects the promise and closes the gate with `HarnessFault`. `assertOpen()` is a synchronous check: ```ts assertOpen(): void { @@ -1791,7 +1825,7 @@ assertOpen(): void { `AbortRequested` is internal expected control flow shared with the breakpoint barrier. It carries the in-progress abort-mutation promise. That promise resolves when the lane job either commits cancellation or observes that a terminal transaction already won; it rejects on fault/close. The nearest procedure with relevant local work catches it; otherwise the operation-task boundary catches it, waits for that lane result, reloads, and either reconciles durable cancellation or resolves the already-terminal result. `HarnessClosed` and `HarnessFault` follow their ordinary rejection paths and perform no cancellation write. -For an accepted operation, the gate applies to starting ordinary hooks, provider requests/fetches, real tools, and retry timers. It does not wrap commits: dependent commits use `Session.mutate`. Pre-acceptance `before_run` is the one hook without an operation gate because no operation exists for `abort()` to cancel; it uses the harness close/fault admission check immediately around pipeline invocation instead. If acceptance later loses the lane race, its result is discarded as specified in §3.6. Best-effort `cancelDeferred` is cancellation cleanup, not ordinary work; it runs only after durable cancellation and uses a close-only signal. +For a driven operation, the gate applies to starting ordinary hooks, provider requests/fetches, real tools, and convenience-owned retry timers. It does not wrap commits: dependent commits use `Session.mutate`. Pre-acceptance `before_run` is the one hook without an operation gate because no operation exists for `requestAbort` to cancel; it uses the harness close/fault admission check immediately around pipeline invocation instead. If acceptance later loses the lane race, its result is discarded as specified in §3.6. Best-effort `cancelDeferred` is cancellation cleanup, not ordinary work; it runs only after durable cancellation and uses a close-only signal. **The admitted-operation boundary is load-bearing.** Preparation must finish first. Then `assertOpen()` and the operation invocation must be adjacent synchronous statements: @@ -1850,7 +1884,7 @@ No other code calls `assertOpen()`. In particular, it is not used for: Each listed integration has abort-first/admission-first tests. The assistant block receives `effectGate.signal` for signal-aware preparation even though preparation itself does not call `assertOpen()`. -Abort starts by synchronously changing the live gate to `aborting` and installing the abort-mutation promise. It then attempts to commit `cancel_requested`; only after that commit succeeds does it pull the controller. Thus the only orders are: +`requestAbort` starts by synchronously changing a matching live gate to `aborting` and installing the abort-mutation promise. It then attempts to commit `cancel_requested`; only after that commit succeeds does it pull the controller. Thus the only orders are: ```text operation admission first @@ -1882,7 +1916,7 @@ An operation transition rereads the latest `lane.state`, `op.state`, leaf, and a Settlement therefore cannot overwrite steer/write acceptance or cancellation with a stale snapshot. It rereads the latest state, verifies that the same request or tool call remains pending, and merges the output while preserving newer inbox/control fields. -What serializes on the lane line: operation acceptance and task installation, resume ownership, queue enqueue and cancel, queue consumption, deferred-write acceptance and application, abort, lane-configuration setters, finish, and lane creation. All durable races consequently have the two orders listed in Part 9. The effect gate is needed only because the synchronous start of an external call is not a durable mutation. +What serializes on the lane line: operation acceptance, drive installation/join/removal, execution inspection, queue enqueue and cancel, queue consumption, deferred-write acceptance and application, cancellation requests, lane-configuration setters, finish, and lane creation. All durable races consequently have the two orders listed in Part 9. The effect gate is needed only because the synchronous start of an external call is not a durable mutation. Harness-global settings use a second process-local mutation line with a monotonically increasing revision. Run acceptance snapshots compaction, queue modes, and tool execution into `RunState.settings`. A generation or structural-generation start snapshots only current stream options and retry policy, then reads lane configuration on its `Session.mutate` job and commits those values with the ready state. Global setters take only the settings line; lane setters take only the lane line. Snapshot procedures take the settings line before the lane line, and no code acquires them in reverse order. This gives setter-first or step-start-first ordering without holding either line across external work. @@ -1940,7 +1974,7 @@ Five register point-lookups: three lane registers, then—only when an operation Then run §3.3's bounded semantic checks over exactly that set. Every named committed object exists and has the expected identity and relationship; tool indices are complete and unique. Unmaterialized settlement-family ids are reservations in state, not objects restore probes for. Storage uniqueness and the atomic settlement transitions prevent a valid pending state from coexisting with materialized content under those ids. Configuration, stream options, and retry policy need no recovery lookup when they are captured inline. -Restore never reads register history, folds anything, scans tables, builds provider context, audits completed operations, or infers a transition from an absent planned value. It installs no `ActiveOperation` and invokes no breakpoint. The application explicitly activates an open operation with `resume()` or durably cancels it with `abort()`. +Restore never reads register history, folds anything, scans tables, builds provider context, audits completed operations, or infers a transition from an absent planned value. It installs no `ActiveOperation` and invokes no breakpoint. The application or serving layer explicitly activates an open operation with `drive({ operationId })`; `resume()` is the convenience composition that inspects and drives the current operation with one deferred-poll permit. Cancellation is durably admitted through `requestAbort(operationId)` or its `abort()` convenience wrapper. ### Worked example — crash in the uncertain window @@ -1956,7 +1990,7 @@ op.state/op_9 -> { phase: assistant effect_pending, attempt: 1, ``` -`create()` reports the suspended operation and does nothing else. On `resume()`, activation recovery sees that no prior task or provider promise survived and applies §4.5 from the captured restart point. +`create()` reports the suspended operation and does nothing else. On the next matching `drive()`—whether called directly by a scheduler or through `resume()`—activation recovery sees that no prior task or provider promise survived and applies §4.5 from the captured restart point. ### Per backend @@ -1973,13 +2007,13 @@ Admission resolves configured identities and returns `Err(MissingIdentities)` be - Never suspend between structural requests of one attempt: earlier summary text is process-local. A missing identity there fails that attempt in-band so it can retry/fail under the captured policy. - Orphaned `effect_pending` first performs unknown-outcome recovery even when registrations are missing. Synthetic assistant settlement, unsafe-tool interruption, queue application, and finish require no identity. A safe-tool replay or a later `ready` request preflights only the identity that it would actually invoke. -A phase-aware `resume()` precheck returns `Err(MissingIdentities)` only when its next work would need a missing registration; it does not block identity-free recovery. Registering missing pieces never auto-starts a task. +A phase-aware `drive()` precheck returns a `waiting/missing_identities` result only when its next work would need a missing registration; it does not block identity-free recovery. The `resume()` convenience maps that wait to `Err(MissingIdentities)` as before. Registering missing pieces never auto-starts a task. ## 4.5 Activation and crash recovery -Recovery begins only when an open operation has no `ActiveOperation` and an explicit call activates it. `AgentHarness.create()` never activates. Fresh acceptance starts at an ordinary state and skips orphan recovery. `resume()` installs a task with one deferred-poll permit, then begins its activation prelude. `abort()` with no task installs a task directly in cancellation reconciliation after making cancellation durable. +Recovery begins only when an open operation has no `ActiveOperation` and a matching `drive({ operationId })` activates it. `AgentHarness.create()` never activates. The first same-process drive after `accept` is fresh ordinary execution and skips orphan recovery. A drive of an operation restored by `create()` begins its activation prelude. The `resume()` convenience requests the same drive with one deferred-poll permit. `requestAbort()` with no task commits cancellation but installs nothing; the next drive enters cancellation reconciliation directly. -The prelude first reloads durable control. If cancellation is requested, it does **not** run `before_resume`; it performs no ordinary recovery and enters §4.6. Under running control it reaches the `before_resume` breakpoint, calls `effectGate.assertOpen()`, and immediately invokes that hook aggregate. Abort racing this boundary is therefore ordered by the same gate as every other accepted-operation hook. After the hook, identity preflight is phase-aware: it never blocks a synthetic recovery that needs no external registration. The prelude then handles only pending effects whose JavaScript continuations are necessarily gone: +The resume prelude first reloads durable control. If cancellation is requested, it does **not** run `before_resume`; it performs no ordinary recovery and enters §4.6. Under running control it reaches the `before_resume` breakpoint, calls `effectGate.assertOpen()`, and immediately invokes that hook aggregate. `requestAbort` racing this boundary is therefore ordered by the same gate as every other accepted-operation hook. After the hook, identity preflight is phase-aware: it never blocks a synthetic recovery that needs no external registration. The prelude then handles only pending effects whose JavaScript continuations are necessarily gone: | Orphaned restart point | Activation recovery | |---|---| @@ -2001,41 +2035,47 @@ Atomic transactions have no internal prefix, so every repeat-sensitive effect st Queue application and final structural commits remain atomic as specified in Part 3. A crash before one sees the prior complete state; a crash after one sees the next complete state. A crash after durable abort activates cancellation reconciliation. A crash after terminal cleanup sees an idle lane and `lane.lastResult`. -Retry waits are ordinary restartable procedures: +Retry waits are ordinary restartable states with two caller policies: ```text retry_wait -→ breakpoint +→ drive({ waitForRetry: false }) returns waiting/notBefore with no timer +→ caller schedules a wake and later drives the same operation id + +retry_wait +→ drive({ waitForRetry: true }) reaches the retry breakpoint → assert EffectGate open and immediately start retry timer → deadline: Session.mutate verifies the same wait and commits ready -→ abort: timer wakes after durable cancellation and reconciliation runs +→ requestAbort: timer wakes after durable cancellation and reconciliation runs → close: local task rejects; no durable write ``` +If a drive deadline precedes `notBefore`, it returns `waiting` even when `waitForRetry` is true. At or after `notBefore`, either policy verifies the same durable wait and commits `ready` without an unnecessary timer. + ## 4.6 Abort and cancellation reconciliation -Abort is durable control plus process-local prevention/signalling. It is not another operation phase. +Cancellation is durable control plus process-local prevention/signalling. It is not another operation phase. `requestAbort(operationId)` is the primitive; `abort()` inspects the current id, invokes that primitive, and ensures a cancellation drive pass exists. ### Live task -For a live operation, the first `abort()` performs this order: +For a live operation, the first matching `requestAbort()` performs this order: ```text -1. synchronously put ActiveOperation.effectGate into aborting state and install +1. synchronously put the matching `ActiveOperation.effectGate` into aborting state and install the abort-mutation promise; 2. reject any interruptible ordinary breakpoint with `AbortRequested` carrying that promise; cancellation-reconciliation barriers are not interruptible; -3. Session.mutate rereads the latest operation and either commits - cancel_requested or observes that a terminal transaction already won; -4. for runs, atomically move current steer/follow-up ids to control.drained* - and empty those inbox lists; their pending.entry registers remain; -5. after a cancellation commit, resolve the promise and pull the EffectGate's - AbortController; after a terminal winner, resolve it and close with OperationEnded; -6. return AbortResult once cancellation is durable, or NoActiveOperation when - terminal won; a still-open task continues reconciliation. +3. `Session.mutate` rereads the latest operation and either commits + `cancel_requested` or observes that the expected id no longer owns the lane; +4. for runs, atomically move current steer/follow-up ids to `control.drained*` + and empty those inbox lists; their `pending.entry` registers remain; +5. after a cancellation commit, resolve the promise and pull the `EffectGate`'s + `AbortController`; after an ownership mismatch, resolve it and close with `OperationEnded`; +6. return `AbortRequestResult` once cancellation is durable, or `OperationMismatch` + when the fence failed; a matching live pass continues reconciliation. ``` -The cancellation mutation leaves the current phase untouched. A later `abort()` while the operation remains open reuses the same durable marker and drained payloads. If the terminal transaction won the lane line first, `abort()` sees an idle lane and returns `NoActiveOperation`. +The cancellation mutation leaves the current phase untouched. A later `requestAbort()` for the same operation while it remains open reuses the same durable marker and drained payloads and reports `newlyRequested: false`. If the terminal transaction or another operation won first, the expected-id fence returns `OperationMismatch`. The `abort()` convenience maps an idle lane to its existing `NoActiveOperation` error. The effect gate orders only whether external work started. The lane mutation line separately orders cancellation against settlement: @@ -2047,7 +2087,7 @@ With queued steer/follow-up, successful abort removes their ids from the inbox, ### No live task -After reopen or suspension there is no gate to close or work to signal. `abort()` creates a deferred task record, enters the lane mutation line, writes or observes `cancel_requested`, installs the `ActiveOperation` before releasing the line, then starts reconciliation after the mutation returns. In manual mode that task parks at its first cancellation breakpoint; in automatic mode it proceeds immediately. +After reopen, safe yield, or suspension there may be no gate to close or work to signal. `requestAbort()` then writes or observes `cancel_requested` and returns without installing `ActiveOperation`. A scheduler sets a wake and later calls `drive`. The `abort()` convenience ensures a same-id drive pass is claimed after the marker; in manual mode that pass parks at its first cancellation breakpoint, and in automatic mode it proceeds immediately. A crash between the marker and that claim is harmless because the next drive observes durable cancellation first. ### Reconciliation @@ -2087,19 +2127,19 @@ seal harness admission A harness-wide admission barrier orders close against every operation and public commit. A commit admitted first may finish and close waits for it; close first prevents admission. A provider/tool result produced after sealing cannot commit. Durable state therefore stops at the last committed restart point, often `effect_pending`, exactly as after process death. -Reopening reports the open operation. A later `resume()` applies activation recovery. No close-specific recovery path exists, and the durable aborted-implies-cancelled invariant remains true because close admits no locally aborted settlement under running control. +Reopening reports the open operation. A later matching `drive()`—direct or through `resume()`—applies activation recovery. No close-specific recovery path exists, and the durable aborted-implies-cancelled invariant remains true because close admits no locally aborted settlement under running control. ## 4.8 Faults A failed admitted storage commit faults the whole harness. Fault closes effect gates, rejects barriers and pending/future calls with `HarnessFault`, and requires process restart; it is never an expected `Err` result. `faulted:true` appears in snapshots obtained before observation closes. Reopen restores from the last successful transactions. -Close rejects accepted local operation promises with `HarnessClosed`; calls not yet accepted return `Err(Closed)`. Surfaces without a `Result` channel reject with `HarnessClosed` on and after close. Provider, tool, and isolated hook failures remain per-lane and in-band. A throw/rejection from trusted deterministic application computation (`systemPrompt`, `toolContext`, `toProviderMessages`, or an `entryProjector`) faults the harness. `AgentTool.prepareArguments` is the deliberate exception normalized to a synthetic tool error. +Close rejects active drive and convenience-operation promises with `HarnessClosed`; already-resolved admissions remain durable, while calls not yet accepted return `Err(Closed)`. Surfaces without a `Result` channel reject with `HarnessClosed` on and after close. Provider, tool, and isolated hook failures remain per-lane and in-band. A throw/rejection from trusted deterministic application computation (`systemPrompt`, `toolContext`, `toProviderMessages`, or an `entryProjector`) faults the harness. `AgentTool.prepareArguments` is the deliberate exception normalized to a synthetic tool error. ## 4.9 External finalization -An authorized administrative operation may commit the terminal transaction while a task still exists. It uses the same lane mutation line. The next task mutation rereads current registers and finds that its operation is no longer current; the mutation raises the internal `OperationEnded` signal rather than returning a normal stale boolean. +An authorized administrative operation may commit the terminal transaction while a task still exists. It uses the same lane mutation line. This specification adds no public `forceFinalize()` method: authorization, SLA policy, and initiation belong to repository or hosting administration, while the terminal-transaction helper and live-task notification remain package-internal. The next task mutation rereads current registers and finds that its operation is no longer current; the mutation raises the internal `OperationEnded` signal rather than returning a normal stale boolean. -The task then stops, closes its effect gate, discards process-local results without writing, emits end observation, and resolves from the matching `lane.lastResult` written by the terminal transaction. It never recreates operation registers or commits a competing terminal result. A suspended operation needs no task to stop; a later `resume()` sees an idle lane and returns `NothingToResume`, while the application can read `getLastResult()`. +The task then stops, closes its effect gate, discards process-local results without writing, emits end observation, and resolves from the matching `lane.lastResult` written by the terminal transaction. It never recreates operation registers or commits a competing terminal result. An unowned operation needs no task to stop; a later matching `drive()` returns the settled result from `lane.lastResult`, while `resume()` sees an idle lane and returns `NothingToResume`. At most one terminal transaction commits because every candidate rereads ownership on the lane line before writing. An in-process finalizer publishes the terminal events, records the matching result on the `ActiveOperation`, closes any parked breakpoint and its gate with `OperationEnded` immediately after the terminal commit, and pulls the controller so cooperative provider/tool work stops. The task then resolves without publishing a duplicate terminal event once its current awaited work returns; its outer `finally` removes the record. A new operation therefore waits/gets `LaneBusy` during that cleanup even though durable state is already idle. A replacement-process finalizer first acquires ownership after close/crash, so no live task exists to notify. @@ -2107,9 +2147,54 @@ At most one terminal transaction commits because every candidate rereads ownersh ## 5.1 The lane surface -Expected rejection returns `Result.err`. Accepted operations return `Result.ok`, including failed, aborted, and suspended outcomes. Storage faults, close during accepted work, and invariant defects reject the promise. +Expected rejection returns `Result.err`. `accept` resolves only after durable operation acceptance; `drive` resolves after one owned pass settles, waits, or yields. Convenience operations return `Result.ok` for completed, failed, aborted, and suspended outcomes. Storage faults, close during active work, and invariant defects reject the promise. ```ts +interface NavigateOptions { summarize?: boolean; label?: string; customInstructions?: string } + +type OperationRequest = + | { kind: "prompt"; operationId?: string; prompt: string; images?: ImageContent[] } + | { kind: "prompt"; operationId?: string; + prompt: AgentMessage | AgentMessage[]; images?: never } + | { kind: "skill"; operationId?: string; name: string; + additionalInstructions?: string } + | { kind: "prompt_template"; operationId?: string; name: string; args?: string[] } + | { kind: "compaction"; operationId?: string; customInstructions?: string } + | { kind: "navigation"; operationId?: string; targetId: string | null; + options?: NavigateOptions }; + +interface OperationAdmission { + operationId: string; + kind: "run" | "compaction" | "navigation"; + startedAt: number; +} + +interface DriveOptions { + /** Expected durable owner; a stale call must never drive another operation. */ + operationId: string; + /** Epoch-ms budget checked only at effect-free safe boundaries. */ + deadline?: number; + /** Wait locally through retry_wait; false returns waiting/notBefore. Default false. */ + waitForRetry?: boolean; + /** Permit at most one poll of a suspended deferred response. Default false. */ + pollDeferred?: boolean; +} + +interface CurrentOperationInfo { + id: string; + kind: "run" | "compaction" | "navigation"; + status: "running" | "suspended" | "aborting"; + startedAt: number; + suspended?: SuspendedOperation; +} + +interface LaneExecutionInfo { + lane: string; + leafId: string | null; + current: CurrentOperationInfo | null; + lastResult?: LaneLastResult; +} + interface AgentLane { readonly name: string; getLeafId(): Promise; @@ -2117,6 +2202,13 @@ interface AgentLane { first terminal transaction. Never consulted by recovery. */ getLastResult(): Promise; + // General execution primitives. Convenience methods below compose these. + accept(request: OperationRequest): Promise; + drive(options: DriveOptions): Promise; + requestAbort(operationId: string): Promise; + /** Coherent current + latest-terminal read on the lane mutation line. */ + inspectExecution(): Promise; + prompt(text: string, images?: ImageContent[]): Promise; prompt(message: AgentMessage | AgentMessage[]): Promise; skill(name: string, additionalInstructions?: string): Promise; @@ -2150,14 +2242,17 @@ interface AgentLane { watch(): Promise>; } -interface NavigateOptions { summarize?: boolean; label?: string; customInstructions?: string } interface ActionInfo { kind: string; description: string; details?: JsonValue } interface WatchHandle { snapshot: T; start(listener: EventListener): void; unsubscribe(): void } ``` -Skill/template expansion precedes storage. Prompt intent names only normalized caller messages, excluding captured `nextRun` and hook injections. +`accept` starts no driver. Omitted operation ids are minted before pre-acceptance work; supplied ids follow §1.2. A supplied id is correlation, not an unbounded idempotency index: a scheduler first calls `inspectExecution`, drives when that id is current, settles its own receipt when that id is latest-terminal, and calls `accept` only when neither is true. Skill/template expansion precedes storage. Prompt intent names only normalized caller messages, excluding captured `nextRun` and hook injections. -`getLastResult()` is the post-crash reconciliation path: an application that accepted an operation, lost its process, and reopened reads the `lane.lastResult` register for the outcome its promise never delivered (§3.13). It is also how a caller learns the outcome of an operation finalized externally (§4.9). +`drive` is fenced by `operationId`. If that id is current and unowned, it claims one pass; if the same pass is already active, it joins; if it is the latest terminal id, it returns the hydrated settled outcome; otherwise it returns `OperationMismatch`. A deadline can prevent the next effect but cannot preempt one already admitted. Same-operation joiners receive the installed pass's outcome and may call `drive` again if their policy requires more progress. + +The convenience surface adds only caller-lifetime policy: `prompt`/`skill`/`compact`/`navigateTree` call `accept` then drive the returned id with local retry waiting; `resume` inspects and drives the current id with one deferred-poll permit; `abort` requests cancellation and ensures reconciliation is driven. A serving layer may instead store `OperationAdmission`, return to its caller, schedule from `waiting.notBefore`, and invoke the same primitives later. + +`getLastResult()` is the post-crash reconciliation path: an application that accepted an operation, lost its process, and reopened reads the `lane.lastResult` register for the outcome its promise never delivered (§3.13). `inspectExecution()` reads current ownership and that latest result coherently, which lets a scheduler reconcile its own durable submission without an accept/settle race. `lane.lastResult` is still overwritten by the next terminal operation; permanent per-submission receipts remain a serving-layer concern. It is also how a caller learns the outcome of an operation finalized externally (§4.9). `waitForIdle()` registers on the lane mutation line and resolves when all earlier admitted lane jobs have settled, `currentOperationId` is null, and no process-local operation/admission reservation is held. Later operations may start immediately after it resolves. Multiple waiters resolve together; close/fault rejects pending waiters. @@ -2213,6 +2308,7 @@ Expected errors use the existing `TaggedError` implementation in `harness/result | tag | fields beyond `message` | |---|---| | `LaneBusy` | `lane`, `operationId`, `operationKind` | +| `OperationMismatch` | `lane`, `expectedOperationId`, optional `currentOperationId` and `lastOperationId` | | `MissingIdentities` | `lane`, `tools`, `models` | | `NoActiveRun`, `NoActiveOperation`, `NothingToResume`, `NothingToCompact` | `lane` | | `InvalidMessage`, `InvalidNavigation` | `lane`, `reason` | @@ -2222,6 +2318,35 @@ Expected errors use the existing `TaggedError` implementation in `harness/result | `Closed` | none | ```ts +type OperationAdmissionError = + | LaneBusy | MissingIdentities | InvalidMessage | UnknownSkill | UnknownTemplate + | NothingToCompact | InvalidNavigation | UnknownTarget | Closed; +type OperationAdmissionResult = Result; + +type TerminalOperationOutcome = + | ({ operation: "run"; runId: string } & Exclude) + | ({ operation: "compaction"; runId: string } & + Exclude) + | ({ operation: "navigation"; runId: string } & + Exclude); + +type DriveOutcome = + | { kind: "settled"; operationId: string; outcome: TerminalOperationOutcome } + | { kind: "waiting"; operationId: string; reason: "retry"; notBefore: number } + | { kind: "waiting"; operationId: string; reason: "deferred"; + deferred: DeferredHandle } + | { kind: "waiting"; operationId: string; reason: "missing_identities"; + missing: { tools: string[]; models: string[] } } + | { kind: "yielded"; operationId: string }; +type DriveResult = Result; + +type AbortRequestResult = Result<{ + operationId: string; + newlyRequested: boolean; + steer: AgentMessage[]; + followUp: AgentMessage[]; +}, OperationMismatch | Closed>; + type RunResult = Result<{ runId: string } & RunOutcome, LaneBusy | MissingIdentities | InvalidMessage | UnknownSkill | UnknownTemplate | Closed>; type CompactionResult = Result<{ runId: string } & CompactionOutcome, @@ -2229,7 +2354,7 @@ type CompactionResult = Result<{ runId: string } & CompactionOutcome, type NavigationResult = Result<{ runId: string } & NavigationOutcome, LaneBusy | MissingIdentities | InvalidNavigation | UnknownTarget | Closed>; type ResumeResult = Result; + NothingToResume | MissingIdentities | Closed>; type QueueResult = Result<{ entryId: string }, NoActiveRun | InvalidMessage | Closed>; type NextRunResult = Result<{ entryId: string }, InvalidMessage | Closed>; type CancelQueuedResult = Result< @@ -2245,7 +2370,7 @@ class HarnessFault extends Error { class HarnessClosed extends Error {} ``` -`cancelQueued` has no unknown-item error: an id that is neither pending nor materialized returns `not_found` (§3.11) — previously cancelled, cleared by abort, or never existed — and a client retrying a lost cancel treats it as success. `AbortResult`'s steer/follow-up payloads are dereferenced from the drained items' surviving `pending.entry` registers (§4.6). `recordUsage` mints its ledger row id at commit (§1.6) and returns it. +`cancelQueued` has no unknown-item error: an id that is neither pending nor materialized returns `not_found` (§3.11) — previously cancelled, cleared by abort, or never existed — and a client retrying a lost cancel treats it as success. `AbortRequestResult` and `AbortResult` dereference steer/follow-up payloads from the drained items' surviving `pending.entry` registers (§4.6). `requestAbort` is idempotent for the same open operation and reports whether it wrote the marker; its expected-id fence fails rather than cancelling another operation. `recordUsage` mints its ledger row id at commit (§1.6) and returns it. `runId` is the operation's durable `operationId`; the public name remains for compatibility. `HarnessFault` and `HarnessClosed` reject promises; they are not tagged expected errors and not members of these unions. @@ -2318,6 +2443,25 @@ type SuspendedOperation = { ### Options ```ts +interface AgentHarnessToolInvocation { + /** Opaque session-unique logical call id; equals the reserved result entry id. */ + invocationId: string; + operationId: string; + turnId: string; +} + +type AgentHarnessTool = + Omit, "execute"> & { + execute(toolCallId: string, params: Static, + signal: AbortSignal | undefined, + onUpdate: AgentToolUpdateCallback | undefined, + context: TContext, + invocation: AgentHarnessToolInvocation): + Promise>; + }; + /** AgentHarnessStreamOptions is the curated source type from §0.7. It excludes signal and provider lifecycle callbacks, which the harness owns. */ interface AgentHarnessOptions { @@ -2342,7 +2486,7 @@ interface AgentHarnessOptions Message[] | Promise; entryProjectors?: Record; @@ -2361,7 +2505,7 @@ type EntryProjector = (entry: CustomEntry) => Initial, replacement, and hook-patched stream options are trusted typed internal values. Patch deletion semantics are applied before publication; extensions that return values outside the declared types are defective rather than runtime-validated by the harness. -`systemPrompt`, `toolContext`, `toProviderMessages`, and `entryProjectors` are deterministic/idempotent computation callbacks and may repeat after a crash; effectful interception belongs in hooks. `before_run` receives one preview evaluation of `systemPrompt`. A hook override is fixed in `Operation`; without one, the callback is evaluated again per provider request. +`systemPrompt`, `toolContext`, `toProviderMessages`, and `entryProjectors` are deterministic/idempotent computation callbacks and may repeat after a crash; effectful interception belongs in hooks. `before_run` receives one preview evaluation of `systemPrompt`. A hook override is fixed in `Operation`; without one, the callback is evaluated again per provider request. `toolContext` is still resolved once per live batch; each bound tool call additionally receives its own stable `AgentHarnessToolInvocation`. A wrapper may declare `replay: "safe"` and use `invocationId` to memoize durable substeps in an application-owned store; the harness neither names nor stores those substeps. ## 5.3 SessionTree @@ -2448,7 +2592,7 @@ interface SessionSnapshot { } ``` -`operation.status` derives from durable state plus a process-local suspension marker: `suspended` for deferred, restored, or missing-identity suspension; `aborting` when `control.status === "cancel_requested"`; otherwise `running`. The missing-identity marker stores the exact `SuspendedOperation`, survives until a successful resume attempt or abort in this process, and is reconstructed as `reason:"crash"` after reopen. It changes snapshots but never durable recovery state. `queues` and `pendingWrites` derive from `inbox` and `pendingNextRun`, with content dereferenced from each id's `pending.entry` register; abort-drained items are exposed only through `AbortResult` and `SuspendedOperation.aborting`, never as still-queued. `streamingMessage` and `runningTools` are process-local extras layered on top. +`operation.status` derives from durable state plus process-local drive ownership: `suspended` whenever an open operation has no `ActiveOperation` (including accepted-but-undriven, hosted retry wait, safe yield, deferred, restored, or missing-identity state); `aborting` when `control.status === "cancel_requested"`; otherwise `running`. `suspended` details are present when the existing `SuspendedOperation` vocabulary can describe the reason; a transient accepted/yielded state needs no invented durable descriptor. The missing-identity marker stores the exact `SuspendedOperation`, survives until a successful drive/resume attempt or cancellation in this process, and is reconstructed as `reason:"crash"` after reopen. It changes snapshots but never durable recovery state. `queues` and `pendingWrites` derive from `inbox` and `pendingNextRun`, with content dereferenced from each id's `pending.entry` register; abort-drained items are exposed only through `AbortResult` and `SuspendedOperation.aborting`, never as still-queued. `streamingMessage` and `runningTools` are process-local extras layered on top. Rules: @@ -2461,9 +2605,9 @@ Rules: ## 5.5 Events -One flat stream. `events.on(type, listener)` matches across the harness; lane watchers filter as above. Events are **passive**: listeners cannot mutate execution, payloads are isolated from procedure state, and a throw produces `handler_error` plus telemetry without affecting execution. Only hooks intercept. +One flat stream. `events.on(type, listener)` matches across the harness; lane watchers filter as above. Events are **passive**: listeners cannot transform the in-flight procedure, payloads are isolated from procedure state and from other listeners, and a throw produces `handler_error` plus telemetry without affecting execution. Listeners run in registration order and delivery awaits each one. A listener may read harness state and perform external observational effects, but must not call state-mutating harness methods; interception belongs in hooks, and new operations belong to an external caller after listener delivery. This avoids reentrant mutation and event-delivery deadlocks. -Durable-fact events fire **after** commit — `entry_added` means queryable. Multi-write events wait for full success, then follow mutation order. Process-local lifecycle events need not be durable: `message_end` precedes the entry insert. +Durable-fact events fire **after** commit — `entry_added` means queryable. Multi-write events wait for full success, then follow mutation order. `run_start`, standalone `compaction_start`, and `navigation_start` publish after their acceptance transaction and before `accept` resolves; no post-acceptance work has started yet. Explicit accept+drive and their convenience composition publish the same lifecycle once. Process-local lifecycle events need not be durable: `message_end` precedes the entry insert. ```ts type HarnessEventPayload = @@ -2578,7 +2722,7 @@ interface Events { } ``` -`lane` is required on run/turn/retry/message/tool, entry/write/queue, lane model/thinking/active-tool configuration, structural, and lane-created events. It is absent on facts, faults, and harness-global configuration. `handler_error` follows the failed handler's scope. `usage` is the global-delivery exception: base `lane` is absent, while its payload carries the origin lane and the complete ledger row, including its durable `seq` (§1.6). `recovery: true` appears on process-local lifecycle re-emitted by `resume()`, never on events for already-existing durable entries. Cross-lane events are process ordered, not globally sequence ordered. A totals consumer keeps the greatest usage `row.seq` it has applied, preventing a late older event from regressing totals. +`lane` is required on run/turn/retry/message/tool, entry/write/queue, lane model/thinking/active-tool configuration, structural, and lane-created events. It is absent on facts, faults, and harness-global configuration. `handler_error` follows the failed handler's scope. `usage` is the global-delivery exception: base `lane` is absent, while its payload carries the origin lane and the complete ledger row, including its durable `seq` (§1.6). `recovery: true` appears on process-local lifecycle re-emitted by resume activation (`drive` directly or through `resume()`), never on events for already-existing durable entries. Cross-lane events are process ordered, not globally sequence ordered. A totals consumer keeps the greatest usage `row.seq` it has applied, preventing a late older event from regressing totals. Ordering for a streamed assistant response, asserted exactly by the conformance tests: @@ -2611,7 +2755,7 @@ Deferred and recovery brackets are deterministic: - initial assistant generation uses `turnId = stepId`; a durable deferred response ends that turn, then emits `run_suspend`; - missing-identity suspension emits the operation-specific `run_suspend`, `compaction_suspend`, or `navigation_suspend`; no end event fires until that open operation later completes; -- every application `resume()` emits `run_resume`; `recovery:true` is present only when this harness restored the operation after process loss, not for same-process deferred resume; +- every application `resume()` emits `run_resume`; a direct `drive` emits the same lifecycle when it performs resume activation. `recovery:true` is present only when this harness restored the operation after process loss, not for a same-process continuation, hosted retry wake, safe yield, or deferred poll; - one deferred poll opens a turn whose durable id is `${stepId}:poll:${poll}`. Pending/error/ready settlement and any ready tool batch complete inside that turn, followed by `turn_end` and then suspend/failure/checkpoint; - restored unresolved tools re-open their persisted `ToolBatch.turnId` with `recovery:true`, emit only new replay/interruption tool lifecycle, then close that recovery turn. Existing message/entry events are never replayed; - resumed structural work re-emits its structural start with `recovery:true`; structural streams emit no message lifecycle and their typed result alone emits `entry_added`. @@ -2720,7 +2864,7 @@ One accepted-operation hook invocation reaches one breakpoint, calls `EffectGate | Hook | When | Event | Result | |---|---|---|---| | `before_run` | once, before acceptance, outside the mutation line | `{ prompt, systemPrompt, resources }` | `{ messages?, systemPrompt?, resumeData? }` | -| `before_resume` | on `resume()`, after durable control is confirmed running and before recovery/ordinary work; must be idempotent | `BeforeResumePrepared + { lane, runId, resumeData? }` | `void` | +| `before_resume` | on resume activation by `drive` (including `resume()`), after durable control is confirmed running and before recovery/ordinary work; must be idempotent | `BeforeResumePrepared + { lane, runId, resumeData? }` | `void` | | `before_run_end` | at a normal finish boundary | `{ runId, messages }` | `{ followUp? }` | | `transform_context` | per request, `AgentMessage` level, before `toProviderMessages` | `{ messages }` | `{ messages }` | | `before_request` | per request, provider-neutral options | `{ model, step, attempt, streamOptions }` | `{ streamOptions? }` | @@ -2909,7 +3053,7 @@ planned call Unknown tools, `prepareArguments` failures, invalid initial/replacement arguments, and blocked calls produce `ImmediateToolOutcome` and skip intent/execution. `AgentTool.prepareArguments` remains deterministic/idempotent computation and may repeat before intent; effectful policy belongs in `before_tool`. At `tool.execute` operation admission, `executeToolCall` calls `EffectGate.assertOpen()` immediately before `tool.execute` and passes `EffectGate.signal`; it also converts expected tool throws to an error result, stops accepting updates after settlement, and emits the required raw tool-effect telemetry. `finalizeToolCall` applies the documented field-by-field patch semantics. -Before starting any call in a live batch, the tool-batch procedure resolves `toolContext` once and binds the complete captured active-tool set into ordinary `AgentTool` adapters retained in a procedure-local snapshot. A missing captured active tool therefore suspends before any call starts; provider calls to names outside that active snapshot still become ordinary unknown-tool results. Every call in the batch observes the same context. Safe replay after restart creates one new snapshot. `AgentTool.replay` defaults to `"never"` when omitted. +Before starting any call in a live batch, the tool-batch procedure resolves `toolContext` once and binds the complete captured active-tool set into ordinary `AgentTool` adapters retained in a procedure-local snapshot. Each adapter also binds `AgentHarnessToolInvocation { invocationId: resultEntryId, operationId, turnId }` for its call. A missing captured active tool therefore suspends before any call starts; provider calls to names outside that active snapshot still become ordinary unknown-tool results. Every call in the batch observes the same application context and its own stable invocation identity. Safe replay after restart creates one new snapshot and passes the same `invocationId`. `AgentTool.replay` defaults to `"never"` when omitted. There is deliberately no harness `executeToolBatch`. In parallel mode the direct procedure makes one source-ordered start pass. Each position either starts a real promise or records an immediate prepared/blocked/invalid outcome locally. It does not await an earlier real promise before clearing and starting later positions. It then makes one source-ordered settlement pass: await/finalize/commit a real result or commit the retained immediate outcome at each position. Thus a later immediate outcome never commits ahead of an earlier running call, but it also does not prevent still-later calls from starting. A crash discards the local pass and durable planned/pending states recover normally. The same procedure owns cancellation and durable batch completion. Genuine-`length` calls bypass these phases and receive their specified synthetic results (§3.7). @@ -3022,27 +3166,28 @@ The design conclusion: the volatile part of the system — orchestration — was One shared slice lands the complete type surface; everything after it splits into two independent tracks. **Track S** (storage, search, dev TUI) parallelizes across owners — its slices depend only on slices 1–2 and never on each other. **Track R** (runtime) is sequential, runs entirely against the Memory backend, and never waits on Track S. The tracks cannot block each other. -Each slice implements its named behavior end to end and adds focused tests for its normal path, every state it introduces, every owned crash boundary, and both orders of owned races. Passing those tests and `npm run check` is its acceptance criterion. If implementation exposes a design contradiction, missing transition, or materially simpler design, stop and send it for review — do not silently improvise a new durable contract inside a slice. +Each slice implements its named behavior end to end and adds focused tests for its normal path, every state it introduces, every owned crash boundary, and both orders of owned races. State-specific restore checks, identity preflight, hooks, events, and breakpoint descriptors land with the slice that first creates or executes that state; earlier slices do not implement generic future-state machinery. The complete public types may precede runtime behavior, but unimplemented methods remain direct stubs rather than speculative shared execution paths. Passing those tests and `npm run check` is each slice's acceptance criterion. If implementation exposes a design contradiction, missing transition, or materially simpler design, stop and send it for review — do not silently improvise a new durable contract inside a slice. | # | Slice | Implement | Required focused tests | |---|---|---|---| -| 1 | **Types** (complete) | The complete shared type surface, behavior-free: `Entry`/`Register`/`UsageRow` and `RegisterValues` including the full Part 3 state tree, `Write`/`Transaction`/`Storage`/`SessionReader`/`SessionMutator`/`Session`/`SessionTree`/`SessionRepo`, scans, the id-generator, the search interfaces from §2.8, `storageVersion`, and the Part 5 surface types (results, errors, events, snapshots, hooks). Delete `packages/agent/src/harness/**` and its tests outright; patch remaining consumers. The repo may not compile mid-slice; it compiles again — `npm run check` clean — at the end. | Type-level only; no behavior. || 2 | **Session layer, Memory, conformance** | Entry materialization with inline payloads, atomic configured lane creation, lane/config/state registers, facts, branch/global queries, context projection, `SessionTree`/views, UUIDv7 generator with follower minting, stats projection, the Memory backend with repository lifecycle/forks and the `storageVersion` gate at open, the backend conformance suite, and the instrumented-storage decorator (Part 9). Internal values pass directly between Session and Storage without codecs, cloning, or runtime shape validation. | Rollback, sequence order, duplicate ids, register set/delete/recreate, delete-of-absent-key no-op, atomic lane creation and duplicate/name/anchor rejection, fact deletion vs JSON `null`, stats-equals-ledger, follower minting, placement, divergence, filters/cursors/stops, custom entries with and without data, context projection, fork before first attachment, configured fork snapshots/facts/zero ledger, close. | +| 1 | **Types** (complete through the original runtime surface) | The shared storage/session/state surface: `Entry`/`Register`/`UsageRow` and `RegisterValues` including the full Part 3 state tree, `Write`/`Transaction`/`Storage`/`SessionReader`/`SessionMutator`/`Session`/`SessionTree`/`SessionRepo`, scans, the id-generator, the search interfaces from §2.8, `storageVersion`, and the original Part 5 surface. R1 extends the runtime-facing types with `OperationRequest`, `accept`/`drive`/`requestAbort`/`inspectExecution`, `OperationMismatch`, and `AgentHarnessToolInvocation`; no storage migration is involved. | Type-level only; no behavior. | +| 2 | **Session layer, Memory, conformance** | Entry materialization with inline payloads, atomic configured lane creation, lane/config/state registers, facts, branch/global queries, context projection, `SessionTree`/views, UUIDv7 generator with follower minting, stats projection, the Memory backend with repository lifecycle/forks and the `storageVersion` gate at open, the backend conformance suite, and the instrumented-storage decorator (Part 9). Internal values pass directly between Session and Storage without codecs, cloning, or runtime shape validation. | Rollback, sequence order, duplicate ids, register set/delete/recreate, delete-of-absent-key no-op, atomic lane creation and duplicate/name/anchor rejection, fact deletion vs JSON `null`, stats-equals-ledger, follower minting, placement, divergence, filters/cursors/stops, custom entries with and without data, context projection, fork before first attachment, configured fork snapshots/facts/zero ledger, close. | | S1 | **JSONL** | Format 4: single-item/array transaction lines, register set/delete replay, header `storageVersion`, torn-tail handling, snapshot compaction (GC keep-predicate), the file-based repository, format-3 read normalization and first-write temp/rename conversion with id re-minting (Appendix B). Replace the unfinished current v4 without migration. | Backend conformance, corrupt interior/final lines, whole-array tear, compaction logical-equivalence, every format-3 rule including id re-minting and reference remapping, resolved/unresolved parent paths, aggregate imported usage adjustment. | | S2 | **SQLite** | One database file per session: entries/registers/usage-ledger tables, one-row session/lease rows, transactions, `storageVersion`, the file-based repository, segmented branch cache, `VACUUM INTO`-based rewrite/fork, and explicit repair. No values table, no `slot_history`, no `getLog`, no search projection, no migration. | Shared conformance, `BEGIN IMMEDIATE`, fencing, query plans, segment-chain soundness, register upsert/delete, forks/stats/repair. | | S3 | **Search** | The standalone `SessionSearchService` plus repo catch-up utilities (§2.8): core entry search as `SessionSearch`, session-level ranked results, optional `searchEntries?: SessionSearch`, `remove()`, the `SessionSearchSyncTarget` cursor/index-batch contract, sync enumeration and catch-up outside the service contract, debounced notify as a utility, `(sessionId, storeGeneration)` cursor keys, and the reference SQLite FTS5 implementation working over any backend's repository through the sync utility. | Cursor catch-up from empty against existing sessions, idempotent re-index after crash mid-batch, notify-utility/sweep equivalence, sessions-vs-entries queries and ranking, removal and reconciliation, shared-index multi-process discipline. | | S4 | **Dev TUI and Client** | A minimal `AgentClient` over one lane — `LaneSnapshot` plus `watch()` events, `prompt`/`steer`/`followUp`/`abort`/`resume`/`cancelQueued`, `lane.lastResult` read — and a throwaway alt-screen TUI on `packages/tui`: transcript from snapshot and events, input box, status/queue display, abort key. Built first against a scripted fake client on the slice-1 types; binds to the real harness as Track R lands. Not final. | Compiles; fake-client smoke test. No durability obligations. | -| R1 | **Runtime shell and execution primitives** | Lane/settings mutation lines; one `ActiveOperation` slot per lane; `ActionInfo` breakpoint barrier; `EffectGate` and its internal abort control flow; total-state validation (idle lanes included); hook/event primitives; restore inventory (five register reads plus bounded hydration); dispatch-time identity resolution; fault/close plumbing. Also implement the independently testable behavior-only blocks under `packages/agent/src/harness/execution/` from §5.7: assistant context/options/stream observation/response transformation and tool preparation/execution/finalization. They know nothing about durable operation state, lanes, retries, queues, or storage; `agent-loop.ts` remains unchanged. Public operations may still report not implemented. | Breakpoint publication/release, stable `ActionInfo.kind` and JSON-safe details for R1 boundaries, automatic/manual barrier equivalence, nested barriers, abort interrupting a parked ordinary barrier, zero work while parked, single task installation/removal, hook aggregation, event buffering, restore without history reads, idle-lane validation. Direct assistant/tool block tests from §5.7. Abort-first/start-first at every accepted-operation R1 `EffectGate` integration (hook aggregate, Models operation, `tool.execute`): `assertOpen()` is immediately adjacent to invocation, abort-first invokes nothing, and start-first passes the gate signal to the admitted work. Close-first/start-first covers pre-acceptance `before_run`. | -| R2 | **Minimal no-tool run** | Compose R1's assistant block with prompt expansion, `before_run`, atomic acceptance plus `ActiveOperation` installation, pending-capture placement, captured request options/thinking inline, payload/response hooks, one generation intent/effect/settlement, usage, the terminal transaction (register cleanup plus `lane.lastResult`), results, basic events/telemetry. Define the stable `ActionInfo.kind`/details catalog for every boundary introduced here. | Successful run with final assistant fields, caller rejection and adapter-reported provider failures, exact breakpoint/transaction/event order around the assistant block, stable JSON-safe breakpoint descriptors, terminal cleanup completeness and `lastResult`, automatic/manual identical state, close at every boundary. | -| R3 | **Generation recovery and retry** | Retry waits, activation-only unknown-effect recovery, synthetic cap settlement, ordinary stop/error/deferred classification, provider-compliant `aborted`, and failure-drain foundation. Define the stable breakpoint descriptors for retry timers and recovery commits. Overflow classification remains explicitly unimplemented until R9. | Every generation state before/after reopen, caps/backoff, timer abort-first/start-first, stable breakpoint descriptors, stop/error/aborted/deferred classification, missing identities. | -| R4 | **Tools** | Compose R1's tool phases with bound `AgentHarnessTool` context, durable complete plans, `op.tool_args/{opId}:{stepId}:{i}` registers with batch-completion deletion, activation recovery/replay, sequential/parallel procedure-local promises, blocked terminate, genuine-length results, tool events/hooks/usage. Define the stable breakpoint descriptors for tool boundaries. | A built-in context-bound tool, operation integration for model-supplied and hook-replacement argument failures, stable breakpoint descriptors, every planned/pending/completed state, tool-args register lifecycle including crash-leak prefix cleanup, safe/unsafe replay, source-ordered start/settlement passes, termination, abort-ready planned states. | -| R5 | **Inbox, configuration, and writes** | `nextRun`/steer/follow-up via `pending.entry` registers, `cancelQueued` triage (`not_found`), durable drain markers, checkpoint consumption with register deletion, immediate total config setters, deferred tree writes, adjustments, and stable breakpoint descriptors for the checkpoint boundaries introduced here. | Capture/cancel/consume races, repeated cancellation answering `not_found`, one-at-a-time crash after one drain, register/entry exclusivity at every boundary, custom-write continuation, config-step race, writes surviving reopen. | -| R6 | **Abort, close, and failure drain** | Orthogonal control, drained ids in control with surviving pending registers, signalling, per-phase reconciliation, best-effort cancellation of the current deferred source, waiters/run-when-idle, controlled-crash close, terminal deletion of inbox-and-drained registers, external-finalization stop on absent operation registers (§4.9), and stable breakpoint descriptors for cancellation/failure boundaries. | Abort at every existing state, repeated abort, abort interrupting each parked ordinary breakpoint without executing its following statement, deferred cancellation, live/restored tool outcomes including preservation of every started parallel promise result, writes before finish, drained-register survival and terminal deletion, close races, an externally finalized operation signalling/stopping its task without writes or duplicate end events and resolving from `lastResult`, failure revived only by projecting input. | -| R7 | **Deferred provider redemption** | One poll per resume, copied configuration/options inline, per-poll request hooks, exact source lineage/equality, fresh intent after unknown poll, mismatch-to-error, ready tools, advancement of R6 cancellation to each newest source, and stable poll breakpoint descriptors. | Repeated pending, ready/error/aborted/mismatch, crash positions, no cap/backoff/loop, newest-handle cancellation. | -| R8 | **Manual compaction** | Reserved-lane admission, the `op.preparation/{opId}:{taskId}` register, total structural state, hook/generated sources, nested request intents/usage, retained tail, retry/recovery/abort, and stable structural breakpoint descriptors. | Empty/reservation race, hook decline/result, crash after request one of split-turn generation, every state/crash, no public summary-stream messages. | +| R1 | **Runtime shell and execution primitives** | Extend the public types with `OperationRequest`, admission/drive/cancellation/inspection results, `OperationMismatch`, and `AgentHarnessToolInvocation`. Implement lane/settings mutation lines; one drive-pass `ActiveOperation` slot per lane; expected-id fencing, same-operation drive joining, coherent `inspectExecution`, safe deadline-yield plumbing, `ActionInfo` breakpoint barrier, `EffectGate` and its internal abort control flow, base current-state validation (lane completeness, operation ownership, and intent/state kind), hook/event primitives, restore inventory (five register reads plus bounded base hydration), registry lookup plumbing, and fault/close plumbing. Also implement the independently testable behavior-only blocks under `packages/agent/src/harness/execution/` from §5.7; they know nothing about durable operation state, lanes, retries, queues, or storage, and `agent-loop.ts` remains unchanged. Operation-kind acceptance and ordinary driving may still report not implemented. | Type surface clean; coherent inspection; expected-id mismatch and latest-result settlement lookup; one same-id pass installed and joined; stale-id drive never touches a newer operation; safe yield starts no following work; breakpoint publication/release; stable `ActionInfo.kind` and JSON-safe details; automatic/manual barrier equivalence; nested barriers; cancellation interrupting a parked ordinary barrier; zero work while parked; task installation/removal; hook aggregation; ordered passive event buffering; restore without history reads; base idle/open-lane validation. Direct assistant/tool block tests from §5.7. Abort-first/start-first at every accepted-operation R1 `EffectGate` integration: `assertOpen()` is immediately adjacent to invocation, abort-first invokes nothing, and start-first passes the gate signal to the admitted work. Close-first/start-first covers pre-acceptance `before_run`. | +| R2 | **Minimal no-tool run** | Compose R1's assistant block with generic prompt `accept`, optional caller-supplied operation id, prompt expansion, `before_run`, the acceptance transaction without task installation, pending-capture placement, fresh `drive`, and `prompt` as `accept`+`drive`; add run/checkpoint/generation semantic restore checks, captured request options/thinking and model preflight, payload/response hooks, one generation intent/effect/settlement, usage, terminal cleanup plus `lane.lastResult`, results, basic events/telemetry, and stable breakpoint descriptors. | Acceptance returns before any drive work; crash between accept and drive resumes the initial state; prompt convenience and explicit accept+drive produce byte-identical writes/events; supplied operation id reaches `op.meta`/events/result; successful run with final assistant fields; caller rejection and adapter-reported failures; exact ordering; terminal cleanup; same-id drive join during the accept/drive gap; close at every boundary; invalid run/checkpoint/generation references. | +| R3 | **Generation recovery and retry** | Activation-only unknown-effect recovery, synthetic cap settlement, ordinary stop/error/deferred classification, provider-compliant `aborted`, failure-drain foundation, and both retry-wait policies: explicit drive returns `waiting/notBefore`, while convenience drive may own the abort-aware timer. Define stable breakpoint descriptors for retry timers and recovery commits. Overflow classification remains explicitly unimplemented until R9. | Every generation state before/after reopen; accept/close/reopen/drive; caps/backoff; no timer under `waitForRetry:false`; due and future `notBefore`; deadline before retry; convenience/explicit-drive durable equivalence; timer abort-first/start-first; stable descriptors; stop/error/aborted/deferred classification; missing identities. | +| R4 | **Tools** | Compose R1's tool phases with bound `AgentHarnessTool` context and per-call `AgentHarnessToolInvocation { invocationId: resultEntryId, operationId, turnId }`, durable complete plans, tool-batch semantic restore checks and tool identity preflight, `op.tool_args/{opId}:{stepId}:{i}` registers with batch-completion deletion, activation recovery/replay, sequential/parallel procedure-local promises, blocked terminate, genuine-length results, tool events/hooks/usage, and stable breakpoint descriptors. | A built-in context-bound tool; stable invocation id unique across calls and unchanged on safe replay; operation integration for model-supplied and hook-replacement argument failures; every planned/pending/completed state; invalid source/result/argument-register relationships; tool-args lifecycle including crash-leak cleanup; safe/unsafe replay; source-ordered passes; termination; abort-ready planned states. | +| R5 | **Inbox, configuration, and writes** | `nextRun`/steer/follow-up via `pending.entry` registers, pending-payload and queue semantic restore checks, `cancelQueued` triage (`not_found`), durable drain markers, checkpoint consumption with register deletion, immediate total config setters, deferred tree writes, adjustments, and stable breakpoint descriptors for the checkpoint boundaries introduced here. | Capture/cancel/consume races, repeated cancellation answering `not_found`, invalid queue payload kinds and missing pending registers, one-at-a-time crash after one drain, register/entry exclusivity at every boundary, custom-write continuation, config-step race, writes surviving reopen. | +| R6 | **Abort, close, and failure drain** | Add expected-id `requestAbort` as commit/signalling only: it never installs a task when none exists. Compose `abort()` from inspection, `requestAbort`, and ensuring a same-id drive pass. Implement orthogonal control and cancellation semantic restore checks, drained ids with surviving pending registers, per-phase reconciliation, best-effort deferred cancellation, waiters/run-when-idle, controlled-crash close, terminal deletion, external-finalization stop on absent registers (§4.9), and stable cancellation/failure descriptors. | Request cancellation with and without a live pass; same-id idempotency; stale-id mismatch; convenience/primitive durable equivalence; invalid cancelled queue/control combinations and aborted responses under running control; abort at every state; parked-breakpoint interruption; deferred cancellation; live/restored tool outcomes; writes before finish; drained-register survival/deletion; close races; externally finalized task stop/result; failure revived only by projecting input. | +| R7 | **Deferred provider redemption** | One poll per drive pass carrying `pollDeferred:true`; `resume()` composes inspection and that drive option. Add deferred-state semantic restore checks and provider identity preflight. Preserve copied configuration/options, per-poll hooks, exact source lineage/equality, fresh intent after unknown poll, mismatch-to-error, ready tools, advancement of R6 cancellation, and stable poll descriptors. | `pollDeferred:false` returns waiting without work; one permit performs at most one poll; invalid/missing/mismatched durable handles; repeated pending, ready/error/aborted/mismatch, crash positions, convenience/primitive equivalence, no cap/backoff/loop, newest-handle cancellation. | +| R8 | **Manual compaction** | Add compaction `OperationRequest` acceptance without task installation and compose `compact()` from accept+drive. Implement reserved-lane preparation, `op.preparation/{opId}:{taskId}`, total structural state and its semantic restore checks, structural model preflight, hook/generated sources, nested request intents/usage, retained tail, retry/recovery/abort, and stable structural descriptors. | Explicit accept+drive versus convenience equivalence; empty/reservation race; accept/close/reopen/drive; invalid task/preparation/source relationships; hook decline/result; crash after request one of split-turn generation; every state/crash; no public summary-stream messages. | | R9 | **Threshold and overflow compaction** | In-run structural decision, durable once-per-trigger threshold marker, continuation preservation, all overflow predicates, atomic response/preparation publication, specified normalization/projection, one overflow recovery flag, bounded second failure, and stable breakpoint descriptors for added boundaries. | Threshold decline/empty across reopen, all overflow classifier/preparation inputs, no overflow tool plan, genuine length, crash/reopen at every transition. | -| R10 | **Navigation** | Validation, summarized decision/generation, one final transaction combining move/summary/leaf/label with the terminal writes, summary-only navigation hook, and stable navigation breakpoint descriptors. | Root/current/unknown rejection, summarized/unsummarized paths, final leaf at summary, abort race, exact atomic publication including register cleanup. | +| R10 | **Navigation** | Add navigation `OperationRequest` acceptance without task installation and compose `navigateTree()` from accept+drive. Implement navigation source/target/summary semantic restore checks, validation, summarized decision/generation, one final transaction combining move/summary/leaf/label with terminal writes, summary-only navigation hook, and stable descriptors. | Explicit accept+drive versus convenience equivalence; root/current/unknown rejection; invalid durable source/target/summary combinations; accept/close/reopen/drive; summarized/unsummarized paths; final leaf at summary; abort race; exact atomic publication including cleanup. | | R11 | **Schema version and migrations** | Chained migrate-on-open under the writer lease, migration registry with total register mappings — open operations' `op.meta`/`op.state` included (§7.4), JSONL lenient old-shape replay and mandatory post-migration compaction, refuse-newer. | Version gate (equal/older/newer), chained idempotent migrations across crash, an open-operation state mapped across a state-machine change and resuming correctly, lenient replay of superseded shapes, compaction retiring old bytes. | -| R12 | **Surface completion** | Complete snapshots/watch, event catalog/order/filtering, telemetry instrumentation/schema freshness, public exports, backend parity, and remove any remaining dead scaffold code — including the S4 fake client. | Snapshot/event gap, attach during every live state, sensitive-event/content-free-telemetry assertions, full race/crash matrix on all backends. | +| R12 | **Surface completion** | Complete snapshots/watch, event catalog/order/filtering, telemetry instrumentation/schema freshness, public exports, backend parity, hosted-control/convenience surface documentation, and remove remaining scaffold code — including the S4 fake client. | Snapshot/event gap; attach during accepted-undriven, active, waiting, yielded, and terminal states; stale-wake fencing; convenience/primitive equivalence across all operation kinds; sensitive-event/content-free telemetry; full race/crash matrix on all backends. | | P1 | **Protocol schemas** | Future work after the internal harness is complete: define shared TypeBox schemas for serializable pi-ai and harness protocol data, derive the corresponding TypeScript types from those schemas, and reuse them across client/server protocol boundaries. Validation runs only on untrusted wire input and never inside Session, Storage, operation procedures, or in-process extensions. | Schema/type parity, accepted and rejected client/server payloads, protocol round trips, and no validators or schema construction on internal storage paths. | Existing source guidance: @@ -3078,7 +3223,7 @@ Operations: 12. `lane.state/{lane}` confers lane ownership, and `op.state/{operationId}` confers operation-state ownership. An open lane names operation O, `op.meta/O` holds that lane's compatible `Operation`, and `op.state/O` holds an `OperationState` compatible with O's intent kind; state values carry no duplicate owner metadata. 13. `op.*` registers and operation-owned `pending.entry` registers exist **iff** their operation is open: the terminal transaction deletes them atomically with clearing `currentOperationId` (§3.13). Lane-owned `pendingNextRun` registers are never deleted by it. -14. Acceptance must observe `currentOperationId === null`. +14. Acceptance must observe `currentOperationId === null`, commits no `ActiveOperation`, and returns before any post-acceptance hook/provider/tool/timer work begins. A supplied operation id obeys §1.2 and is the exact id written to `op.meta`, events, and `lane.lastResult`. 15. A reserved id may exist only with the content its intent named. There are exactly two reservation regimes (§2.2): settlement-family ids are strings in `op.state`; queued-content ids are `pending.entry` registers — until placement or cancellation, exactly one of register and entry exists. 16. Only terminal transitions construct a `LaneLastResult`. A terminal outcome is observable once through the live promise and thereafter through `lane.lastResult` until the next terminal transaction on that lane; recovery never reads it. 17. At most one operation is open per lane. Two is corruption. @@ -3086,17 +3231,24 @@ Operations: 19. **The settlement transaction that commits a response with `stopReason: "aborted"` must, in that same transaction, write an operation state with `control.status === "cancel_requested"`.** The invariant is scoped to the committing transaction — later terminal cleanup or forks may remove the state without violating it. Providers must comply with the harness-owned signal contract; violation is corruption. 20. Current-state semantic checks (§3.3) run on every latest lane/operation state before execution — idle lanes included (§4.4). `lane.lastResult` never determines an open operation's next procedure. 21. At most one terminal transaction ever commits per operation. A task mutation that finds its operation's registers absent raises internal `OperationEnded`; the task stops without writing and resolves from `lane.lastResult` (§4.9). -22. At most one `ActiveOperation` exists per lane. Fresh acceptance, resume, and abort-without-a-task install it before releasing the lane mutation line. Every task's outer `finally` removes it in a no-write lane job only after final local observation; external finalization retains and signals it until that path runs. +22. At most one `ActiveOperation` drive pass exists per lane. Acceptance and taskless `requestAbort` never install one. A matching `drive` installs it before releasing the lane mutation line; another matching drive joins that pass, and a stale id starts nothing. Every pass's outer `finally` removes it in a no-write lane job only after final local observation; external finalization retains and signals it until that path runs. 23. The §4.2 `assertOpen()` catalog is complete. Every listed hook/provider/tool/timer integration checks immediately before invocation with no yield between them; no unlisted code checks the operation gate. Preparation stays before the check, and admitted asynchronous provider setup/delegation are continuations of the same operation owning `EffectGate.signal`. +24. `drive` and `requestAbort` are fenced by expected operation id. They may affect only that current operation; `drive` may also return its matching latest terminal result. A stale wake for A cannot drive or cancel B. +25. `drive` returns `yielded` only at an effect-free safe boundary. It starts no following transition, hook, provider, tool, or timer after deciding to yield. An already-admitted effect may outlive the caller's deadline and must settle normally or be recovered after task loss. +26. Convenience operations and their explicit primitive compositions produce the same durable writes, events, results, and recovery behavior. Their only difference is process-local waiting/scheduling policy. +27. Each logical tool call's public `invocationId` is its reserved `resultEntryId`: unique within the session and unchanged across safe replay. ## 9.2 Race catalog -Each race has exactly two durable histories. Test both, in manual drive, in both orders. +Each durable mutation race has exactly two durable histories. Process-local ownership races additionally have the one-driver/join or fence outcome shown below. Test every listed order under manual breakpoints. | Race | Orders | |---|---| -| `prompt` vs `prompt` on one lane | one accepts, one gets `LaneBusy` | -| `abort` vs response settlement | marker first → normalized `aborted`; response first → stop reason preserved | +| `prompt` vs `prompt` on one lane | both compose `accept`; one accepts, one gets `LaneBusy` | +| `accept(A)` vs process loss before `drive(A)` | acceptance absent → serving layer retries; acceptance present → restored initial state drives normally, with no unknown effect | +| `drive(A)` vs `drive(A)` | one installs the pass; the other joins exactly that pass and may drive again after its outcome | +| stale `drive(A)`/`requestAbort(A)` vs current B | expected-id mismatch; B is untouched | +| `requestAbort` vs response settlement | marker first → normalized `aborted`; response first → stop reason preserved | | `abort` vs started tool result commit | abort first → real result settles under cancelled control; result first → real result commits normally before cancellation | | `abort` vs `before_run_end` follow-up | follow-up dropped; or committed and the run continues | | `cancelQueued` vs checkpoint consumption | `cancelled`; or `already_consumed` | @@ -3105,16 +3257,17 @@ Each race has exactly two durable histories. Test both, in manual drive, in both | `nextRun` vs acceptance | captured by this run; or stays for the next | | manual-compaction reservation vs idle tree write | reservation first → write waits; write first → preparation uses the new leaf | | deferred write vs abort | write survives abort either way | -| `abort` vs ordinary operation admission | admission first → operation is invoked with the signal; abort first → gate refuses invocation | -| `abort` vs parked ordinary breakpoint | abort rejects the interruptible barrier with `AbortRequested`; the ordinary statement after it does not run, and reconciliation proceeds after the lane mutation | +| `requestAbort` vs ordinary operation admission | admission first → operation is invoked with the signal; cancellation first → gate refuses invocation | +| `requestAbort` vs parked ordinary breakpoint | cancellation rejects the interruptible barrier with `AbortRequested`; the ordinary statement after it does not run, and reconciliation proceeds after the lane mutation | +| drive deadline vs next ordinary effect | yield decision first → no effect starts; effect admission first → it may finish after the deadline before the pass can return | | `close` vs parked manual action | action rejected unexecuted; durable state is the committed prefix | | `close` vs settlement | settlement abandoned, state stays `effect_pending`; or it committed before the flag was set | ## 9.3 Test tiers -**Tier A — state and resume.** For every state in Part 3, construct it durably, close, reopen, activate it, and assert the next breakpoint, suspension, or terminal result. Coverage must include: restore with no branch walk and no re-resolution of captured step configuration; assistant intent with no settlement, below and at the retry cap; settlement followed by each classification branch; every settled stop reason surviving except the two deliberate normalizations; a self-contained deferred step with copied configuration, consecutive polls, repeated equal-handle pending responses, ready and terminal responses, and handle-mismatch normalization into durable failure; every tool state including planned, effect_pending safe and unsafe, and completed; a batch where every call sets `terminate` finishing the run with no further request; genuine-`length` batches proving no execution and one explanatory result per call; every overflow crash position, including that the compacted `retainedTail` omits the normalized-`error` response by the ordinary projection rule; every navigation state with no post-move generation; abort at every position; missing identities on accept and on resume; every terminal transaction proving complete register deletion (including tool-args prefix-scan cleanup of crash-leaked keys), `lane.lastResult` correctness, and preserved `pendingNextRun`; register/entry exclusivity for every queued id at every crash boundary; and every half-completed recovery prefix. +**Tier A — state and drive.** For every state in Part 3, construct it durably, close, reopen, drive its expected operation id, and assert the next breakpoint, wait, yield, or terminal result. Coverage must include: restore with no branch walk and no re-resolution of captured step configuration; assistant intent with no settlement, below and at the retry cap; settlement followed by each classification branch; every settled stop reason surviving except the two deliberate normalizations; a self-contained deferred step with copied configuration, consecutive polls, repeated equal-handle pending responses, ready and terminal responses, and handle-mismatch normalization into durable failure; every tool state including planned, effect_pending safe and unsafe, and completed; a batch where every call sets `terminate` finishing the run with no further request; genuine-`length` batches proving no execution and one explanatory result per call; every overflow crash position, including that the compacted `retainedTail` omits the normalized-`error` response by the ordinary projection rule; every navigation state with no post-move generation; abort at every position; missing identities on accept and on resume; every terminal transaction proving complete register deletion (including tool-args prefix-scan cleanup of crash-leaked keys), `lane.lastResult` correctness, and preserved `pendingNextRun`; register/entry exclusivity for every queued id at every crash boundary; and every half-completed recovery prefix. -For each recovery prefix: close, reopen, resume, and compare against uninterrupted recovery. Invoking recovery twice from the initial prefix is **not** sufficient. +For each recovery prefix: close, reopen, drive, and compare against uninterrupted recovery. Invoking recovery twice from the initial prefix is **not** sufficient. Every operation kind also covers accept → close before first drive → reopen → drive. One corruption assertion constructs an `aborted` response with running control directly and requires load rejection. Provider conformance separately proves implementations emit `aborted` only for the supplied signal. @@ -3125,7 +3278,7 @@ One corruption assertion constructs an `aborted` response with running control d **Cross-cutting:** - **Backend conformance.** One suite, three backends, identical results — identical query results, register states, and stats after every scenario, including register set/delete/recreate semantics and torn-transaction handling. Internal values are not cloned or shape-validated. Write-order assertions use the instrumented decorator, never a durable log. -- **Drive equivalence.** The same scenario in automatic and manual drive must produce byte-identical durable state. +- **Drive equivalence.** The same scenario in automatic and manual breakpoint mode must produce byte-identical durable state. Convenience calls and explicit `accept`/`drive`/`requestAbort` compositions must likewise produce byte-identical durable state and equivalent events/results. - **Breakpoint catalog.** Each runtime slice specifies and tests the stable `ActionInfo.kind` values and JSON-safe details for the boundaries it introduces. A parked operation performs no following procedure work; abort rejects interruptible ordinary barriers into reconciliation but leaves cancellation barriers parked; close/fault rejects every barrier. - **Effect-start gate.** Cover every item in §4.2's complete catalog and assert that no other path calls `assertOpen()`. At each listed integration, force both orders of abort versus operation admission: the check and invocation are adjacent, abort-first invokes nothing, and admission-first gives the complete operation `EffectGate.signal`. Provider tests assert that request preparation precedes the check and that the same signal reaches Models auth/lazy/provider work. Hook tests treat each aggregate pipeline as one admitted unit. A cancelled activation must enter reconciliation without invoking `before_resume`. - **Signal ownership.** No public surface accepts a signal; a `before_request` patch carrying one has it stripped. Assert by type and by test. @@ -3147,12 +3300,14 @@ One corruption assertion constructs an `aborted` response with running control d | **Session** | One conversation: tree, facts, ledger, lanes. | | **Lane** | Named cursor into the tree with its own config, queues, and one operation. | | **Operation** | One accepted unit of work: run, compaction, or navigation. | +| **Acceptance** | The atomic creation of one durable operation by `accept`; it starts no driver. | +| **Drive pass** | One process-local ownership span created or joined by `drive`, ending at settlement, a durable wait, or safe yield. | | **Effect** | Anything not pure computation: commit, provider request, tool, hook, timer. | | **Repeat-sensitive effect** | One whose repetition is observable outside the harness. | | **Operation state** | The complete durable state of one open operation — the `op.state` register and restart point after task loss. | -| **Active operation** | The one process-local async task currently executing a lane's open operation, with its completion promise and `EffectGate`. | +| **Active operation** | The one process-local async task for the lane's current drive pass, with its completion promise and `EffectGate`. | | **Breakpoint** | A manual-drive barrier described by `ActionInfo`; it parks before ordinary code but does not execute that code. | -| **Effect gate** | Process-local synchronous arbitration of ordinary operation admission against abort, paired with the operation's cooperative signal. | +| **Effect gate** | Process-local synchronous arbitration of ordinary operation admission against a cancellation request, paired with the operation's cooperative signal. | | **Reserved id** | An id minted before its content exists: a string in `op.state` (settlement family) or a `pending.entry` key (queued content). | | **Follower id** | An id minted with its leader's 48-bit timestamp so a call/result group shares one time prefix (§1.2). | | **Lane mutation line** | Per-lane serialization point where all state-dependent mutations queue. | diff --git a/packages/agent/src/harness/agent-harness-runtime.ts b/packages/agent/src/harness/agent-harness-runtime.ts new file mode 100644 index 000000000..0231f1259 --- /dev/null +++ b/packages/agent/src/harness/agent-harness-runtime.ts @@ -0,0 +1,1105 @@ +import type { Api, AssistantMessage, ImageContent, Model, RetryPolicy, Usage } from "@earendil-works/pi-ai"; +import type { AgentMessage, QueueMode, ThinkingLevel } from "../types.ts"; +import { + type AbortRequestResult, + type AbortResult, + type ActionInfo, + type AgentHarness, + type AgentHarnessOptions, + type AgentLane, + type CancelQueuedResult, + Closed, + type CompactionResult, + type CreateLaneResult, + type CurrentOperationInfo, + type DriveOptions, + type DriveResult, + HarnessClosed, + type HarnessEvent, + HarnessFault, + InvalidLane, + type LaneExecutionInfo, + LaneExists, + type LaneInfo, + type LaneSnapshot, + type NavigateOptions, + type NavigationResult, + type NextRunResult, + type OperationAdmissionResult, + OperationMismatch, + type OperationRequest, + type QueueResult, + type RecordUsageResult, + type Resources, + type ResumeResult, + type RunResult, + type SessionSnapshot, + type SuspendedOperation, + type TerminalOperationOutcome, + UnknownTarget, + type WatchHandle, +} from "./agent-harness.ts"; +import { type CompactionSettings, DEFAULT_COMPACTION_SETTINGS } from "./compaction/compaction.ts"; +import { HarnessEventBus } from "./events.ts"; +import { BreakpointBarrier } from "./execution/breakpoint.ts"; +import { OperationEffectGate } from "./execution/effect-gate.ts"; +import { HookRegistry } from "./hooks.ts"; +import { type RestoredLane, restoreLane } from "./restore.ts"; +import { Result } from "./result.ts"; +import { LaneMutationLine } from "./session/lane-mutations.ts"; +import { + SessionInvalidLaneError, + SessionInvariantError, + SessionLaneExistsError, + SessionUnknownTargetError, +} from "./session/session.ts"; +import type { + JsonValue, + LaneConfiguration, + LaneLastResult, + Session, + SessionMutator, + SessionTree, +} from "./session/types.ts"; +import type { AgentHarnessStreamOptions, AgentHarnessTool } from "./types.ts"; + +const DEFAULT_RETRY_POLICY: RetryPolicy = { enabled: true, maxRetries: 3, baseDelayMs: 1_000 }; + +class RuntimeSliceNotImplemented extends Error { + constructor(operation: string) { + super(`${operation} is not implemented until its later AgentHarness runtime slice`); + this.name = "RuntimeSliceNotImplemented"; + } +} + +interface RuntimeSettings { + tools: AgentHarnessTool[]; + resources: Resources; + streamOptions: AgentHarnessStreamOptions; + retryPolicy: RetryPolicy; + compaction: CompactionSettings; + steeringMode: QueueMode; + followUpMode: QueueMode; +} + +interface DeferredValue { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +} + +interface ActiveOperation { + operationId: string; + completion: Promise; + resolve: (result: DriveResult) => void; + reject: (error: unknown) => void; + effectGate: OperationEffectGate; + task?: Promise; +} + +type DriveArbitration = + | { kind: "result"; result: DriveResult } + | { kind: "join"; completion: Promise } + | { kind: "installed"; active: ActiveOperation }; + +function deferredValue(): DeferredValue { + let resolvePromise: ((value: T) => void) | undefined; + let rejectPromise: ((error: unknown) => void) | undefined; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + return { + promise, + resolve(value) { + resolvePromise?.(value); + }, + reject(error) { + rejectPromise?.(error); + }, + }; +} + +export async function createAgentHarness( + options: AgentHarnessOptions, +): Promise<{ harness: AgentHarness; suspended: SuspendedOperation[] }> { + const runtime = new AgentHarnessRuntime(options); + try { + const suspended = await runtime.initialize(); + return { harness: runtime, suspended }; + } catch (error) { + throw runtime.fault(error); + } +} + +class AgentHarnessRuntime implements AgentHarness { + readonly name = "main"; + readonly session: SessionTree; + readonly hooks: HookRegistry; + readonly events: HarnessEventBus; + readonly sessionStorage: Session; + readonly models: AgentHarnessOptions["models"]; + readonly driveMode: "automatic" | "manual"; + readonly seed: LaneConfiguration; + readonly settingsLine = new LaneMutationLine(); + readonly laneRuntimes = new Map>(); + readonly activeOperations = new Map(); + readonly restoredSuspensions = new Map(); + settings: RuntimeSettings; + settingsRevision = 0; + state: "open" | "faulted" | "closing" | "closed" = "open"; + faultError: HarnessFault | undefined; + closePromise: Promise | undefined; + + constructor(options: AgentHarnessOptions) { + validateRetryPolicy(options.retry ?? DEFAULT_RETRY_POLICY); + validateCompactionSettings(options.compaction ?? DEFAULT_COMPACTION_SETTINGS); + this.sessionStorage = options.session; + this.models = options.models; + this.driveMode = options.drive ?? "automatic"; + const tools = [...(options.tools ?? [])]; + this.seed = { + model: { provider: options.model.provider, modelId: options.model.id }, + thinkingLevel: options.thinkingLevel ?? "off", + activeToolNames: [...(options.activeToolNames ?? tools.map((tool) => tool.name))], + }; + this.settings = { + tools, + resources: options.resources ?? {}, + streamOptions: options.streamOptions ?? {}, + retryPolicy: options.retry ?? DEFAULT_RETRY_POLICY, + compaction: options.compaction ?? DEFAULT_COMPACTION_SETTINGS, + steeringMode: options.steeringMode ?? "all", + followUpMode: options.followUpMode ?? "all", + }; + this.events = new HarnessEventBus(); + this.hooks = new HookRegistry((error, hook, lane) => + this.events.emit({ + type: "handler_error", + kind: "hook", + hook, + error: error.message, + ...(error.stack === undefined ? {} : { stack: error.stack }), + lane, + }), + ); + this.session = options.session.view("main"); + } + + async initialize(): Promise { + await this.initializeMainConfiguration(); + const [leaves, configurations, states, lastResults] = await Promise.all([ + this.sessionStorage.listRegisters("lane.leaf"), + this.sessionStorage.listRegisters("lane.config"), + this.sessionStorage.listRegisters("lane.state"), + this.sessionStorage.listRegisters("lane.lastResult"), + ]); + const leafNames = new Set(leaves.map((register) => register.key)); + if (!leafNames.has("main")) throw new SessionInvariantError("Session is missing main lane"); + for (const register of [...configurations, ...states, ...lastResults]) { + if (!leafNames.has(register.key)) { + throw new SessionInvariantError( + `Lane ${JSON.stringify(register.key)} has ${register.namespace} without lane.leaf`, + ); + } + } + + const suspended: SuspendedOperation[] = []; + for (const { key: lane } of leaves) { + const restored = await this.sessionStorage.mutate(lane, (reader) => restoreLane(reader, lane)); + const runtime = new AgentLaneRuntime(this, lane); + this.laneRuntimes.set(lane, runtime); + if (restored.current === undefined) continue; + const descriptor = this.describeSuspension(restored); + this.restoredSuspensions.set(lane, descriptor); + suspended.push(descriptor); + } + return suspended; + } + + async lane(name: string): Promise { + this.assertOpen(); + return this.laneRuntimes.get(name); + } + + async createLane(name: string, at: string | null): Promise { + const closed = this.resultClosedError(); + if (closed !== undefined) return Result.err(closed); + try { + await this.sessionStorage.createLane(name, at, cloneConfiguration(this.seed)); + const lane = new AgentLaneRuntime(this, name); + this.laneRuntimes.set(name, lane); + await this.events.emit({ type: "lane_created", lane: name, at }); + return Result.ok(lane); + } catch (error) { + if (error instanceof SessionLaneExistsError) { + return Result.err(new LaneExists({ lane: error.lane, message: error.message })); + } + if (error instanceof SessionInvalidLaneError) { + return Result.err(new InvalidLane({ lane: error.lane, reason: error.reason, message: error.message })); + } + if (error instanceof SessionUnknownTargetError) { + return Result.err(new UnknownTarget({ targetId: error.targetId, message: error.message })); + } + throw this.fault(error); + } + } + + async lanes(): Promise { + this.assertOpen(); + return Promise.all( + [...this.laneRuntimes.values()].map(async (lane) => { + const execution = await lane.inspectExecution(); + return { + name: lane.name, + leafId: execution.leafId, + operation: + execution.current === null + ? null + : { + id: execution.current.id, + kind: execution.current.kind, + status: execution.current.status, + }, + }; + }), + ); + } + + getTools(): Promise[]> { + return this.readSettings((settings) => [...settings.tools]); + } + + setTools(tools: AgentHarnessTool[]): Promise { + return this.writeSettings((settings) => ({ ...settings, tools: [...tools] }), { + type: "config_update", + property: "tools", + }); + } + + getResources(): Promise { + return this.readSettings((settings) => settings.resources); + } + + setResources(resources: Resources): Promise { + return this.writeSettings((settings) => ({ ...settings, resources }), { + type: "config_update", + property: "resources", + }); + } + + getStreamOptions(): Promise["streamOptions"]>> { + return this.readSettings((settings) => settings.streamOptions); + } + + setStreamOptions(options: NonNullable["streamOptions"]>): Promise { + return this.writeSettings((settings) => ({ ...settings, streamOptions: options }), { + type: "config_update", + property: "streamOptions", + }); + } + + getRetryPolicy(): Promise { + return this.readSettings((settings) => settings.retryPolicy); + } + + setRetryPolicy(policy: RetryPolicy): Promise { + validateRetryPolicy(policy); + return this.writeSettings((settings) => ({ ...settings, retryPolicy: policy }), { + type: "config_update", + property: "retryPolicy", + }); + } + + getCompactionSettings(): Promise { + return this.readSettings((settings) => settings.compaction); + } + + setCompactionSettings(compaction: CompactionSettings): Promise { + validateCompactionSettings(compaction); + return this.writeSettings((settings) => ({ ...settings, compaction }), { + type: "config_update", + property: "compactionSettings", + }); + } + + getSteeringMode(): Promise { + return this.readSettings((settings) => settings.steeringMode); + } + + setSteeringMode(steeringMode: QueueMode): Promise { + return this.writeSettings((settings) => ({ ...settings, steeringMode }), { + type: "config_update", + property: "steeringMode", + }); + } + + getFollowUpMode(): Promise { + return this.readSettings((settings) => settings.followUpMode); + } + + setFollowUpMode(followUpMode: QueueMode): Promise { + return this.writeSettings((settings) => ({ ...settings, followUpMode }), { + type: "config_update", + property: "followUpMode", + }); + } + + async watchSession(): Promise> { + this.assertOpen(); + throw new RuntimeSliceNotImplemented("watchSession"); + } + + close(): Promise { + if (this.closePromise !== undefined) return this.closePromise; + this.state = "closing"; + const error = new HarnessClosed(); + this.hooks.close(error); + this.events.close(error); + for (const lane of this.laneRuntimes.values()) lane.breakpoint.close(error); + for (const active of this.activeOperations.values()) { + active.effectGate.close(error); + active.reject(error); + } + this.closePromise = (async () => { + await this.settingsLine.seal(error); + await Promise.allSettled( + [...this.activeOperations.values()].flatMap((active) => (active.task === undefined ? [] : [active.task])), + ); + await this.sessionStorage.close(); + this.state = "closed"; + })(); + return this.closePromise; + } + + getLeafId(): Promise { + return this.mainLane().getLeafId(); + } + getLastResult(): Promise { + return this.mainLane().getLastResult(); + } + accept(request: OperationRequest): Promise { + return this.mainLane().accept(request); + } + drive(options: DriveOptions): Promise { + return this.mainLane().drive(options); + } + requestAbort(operationId: string): Promise { + return this.mainLane().requestAbort(operationId); + } + inspectExecution(): Promise { + return this.mainLane().inspectExecution(); + } + prompt(text: string, images?: ImageContent[]): Promise; + prompt(message: AgentMessage | AgentMessage[]): Promise; + prompt(message: string | AgentMessage | AgentMessage[], images?: ImageContent[]): Promise { + return typeof message === "string" ? this.mainLane().prompt(message, images) : this.mainLane().prompt(message); + } + skill(name: string, additionalInstructions?: string): Promise { + return this.mainLane().skill(name, additionalInstructions); + } + promptFromTemplate(name: string, args?: string[]): Promise { + return this.mainLane().promptFromTemplate(name, args); + } + compact(options?: { customInstructions?: string }): Promise { + return this.mainLane().compact(options); + } + navigateTree(targetId: string | null, options?: NavigateOptions): Promise { + return this.mainLane().navigateTree(targetId, options); + } + resume(): Promise { + return this.mainLane().resume(); + } + abort(): Promise { + return this.mainLane().abort(); + } + steer(message: string | AgentMessage, images?: ImageContent[]): Promise { + return this.mainLane().steer(message, images); + } + followUp(message: string | AgentMessage, images?: ImageContent[]): Promise { + return this.mainLane().followUp(message, images); + } + nextRun(message: string | AgentMessage, images?: ImageContent[]): Promise { + return this.mainLane().nextRun(message, images); + } + cancelQueued(entryId: string): Promise { + return this.mainLane().cancelQueued(entryId); + } + recordUsage(usage: Usage, options?: { entryId?: string; details?: JsonValue }): Promise { + return this.mainLane().recordUsage(usage, options); + } + waitForIdle(): Promise { + return this.mainLane().waitForIdle(); + } + runWhenIdle(callback: () => void | Promise): Promise { + return this.mainLane().runWhenIdle(callback); + } + peekAction(): Promise { + return this.mainLane().peekAction(); + } + executeAction(): Promise { + return this.mainLane().executeAction(); + } + runToCompletion(): Promise { + return this.mainLane().runToCompletion(); + } + getModel(): Promise | undefined> { + return this.mainLane().getModel(); + } + setModel(model: Model): Promise { + return this.mainLane().setModel(model); + } + getThinkingLevel(): Promise { + return this.mainLane().getThinkingLevel(); + } + setThinkingLevel(level: ThinkingLevel): Promise { + return this.mainLane().setThinkingLevel(level); + } + getActiveTools(): Promise { + return this.mainLane().getActiveTools(); + } + setActiveTools(names: string[]): Promise { + return this.mainLane().setActiveTools(names); + } + watch(): Promise> { + return this.mainLane().watch(); + } + + async driveLane(lane: AgentLaneRuntime, options: DriveOptions): Promise { + const closed = this.resultClosedError(); + if (closed !== undefined) return Result.err(closed); + let arbitration: DriveArbitration; + try { + arbitration = await this.sessionStorage.mutate(lane.name, async (reader): Promise => { + const restored = await restoreLane(reader, lane.name, { includeLastResult: true }); + const currentId = restored.laneState.currentOperationId; + if (currentId === null) { + if (restored.lastResult?.operationId === options.operationId) { + return { + kind: "result", + result: Result.ok({ + kind: "settled", + operationId: options.operationId, + outcome: await hydrateTerminalOutcome(reader, restored.lastResult), + }), + }; + } + return { kind: "result", result: Result.err(this.mismatch(lane.name, options.operationId, restored)) }; + } + if (currentId !== options.operationId) { + return { kind: "result", result: Result.err(this.mismatch(lane.name, options.operationId, restored)) }; + } + const existing = this.activeOperations.get(lane.name); + if (existing !== undefined) { + if (existing.operationId !== options.operationId) { + throw new SessionInvariantError(`Lane ${JSON.stringify(lane.name)} has a task for another operation`); + } + return { kind: "join", completion: existing.completion }; + } + const deferred = deferredValue(); + const active: ActiveOperation = { + operationId: options.operationId, + completion: deferred.promise, + resolve: deferred.resolve, + reject: deferred.reject, + effectGate: new OperationEffectGate(), + }; + this.activeOperations.set(lane.name, active); + return { kind: "installed", active }; + }); + } catch (error) { + throw this.fault(error); + } + + if (arbitration.kind === "result") return arbitration.result; + if (arbitration.kind === "join") return arbitration.completion; + this.startDrivePass(lane, arbitration.active, options); + return arbitration.active.completion; + } + + async inspectLane(lane: AgentLaneRuntime): Promise { + this.assertOpen(); + try { + return await this.sessionStorage.mutate(lane.name, async (reader) => { + const restored = await restoreLane(reader, lane.name, { includeLastResult: true }); + return { + lane: lane.name, + leafId: restored.leafId, + current: this.currentInfo(lane.name, restored), + ...(restored.lastResult === undefined ? {} : { lastResult: restored.lastResult }), + }; + }); + } catch (error) { + throw this.fault(error); + } + } + + async updateLaneConfiguration( + lane: string, + update: (configuration: LaneConfiguration) => LaneConfiguration, + ): Promise<{ previous: LaneConfiguration; value: LaneConfiguration }> { + this.assertOpen(); + try { + return await this.sessionStorage.mutate(lane, async (mutator) => { + const stored = await mutator.getRegister("lane.config", lane); + if (stored === undefined) { + throw new SessionInvariantError(`Lane ${JSON.stringify(lane)} is missing lane.config`); + } + const value = update(stored.value); + await mutator.commit({ + writes: [{ kind: "register", op: "set", namespace: "lane.config", key: lane, value }], + }); + return { previous: stored.value, value }; + }); + } catch (error) { + throw this.fault(error); + } + } + + describeSuspension(restored: RestoredLane): SuspendedOperation { + const current = restored.current; + if (current === undefined) throw new SessionInvariantError(`Lane ${restored.lane} is not suspended`); + return { + lane: restored.lane, + operationId: current.operation.operationId, + kind: current.operation.intent.kind, + startedAt: current.operation.startedAt, + reason: "crash", + ...(current.operation.intent.kind !== "run" + ? {} + : { + prompt: current.operation.intent.promptEntryIds.map((id) => { + const entry = current.entries.get(id); + if (entry?.type !== "message") throw new SessionInvariantError(`Prompt entry ${id} is missing`); + return entry.message; + }), + }), + }; + } + + assertOpen(): void { + if (this.faultError !== undefined) throw this.faultError; + if (!this.isOpen()) throw new HarnessClosed(); + } + + isOpen(): boolean { + return this.state === "open"; + } + + closedError(): Closed { + return new Closed({ message: "AgentHarness is closed" }); + } + + resultClosedError(): Closed | undefined { + if (this.faultError !== undefined) throw this.faultError; + return this.isOpen() ? undefined : this.closedError(); + } + + fault(cause: unknown): HarnessFault { + if (this.faultError !== undefined) return this.faultError; + const normalized = cause instanceof Error ? cause : new Error(String(cause)); + const fault = new HarnessFault("AgentHarness storage or invariant fault", normalized); + this.faultError = fault; + this.state = "faulted"; + this.hooks.close(fault); + for (const lane of this.laneRuntimes.values()) lane.breakpoint.close(fault); + for (const active of this.activeOperations.values()) { + active.effectGate.close(fault); + active.reject(fault); + } + void this.events.emit({ type: "fault", code: "harness_fault", message: fault.message }); + this.events.close(fault); + return fault; + } + + private mainLane(): AgentLaneRuntime { + const lane = this.laneRuntimes.get("main"); + if (lane === undefined) throw new SessionInvariantError("AgentHarness main lane is not initialized"); + return lane; + } + + private async initializeMainConfiguration(): Promise { + await this.sessionStorage.mutate("main", async (mutator) => { + const [leaf, state, configuration, lastResult] = await Promise.all([ + mutator.getRegister("lane.leaf", "main"), + mutator.getRegister("lane.state", "main"), + mutator.getRegister("lane.config", "main"), + mutator.getRegister("lane.lastResult", "main"), + ]); + if (leaf === undefined || state === undefined) { + throw new SessionInvariantError("Session main lane has incomplete durable state"); + } + if (configuration !== undefined) return; + if (state.value.currentOperationId !== null || lastResult !== undefined) { + throw new SessionInvariantError("Configured or active main lane is missing lane.config"); + } + await mutator.commit({ + writes: [ + { + kind: "register", + op: "set", + namespace: "lane.config", + key: "main", + value: cloneConfiguration(this.seed), + }, + ], + }); + }); + } + + private startDrivePass(lane: AgentLaneRuntime, active: ActiveOperation, options: DriveOptions): void { + active.task = (async () => { + try { + const result = await this.executeDrivePass(lane, active, options); + await this.removeActiveOperation(lane.name, active); + active.resolve(result); + } catch (error) { + await this.removeActiveOperation(lane.name, active); + active.reject(error); + } + })(); + } + + private async executeDrivePass( + lane: AgentLaneRuntime, + active: ActiveOperation, + options: DriveOptions, + ): Promise { + let restored: RestoredLane; + try { + restored = await this.sessionStorage.mutate(lane.name, (reader) => restoreLane(reader, lane.name)); + } catch (error) { + throw this.fault(error); + } + if (restored.current === undefined || restored.current.operation.operationId !== active.operationId) { + let inspected: RestoredLane; + try { + inspected = await this.sessionStorage.mutate(lane.name, (reader) => + restoreLane(reader, lane.name, { includeLastResult: true }), + ); + } catch (error) { + throw this.fault(error); + } + if (inspected.lastResult?.operationId === active.operationId) { + let outcome: TerminalOperationOutcome; + try { + outcome = await this.sessionStorage.mutate(lane.name, (reader) => + hydrateTerminalOutcome(reader, inspected.lastResult!), + ); + } catch (error) { + throw this.fault(error); + } + return Result.ok({ kind: "settled", operationId: active.operationId, outcome }); + } + return Result.err(this.mismatch(lane.name, active.operationId, inspected)); + } + if (deadlineReached(options)) return Result.ok({ kind: "yielded", operationId: active.operationId }); + + await lane.breakpoint.hit({ + kind: "runtime.dispatch", + description: "Advance durable operation", + details: { operationId: active.operationId, operationKind: restored.current.state.kind }, + }); + if (deadlineReached(options)) return Result.ok({ kind: "yielded", operationId: active.operationId }); + throw new RuntimeSliceNotImplemented(`drive(${restored.current.state.kind})`); + } + + private async removeActiveOperation(lane: string, active: ActiveOperation): Promise { + if (this.state === "open") { + await this.sessionStorage.mutate(lane, () => { + if (this.activeOperations.get(lane) === active) this.activeOperations.delete(lane); + }); + return; + } + if (this.activeOperations.get(lane) === active) this.activeOperations.delete(lane); + } + + private currentInfo(lane: string, restored: RestoredLane): CurrentOperationInfo | null { + const current = restored.current; + if (current === undefined) return null; + const active = this.activeOperations.get(lane); + if (active !== undefined && active.operationId !== current.operation.operationId) { + throw new SessionInvariantError(`Lane ${JSON.stringify(lane)} has a task for another operation`); + } + const status = + current.state.control.status === "cancel_requested" + ? "aborting" + : active?.operationId === current.operation.operationId + ? "running" + : "suspended"; + const suspended = status === "suspended" ? this.suspensionForInspection(restored) : undefined; + return { + id: current.operation.operationId, + kind: current.state.kind, + status, + startedAt: current.operation.startedAt, + ...(suspended === undefined ? {} : { suspended }), + }; + } + + private suspensionForInspection(restored: RestoredLane): SuspendedOperation | undefined { + const remembered = this.restoredSuspensions.get(restored.lane); + return remembered?.operationId === restored.current?.operation.operationId ? remembered : undefined; + } + + private mismatch(lane: string, expectedOperationId: string, restored: RestoredLane): OperationMismatch { + const currentOperationId = restored.laneState.currentOperationId ?? undefined; + const lastOperationId = restored.lastResult?.operationId; + return new OperationMismatch({ + lane, + expectedOperationId, + ...(currentOperationId === undefined ? {} : { currentOperationId }), + ...(lastOperationId === undefined ? {} : { lastOperationId }), + message: `Operation ${expectedOperationId} does not own lane ${JSON.stringify(lane)}`, + }); + } + + private async readSettings(read: (settings: RuntimeSettings) => T): Promise { + this.assertOpen(); + return this.settingsLine.run("settings", () => read(this.settings)); + } + + private async writeSettings( + update: (settings: RuntimeSettings) => RuntimeSettings, + event: Extract, + ): Promise { + this.assertOpen(); + await this.settingsLine.run("settings", () => { + this.settings = update(this.settings); + this.settingsRevision++; + }); + await this.events.emit(event); + } +} + +class AgentLaneRuntime implements AgentLane { + readonly name: string; + readonly session: SessionTree; + readonly breakpoint: BreakpointBarrier; + private readonly harness: AgentHarnessRuntime; + + constructor(harness: AgentHarnessRuntime, name: string) { + this.harness = harness; + this.name = name; + this.session = harness.sessionStorage.view(name); + this.breakpoint = new BreakpointBarrier(harness.driveMode); + } + + async getLeafId(): Promise { + this.harness.assertOpen(); + return this.session.getLeafId(); + } + + async getLastResult(): Promise { + this.harness.assertOpen(); + return (await this.harness.sessionStorage.getRegister("lane.lastResult", this.name))?.value; + } + + accept(_request: OperationRequest): Promise { + return this.unimplementedResult("accept"); + } + + drive(options: DriveOptions): Promise { + return this.harness.driveLane(this, options); + } + + requestAbort(_operationId: string): Promise { + return this.unimplementedResult("requestAbort"); + } + + inspectExecution(): Promise { + return this.harness.inspectLane(this); + } + + prompt(_text: string, _images?: ImageContent[]): Promise; + prompt(_message: AgentMessage | AgentMessage[]): Promise; + prompt(_message: string | AgentMessage | AgentMessage[], _images?: ImageContent[]): Promise { + return this.unimplementedResult("prompt"); + } + skill(_name: string, _additionalInstructions?: string): Promise { + return this.unimplementedResult("skill"); + } + promptFromTemplate(_name: string, _args?: string[]): Promise { + return this.unimplementedResult("promptFromTemplate"); + } + compact(_options?: { customInstructions?: string }): Promise { + return this.unimplementedResult("compact"); + } + navigateTree(_targetId: string | null, _options?: NavigateOptions): Promise { + return this.unimplementedResult("navigateTree"); + } + resume(): Promise { + return this.unimplementedResult("resume"); + } + abort(): Promise { + return this.unimplementedResult("abort"); + } + steer(_message: string | AgentMessage, _images?: ImageContent[]): Promise { + return this.unimplementedResult("steer"); + } + followUp(_message: string | AgentMessage, _images?: ImageContent[]): Promise { + return this.unimplementedResult("followUp"); + } + nextRun(_message: string | AgentMessage, _images?: ImageContent[]): Promise { + return this.unimplementedResult("nextRun"); + } + cancelQueued(_entryId: string): Promise { + return this.unimplementedResult("cancelQueued"); + } + recordUsage(_usage: Usage, _options?: { entryId?: string; details?: JsonValue }): Promise { + return this.unimplementedResult("recordUsage"); + } + waitForIdle(): Promise { + return this.notImplemented("waitForIdle"); + } + runWhenIdle(_callback: () => void | Promise): Promise { + return this.notImplemented("runWhenIdle"); + } + async peekAction(): Promise { + this.harness.assertOpen(); + return this.harness.sessionStorage.mutate(this.name, () => this.breakpoint.peek()); + } + async executeAction(): Promise { + this.harness.assertOpen(); + return this.harness.sessionStorage.mutate(this.name, () => this.breakpoint.release()); + } + async runToCompletion(): Promise { + this.harness.assertOpen(); + while (true) { + const active = this.harness.activeOperations.get(this.name); + if (active === undefined) return; + const action = this.breakpoint.peek(); + if (action !== undefined) { + this.breakpoint.release(); + continue; + } + await Promise.race([ + active.completion.then( + () => undefined, + () => undefined, + ), + this.breakpoint.waitForChange(), + ]); + } + } + async getModel(): Promise | undefined> { + this.harness.assertOpen(); + const configuration = await this.getConfiguration(); + return this.harness.models.getModel(configuration.model.provider, configuration.model.modelId); + } + async setModel(model: Model): Promise { + const { previous, value } = await this.harness.updateLaneConfiguration(this.name, (configuration) => ({ + ...configuration, + model: { provider: model.provider, modelId: model.id }, + })); + await this.harness.events.emit({ + type: "config_update", + property: "model", + value: { ...value.model }, + previous: { ...previous.model }, + lane: this.name, + }); + } + async getThinkingLevel(): Promise { + return (await this.getConfiguration()).thinkingLevel; + } + async setThinkingLevel(thinkingLevel: ThinkingLevel): Promise { + const { previous } = await this.harness.updateLaneConfiguration(this.name, (configuration) => ({ + ...configuration, + thinkingLevel, + })); + await this.harness.events.emit({ + type: "config_update", + property: "thinkingLevel", + value: thinkingLevel, + previous: previous.thinkingLevel, + lane: this.name, + }); + } + async getActiveTools(): Promise { + return [...(await this.getConfiguration()).activeToolNames]; + } + async setActiveTools(activeToolNames: string[]): Promise { + const names = [...activeToolNames]; + const { previous } = await this.harness.updateLaneConfiguration(this.name, (configuration) => ({ + ...configuration, + activeToolNames: names, + })); + await this.harness.events.emit({ + type: "config_update", + property: "activeTools", + value: [...names], + previous: [...previous.activeToolNames], + lane: this.name, + }); + } + watch(): Promise> { + return this.notImplemented("watch"); + } + + private async getConfiguration(): Promise { + this.harness.assertOpen(); + try { + const configuration = await this.harness.sessionStorage.getRegister("lane.config", this.name); + if (configuration === undefined) { + throw new SessionInvariantError(`Lane ${JSON.stringify(this.name)} is missing lane.config`); + } + return configuration.value; + } catch (error) { + throw this.harness.fault(error); + } + } + + private async unimplementedResult(operation: string) { + const closed = this.harness.resultClosedError(); + if (closed !== undefined) return Result.err(closed); + throw new RuntimeSliceNotImplemented(operation); + } + + private async notImplemented(operation: string): Promise { + this.harness.assertOpen(); + throw new RuntimeSliceNotImplemented(operation); + } +} + +function cloneConfiguration(configuration: LaneConfiguration): LaneConfiguration { + return { ...configuration, model: { ...configuration.model }, activeToolNames: [...configuration.activeToolNames] }; +} + +function validateRetryPolicy(policy: RetryPolicy): void { + if ( + !Number.isSafeInteger(policy.maxRetries) || + policy.maxRetries < 0 || + policy.maxRetries === Number.MAX_SAFE_INTEGER || + !Number.isSafeInteger(policy.baseDelayMs) || + policy.baseDelayMs < 0 + ) { + throw new RangeError("Retry policy values must be finite non-negative safe integers"); + } +} + +function validateCompactionSettings(settings: CompactionSettings): void { + if ( + !Number.isSafeInteger(settings.reserveTokens) || + settings.reserveTokens < 0 || + !Number.isSafeInteger(settings.keepRecentTokens) || + settings.keepRecentTokens < 0 + ) { + throw new RangeError("Compaction token counts must be finite non-negative safe integers"); + } +} + +function deadlineReached(options: DriveOptions): boolean { + return options.deadline !== undefined && Date.now() >= options.deadline; +} + +async function hydrateTerminalOutcome( + reader: Pick, + lastResult: LaneLastResult, +): Promise { + const referencedIds = [ + ...(lastResult.leafId === null ? [] : [lastResult.leafId]), + ...(lastResult.kind === "run" && lastResult.finalAssistantEntryId !== undefined + ? [lastResult.finalAssistantEntryId] + : []), + ...(lastResult.kind === "navigation" && + lastResult.outcome === "completed" && + lastResult.summaryEntryId !== undefined + ? [lastResult.summaryEntryId] + : []), + ]; + const entries = await reader.getEntries([...new Set(referencedIds)]); + if (lastResult.leafId !== null && !entries.has(lastResult.leafId)) { + throw new SessionInvariantError(`Terminal leaf ${lastResult.leafId} is missing`); + } + if (lastResult.kind === "run") { + const final = + lastResult.finalAssistantEntryId === undefined ? undefined : entries.get(lastResult.finalAssistantEntryId); + if (lastResult.finalAssistantEntryId !== undefined && final === undefined) { + throw new SessionInvariantError(`Final assistant ${lastResult.finalAssistantEntryId} is missing`); + } + if (final !== undefined && (final.type !== "message" || final.message.role !== "assistant")) { + throw new SessionInvariantError(`Final assistant ${lastResult.finalAssistantEntryId} is invalid`); + } + const finalMessage = final?.message as AssistantMessage | undefined; + const finalFields = + final === undefined || finalMessage === undefined ? {} : { finalEntryId: final.id, finalMessage }; + if (lastResult.outcome === "failed") { + return { + operation: "run", + runId: lastResult.operationId, + kind: "failed", + leafId: lastResult.leafId, + error: lastResult.error, + ...finalFields, + }; + } + return { + operation: "run", + runId: lastResult.operationId, + kind: lastResult.outcome, + leafId: lastResult.leafId, + ...finalFields, + }; + } + if (lastResult.kind === "compaction") { + if (lastResult.outcome === "completed") { + const entry = entries.get(lastResult.leafId); + if (entry?.type !== "compaction") throw new SessionInvariantError("Completed compaction leaf is invalid"); + return { + operation: "compaction", + runId: lastResult.operationId, + kind: "completed", + leafId: lastResult.leafId, + entry, + }; + } + if (lastResult.outcome === "failed") { + return { + operation: "compaction", + runId: lastResult.operationId, + kind: "failed", + leafId: lastResult.leafId, + error: lastResult.error, + }; + } + return { + operation: "compaction", + runId: lastResult.operationId, + kind: lastResult.outcome, + leafId: lastResult.leafId, + }; + } + if (lastResult.outcome === "completed") { + const summary = lastResult.summaryEntryId === undefined ? undefined : entries.get(lastResult.summaryEntryId); + if (lastResult.summaryEntryId !== undefined && summary === undefined) { + throw new SessionInvariantError(`Navigation summary ${lastResult.summaryEntryId} is missing`); + } + if (summary !== undefined && summary.type !== "branch_summary") { + throw new SessionInvariantError(`Navigation summary ${lastResult.summaryEntryId} is invalid`); + } + return { + operation: "navigation", + runId: lastResult.operationId, + kind: "completed", + oldLeafId: lastResult.oldLeafId, + newLeafId: lastResult.leafId, + ...(summary === undefined ? {} : { summaryEntry: summary }), + }; + } + if (lastResult.outcome === "failed") { + return { + operation: "navigation", + runId: lastResult.operationId, + kind: "failed", + leafId: lastResult.leafId, + error: lastResult.error, + }; + } + return { + operation: "navigation", + runId: lastResult.operationId, + kind: lastResult.outcome, + leafId: lastResult.leafId, + }; +} diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index 0b8adf224..8b123e20a 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -13,6 +13,7 @@ import type { } from "@earendil-works/pi-ai"; import type { TelemetryContext } from "@earendil-works/pi-telemetry"; import type { AgentMessage, AgentToolResult, QueueMode, ThinkingLevel } from "../types.ts"; +import { createAgentHarness } from "./agent-harness-runtime.ts"; import type { BranchPreparation, BranchSummaryResult } from "./compaction/branch-summarization.ts"; import type { CompactionPreparation, CompactionSettings, CompactResult } from "./compaction/compaction.ts"; import { type Result, TaggedError } from "./result.ts"; @@ -45,6 +46,13 @@ export class LaneBusy extends TaggedError("LaneBusy")<{ operationKind: "run" | "compaction" | "navigation"; message: string; }> {} +export class OperationMismatch extends TaggedError("OperationMismatch")<{ + lane: string; + expectedOperationId: string; + currentOperationId?: string; + lastOperationId?: string; + message: string; +}> {} export class MissingIdentities extends TaggedError("MissingIdentities")<{ lane: string; tools: string[]; @@ -150,7 +158,7 @@ export type NavigationResult = Result< { runId: string } & NavigationOutcome, LaneBusy | MissingIdentities | InvalidNavigation | UnknownTarget | Closed >; -export type ResumeResult = Result; +export type ResumeResult = Result; export type QueueResult = Result<{ entryId: string }, NoActiveRun | InvalidMessage | Closed>; export type NextRunResult = Result<{ entryId: string }, InvalidMessage | Closed>; export type CancelQueuedResult = Result<{ kind: "cancelled" | "already_consumed" | "not_found" }, Closed>; @@ -167,6 +175,82 @@ export interface NavigateOptions { customInstructions?: string; } +export type OperationRequest = + | { kind: "prompt"; operationId?: string; prompt: string; images?: ImageContent[] } + | { kind: "prompt"; operationId?: string; prompt: AgentMessage | AgentMessage[]; images?: never } + | { kind: "skill"; operationId?: string; name: string; additionalInstructions?: string } + | { kind: "prompt_template"; operationId?: string; name: string; args?: string[] } + | { kind: "compaction"; operationId?: string; customInstructions?: string } + | { kind: "navigation"; operationId?: string; targetId: string | null; options?: NavigateOptions }; + +export interface OperationAdmission { + operationId: string; + kind: "run" | "compaction" | "navigation"; + startedAt: number; +} + +export type OperationAdmissionError = + | LaneBusy + | MissingIdentities + | InvalidMessage + | UnknownSkill + | UnknownTemplate + | NothingToCompact + | InvalidNavigation + | UnknownTarget + | Closed; +export type OperationAdmissionResult = Result; + +export interface DriveOptions { + operationId: string; + deadline?: number; + waitForRetry?: boolean; + pollDeferred?: boolean; +} + +export interface CurrentOperationInfo { + id: string; + kind: "run" | "compaction" | "navigation"; + status: "running" | "suspended" | "aborting"; + startedAt: number; + suspended?: SuspendedOperation; +} + +export interface LaneExecutionInfo { + lane: string; + leafId: string | null; + current: CurrentOperationInfo | null; + lastResult?: LaneLastResult; +} + +export type TerminalOperationOutcome = + | ({ operation: "run"; runId: string } & Exclude) + | ({ operation: "compaction"; runId: string } & Exclude) + | ({ operation: "navigation"; runId: string } & Exclude); + +export type DriveOutcome = + | { kind: "settled"; operationId: string; outcome: TerminalOperationOutcome } + | { kind: "waiting"; operationId: string; reason: "retry"; notBefore: number } + | { kind: "waiting"; operationId: string; reason: "deferred"; deferred: DeferredHandle } + | { + kind: "waiting"; + operationId: string; + reason: "missing_identities"; + missing: { tools: string[]; models: string[] }; + } + | { kind: "yielded"; operationId: string }; +export type DriveResult = Result; + +export type AbortRequestResult = Result< + { + operationId: string; + newlyRequested: boolean; + steer: AgentMessage[]; + followUp: AgentMessage[]; + }, + OperationMismatch | Closed +>; + export interface ActionInfo { kind: string; description: string; @@ -189,17 +273,26 @@ export interface LaneInfo { }; } -export interface SuspendedOperation { +export type SuspendedOperation = { lane: string; operationId: string; kind: "run" | "compaction" | "navigation"; - reason: "crash" | "deferred" | "missing_identities"; startedAt: number; prompt?: AgentMessage[]; - deferred?: DeferredHandle; aborting?: { steer: AgentMessage[]; followUp: AgentMessage[] }; - missing: { tools: string[]; models: string[] }; -} +} & ( + | { reason: "deferred"; deferred: DeferredHandle; missing?: never } + | { + reason: "missing_identities"; + missing: { tools: string[]; models: string[] }; + deferred?: never; + } + | { + reason: "crash"; + deferred?: DeferredHandle; + missing?: { tools: string[]; models: string[] }; + } +); export interface QueuedItem { entryId: string; @@ -251,6 +344,18 @@ export type HarnessEventPayload = reason: "missing_identities"; missing: { tools: string[]; models: string[] }; } + | { + type: "compaction_suspend"; + runId: string; + reason: "missing_identities"; + missing: { tools: string[]; models: string[] }; + } + | { + type: "navigation_suspend"; + runId: string; + reason: "missing_identities"; + missing: { tools: string[]; models: string[] }; + } | { type: "run_abort"; runId: string; steer: AgentMessage[]; followUp: AgentMessage[] } | ({ type: "run_end"; runId: string; leafId: string | null } & ( | ({ outcome: "completed" | "aborted" } & OptionalFinalAssistant) @@ -531,6 +636,10 @@ export interface AgentLane { readonly name: string; getLeafId(): Promise; getLastResult(): Promise; + accept(request: OperationRequest): Promise; + drive(options: DriveOptions): Promise; + requestAbort(operationId: string): Promise; + inspectExecution(): Promise; prompt(text: string, images?: ImageContent[]): Promise; prompt(message: AgentMessage | AgentMessage[]): Promise; skill(name: string, additionalInstructions?: string): Promise; @@ -588,3 +697,6 @@ export interface AgentHarnessConstructor { options: AgentHarnessOptions, ): Promise<{ harness: AgentHarness; suspended: SuspendedOperation[] }>; } + +/** Runtime constructor for attaching the durable harness to one open session. */ +export const AgentHarness: AgentHarnessConstructor = { create: createAgentHarness }; diff --git a/packages/agent/src/harness/events.ts b/packages/agent/src/harness/events.ts new file mode 100644 index 000000000..ab2f57a2e --- /dev/null +++ b/packages/agent/src/harness/events.ts @@ -0,0 +1,146 @@ +import type { EventListener, Events, HarnessEvent, HarnessEventType, WatchHandle } from "./agent-harness.ts"; + +type UntypedEventListener = (event: HarnessEvent) => void | Promise; + +/** Passive harness event bus with isolated handler failures. */ +export class HarnessEventBus implements Events { + private readonly listeners = new Map>(); + private readonly watchListeners = new Set(); + private deliveryTail: Promise = Promise.resolve(); + private closedError: Error | undefined; + + on( + type: TType, + listener: EventListener>, + ): () => void { + if (this.closedError !== undefined) throw this.closedError; + const wrapped: UntypedEventListener = (event) => listener(event as Extract); + let listeners = this.listeners.get(type); + if (listeners === undefined) { + listeners = new Set(); + this.listeners.set(type, listeners); + } + listeners.add(wrapped); + return () => listeners?.delete(wrapped); + } + + emit(event: HarnessEvent): Promise { + if (this.closedError !== undefined) return Promise.resolve(); + const delivery = this.deliveryTail.then(() => this.deliver(event, true)); + this.deliveryTail = delivery.catch(() => {}); + return delivery; + } + + watch(snapshot: T, filter: (event: HarnessEvent) => boolean): WatchHandle { + if (this.closedError !== undefined) throw this.closedError; + const watcher = new BufferedEventWatcher(snapshot, async (error, event) => { + if (event.type === "handler_error") return; + const normalized = error instanceof Error ? error : new Error(String(error)); + const lane = "lane" in event && typeof event.lane === "string" ? event.lane : undefined; + await this.emit({ + type: "handler_error", + kind: "event", + event: event.type, + error: normalized.message, + ...(normalized.stack === undefined ? {} : { stack: normalized.stack }), + ...(lane === undefined ? {} : { lane }), + }); + }); + const watchListener: UntypedEventListener = (event) => { + if (filter(event)) watcher.push(event); + }; + this.watchListeners.add(watchListener); + watcher.setUnsubscribe(() => this.watchListeners.delete(watchListener)); + return watcher; + } + + close(error: Error): void { + this.closedError ??= error; + void this.deliveryTail.finally(() => { + this.listeners.clear(); + this.watchListeners.clear(); + }); + } + + private async deliver(event: HarnessEvent, reportErrors: boolean): Promise { + const listeners = [...(this.listeners.get(event.type) ?? []), ...this.watchListeners]; + for (const listener of listeners) { + try { + await listener(structuredClone(event)); + } catch (error) { + if (!reportErrors || event.type === "handler_error") continue; + const normalized = error instanceof Error ? error : new Error(String(error)); + const lane = "lane" in event && typeof event.lane === "string" ? event.lane : undefined; + const handlerError: HarnessEvent = { + type: "handler_error", + kind: "event", + event: event.type, + error: normalized.message, + ...(normalized.stack === undefined ? {} : { stack: normalized.stack }), + ...(lane === undefined ? {} : { lane }), + }; + await this.deliver(handlerError, false); + } + } + } +} + +class BufferedEventWatcher implements WatchHandle { + readonly snapshot: T; + private readonly onError: (error: unknown, event: HarnessEvent) => void | Promise; + private buffer: HarnessEvent[] = []; + private listener: EventListener | undefined; + private unsubscribeCallback: (() => void) | undefined; + private deliveryTail: Promise = Promise.resolve(); + private state: "buffering" | "started" | "unsubscribed" = "buffering"; + + constructor(snapshot: T, onError: (error: unknown, event: HarnessEvent) => void | Promise) { + this.snapshot = snapshot; + this.onError = onError; + } + + start(listener: EventListener): void { + if (this.state !== "buffering") throw new Error("WatchHandle.start() may be called only once"); + this.state = "started"; + this.listener = listener; + const buffered = this.buffer; + this.buffer = []; + for (const event of buffered) this.enqueue(event); + } + + unsubscribe(): void { + if (this.state === "unsubscribed") return; + this.state = "unsubscribed"; + this.buffer = []; + this.listener = undefined; + this.unsubscribeCallback?.(); + this.unsubscribeCallback = undefined; + } + + push(event: HarnessEvent): void { + if (this.state === "unsubscribed") return; + if (this.state === "buffering") { + this.buffer.push(event); + return; + } + this.enqueue(event); + } + + setUnsubscribe(callback: () => void): void { + this.unsubscribeCallback = callback; + } + + private enqueue(event: HarnessEvent): void { + const listener = this.listener; + if (listener === undefined) return; + this.deliveryTail = this.deliveryTail + .then(async () => { + if (this.state === "started") await listener(event); + }) + .catch(async (error) => { + try { + await this.onError(error, event); + } catch {} + }); + } +} diff --git a/packages/agent/src/harness/execution/assistant.ts b/packages/agent/src/harness/execution/assistant.ts index ab0debd4a..29e7f8d8b 100644 --- a/packages/agent/src/harness/execution/assistant.ts +++ b/packages/agent/src/harness/execution/assistant.ts @@ -12,6 +12,7 @@ import type { TelemetryContext } from "@earendil-works/pi-telemetry"; import type { AgentMessage, AgentTool, ThinkingLevel } from "../../types.ts"; import type { SettledAssistantMessage } from "../session/types.ts"; import type { AgentHarnessStreamOptions } from "../types.ts"; +import { AbortRequested } from "./effect-gate.ts"; /** HTTP response metadata captured before the provider response body is consumed. */ export interface AssistantResponseMetadata { @@ -118,7 +119,15 @@ export async function streamHarnessAssistant( if (!started) { await config.observer.start({ ...settled }); } - const finalMessage = config.afterResponse ? await config.afterResponse(settled, metadata) : settled; + let finalMessage = settled; + if (config.afterResponse) { + try { + finalMessage = await config.afterResponse(settled, metadata); + } catch (error) { + if (!(error instanceof AbortRequested)) throw error; + await error.cancellation; + } + } await config.observer.end(finalMessage); return finalMessage; } diff --git a/packages/agent/src/harness/execution/breakpoint.ts b/packages/agent/src/harness/execution/breakpoint.ts new file mode 100644 index 000000000..867138b74 --- /dev/null +++ b/packages/agent/src/harness/execution/breakpoint.ts @@ -0,0 +1,78 @@ +import type { ActionInfo } from "../agent-harness.ts"; +import { AbortRequested } from "./effect-gate.ts"; + +interface ParkedBreakpoint { + info: ActionInfo; + interruptOnAbort: boolean; + resolve: () => void; + reject: (error: Error) => void; +} + +/** Process-local manual-execution barrier. It owns no durable state or work. */ +export class BreakpointBarrier { + private readonly mode: "automatic" | "manual"; + private parked: ParkedBreakpoint | undefined; + private closedError: Error | undefined; + private readonly changeWaiters = new Set<() => void>(); + + constructor(mode: "automatic" | "manual") { + this.mode = mode; + } + + hit(info: ActionInfo, options: { interruptOnAbort?: boolean } = {}): Promise { + if (this.closedError !== undefined) return Promise.reject(this.closedError); + if (this.mode === "automatic") return Promise.resolve(); + if (this.parked !== undefined) { + return Promise.reject(new Error(`Breakpoint ${JSON.stringify(this.parked.info.kind)} is already parked`)); + } + return new Promise((resolve, reject) => { + this.parked = { + info, + interruptOnAbort: options.interruptOnAbort !== false, + resolve, + reject, + }; + this.notifyChange(); + }); + } + + peek(): ActionInfo | undefined { + return this.parked?.info; + } + + release(): ActionInfo | undefined { + const parked = this.parked; + if (parked === undefined) return undefined; + this.parked = undefined; + parked.resolve(); + this.notifyChange(); + return parked.info; + } + + interrupt(cancellation: Promise): void { + const parked = this.parked; + if (parked === undefined || !parked.interruptOnAbort) return; + this.parked = undefined; + parked.reject(new AbortRequested(cancellation)); + this.notifyChange(); + } + + close(error: Error): void { + this.closedError ??= error; + const parked = this.parked; + this.parked = undefined; + parked?.reject(this.closedError); + this.notifyChange(); + } + + /** Resolve when the parked action changes or the barrier closes. */ + waitForChange(): Promise { + if (this.closedError !== undefined) return Promise.resolve(); + return new Promise((resolve) => this.changeWaiters.add(resolve)); + } + + private notifyChange(): void { + for (const resolve of this.changeWaiters) resolve(); + this.changeWaiters.clear(); + } +} diff --git a/packages/agent/src/harness/execution/effect-gate.ts b/packages/agent/src/harness/execution/effect-gate.ts index e783d3e67..789558a0a 100644 --- a/packages/agent/src/harness/execution/effect-gate.ts +++ b/packages/agent/src/harness/execution/effect-gate.ts @@ -1,7 +1,61 @@ +/** Expected internal control flow when cancellation wins effect admission. */ +export class AbortRequested extends Error { + readonly cancellation: Promise; + + constructor(cancellation: Promise) { + super("Abort requested"); + this.name = "AbortRequested"; + this.cancellation = cancellation; + } +} + +type EffectGateState = + | { status: "open" } + | { status: "aborting"; cancellation: Promise } + | { status: "closed"; error: Error }; + /** Process-local admission gate for one operation's external work. */ export interface EffectGate { /** The operation-owned cooperative signal. */ readonly signal: AbortSignal; /** Synchronously throws when ordinary work may no longer start. */ assertOpen(): void; + /** Close ordinary starts before the durable cancellation mutation begins. */ + beginAbort(cancellation: Promise): void; + /** Pull the cooperative signal only after cancellation is durable. */ + signalAbort(): void; + /** Permanently stop starts and signal already-admitted work. */ + close(error: Error): void; +} + +/** Default {@link EffectGate} implementation used by drive passes. */ +export class OperationEffectGate implements EffectGate { + private readonly controller = new AbortController(); + private state: EffectGateState = { status: "open" }; + + get signal(): AbortSignal { + return this.controller.signal; + } + + assertOpen(): void { + if (this.state.status === "aborting") throw new AbortRequested(this.state.cancellation); + if (this.state.status === "closed") throw this.state.error; + } + + beginAbort(cancellation: Promise): void { + if (this.state.status === "open") { + this.state = { status: "aborting", cancellation }; + } + } + + signalAbort(): void { + if (this.state.status !== "aborting" || this.controller.signal.aborted) return; + this.controller.abort(new AbortRequested(this.state.cancellation)); + } + + close(error: Error): void { + if (this.state.status === "closed") return; + this.state = { status: "closed", error }; + if (!this.controller.signal.aborted) this.controller.abort(error); + } } diff --git a/packages/agent/src/harness/hooks.ts b/packages/agent/src/harness/hooks.ts new file mode 100644 index 000000000..3267668b6 --- /dev/null +++ b/packages/agent/src/harness/hooks.ts @@ -0,0 +1,447 @@ +import type { BeforeResumePrepared, HookHandler, HookInvocation, HookMap, HookName, Hooks } from "./agent-harness.ts"; +import type { EffectGate } from "./execution/effect-gate.ts"; +import type { JsonValue } from "./session/types.ts"; +import type { AgentHarnessStreamOptions, AgentHarnessStreamOptionsPatch } from "./types.ts"; + +interface HookRegistration { + id?: string; + handler: (event: unknown) => unknown | Promise; +} + +type HookErrorReporter = (error: Error, hook: HookName, lane: string) => void | Promise; + +export interface BeforeRunAggregate { + result: HookMap["before_run"]["result"]; + resumeData: Record; +} + +/** Ordered harness hook registry and aggregate runner. */ +export class HookRegistry implements Hooks { + private readonly registrations = new Map(); + private readonly reportError: HookErrorReporter; + private closedError: Error | undefined; + + constructor(reportError: HookErrorReporter) { + this.reportError = reportError; + } + + on(name: TName, handler: HookHandler, options: { id?: string } = {}): () => void { + if (this.closedError !== undefined) throw this.closedError; + if ((name === "before_run" || name === "before_resume") && options.id === undefined) { + throw new Error(`${name} hooks require a stable id`); + } + const registrations = this.registrations.get(name) ?? []; + if (options.id !== undefined && registrations.some((registration) => registration.id === options.id)) { + throw new Error(`Duplicate ${name} hook id: ${options.id}`); + } + const registration: HookRegistration = { + ...(options.id === undefined ? {} : { id: options.id }), + handler: (event) => handler(event as HookInvocation), + }; + registrations.push(registration); + this.registrations.set(name, registrations); + return () => { + const index = registrations.indexOf(registration); + if (index !== -1) registrations.splice(index, 1); + }; + } + + /** Invoke one accepted-operation aggregate after synchronously passing its effect gate. */ + runWithGate( + name: TName, + event: HookInvocation, + effectGate: EffectGate, + ): Promise { + effectGate.assertOpen(); + return this.runAdmitted(name, event); + } + + /** Preserve each before_run handler's restart data under its stable id. */ + runBeforeAcceptanceWithResumeData( + event: HookInvocation<"before_run">, + assertHarnessOpen: () => void, + ): Promise { + assertHarnessOpen(); + return this.beforeRunAggregate(event); + } + + /** Give each before_resume handler only the restart data written by its matching before_run id. */ + runBeforeResumeWithGate( + event: BeforeResumePrepared & { lane: string; runId: string }, + resumeData: Readonly>, + effectGate: EffectGate, + ): Promise { + effectGate.assertOpen(); + return this.beforeResumeAdmitted(event, resumeData); + } + + close(error: Error): void { + this.closedError ??= error; + } + + private async runAdmitted( + name: TName, + event: HookInvocation, + ): Promise { + if (this.closedError !== undefined) throw this.closedError; + const result = await this.aggregate(name, event); + return result as HookMap[TName]["result"]; + } + + private async aggregate(name: HookName, event: HookInvocation): Promise { + switch (name) { + case "before_run": + return (await this.beforeRunAggregate(event as HookInvocation<"before_run">)).result; + case "before_resume": + await this.invokeAll(name, event, () => {}); + return undefined; + case "before_run_end": { + let followUp: string | undefined; + await this.invokeAll(name, event, (value) => { + const result = value as HookMap["before_run_end"]["result"]; + if (result?.followUp !== undefined) followUp = result.followUp; + }); + return followUp === undefined ? undefined : { followUp }; + } + case "transform_context": + return this.transformContext(event as HookInvocation<"transform_context">); + case "before_request": + return this.beforeRequest(event as HookInvocation<"before_request">); + case "before_payload": + return this.beforePayload(event as HookInvocation<"before_payload">); + case "after_response": + return this.afterResponse(event as HookInvocation<"after_response">); + case "before_tool": + return this.beforeTool(event as HookInvocation<"before_tool">); + case "after_tool": + return this.afterTool(event as HookInvocation<"after_tool">); + case "before_compaction": + return this.firstStructural(name, event as HookInvocation<"before_compaction">, "compaction"); + case "before_navigation": + return this.firstStructural(name, event as HookInvocation<"before_navigation">, "summary"); + } + } + + private async beforeRunAggregate(event: HookInvocation<"before_run">): Promise { + let prompt = event.prompt; + let systemPrompt = event.systemPrompt; + let injected: HookMap["before_run"]["event"]["prompt"] = []; + const resumeData = Object.create(null) as Record; + for (const registration of this.registrationsFor("before_run")) { + try { + const result = (await registration.handler({ + ...event, + prompt, + systemPrompt, + })) as HookMap["before_run"]["result"]; + if (result?.messages !== undefined) { + injected = [...injected, ...result.messages]; + prompt = [...prompt, ...result.messages]; + } + if (result?.systemPrompt !== undefined) systemPrompt = result.systemPrompt; + if (result?.resumeData !== undefined) resumeData[registration.id!] = result.resumeData; + } catch (error) { + await this.reportError(error instanceof Error ? error : new Error(String(error)), "before_run", event.lane); + } + } + return { + result: { + ...(injected.length === 0 ? {} : { messages: injected }), + ...(systemPrompt === event.systemPrompt ? {} : { systemPrompt }), + }, + resumeData, + }; + } + + private async beforeResumeAdmitted( + event: BeforeResumePrepared & { lane: string; runId: string }, + resumeData: Readonly>, + ): Promise { + if (this.closedError !== undefined) throw this.closedError; + for (const registration of this.registrationsFor("before_resume")) { + try { + const data = + registration.id !== undefined && Object.hasOwn(resumeData, registration.id) + ? resumeData[registration.id] + : undefined; + await registration.handler({ + ...event, + ...(data === undefined ? {} : { resumeData: data }), + }); + } catch (error) { + await this.reportError( + error instanceof Error ? error : new Error(String(error)), + "before_resume", + event.lane, + ); + } + } + } + + private async beforeTool(event: HookInvocation<"before_tool">): Promise { + let args = event.args; + let block: { reason: string; terminate?: boolean } | undefined; + for (const registration of this.registrationsFor("before_tool")) { + try { + const result = (await registration.handler({ ...event, args })) as HookMap["before_tool"]["result"]; + if (result?.args !== undefined) args = result.args; + if (result?.block !== undefined) { + block = result.block; + break; + } + } catch (error) { + const normalized = error instanceof Error ? error : new Error(String(error)); + await this.reportError(normalized, "before_tool", event.lane); + block = { reason: normalized.message }; + break; + } + } + return { + ...(args === event.args ? {} : { args }), + ...(block === undefined ? {} : { block }), + }; + } + + private async transformContext( + event: HookInvocation<"transform_context">, + ): Promise { + let messages = event.messages; + for (const registration of this.registrationsFor("transform_context")) { + try { + const result = (await registration.handler({ + ...event, + messages, + })) as HookMap["transform_context"]["result"]; + if (result?.messages !== undefined) messages = result.messages; + } catch (error) { + await this.reportError( + error instanceof Error ? error : new Error(String(error)), + "transform_context", + event.lane, + ); + } + } + return { messages }; + } + + private async beforeRequest(event: HookInvocation<"before_request">): Promise { + let streamOptions = event.streamOptions; + let changed = false; + for (const registration of this.registrationsFor("before_request")) { + try { + const result = (await registration.handler({ + ...event, + streamOptions, + })) as HookMap["before_request"]["result"]; + if (result?.streamOptions !== undefined) { + streamOptions = applyStreamOptionsPatch(streamOptions, result.streamOptions); + changed = true; + } + } catch (error) { + await this.reportError( + error instanceof Error ? error : new Error(String(error)), + "before_request", + event.lane, + ); + } + } + return changed ? { streamOptions: createStreamOptionsPatch(event.streamOptions, streamOptions) } : undefined; + } + + private async beforePayload(event: HookInvocation<"before_payload">): Promise { + let payload = event.payload; + for (const registration of this.registrationsFor("before_payload")) { + try { + const result = (await registration.handler({ ...event, payload })) as HookMap["before_payload"]["result"]; + if (result?.payload !== undefined) payload = result.payload; + } catch (error) { + await this.reportError( + error instanceof Error ? error : new Error(String(error)), + "before_payload", + event.lane, + ); + } + } + return { payload }; + } + + private async afterResponse(event: HookInvocation<"after_response">): Promise { + let message = event.message; + for (const registration of this.registrationsFor("after_response")) { + try { + const result = (await registration.handler({ ...event, message })) as HookMap["after_response"]["result"]; + if (result?.message !== undefined) message = result.message; + } catch (error) { + await this.reportError( + error instanceof Error ? error : new Error(String(error)), + "after_response", + event.lane, + ); + } + } + return { message }; + } + + private async afterTool(event: HookInvocation<"after_tool">): Promise { + let current = { + content: event.content, + details: event.details, + isError: event.isError, + usage: event.usage, + }; + const aggregate: NonNullable = {}; + for (const registration of this.registrationsFor("after_tool")) { + try { + const result = (await registration.handler({ ...event, ...current })) as HookMap["after_tool"]["result"]; + if (result === undefined) continue; + if (result.content !== undefined) aggregate.content = result.content; + if (result.details !== undefined) aggregate.details = result.details; + if (result.isError !== undefined) aggregate.isError = result.isError; + if (result.usage !== undefined) aggregate.usage = result.usage; + if (result.terminate !== undefined) aggregate.terminate = result.terminate; + current = { + content: result.content === undefined ? current.content : result.content, + details: result.details === undefined ? current.details : result.details, + isError: result.isError === undefined ? current.isError : result.isError, + usage: result.usage === undefined ? current.usage : result.usage, + }; + } catch (error) { + await this.reportError(error instanceof Error ? error : new Error(String(error)), "after_tool", event.lane); + } + } + return Object.keys(aggregate).length === 0 ? undefined : aggregate; + } + + private async firstStructural( + name: "before_compaction" | "before_navigation", + event: HookInvocation<"before_compaction"> | HookInvocation<"before_navigation">, + resultField: "compaction" | "summary", + ): Promise { + for (const registration of this.registrationsFor(name)) { + try { + const value = await registration.handler(event); + if (value === undefined || value === null || typeof value !== "object") continue; + const result = value as Record; + if (result.decline === true && result[resultField] !== undefined) { + await this.reportError( + new Error(`${name} hook cannot return both decline and ${resultField}`), + name, + event.lane, + ); + continue; + } + if (result.decline === true || result[resultField] !== undefined) return value; + } catch (error) { + await this.reportError(error instanceof Error ? error : new Error(String(error)), name, event.lane); + } + } + return undefined; + } + + private registrationsFor(name: HookName): HookRegistration[] { + return [...(this.registrations.get(name) ?? [])]; + } + + private async invokeAll( + name: HookName, + event: HookInvocation, + apply: (value: unknown) => void, + ): Promise { + for (const registration of this.registrationsFor(name)) { + try { + apply(await registration.handler(event)); + } catch (error) { + await this.reportError(error instanceof Error ? error : new Error(String(error)), name, event.lane); + } + } + } +} + +function applyStreamOptionsPatch( + base: AgentHarnessStreamOptions, + patch: AgentHarnessStreamOptionsPatch, +): AgentHarnessStreamOptions { + const next: AgentHarnessStreamOptions = { ...base }; + for (const key of [ + "transport", + "timeoutMs", + "maxRetries", + "maxRetryDelayMs", + "cacheRetention", + "deferred", + ] as const) { + if (!(key in patch)) continue; + const value = patch[key]; + if (value === undefined) delete next[key]; + else Object.assign(next, { [key]: value }); + } + if ("headers" in patch) { + if (patch.headers === undefined) delete next.headers; + else { + const headers = { ...next.headers }; + for (const [key, value] of Object.entries(patch.headers)) { + if (value === undefined) delete headers[key]; + else headers[key] = value; + } + next.headers = headers; + } + } + if ("metadata" in patch) { + if (patch.metadata === undefined) delete next.metadata; + else { + const metadata = { ...next.metadata }; + for (const [key, value] of Object.entries(patch.metadata)) { + if (value === undefined) delete metadata[key]; + else metadata[key] = value; + } + next.metadata = metadata; + } + } + return next; +} + +function createStreamOptionsPatch( + base: AgentHarnessStreamOptions, + value: AgentHarnessStreamOptions, +): AgentHarnessStreamOptionsPatch { + const patch: AgentHarnessStreamOptionsPatch = {}; + for (const key of [ + "transport", + "timeoutMs", + "maxRetries", + "maxRetryDelayMs", + "cacheRetention", + "deferred", + ] as const) { + if (base[key] !== value[key]) Object.assign(patch, { [key]: value[key] }); + } + if (base.headers !== value.headers) { + if (value.headers === undefined) patch.headers = undefined; + else { + const headers: Record = {}; + for (const key of Object.keys(base.headers ?? {})) { + if (!(key in value.headers)) headers[key] = undefined; + } + for (const [key, header] of Object.entries(value.headers)) { + if (base.headers?.[key] !== header) headers[key] = header; + } + if (base.headers === undefined && Object.keys(headers).length === 0) patch.headers = {}; + else if (Object.keys(headers).length !== 0) patch.headers = headers; + } + } + if (base.metadata !== value.metadata) { + if (value.metadata === undefined) patch.metadata = undefined; + else { + const metadata: Record = {}; + for (const key of Object.keys(base.metadata ?? {})) { + if (!(key in value.metadata)) metadata[key] = undefined; + } + for (const [key, metadataValue] of Object.entries(value.metadata)) { + if (base.metadata?.[key] !== metadataValue) metadata[key] = metadataValue; + } + if (base.metadata === undefined && Object.keys(metadata).length === 0) patch.metadata = {}; + else if (Object.keys(metadata).length !== 0) patch.metadata = metadata; + } + } + return patch; +} diff --git a/packages/agent/src/harness/restore.ts b/packages/agent/src/harness/restore.ts new file mode 100644 index 000000000..6435ac1e7 --- /dev/null +++ b/packages/agent/src/harness/restore.ts @@ -0,0 +1,103 @@ +import { SessionInvariantError } from "./session/session.ts"; +import type { + Entry, + LaneConfiguration, + LaneLastResult, + LaneState, + Operation, + OperationState, + SessionReader, +} from "./session/types.ts"; + +export interface RestoredOperation { + operation: Operation; + state: OperationState; + entries: Map; +} + +export interface RestoredLane { + lane: string; + leafId: string | null; + configuration: LaneConfiguration; + laneState: LaneState; + lastResult?: LaneLastResult; + current?: RestoredOperation; +} + +interface RestoreOptions { + includeLastResult?: boolean; +} + +/** Load and validate the lane and operation ownership needed by the R1 shell. */ +export async function restoreLane( + reader: SessionReader, + lane: string, + options: RestoreOptions = {}, +): Promise { + const [configuration, laneState, leaf, lastResult] = await Promise.all([ + reader.getRegister("lane.config", lane), + reader.getRegister("lane.state", lane), + reader.getRegister("lane.leaf", lane), + options.includeLastResult ? reader.getRegister("lane.lastResult", lane) : Promise.resolve(undefined), + ]); + if (configuration === undefined) invariant(lane, "is missing lane.config"); + if (laneState === undefined) invariant(lane, "is missing lane.state"); + if (leaf === undefined) invariant(lane, "is missing lane.leaf"); + + const operationId = laneState.value.currentOperationId; + const [operation, operationState] = + operationId === null + ? [undefined, undefined] + : await Promise.all([reader.getRegister("op.meta", operationId), reader.getRegister("op.state", operationId)]); + if (operationId !== null && operation === undefined) { + invariant(lane, `names operation ${operationId}, which is missing op.meta`); + } + if (operationId !== null && operationState === undefined) { + invariant(lane, `names operation ${operationId}, which is missing op.state`); + } + + const expectations = new Map(); + if (leaf.value !== null) expectations.set(leaf.value, "any"); + if (operation !== undefined && operationState !== undefined) { + if (operation.value.operationId !== operationId) { + invariant(lane, `op.meta/${operationId} has another operation id`); + } + if (operation.value.lane !== lane) { + invariant(lane, `operation ${operationId} belongs to lane ${operation.value.lane}`); + } + if (operation.value.intent.kind !== operationState.value.kind) { + invariant( + lane, + `operation ${operationId} intent ${operation.value.intent.kind} does not match state ${operationState.value.kind}`, + ); + } + if (operation.value.sourceLeafId !== null) expectations.set(operation.value.sourceLeafId, "any"); + if (operation.value.intent.kind === "run") { + for (const id of operation.value.intent.promptEntryIds) expectations.set(id, "message"); + } else if (operation.value.intent.kind === "navigation" && operation.value.intent.targetId !== null) { + expectations.set(operation.value.intent.targetId, "any"); + } + } + + const entries = await reader.getEntries([...expectations.keys()]); + for (const [id, expected] of expectations) { + const entry = entries.get(id); + if (entry === undefined) invariant(lane, `references missing entry ${id}`); + if (expected === "message" && entry.type !== "message") invariant(lane, `entry ${id} is not a message`); + } + + return { + lane, + leafId: leaf.value, + configuration: configuration.value, + laneState: laneState.value, + ...(lastResult === undefined ? {} : { lastResult: lastResult.value }), + ...(operation === undefined || operationState === undefined + ? {} + : { current: { operation: operation.value, state: operationState.value, entries } }), + }; +} + +function invariant(lane: string, message: string): never { + throw new SessionInvariantError(`Lane ${JSON.stringify(lane)} ${message}`); +} diff --git a/packages/agent/src/harness/session/types.ts b/packages/agent/src/harness/session/types.ts index a7bf2116c..2ee7d3c8d 100644 --- a/packages/agent/src/harness/session/types.ts +++ b/packages/agent/src/harness/session/types.ts @@ -292,20 +292,47 @@ export interface LaneState { pendingNextRun: string[]; } -export type LaneLastResult = { - operationId: string; - kind: "run" | "compaction" | "navigation"; - leafId: string | null; - finalAssistantEntryId?: string; -} & ( - | { outcome: "failed"; error: OperationError; runCompletion?: never } - | { - outcome: "completed"; - error?: never; - runCompletion?: "assistant" | "terminated_tools"; - } - | { outcome: "declined" | "aborted"; error?: never; runCompletion?: never } -); +type FailedLaneLastResult = { outcome: "failed"; error: OperationError; runCompletion?: never }; +type AbortedLaneLastResult = { outcome: "aborted"; error?: never; runCompletion?: never }; +type StructuralLaneLastResultOutcome = + | FailedLaneLastResult + | AbortedLaneLastResult + | { outcome: "declined"; error?: never; runCompletion?: never } + | { outcome: "completed"; error?: never; runCompletion?: never }; + +export type LaneLastResult = + | ({ + operationId: string; + kind: "run"; + leafId: string; + finalAssistantEntryId?: string; + } & ( + | FailedLaneLastResult + | AbortedLaneLastResult + | { + outcome: "completed"; + error?: never; + runCompletion: "assistant" | "terminated_tools"; + } + )) + | ({ + operationId: string; + kind: "compaction"; + leafId: string; + finalAssistantEntryId?: never; + } & StructuralLaneLastResultOutcome) + | ({ + operationId: string; + kind: "navigation"; + leafId: string | null; + oldLeafId: string | null; + finalAssistantEntryId?: never; + } & ( + | FailedLaneLastResult + | AbortedLaneLastResult + | { outcome: "declined"; error?: never; runCompletion?: never; summaryEntryId?: never } + | { outcome: "completed"; error?: never; runCompletion?: never; summaryEntryId?: string } + )); export type PendingEntry = | { type: "message"; payload: AgentMessage } diff --git a/packages/agent/src/harness/types.ts b/packages/agent/src/harness/types.ts index e7d278b99..8cc842c3b 100644 --- a/packages/agent/src/harness/types.ts +++ b/packages/agent/src/harness/types.ts @@ -77,6 +77,14 @@ export interface AgentHarnessResources< skills?: TSkill[]; } +/** Stable harness identity for one logical tool call, unchanged during safe replay. */ +export interface AgentHarnessToolInvocation { + /** Opaque session-unique id equal to the call's reserved result-entry id. */ + invocationId: string; + operationId: string; + turnId: string; +} + /** Tool definition executed by an {@link AgentHarness} with an application-defined context. */ export type AgentHarnessTool< TContext extends object | undefined, @@ -90,6 +98,7 @@ export type AgentHarnessTool< signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback | undefined, context: TContext, + invocation: AgentHarnessToolInvocation, ): Promise>; }; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 2e8ef7546..e3c0150d5 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -108,6 +108,7 @@ export { type AgentHarnessStreamOptionsPatch, type AgentHarnessTool, type AgentHarnessToolContextSource, + type AgentHarnessToolInvocation, BranchSummaryError, type BranchSummaryErrorCode, CompactionError, diff --git a/packages/agent/test/harness/agent-harness-runtime.test.ts b/packages/agent/test/harness/agent-harness-runtime.test.ts new file mode 100644 index 000000000..d848a0714 --- /dev/null +++ b/packages/agent/test/harness/agent-harness-runtime.test.ts @@ -0,0 +1,552 @@ +import { type Api, createModels, fauxProvider, type Model } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { restoreLane } from "../../src/harness/restore.ts"; +import { MemoryStorage } from "../../src/harness/session/memory.ts"; +import { StorageBackedSession } from "../../src/harness/session/session.ts"; +import { + AgentHarness, + type AgentHarness as AgentHarnessInstance, + DEFAULT_COMPACTION_SETTINGS, + HarnessClosed, + HarnessFault, + type LaneConfiguration, + MemorySessionRepo, + type Session, + type SessionReader, + type Transaction, +} from "../../src/index.ts"; + +interface Fixture { + harness: AgentHarnessInstance; + session: Session; + repo: MemorySessionRepo; + model: Model; +} + +const fixtures: Fixture[] = []; + +class FailingMemoryStorage extends MemoryStorage { + failCommits = false; + + override commit(transaction: Transaction) { + return this.failCommits ? Promise.reject(new Error("commit failed")) : super.commit(transaction); + } +} + +async function createFixture(options: { drive?: "automatic" | "manual" } = {}): Promise { + const repo = new MemorySessionRepo(); + const session = await repo.create({}); + const faux = fauxProvider(); + const models = createModels(); + models.setProvider(faux.provider); + const { harness } = await AgentHarness.create({ + session, + models, + model: faux.getModel(), + drive: options.drive, + }); + const fixture = { harness, session, repo, model: faux.getModel() }; + fixtures.push(fixture); + return fixture; +} + +async function installCheckpointRun( + session: Session, + operationId = session.idGenerator.next(), +): Promise<{ operationId: string; entryId: string }> { + const entryId = session.idGenerator.next(); + await session.mutate("main", async (mutator) => { + const laneState = await mutator.getRegister("lane.state", "main"); + if (laneState === undefined) throw new Error("missing lane state"); + await mutator.commit({ + writes: [ + { + kind: "entry", + entry: { + id: entryId, + parentId: null, + type: "message", + message: { role: "user", content: "hello", timestamp: 1 }, + }, + }, + { kind: "register", op: "set", namespace: "lane.leaf", key: "main", value: entryId }, + { + kind: "register", + op: "set", + namespace: "op.meta", + key: operationId, + value: { + operationId, + lane: "main", + sourceLeafId: null, + startedAt: 1, + intent: { kind: "run", promptEntryIds: [entryId] }, + }, + }, + { + kind: "register", + op: "set", + namespace: "op.state", + key: operationId, + value: { + kind: "run", + control: { status: "running" }, + settings: { + compaction: DEFAULT_COMPACTION_SETTINGS, + steeringMode: "all", + followUpMode: "all", + toolExecution: "parallel", + }, + phase: { + kind: "checkpoint", + continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, + triggerEntryId: entryId, + }, + inbox: { steer: [], followUp: [], writes: [] }, + latestAssistantEntryId: null, + }, + }, + { + kind: "register", + op: "set", + namespace: "lane.state", + key: "main", + value: { ...laneState.value, currentOperationId: operationId }, + }, + ], + }); + }); + return { operationId, entryId }; +} + +async function waitForAction(harness: AgentHarnessInstance): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + if ((await harness.peekAction()) !== undefined) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("action did not park"); +} + +afterEach(async () => { + for (const fixture of fixtures.splice(0)) { + await fixture.harness.close(); + await fixture.repo.close(); + } +}); + +describe("AgentHarness runtime shell", () => { + it("seeds main, reads configuration, and creates independently seeded lanes", async () => { + const { harness, model } = await createFixture(); + expect(await harness.getModel()).toEqual(model); + expect(await harness.inspectExecution()).toMatchObject({ lane: "main", leafId: null, current: null }); + + await harness.setThinkingLevel("high"); + let observedResources = false; + harness.events.on("config_update", async (event) => { + if (event.property !== "resources") return; + observedResources = (await harness.getResources()).skills?.[0]?.name === "test"; + }); + await harness.setResources({ + skills: [{ name: "test", description: "test", content: "test", filePath: "/test" }], + }); + expect(observedResources).toBe(true); + harness.events.on("config_update", (event) => { + if (event.property === "activeTools") event.value.push("listener-mutation"); + }); + await harness.setActiveTools(["read"]); + expect(await harness.getActiveTools()).toEqual(["read"]); + expect((harness as AgentHarnessInstance & { settingsRevision: number }).settingsRevision).toBe(1); + const created = await harness.createLane("worker", null); + expect(created.ok).toBe(true); + if (!created.ok) return; + expect(await created.value.getThinkingLevel()).toBe("off"); + expect((await harness.lanes()).map((lane) => lane.name).sort()).toEqual(["main", "worker"]); + }); + + it("fences stale drives and yields before publishing a breakpoint", async () => { + const { harness, session } = await createFixture({ drive: "manual" }); + const current = await installCheckpointRun(session); + + const staleOperationId = session.idGenerator.next(); + const stale = await harness.drive({ operationId: staleOperationId, deadline: 0 }); + expect(stale.ok).toBe(false); + if (!stale.ok) { + expect(stale.error).toMatchObject({ + _tag: "OperationMismatch", + currentOperationId: current.operationId, + }); + } + expect(await harness.peekAction()).toBeUndefined(); + + const yielded = await harness.drive({ operationId: current.operationId, deadline: 0 }); + expect(yielded).toEqual({ + ok: true, + value: { kind: "yielded", operationId: current.operationId }, + }); + expect(await harness.peekAction()).toBeUndefined(); + expect((await harness.inspectExecution()).current?.status).toBe("suspended"); + }); + it("installs one same-operation drive pass and joins it", async () => { + const { harness, session } = await createFixture({ drive: "manual" }); + const current = await installCheckpointRun(session); + + const first = harness.drive({ operationId: current.operationId }); + await waitForAction(harness); + expect(await harness.peekAction()).toMatchObject({ + kind: "runtime.dispatch", + details: { operationId: current.operationId, operationKind: "run" }, + }); + expect((await harness.inspectExecution()).current?.status).toBe("running"); + const second = harness.drive({ operationId: current.operationId, deadline: 0 }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await harness.executeAction(); + + const [left, right] = await Promise.allSettled([first, second]); + expect(left.status).toBe("rejected"); + expect(right.status).toBe("rejected"); + if (left.status === "rejected" && right.status === "rejected") { + expect(left.reason).toBe(right.reason); + expect(left.reason).toMatchObject({ name: "RuntimeSliceNotImplemented" }); + } + expect((await harness.inspectExecution()).current?.status).toBe("suspended"); + }); + + it("faults inspection when process and durable operation ownership disagree", async () => { + const { harness, session } = await createFixture({ drive: "manual" }); + const current = await installCheckpointRun(session); + const drive = harness.drive({ operationId: current.operationId }); + await waitForAction(harness); + const [operation, state, laneState] = await Promise.all([ + session.getRegister("op.meta", current.operationId), + session.getRegister("op.state", current.operationId), + session.getRegister("lane.state", "main"), + ]); + if (operation === undefined || state === undefined || laneState === undefined) { + throw new Error("missing operation state"); + } + const replacementId = session.idGenerator.next(); + await session.mutate("main", (mutator) => + mutator.commit({ + writes: [ + { + kind: "register", + op: "set", + namespace: "op.meta", + key: replacementId, + value: { ...operation.value, operationId: replacementId }, + }, + { kind: "register", op: "set", namespace: "op.state", key: replacementId, value: state.value }, + { + kind: "register", + op: "set", + namespace: "lane.state", + key: "main", + value: { ...laneState.value, currentOperationId: replacementId }, + }, + ], + }), + ); + + await expect(harness.inspectExecution()).rejects.toBeInstanceOf(HarnessFault); + await expect(drive).rejects.toBeInstanceOf(HarnessFault); + }); + + it("yields after a parked boundary when the deadline expires", async () => { + const { harness, session } = await createFixture({ drive: "manual" }); + const current = await installCheckpointRun(session); + const now = vi.spyOn(Date, "now").mockReturnValue(100); + try { + const drive = harness.drive({ operationId: current.operationId, deadline: 150 }); + await waitForAction(harness); + now.mockReturnValue(200); + await harness.executeAction(); + await expect(drive).resolves.toEqual({ + ok: true, + value: { kind: "yielded", operationId: current.operationId }, + }); + } finally { + now.mockRestore(); + } + }); + + it("validates base operation ownership and intent kind", async () => { + const { session } = await createFixture(); + const current = await installCheckpointRun(session); + const stored = await session.getRegister("op.meta", current.operationId); + if (stored === undefined) throw new Error("missing operation"); + const replaceOperation = (value: typeof stored.value) => + session.mutate("main", (mutator) => + mutator.commit({ + writes: [{ kind: "register", op: "set", namespace: "op.meta", key: current.operationId, value }], + }), + ); + + await replaceOperation({ ...stored.value, lane: "worker" }); + await expect(restoreLane(session, "main")).rejects.toThrow(/belongs to lane worker/); + await replaceOperation({ + ...stored.value, + intent: { kind: "navigation", targetId: null, summarize: false }, + }); + await expect(restoreLane(session, "main")).rejects.toThrow(/intent navigation does not match state run/); + await replaceOperation(stored.value); + }); + + it("restores open operations without activating them", async () => { + const repo = new MemorySessionRepo(); + const session = await repo.create({}); + const faux = fauxProvider(); + const models = createModels(); + models.setProvider(faux.provider); + const seed: LaneConfiguration = { + model: { provider: faux.getModel().provider, modelId: faux.getModel().id }, + thinkingLevel: "off", + activeToolNames: [], + }; + await session.mutate("main", (mutator) => + mutator.commit({ + writes: [{ kind: "register", op: "set", namespace: "lane.config", key: "main", value: seed }], + }), + ); + const current = await installCheckpointRun(session); + + const { harness, suspended } = await AgentHarness.create({ + session, + models, + model: faux.getModel(), + drive: "manual", + }); + fixtures.push({ harness, session, repo, model: faux.getModel() }); + + expect(suspended).toEqual([ + expect.objectContaining({ + lane: "main", + operationId: current.operationId, + kind: "run", + reason: "crash", + }), + ]); + expect((await harness.inspectExecution()).current).toMatchObject({ + id: current.operationId, + status: "suspended", + }); + expect(await harness.peekAction()).toBeUndefined(); + + const registerReads: string[] = []; + const entryReads: string[][] = []; + const reader: SessionReader = { + getEntries(ids) { + entryReads.push(ids); + return session.getEntries(ids); + }, + getRegister(namespace, key) { + registerReads.push(`${namespace}/${key}`); + return session.getRegister(namespace, key); + }, + listRegisters() { + throw new Error("restore must not scan registers"); + }, + }; + await restoreLane(reader, "main"); + expect(registerReads).toEqual([ + "lane.config/main", + "lane.state/main", + "lane.leaf/main", + `op.meta/${current.operationId}`, + `op.state/${current.operationId}`, + ]); + expect(entryReads).toEqual([[current.entryId]]); + }); + + it("returns and hydrates a matching latest terminal result", async () => { + const { harness, session } = await createFixture(); + const current = await installCheckpointRun(session); + await session.mutate("main", async (mutator) => { + const laneState = await mutator.getRegister("lane.state", "main"); + if (laneState === undefined) throw new Error("missing lane state"); + await mutator.commit({ + writes: [ + { kind: "register", op: "delete", namespace: "op.meta", key: current.operationId }, + { kind: "register", op: "delete", namespace: "op.state", key: current.operationId }, + { + kind: "register", + op: "set", + namespace: "lane.lastResult", + key: "main", + value: { + operationId: current.operationId, + kind: "run", + outcome: "completed", + leafId: current.entryId, + runCompletion: "terminated_tools", + }, + }, + { + kind: "register", + op: "set", + namespace: "lane.state", + key: "main", + value: { ...laneState.value, currentOperationId: null }, + }, + ], + }); + }); + + const result = await harness.drive({ operationId: current.operationId }); + expect(result).toEqual({ + ok: true, + value: { + kind: "settled", + operationId: current.operationId, + outcome: { + operation: "run", + runId: current.operationId, + kind: "completed", + leafId: current.entryId, + }, + }, + }); + + const navigationId = session.idGenerator.next(); + await session.mutate("main", (mutator) => + mutator.commit({ + writes: [ + { + kind: "register", + op: "set", + namespace: "lane.lastResult", + key: "main", + value: { + operationId: navigationId, + kind: "navigation", + outcome: "completed", + oldLeafId: null, + leafId: current.entryId, + }, + }, + ], + }), + ); + const navigation = await harness.drive({ operationId: navigationId }); + expect(navigation).toMatchObject({ + ok: true, + value: { + kind: "settled", + outcome: { + operation: "navigation", + kind: "completed", + oldLeafId: null, + newLeafId: current.entryId, + }, + }, + }); + + const brokenRunId = session.idGenerator.next(); + await session.mutate("main", (mutator) => + mutator.commit({ + writes: [ + { + kind: "register", + op: "set", + namespace: "lane.lastResult", + key: "main", + value: { + operationId: brokenRunId, + kind: "run", + outcome: "completed", + leafId: current.entryId, + finalAssistantEntryId: session.idGenerator.next(), + runCompletion: "assistant", + }, + }, + ], + }), + ); + let fault: unknown; + try { + await harness.drive({ operationId: brokenRunId }); + } catch (error) { + fault = error; + } + expect(fault).toBeInstanceOf(HarnessFault); + expect((fault as HarnessFault).cause).toMatchObject({ + message: expect.stringMatching(/Final assistant.*missing/), + }); + }); + + it("closes a parked drive as a controlled crash", async () => { + const { harness, session, repo } = await createFixture({ drive: "manual" }); + const current = await installCheckpointRun(session); + const drive = harness.drive({ operationId: current.operationId }); + await waitForAction(harness); + + await harness.close(); + await expect(drive).rejects.toBeInstanceOf(HarnessClosed); + expect(await harness.prompt("late")).toMatchObject({ ok: false, error: { _tag: "Closed" } }); + expect(() => harness.events.on("run_start", () => {})).toThrow(HarnessClosed); + const reopened = await repo.open(session.metadata); + expect((await reopened.getRegister("op.state", current.operationId))?.value.kind).toBe("run"); + await reopened.close(); + }); + + it("normalizes initialization commit failures to HarnessFault", async () => { + const storage = new FailingMemoryStorage(); + const session = new StorageBackedSession({ id: "init-fault-test", createdAt: 1, storageVersion: 1 }, storage); + await session.mutate("main", (mutator) => + mutator.commit({ + writes: [ + { kind: "register", op: "set", namespace: "lane.leaf", key: "main", value: null }, + { + kind: "register", + op: "set", + namespace: "lane.state", + key: "main", + value: { currentOperationId: null, pendingNextRun: [] }, + }, + ], + }), + ); + const faux = fauxProvider(); + const models = createModels(); + models.setProvider(faux.provider); + storage.failCommits = true; + + await expect(AgentHarness.create({ session, models, model: faux.getModel() })).rejects.toBeInstanceOf( + HarnessFault, + ); + await session.close(); + }); + + it("faults the harness after an admitted commit fails", async () => { + const storage = new FailingMemoryStorage(); + const session = new StorageBackedSession({ id: "fault-test", createdAt: 1, storageVersion: 1 }, storage); + await session.mutate("main", (mutator) => + mutator.commit({ + writes: [ + { kind: "register", op: "set", namespace: "lane.leaf", key: "main", value: null }, + { + kind: "register", + op: "set", + namespace: "lane.state", + key: "main", + value: { currentOperationId: null, pendingNextRun: [] }, + }, + ], + }), + ); + const faux = fauxProvider(); + const models = createModels(); + models.setProvider(faux.provider); + const { harness } = await AgentHarness.create({ session, models, model: faux.getModel() }); + storage.failCommits = true; + + let fault: unknown; + try { + await harness.setThinkingLevel("high"); + } catch (error) { + fault = error; + } + expect(fault).toBeInstanceOf(HarnessFault); + await expect(harness.getThinkingLevel()).rejects.toBe(fault); + await harness.close(); + }); +}); diff --git a/packages/agent/test/harness/execution-assistant.test.ts b/packages/agent/test/harness/execution-assistant.test.ts index bc2d906f1..5df0492ff 100644 --- a/packages/agent/test/harness/execution-assistant.test.ts +++ b/packages/agent/test/harness/execution-assistant.test.ts @@ -12,6 +12,7 @@ import { import { NOOP_TELEMETRY_CONTEXT } from "@earendil-works/pi-telemetry"; import { describe, expect, it } from "vitest"; import { type AssistantResponseMetadata, streamHarnessAssistant } from "../../src/harness/execution/assistant.ts"; +import { AbortRequested } from "../../src/harness/execution/effect-gate.ts"; import type { AgentMessage } from "../../src/types.ts"; function usage() { @@ -268,6 +269,37 @@ describe("streamHarnessAssistant", () => { expect(options).not.toHaveProperty("reasoning"); }); + it("keeps the raw settlement when cancellation interrupts after_response", async () => { + const final = assistant("raw"); + const ended: AssistantMessage[] = []; + const result = await streamHarnessAssistant([user("prompt")], { + model: model(), + thinkingLevel: "off", + streamOptions: {}, + toProviderMessages, + request: () => { + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => stream.push({ type: "done", reason: "stop", message: final })); + return stream; + }, + afterResponse: async () => { + throw new AbortRequested(Promise.resolve()); + }, + observer: { + start() {}, + update() {}, + end(message) { + ended.push(message); + }, + }, + telemetryContext: NOOP_TELEMETRY_CONTEXT, + signal: new AbortController().signal, + }); + + expect(result).toBe(final); + expect(ended).toEqual([final]); + }); + it("returns provider error settlements through the same lifecycle", async () => { const final = assistant("provider failed", "error"); const events: string[] = []; diff --git a/packages/agent/test/harness/execution-primitives.test.ts b/packages/agent/test/harness/execution-primitives.test.ts new file mode 100644 index 000000000..49dbe948b --- /dev/null +++ b/packages/agent/test/harness/execution-primitives.test.ts @@ -0,0 +1,444 @@ +import { fauxProvider } from "@earendil-works/pi-ai"; +import { describe, expect, it, vi } from "vitest"; +import { HarnessEventBus } from "../../src/harness/events.ts"; +import { BreakpointBarrier } from "../../src/harness/execution/breakpoint.ts"; +import { AbortRequested, OperationEffectGate } from "../../src/harness/execution/effect-gate.ts"; +import { HookRegistry } from "../../src/harness/hooks.ts"; + +function deferred(): { promise: Promise; resolve(): void } { + let resolvePromise: (() => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: () => resolvePromise?.() }; +} + +describe("BreakpointBarrier", () => { + it("parks before work, publishes JSON-safe action data, and releases exactly once", async () => { + const barrier = new BreakpointBarrier("manual"); + let worked = false; + const task = (async () => { + await barrier.hit({ kind: "test.effect", description: "Run effect", details: { attempt: 1 } }); + worked = true; + })(); + + expect(barrier.peek()).toEqual({ + kind: "test.effect", + description: "Run effect", + details: { attempt: 1 }, + }); + expect(JSON.parse(JSON.stringify(barrier.peek()))).toEqual(barrier.peek()); + expect(worked).toBe(false); + expect(barrier.release()?.kind).toBe("test.effect"); + expect(barrier.release()).toBeUndefined(); + await task; + expect(worked).toBe(true); + }); + + it("interrupts ordinary barriers but not cancellation-reconciliation barriers", async () => { + const ordinary = new BreakpointBarrier("manual"); + const cancellation = deferred(); + const parked = ordinary.hit({ kind: "ordinary", description: "ordinary" }); + ordinary.interrupt(cancellation.promise); + await expect(parked).rejects.toBeInstanceOf(AbortRequested); + + const reconciliation = new BreakpointBarrier("manual"); + const protectedPark = reconciliation.hit( + { kind: "cancel.reconcile", description: "reconcile" }, + { interruptOnAbort: false }, + ); + reconciliation.interrupt(cancellation.promise); + expect(reconciliation.peek()?.kind).toBe("cancel.reconcile"); + reconciliation.release(); + await protectedPark; + }); + + it("publishes sequential nested boundaries without conflating them", async () => { + const barrier = new BreakpointBarrier("manual"); + const task = (async () => { + await barrier.hit({ kind: "outer", description: "outer" }); + await barrier.hit({ kind: "inner", description: "inner" }); + })(); + expect(barrier.peek()?.kind).toBe("outer"); + barrier.release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(barrier.peek()?.kind).toBe("inner"); + barrier.release(); + await task; + }); + + it("has equivalent non-parking automatic behavior", async () => { + const barrier = new BreakpointBarrier("automatic"); + await barrier.hit({ kind: "automatic", description: "automatic" }); + expect(barrier.peek()).toBeUndefined(); + }); +}); + +describe("OperationEffectGate", () => { + it("closes starts synchronously and signals only after cancellation commits", async () => { + const gate = new OperationEffectGate(); + const cancellation = deferred(); + gate.beginAbort(cancellation.promise); + + let refusal: unknown; + try { + gate.assertOpen(); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(AbortRequested); + expect((refusal as AbortRequested).cancellation).toBe(cancellation.promise); + expect(gate.signal.aborted).toBe(false); + cancellation.resolve(); + await cancellation.promise; + gate.signalAbort(); + expect(gate.signal.aborted).toBe(true); + }); + + it("permanently closes and signals admitted work", () => { + const gate = new OperationEffectGate(); + const error = new Error("closed"); + gate.close(error); + expect(() => gate.assertOpen()).toThrow(error); + expect(gate.signal.aborted).toBe(true); + }); +}); + +describe("HookRegistry", () => { + it("aggregates in registration order with each handler seeing prior output", async () => { + const errors: Error[] = []; + const hooks = new HookRegistry((error) => { + errors.push(error); + }); + const second = vi.fn((event: { prompt: Array<{ role: string }>; systemPrompt: string }) => { + expect(event.prompt).toHaveLength(2); + expect(event.systemPrompt).toBe("first"); + return { messages: [{ role: "user" as const, content: "second", timestamp: 3 }] }; + }); + hooks.on( + "before_run", + () => ({ + messages: [{ role: "user", content: "first", timestamp: 2 }], + systemPrompt: "first", + }), + { id: "first" }, + ); + hooks.on("before_run", second, { id: "second" }); + + const { result } = await hooks.runBeforeAcceptanceWithResumeData( + { + lane: "main", + runId: "run", + prompt: [{ role: "user", content: "prompt", timestamp: 1 }], + systemPrompt: "base", + resources: {}, + }, + () => {}, + ); + + expect(result).toMatchObject({ + systemPrompt: "first", + messages: [ + { role: "user", content: "first" }, + { role: "user", content: "second" }, + ], + }); + expect(second).toHaveBeenCalledOnce(); + expect(errors).toEqual([]); + }); + + it("checks harness admission immediately before the complete before_run pipeline", async () => { + const hooks = new HookRegistry(() => {}); + const calls: string[] = []; + const release = deferred(); + hooks.on( + "before_run", + async () => { + calls.push("first:start"); + await release.promise; + calls.push("first:end"); + return undefined; + }, + { id: "first" }, + ); + hooks.on( + "before_run", + () => { + calls.push("second"); + return undefined; + }, + { id: "second" }, + ); + const event = { lane: "main", runId: "run", prompt: [], systemPrompt: "", resources: {} }; + const closed = new Error("closed"); + expect(() => + hooks.runBeforeAcceptanceWithResumeData(event, () => { + throw closed; + }), + ).toThrow(closed); + expect(calls).toEqual([]); + + const running = hooks.runBeforeAcceptanceWithResumeData(event, () => {}); + expect(calls).toEqual(["first:start"]); + hooks.on( + "before_run", + () => { + calls.push("late"); + return undefined; + }, + { id: "late" }, + ); + hooks.close(closed); + release.resolve(); + await running; + expect(calls).toEqual(["first:start", "first:end", "second"]); + }); + + it("routes persisted resume data by stable hook id", async () => { + const hooks = new HookRegistry(() => {}); + hooks.on("before_run", () => ({ resumeData: { owner: "proto" } }), { id: "__proto__" }); + hooks.on("before_run", () => ({ resumeData: { owner: "constructor" } }), { id: "constructor" }); + const seen: unknown[] = []; + hooks.on( + "before_resume", + (event) => { + seen.push(event.resumeData); + }, + { id: "__proto__" }, + ); + hooks.on( + "before_resume", + (event) => { + seen.push(event.resumeData); + }, + { id: "constructor" }, + ); + const accepted = await hooks.runBeforeAcceptanceWithResumeData( + { lane: "main", runId: "run", prompt: [], systemPrompt: "", resources: {} }, + () => {}, + ); + + await hooks.runBeforeResumeWithGate( + { lane: "main", runId: "run", kind: "run", prompt: [] }, + accepted.resumeData, + new OperationEffectGate(), + ); + expect(seen).toEqual([{ owner: "proto" }, { owner: "constructor" }]); + }); + + it("preserves clear-all before_request patches across later handlers", async () => { + const hooks = new HookRegistry(() => {}); + hooks.on("before_request", () => ({ streamOptions: { headers: undefined, metadata: undefined } })); + hooks.on("before_request", (event) => { + expect(event.streamOptions.headers).toBeUndefined(); + expect(event.streamOptions.metadata).toBeUndefined(); + return { streamOptions: { headers: { c: "3" }, metadata: { y: 2 } } }; + }); + const result = await hooks.runWithGate( + "before_request", + { + lane: "main", + runId: "run", + model: fauxProvider().getModel(), + step: "assistant", + attempt: 1, + streamOptions: { headers: { a: "1", b: "2" }, metadata: { x: 1 } }, + }, + new OperationEffectGate(), + ); + expect(result).toEqual({ + streamOptions: { + headers: { a: undefined, b: undefined, c: "3" }, + metadata: { x: undefined, y: 2 }, + }, + }); + }); + + it("preserves earlier after_tool fields when a later patch returns undefined", async () => { + const hooks = new HookRegistry(() => {}); + hooks.on("after_tool", () => ({ content: [{ type: "text", text: "patched" }] })); + hooks.on("after_tool", () => ({ content: undefined, isError: false })); + const result = await hooks.runWithGate( + "after_tool", + { + lane: "main", + runId: "run", + toolCallId: "call", + toolName: "tool", + args: {}, + content: [{ type: "text", text: "raw" }], + isError: true, + }, + new OperationEffectGate(), + ); + expect(result).toEqual({ content: [{ type: "text", text: "patched" }], isError: false }); + }); + + it("accepts explicit false structural declines and rejects true conflicts", async () => { + const errors: Error[] = []; + const hooks = new HookRegistry((error) => { + errors.push(error); + }); + const ignored = { summary: "ignored", readFiles: [], modifiedFiles: [] }; + const selected = { summary: "selected", readFiles: [], modifiedFiles: [] }; + hooks.on("before_navigation", () => ({ decline: true, summary: ignored })); + hooks.on("before_navigation", () => ({ decline: false, summary: selected })); + + const result = await hooks.runWithGate( + "before_navigation", + { + lane: "main", + runId: "run", + targetId: "target", + preparation: { + messages: [], + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + totalTokens: 0, + }, + }, + new OperationEffectGate(), + ); + expect(result).toEqual({ decline: false, summary: selected }); + expect(errors).toHaveLength(1); + expect(errors[0].message).toMatch(/cannot return both decline and summary/); + }); + + it("checks the effect gate immediately before admitting the complete pipeline", async () => { + const hooks = new HookRegistry(() => {}); + const calls: string[] = []; + const release = deferred(); + hooks.on( + "before_resume", + async () => { + calls.push("first:start"); + await release.promise; + calls.push("first:end"); + }, + { id: "first" }, + ); + hooks.on( + "before_resume", + () => { + calls.push("second"); + }, + { id: "second" }, + ); + const event = { + kind: "run" as const, + prompt: [], + lane: "main", + runId: "run", + }; + + const abortFirstGate = new OperationEffectGate(); + const cancellation = Promise.resolve(); + abortFirstGate.beginAbort(cancellation); + expect(() => hooks.runBeforeResumeWithGate(event, {}, abortFirstGate)).toThrow(AbortRequested); + expect(calls).toEqual([]); + + const startFirstGate = new OperationEffectGate(); + const running = hooks.runBeforeResumeWithGate(event, {}, startFirstGate); + expect(calls).toEqual(["first:start"]); + startFirstGate.beginAbort(cancellation); + startFirstGate.signalAbort(); + release.resolve(); + await running; + expect(calls).toEqual(["first:start", "first:end", "second"]); + }); +}); + +describe("HarnessEventBus", () => { + it("buffers between snapshot and start, then delivers each event once in order", async () => { + const bus = new HarnessEventBus(); + const watcher = bus.watch({ leafId: null }, () => true); + await bus.emit({ type: "run_start", runId: "one", lane: "main" }); + const seen: string[] = []; + watcher.start((event) => { + seen.push(`${event.type}:${"runId" in event ? event.runId : ""}`); + }); + await bus.emit({ type: "run_start", runId: "two", lane: "main" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(watcher.snapshot).toEqual({ leafId: null }); + expect(seen).toEqual(["run_start:one", "run_start:two"]); + watcher.unsubscribe(); + await bus.emit({ type: "run_start", runId: "three", lane: "main" }); + expect(seen).toHaveLength(2); + }); + + it("isolates each listener from payload mutation", async () => { + const bus = new HarnessEventBus(); + let observed: string[] = []; + bus.on("config_update", (event) => { + if (event.property === "activeTools") event.value.push("mutated"); + }); + bus.on("config_update", (event) => { + if (event.property === "activeTools") observed = event.value; + }); + await bus.emit({ + type: "config_update", + property: "activeTools", + value: ["read"], + previous: [], + lane: "main", + }); + expect(observed).toEqual(["read"]); + }); + + it("serializes concurrent publications in process order", async () => { + const bus = new HarnessEventBus(); + const started = deferred(); + const release = deferred(); + const seen: string[] = []; + bus.on("run_start", async (event) => { + seen.push(`${event.runId}:start`); + if (event.runId === "one") { + started.resolve(); + await release.promise; + } + seen.push(`${event.runId}:end`); + }); + + const one = bus.emit({ type: "run_start", runId: "one", lane: "main" }); + await started.promise; + const two = bus.emit({ type: "run_start", runId: "two", lane: "main" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(seen).toEqual(["one:start"]); + release.resolve(); + await Promise.all([one, two]); + expect(seen).toEqual(["one:start", "one:end", "two:start", "two:end"]); + }); + + it("continues watcher delivery after listener failure and reports it", async () => { + const bus = new HarnessEventBus(); + const failures: string[] = []; + bus.on("handler_error", (event) => { + failures.push(event.error); + }); + const watcher = bus.watch({}, (event) => event.type === "run_start"); + const seen: string[] = []; + watcher.start((event) => { + if (event.type !== "run_start") return; + if (event.runId === "one") throw new Error("watcher failed"); + seen.push(event.runId); + }); + + await bus.emit({ type: "run_start", runId: "one", lane: "main" }); + await bus.emit({ type: "run_start", runId: "two", lane: "main" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(seen).toEqual(["two"]); + expect(failures).toEqual(["watcher failed"]); + }); + + it("isolates listener failures and emits handler_error", async () => { + const bus = new HarnessEventBus(); + const failures: string[] = []; + bus.on("run_start", () => { + throw new Error("listener failed"); + }); + bus.on("handler_error", (event) => { + failures.push(event.error); + }); + await bus.emit({ type: "run_start", runId: "run", lane: "main" }); + expect(failures).toEqual(["listener failed"]); + }); +}); diff --git a/packages/agent/test/harness/execution-tools.test.ts b/packages/agent/test/harness/execution-tools.test.ts index 335c4a9b8..b50cf2dd5 100644 --- a/packages/agent/test/harness/execution-tools.test.ts +++ b/packages/agent/test/harness/execution-tools.test.ts @@ -1,7 +1,7 @@ import { NOOP_TELEMETRY_CONTEXT } from "@earendil-works/pi-telemetry"; import { Type } from "typebox"; import { describe, expect, it, vi } from "vitest"; -import type { EffectGate } from "../../src/harness/execution/effect-gate.ts"; +import { AbortRequested, OperationEffectGate } from "../../src/harness/execution/effect-gate.ts"; import { applyBeforeToolDecision, createToolResultMessage, @@ -52,8 +52,8 @@ function clearPrepared(callToClear: ReturnType) { return cleared; } -function effectGate(signal = new AbortController().signal): EffectGate { - return { signal, assertOpen() {} }; +function effectGate(): OperationEffectGate { + return new OperationEffectGate(); } describe("tool execution primitives", () => { @@ -107,11 +107,11 @@ describe("tool execution primitives", () => { }); it("executes with updates, passes the signal, and ignores late updates", async () => { - const controller = new AbortController(); + const gate = effectGate(); let lateUpdate: ((partial: AgentToolResult<{ value: string }>) => void) | undefined; const execute: AgentTool["execute"] = vi.fn( async (_id, args, signal, onUpdate) => { - expect(signal).toBe(controller.signal); + expect(signal).toBe(gate.signal); onUpdate?.({ content: [{ type: "text", text: "partial" }], details: { value: args.value } }); lateUpdate = onUpdate; return { content: [{ type: "text" as const, text: "done" }], details: { value: args.value } }; @@ -120,12 +120,7 @@ describe("tool execution primitives", () => { const cleared = clearPrepared(prepareToolCall(call(), [tool({ execute })])); const updates: AgentToolResult[] = []; - const result = await executeToolCall( - cleared, - effectGate(controller.signal), - (update) => updates.push(update), - NOOP_TELEMETRY_CONTEXT, - ); + const result = await executeToolCall(cleared, gate, (update) => updates.push(update), NOOP_TELEMETRY_CONTEXT); lateUpdate?.({ content: [{ type: "text", text: "late" }], details: { value: "late" } }); expect(vi.mocked(execute)).toHaveBeenCalledOnce(); @@ -157,18 +152,15 @@ describe("tool execution primitives", () => { expect(text(result.result)).toBe("tool failed"); }); - it("lets effect-gate refusal escape without invoking the tool", async () => { + it("lets abort-first gate refusal escape without invoking the tool", async () => { const execute = vi.fn(tool().execute); const cleared = clearPrepared(prepareToolCall(call(), [tool({ execute })])); - const refusal = new Error("effect start refused"); - const gate: EffectGate = { - signal: new AbortController().signal, - assertOpen() { - throw refusal; - }, - }; + const gate = effectGate(); + gate.beginAbort(Promise.resolve()); - await expect(executeToolCall(cleared, gate, () => {}, NOOP_TELEMETRY_CONTEXT)).rejects.toBe(refusal); + await expect(executeToolCall(cleared, gate, () => {}, NOOP_TELEMETRY_CONTEXT)).rejects.toBeInstanceOf( + AbortRequested, + ); expect(execute).not.toHaveBeenCalled(); }); diff --git a/packages/agent/test/harness/restore.test.ts b/packages/agent/test/harness/restore.test.ts new file mode 100644 index 000000000..ea9bb7a71 --- /dev/null +++ b/packages/agent/test/harness/restore.test.ts @@ -0,0 +1,181 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { restoreLane } from "../../src/harness/restore.ts"; +import { + DEFAULT_COMPACTION_SETTINGS, + type LaneConfiguration, + MemorySessionRepo, + type Operation, + type OperationState, + type Session, +} from "../../src/index.ts"; + +const repos: MemorySessionRepo[] = []; + +async function createConfiguredSession(): Promise { + const repo = new MemorySessionRepo(); + repos.push(repo); + const session = await repo.create({}); + const configuration: LaneConfiguration = { + model: { provider: "test", modelId: "model" }, + thinkingLevel: "off", + activeToolNames: [], + }; + await session.mutate("main", (mutator) => + mutator.commit({ + writes: [{ kind: "register", op: "set", namespace: "lane.config", key: "main", value: configuration }], + }), + ); + return session; +} + +async function installRun(session: Session): Promise<{ + operationId: string; + promptEntryId: string; + operation: Operation; + state: OperationState; +}> { + const operationId = session.idGenerator.next(); + const promptEntryId = session.idGenerator.next(); + const operation: Operation = { + operationId, + lane: "main", + sourceLeafId: null, + startedAt: 1, + intent: { kind: "run", promptEntryIds: [promptEntryId] }, + }; + const state: OperationState = { + kind: "run", + control: { status: "running" }, + settings: { + compaction: DEFAULT_COMPACTION_SETTINGS, + steeringMode: "all", + followUpMode: "all", + toolExecution: "parallel", + }, + phase: { + kind: "checkpoint", + continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, + triggerEntryId: promptEntryId, + }, + inbox: { steer: [], followUp: [], writes: [] }, + latestAssistantEntryId: null, + }; + await session.mutate("main", (mutator) => + mutator.commit({ + writes: [ + { + kind: "entry", + entry: { + id: promptEntryId, + parentId: null, + type: "message", + message: { role: "user", content: "prompt", timestamp: 1 }, + }, + }, + { kind: "register", op: "set", namespace: "lane.leaf", key: "main", value: promptEntryId }, + { kind: "register", op: "set", namespace: "op.meta", key: operationId, value: operation }, + { kind: "register", op: "set", namespace: "op.state", key: operationId, value: state }, + { + kind: "register", + op: "set", + namespace: "lane.state", + key: "main", + value: { currentOperationId: operationId, pendingNextRun: [] }, + }, + ], + }), + ); + return { operationId, promptEntryId, operation, state }; +} + +afterEach(async () => { + for (const repo of repos.splice(0)) await repo.close(); +}); + +describe("restoreLane base validation", () => { + it.each(["lane.config", "lane.state", "lane.leaf"] as const)("rejects a lane missing %s", async (namespace) => { + const session = await createConfiguredSession(); + await session.mutate("main", (mutator) => + mutator.commit({ writes: [{ kind: "register", op: "delete", namespace, key: "main" }] }), + ); + await expect(restoreLane(session, "main")).rejects.toThrow(`missing ${namespace}`); + }); + + it("rejects an idle lane whose leaf is missing", async () => { + const session = await createConfiguredSession(); + const missingId = session.idGenerator.next(); + await session.mutate("main", (mutator) => + mutator.commit({ + writes: [{ kind: "register", op: "set", namespace: "lane.leaf", key: "main", value: missingId }], + }), + ); + await expect(restoreLane(session, "main")).rejects.toThrow(`missing entry ${missingId}`); + }); + + it("rejects missing operation registers and invalid base references", async () => { + const session = await createConfiguredSession(); + const { operationId, operation, state } = await installRun(session); + const writeOperation = (value: Operation) => + session.mutate("main", (mutator) => + mutator.commit({ + writes: [{ kind: "register", op: "set", namespace: "op.meta", key: operationId, value }], + }), + ); + const writeState = (value: OperationState) => + session.mutate("main", (mutator) => + mutator.commit({ + writes: [{ kind: "register", op: "set", namespace: "op.state", key: operationId, value }], + }), + ); + + await session.mutate("main", (mutator) => + mutator.commit({ writes: [{ kind: "register", op: "delete", namespace: "op.meta", key: operationId }] }), + ); + await expect(restoreLane(session, "main")).rejects.toThrow(/missing op.meta/); + await writeOperation(operation); + await session.mutate("main", (mutator) => + mutator.commit({ writes: [{ kind: "register", op: "delete", namespace: "op.state", key: operationId }] }), + ); + await expect(restoreLane(session, "main")).rejects.toThrow(/missing op.state/); + await writeState(state); + + await writeOperation({ ...operation, operationId: session.idGenerator.next() }); + await expect(restoreLane(session, "main")).rejects.toThrow(/has another operation id/); + + const missingSource = session.idGenerator.next(); + await writeOperation({ ...operation, sourceLeafId: missingSource }); + await expect(restoreLane(session, "main")).rejects.toThrow(`missing entry ${missingSource}`); + + const missingPrompt = session.idGenerator.next(); + await writeOperation({ ...operation, intent: { kind: "run", promptEntryIds: [missingPrompt] } }); + await expect(restoreLane(session, "main")).rejects.toThrow(`missing entry ${missingPrompt}`); + + const customEntryId = session.idGenerator.next(); + await session.mutate("main", (mutator) => + mutator.commit({ + writes: [ + { + kind: "entry", + entry: { id: customEntryId, parentId: null, type: "custom", customType: "test" }, + }, + ], + }), + ); + await writeOperation({ ...operation, intent: { kind: "run", promptEntryIds: [customEntryId] } }); + await expect(restoreLane(session, "main")).rejects.toThrow(`entry ${customEntryId} is not a message`); + + const missingTarget = session.idGenerator.next(); + await writeOperation({ + ...operation, + intent: { kind: "navigation", targetId: missingTarget, summarize: false }, + }); + await writeState({ + kind: "navigation", + control: { status: "running" }, + targetId: missingTarget, + summarize: false, + phase: { kind: "ready_to_commit" }, + }); + await expect(restoreLane(session, "main")).rejects.toThrow(`missing entry ${missingTarget}`); + }); +}); diff --git a/packages/agent/test/harness/session-create-lane.test.ts b/packages/agent/test/harness/session-create-lane.test.ts index 1f5bf5c7a..6056fb441 100644 --- a/packages/agent/test/harness/session-create-lane.test.ts +++ b/packages/agent/test/harness/session-create-lane.test.ts @@ -199,8 +199,9 @@ describe("StorageBackedSession.createLane", () => { key: "broken", value: { operationId: ROOT_ID, - kind: "compaction", + kind: "navigation", leafId: null, + oldLeafId: ROOT_ID, outcome: "completed", }, }, diff --git a/packages/agent/test/harness/tools.test.ts b/packages/agent/test/harness/tools.test.ts index a926051c7..3920b4d8c 100644 --- a/packages/agent/test/harness/tools.test.ts +++ b/packages/agent/test/harness/tools.test.ts @@ -103,6 +103,7 @@ class LateOutputExecutionEnv extends NodeExecutionEnv { } const TRUNCATED_OUTPUT_LINES = DEFAULT_MAX_LINES + 1; +const invocation = { invocationId: "test-result", operationId: "test-operation", turnId: "test-turn" }; class TimeoutOutputExecutionEnv extends NodeExecutionEnv { override async exec( @@ -148,6 +149,7 @@ describe("AgentHarness tools", () => { undefined, undefined, context, + invocation, ); const output = textOutput(result); @@ -167,7 +169,14 @@ describe("AgentHarness tools", () => { ), ); - const result = await createReadTool().execute("read-2", { path: "large.txt" }, undefined, undefined, context); + const result = await createReadTool().execute( + "read-2", + { path: "large.txt" }, + undefined, + undefined, + context, + invocation, + ); expect(textOutput(result)).toContain("[Showing lines 1-2000 of 2500. Use offset=2001 to continue.]"); expect(result.details?.truncation).toMatchObject({ @@ -190,6 +199,7 @@ describe("AgentHarness tools", () => { undefined, undefined, context, + invocation, ); expect(result.details).toBeUndefined(); @@ -201,7 +211,14 @@ describe("AgentHarness tools", () => { getOrThrow(await context.env.writeFile("short.txt", "one\ntwo\nthree")); await expect( - createReadTool().execute("read-3", { path: "short.txt", offset: 100 }, undefined, undefined, context), + createReadTool().execute( + "read-3", + { path: "short.txt", offset: 100 }, + undefined, + undefined, + context, + invocation, + ), ).rejects.toThrow("Offset 100 is beyond end of file (3 lines total)"); }); @@ -215,7 +232,14 @@ describe("AgentHarness tools", () => { ); getOrThrow(await context.env.writeFile("image.txt", png)); - const result = await createReadTool().execute("read-4", { path: "image.txt" }, undefined, undefined, context); + const result = await createReadTool().execute( + "read-4", + { path: "image.txt" }, + undefined, + undefined, + context, + invocation, + ); expect(textOutput(result)).toContain("Read image file [image/png]"); expect(result.content).toContainEqual({ @@ -243,7 +267,14 @@ describe("AgentHarness tools", () => { }, }); - const result = await tool.execute("read-bmp", { path: "image.bmp" }, undefined, undefined, context); + const result = await tool.execute( + "read-bmp", + { path: "image.bmp" }, + undefined, + undefined, + context, + invocation, + ); expect(received).toMatchObject({ mimeType: "image/bmp", autoResizeImages: false }); expect(Array.from(received?.bytes ?? [])).toEqual(Array.from(bmp)); @@ -261,6 +292,7 @@ describe("AgentHarness tools", () => { undefined, undefined, context, + invocation, ); expect(textOutput(result)).toBe("Successfully wrote 5 bytes to nested/dir/file.txt"); @@ -279,6 +311,7 @@ describe("AgentHarness tools", () => { { env, }, + invocation, ); await env.firstWriteStarted.promise; controller.abort(); @@ -288,6 +321,7 @@ describe("AgentHarness tools", () => { undefined, undefined, { env }, + invocation, ); await delay(20); @@ -317,6 +351,7 @@ describe("AgentHarness tools", () => { undefined, undefined, context, + invocation, ); expect(textOutput(result)).toBe("Successfully replaced 2 block(s) in edit.txt."); @@ -343,6 +378,7 @@ describe("AgentHarness tools", () => { undefined, undefined, context, + invocation, ), ).rejects.toThrow(/overlap/); expect(getOrThrow(await context.env.readTextFile("edit.txt"))).toBe("one\ntwo\nthree\n"); @@ -360,6 +396,7 @@ describe("AgentHarness tools", () => { undefined, undefined, context, + invocation, ), ).rejects.toThrow(/Could not find the exact text/); await expect( @@ -369,6 +406,7 @@ describe("AgentHarness tools", () => { undefined, undefined, context, + invocation, ), ).rejects.toThrow(/Found 3 occurrences/); }); @@ -384,6 +422,7 @@ describe("AgentHarness tools", () => { controller.signal, undefined, { env }, + invocation, ); await env.firstEditWriteStarted.promise; controller.abort(); @@ -393,6 +432,7 @@ describe("AgentHarness tools", () => { undefined, undefined, { env }, + invocation, ); await delay(20); @@ -417,6 +457,7 @@ describe("AgentHarness tools", () => { undefined, undefined, { env }, + invocation, ), tool.execute( "edit-link", @@ -424,6 +465,7 @@ describe("AgentHarness tools", () => { undefined, undefined, { env }, + invocation, ), ]); @@ -441,6 +483,7 @@ describe("AgentHarness tools", () => { undefined, undefined, context, + invocation, ); expect(getOrThrow(await context.env.readTextFile("target.txt"))).toBe("after\n"); @@ -456,6 +499,7 @@ describe("AgentHarness tools", () => { undefined, undefined, context, + invocation, ); expect(getOrThrow(await context.env.readTextFile("edit.txt"))).toBe("\uFEFFone\r\nTWO\r\n"); @@ -471,6 +515,7 @@ describe("AgentHarness tools", () => { undefined, undefined, context, + invocation, ); expect(textOutput(result)).toContain("out"); @@ -482,10 +527,10 @@ describe("AgentHarness tools", () => { const tool = createBashTool(); await expect( - tool.execute("bash-2", { command: "printf failed; exit 7" }, undefined, undefined, context), + tool.execute("bash-2", { command: "printf failed; exit 7" }, undefined, undefined, context, invocation), ).rejects.toThrow(/failed[\s\S]*Command exited with code 7/); await expect( - tool.execute("bash-3", { command: "sleep 2", timeout: 0.01 }, undefined, undefined, context), + tool.execute("bash-3", { command: "sleep 2", timeout: 0.01 }, undefined, undefined, context, invocation), ).rejects.toThrow(/Command timed out after 0.01 seconds/); }); @@ -499,6 +544,7 @@ describe("AgentHarness tools", () => { undefined, undefined, context, + invocation, ); } catch (cause) { error = cause; @@ -523,6 +569,7 @@ describe("AgentHarness tools", () => { undefined, (update) => updates.push(textOutput(update)), { env }, + invocation, ); await new Promise((resolve) => setTimeout(resolve, 20)); @@ -538,6 +585,7 @@ describe("AgentHarness tools", () => { undefined, undefined, context, + invocation, ); expect(textOutput(result)).toMatch(/Showing last 50\.0KB of line 1 \(line is 58\.6KB\)\. Full output:/); @@ -565,7 +613,14 @@ describe("AgentHarness tools", () => { }, }); - const result = await tool.execute("bash-prepare", { command: ":" }, controller.signal, undefined, context); + const result = await tool.execute( + "bash-prepare", + { command: ":" }, + controller.signal, + undefined, + context, + invocation, + ); expect(receivedContext).toBe(context); expect(receivedSignal).toBe(controller.signal); @@ -580,6 +635,7 @@ describe("AgentHarness tools", () => { undefined, undefined, context, + invocation, ); expect(textOutput(result)).toBe("hello"); @@ -597,6 +653,7 @@ describe("AgentHarness tools", () => { undefined, (update) => updates.push(update), context, + invocation, ); expect(updates.length).toBeLessThan(25); diff --git a/packages/agent/test/harness/types.test.ts b/packages/agent/test/harness/types.test.ts index dea3240ca..96a15a4dd 100644 --- a/packages/agent/test/harness/types.test.ts +++ b/packages/agent/test/harness/types.test.ts @@ -3,6 +3,8 @@ import { expectTypeOf, it } from "vitest"; import type { AgentHarnessOptions, AgentHarnessStreamOptions, + AgentHarnessTool, + AgentHarnessToolInvocation, AgentLane, AgentMessage, AgentTool, @@ -13,6 +15,7 @@ import type { Control, CustomEntry, Deferred, + DriveResult, EntryProjector, Generation, GenerationContext, @@ -22,11 +25,14 @@ import type { HookName, IdGenerator, LaneConfiguration, + LaneExecutionInfo, LaneLastResult, LaneSnapshot, NavigationState, NewEntry, Operation, + OperationAdmissionResult, + OperationRequest, OperationState, RegisterSetWrite, RegisterValues, @@ -410,7 +416,9 @@ it("covers Part 5 results, events, hooks, snapshots, tools, and stream options", | "config_update" | "compaction_start" | "compaction_end" + | "compaction_suspend" | "navigation_start" + | "navigation_suspend" | "navigation_end" | "lane_created" | "usage" @@ -432,6 +440,13 @@ it("covers Part 5 results, events, hooks, snapshots, tools, and stream options", expectTypeOf>().returns.toEqualTypeOf>(); expectTypeOf().not.toEqualTypeOf(); expectTypeOf().returns.toEqualTypeOf>(); + expectTypeOf().returns.toEqualTypeOf>(); + expectTypeOf().returns.toEqualTypeOf>(); + expectTypeOf().returns.toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf< + "prompt" | "skill" | "prompt_template" | "compaction" | "navigation" + >(); + expectTypeOf["execute"]>[5]>().toEqualTypeOf(); expectTypeOf().toEqualTypeOf<"never" | "safe" | undefined>(); expectTypeOf().toEqualTypeOf< boolean | { window?: "15m" | "1h" | "24h" } | undefined