From 2266d19510636958f3a4ed84bfdd7a371c51fda3 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sat, 15 Aug 2026 23:38:04 -0600 Subject: [PATCH 01/16] fix(runtime): harden durable interaction adapters --- docs/api/index.md | 40 +- docs/api/primitive-catalog.md | 6 +- docs/api/runtime.md | 16 +- docs/canonical-api.md | 5 +- examples/p1-parity/run-parity.ts | 6 +- examples/stream-backends/stream-backends.ts | 2 +- .../supervisor-loop/run-supervisor-mcp.ts | 2 +- package.json | 2 +- pnpm-lock.yaml | 60 ++- pnpm-workspace.yaml | 6 +- src/backends.ts | 7 +- src/conversation/conversation-backend.ts | 7 +- src/improvement/agentic-generator.ts | 14 +- src/index.ts | 1 + src/runtime/environment-provider.test.ts | 348 +++++++++++++++- src/runtime/environment-provider.ts | 391 +++++++++++++++--- src/runtime/interaction-capabilities.ts | 36 ++ src/runtime/profile-chat-client.ts | 6 +- src/runtime/profile-execution-backend.ts | 11 +- src/runtime/retained-run-binding.ts | 20 + src/runtime/retained-run-events.ts | 2 + src/runtime/retained-run-handle.ts | 13 +- src/runtime/retained-run-start.ts | 91 +++- src/runtime/retained-run-types.ts | 3 + src/runtime/retained-run.test.ts | 158 +++++++ src/runtime/sandbox-events.ts | 29 ++ src/runtime/strategy.ts | 6 +- src/runtime/stream-agent-turn.test.ts | 210 ++++++++-- src/runtime/stream-agent-turn.ts | 88 ++-- src/runtime/supervise/runtime.ts | 6 + src/runtime/turn-input.ts | 80 +++- src/types.ts | 12 + tests/helpers/durable-retained-provider.ts | 37 +- 33 files changed, 1553 insertions(+), 168 deletions(-) create mode 100644 src/runtime/interaction-capabilities.ts diff --git a/docs/api/index.md b/docs/api/index.md index 94ce2014..710bc324 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -9989,6 +9989,18 @@ keeping the backend transport thin lets domain repos own MCP plumbing. > **content**: `string` +##### parts? + +> `optional` **parts?**: `InputPart`[] + +##### interactions? + +> `optional` **interactions?**: `Readonly`\<`Record`\<`string`, `boolean` \| `undefined`\>\> + +##### providerOptions? + +> `optional` **providerOptions?**: `Record`\<`string`, `unknown`\> + ##### inputs? > `optional` **inputs?**: `Record`\<`string`, `unknown`\> @@ -12138,14 +12150,40 @@ pin `{ type: 'function', function: { name } }`. *** +### RuntimeCanonicalStreamEvent + +> **RuntimeCanonicalStreamEvent** = `StreamEvent` & `object` + +Agent Interface events that do not belong to Runtime's task vocabulary. + +#### Type Declaration + +##### task? + +> `optional` **task?**: [`AgentTaskSpec`](#agenttaskspec) + +##### session? + +> `optional` **session?**: [`RuntimeSession`](#runtimesession) + +##### timestamp? + +> `optional` **timestamp?**: `string` + +*** + ### RuntimeStreamEvent -> **RuntimeStreamEvent** = \{ `type`: `"task_start"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `timestamp`: `string`; \} \| \{ `type`: `"readiness_start"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `timestamp`: `string`; \} \| \{ `type`: `"readiness_end"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `knowledge`: `KnowledgeReadinessReport`; `decision`: [`KnowledgeReadinessDecision`](#knowledgereadinessdecision); `timestamp`: `string`; \} \| \{ `type`: `"questions_start"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `questions`: `UserQuestion`[]; `timestamp`: `string`; \} \| \{ `type`: `"questions_end"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `questions`: `UserQuestion`[]; `userAnswers`: `Record`\<`string`, `string`\>; `timestamp`: `string`; \} \| \{ `type`: `"acquisition_start"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `acquisitionPlans`: `DataAcquisitionPlan`[]; `timestamp`: `string`; \} \| \{ `type`: `"acquisition_end"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `acquisitionPlans`: `DataAcquisitionPlan`[]; `acquiredEvidenceIds`: `string`[]; `timestamp`: `string`; \} \| \{ `type`: `"session_created"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `session`: [`RuntimeSession`](#runtimesession); `timestamp`: `string`; \} \| \{ `type`: `"session_resumed"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `session`: [`RuntimeSession`](#runtimesession); `timestamp`: `string`; \} \| \{ `type`: `"backend_start"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `session`: [`RuntimeSession`](#runtimesession); `backend`: `string`; `metadata?`: `Record`\<`string`, `unknown`\>; `timestamp`: `string`; \} \| \{ `type`: `"text_delta"`; `task?`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `text`: `string`; `timestamp?`: `string`; \} \| \{ `type`: `"reasoning_delta"`; `task?`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `text`: `string`; `timestamp?`: `string`; \} \| \{ `type`: `"tool_call"`; `task?`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `toolName`: `string`; `toolCallId?`: `string`; `args?`: `unknown`; `timestamp?`: `string`; \} \| \{ `type`: `"tool_result"`; `task?`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `toolName`: `string`; `toolCallId?`: `string`; `result?`: `unknown`; `timestamp?`: `string`; \} \| \{ `type`: `"llm_call"`; `task?`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `model`: `string`; `tokensIn?`: `number`; `tokensOut?`: `number`; `tokensKnown?`: `false`; `costUsd?`: `number`; `usdKnown?`: `false`; `estimatedCostUsd?`: `number`; `promptCache?`: `Readonly`\<`Record`\<`string`, `number` \| `string`\>\>; `latencyMs?`: `number`; `finishReason?`: `string`; `timestamp?`: `string`; \} \| \{ `type`: `"artifact"`; `task?`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `artifactId`: `string`; `name?`: `string`; `mimeType?`: `string`; `uri?`: `string`; `content?`: `string`; `metadata?`: `Record`\<`string`, `unknown`\>; `timestamp?`: `string`; \} \| \{ `type`: `"proposal_created"`; `task?`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `proposalId`: `string`; `title`: `string`; `status?`: `"pending"` \| `"approved"` \| `"rejected"`; `content?`: `string`; `timestamp?`: `string`; \} \| \{ `type`: `"backend_error"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `backend`: `string`; `message`: `string`; `recoverable`: `boolean`; `error?`: [`BackendErrorDetail`](#backenderrordetail); `timestamp`: `string`; \} \| \{ `type`: `"backend_end"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `session`: [`RuntimeSession`](#runtimesession); `backend`: `string`; `timestamp`: `string`; \} \| \{ `type`: `"task_end"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `status`: [`AgentTaskStatus`](#agenttaskstatus); `reason`: `string`; `timestamp`: `string`; \} \| \{ `type`: `"final"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `status`: [`AgentTaskStatus`](#agenttaskstatus); `reason`: `string`; `text?`: `string`; `metadata?`: `Record`\<`string`, `unknown`\>; `error?`: [`BackendErrorDetail`](#backenderrordetail); `timestamp`: `string`; \} +> **RuntimeStreamEvent** = [`RuntimeCanonicalStreamEvent`](#runtimecanonicalstreamevent) \| \{ `type`: `"task_start"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `timestamp`: `string`; \} \| \{ `type`: `"readiness_start"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `timestamp`: `string`; \} \| \{ `type`: `"readiness_end"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `knowledge`: `KnowledgeReadinessReport`; `decision`: [`KnowledgeReadinessDecision`](#knowledgereadinessdecision); `timestamp`: `string`; \} \| \{ `type`: `"questions_start"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `questions`: `UserQuestion`[]; `timestamp`: `string`; \} \| \{ `type`: `"questions_end"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `questions`: `UserQuestion`[]; `userAnswers`: `Record`\<`string`, `string`\>; `timestamp`: `string`; \} \| \{ `type`: `"acquisition_start"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `acquisitionPlans`: `DataAcquisitionPlan`[]; `timestamp`: `string`; \} \| \{ `type`: `"acquisition_end"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `acquisitionPlans`: `DataAcquisitionPlan`[]; `acquiredEvidenceIds`: `string`[]; `timestamp`: `string`; \} \| \{ `type`: `"session_created"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `session`: [`RuntimeSession`](#runtimesession); `timestamp`: `string`; \} \| \{ `type`: `"session_resumed"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `session`: [`RuntimeSession`](#runtimesession); `timestamp`: `string`; \} \| \{ `type`: `"backend_start"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `session`: [`RuntimeSession`](#runtimesession); `backend`: `string`; `metadata?`: `Record`\<`string`, `unknown`\>; `timestamp`: `string`; \} \| \{ `type`: `"text_delta"`; `task?`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `text`: `string`; `timestamp?`: `string`; \} \| \{ `type`: `"reasoning_delta"`; `task?`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `text`: `string`; `timestamp?`: `string`; \} \| \{ `type`: `"tool_call"`; `task?`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `toolName`: `string`; `toolCallId?`: `string`; `args?`: `unknown`; `timestamp?`: `string`; \} \| \{ `type`: `"tool_result"`; `task?`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `toolName`: `string`; `toolCallId?`: `string`; `result?`: `unknown`; `timestamp?`: `string`; \} \| \{ `type`: `"llm_call"`; `task?`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `model`: `string`; `tokensIn?`: `number`; `tokensOut?`: `number`; `tokensKnown?`: `false`; `costUsd?`: `number`; `usdKnown?`: `false`; `estimatedCostUsd?`: `number`; `promptCache?`: `Readonly`\<`Record`\<`string`, `number` \| `string`\>\>; `latencyMs?`: `number`; `finishReason?`: `string`; `timestamp?`: `string`; \} \| \{ `type`: `"artifact"`; `task?`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `artifactId`: `string`; `name?`: `string`; `mimeType?`: `string`; `uri?`: `string`; `content?`: `string`; `metadata?`: `Record`\<`string`, `unknown`\>; `timestamp?`: `string`; \} \| \{ `type`: `"proposal_created"`; `task?`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `proposalId`: `string`; `title`: `string`; `status?`: `"pending"` \| `"approved"` \| `"rejected"`; `content?`: `string`; `timestamp?`: `string`; \} \| \{ `type`: `"backend_error"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `backend`: `string`; `message`: `string`; `recoverable`: `boolean`; `error?`: [`BackendErrorDetail`](#backenderrordetail); `timestamp`: `string`; \} \| \{ `type`: `"backend_end"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `session`: [`RuntimeSession`](#runtimesession); `backend`: `string`; `timestamp`: `string`; \} \| \{ `type`: `"task_end"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `status`: [`AgentTaskStatus`](#agenttaskstatus); `reason`: `string`; `timestamp`: `string`; \} \| \{ `type`: `"final"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `session?`: [`RuntimeSession`](#runtimesession); `status`: [`AgentTaskStatus`](#agenttaskstatus); `reason`: `string`; `text?`: `string`; `metadata?`: `Record`\<`string`, `unknown`\>; `error?`: [`BackendErrorDetail`](#backenderrordetail); `timestamp`: `string`; \} **`Stable`** #### Union Members +[`RuntimeCanonicalStreamEvent`](#runtimecanonicalstreamevent) + +*** + ##### Type Literal \{ `type`: `"task_start"`; `task`: [`AgentTaskSpec`](#agenttaskspec); `timestamp`: `string`; \} diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index 2e3888a3..8664d378 100644 --- a/docs/api/primitive-catalog.md +++ b/docs/api/primitive-catalog.md @@ -15,7 +15,7 @@ Every subpath this package declares in `package.json` `exports`. Reach for these ### Root — task lifecycle, conversation, RSI verbs, observability -Import from `@tangle-network/agent-runtime` — 431 exports. +Import from `@tangle-network/agent-runtime` — 432 exports. | Symbol | Kind | Summary | |---|---|---| @@ -264,6 +264,7 @@ Import from `@tangle-network/agent-runtime` — 431 exports. | `RetryableErrorPredicate` | type | Pure judgment of whether an error is worth retrying. Defaults: TimeoutError, AbortError, fetch-level network errors. | | `RetryBackoff` | type | Backoff between attempts. Constant ms, or `(attempt: 1-indexed) => ms`. | | `RootProviderModelEvidence` | type | Provider-observed model identity for the root manager's settled inference turns. | +| `RuntimeCanonicalStreamEvent` | type | Agent Interface events that do not belong to Runtime's task vocabulary. | | `RuntimeHookPhase` | type | Runtime hook contracts. Hooks are execution-scoped observers, not part of an | | `Settled` | type | A settled child, delivered by `scope.next()`. `seq` is the monotonic cursor order | | `SpendChannel` | type | The accounting channels a usage gap leaves incomplete. | @@ -1053,7 +1054,6 @@ Import from `@tangle-network/agent-runtime/kernel` — 772 exports. | `AgentEnvironmentProviderRef` | type | Provider object or registry name accepted by runtime provider adapters. | | `AgentProfileRef` | type | Portable profile reference: inline profile or provider catalog id. | | `AgentTurnBackend` | type | The execution substrate one turn runs on — a closed discriminated union over | -| `AgentTurnInput` | type | One prompt or an exact OpenAI-compatible conversation carried as the turn input. | | `AssertTraceDerivedFindings` | type | The firewall assertion contract, re-stated for the reactive seam (PORT of | | `AuthoredProfile` | type | What the supervisor AUTHORS per sub-task: one complete canonical profile whose name and | | `AuthorizeDownMessage` | type | Product decision over an exact continuation before it is durably recorded or delivered. | @@ -1154,7 +1154,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 772 exports. | `WorktreeCheckRunner` | type | The single shell-command-in-worktree runner seam (replaces the per-executor copies). | | `WorktreePatchArtifact` | type | Terminal artifact of one worktree-CLI run — the canonical worktree-harness result (the captured | -**Undocumented supporting types** (add a TSDoc line at the declaration to earn a table row): `AcquireOptions`, `AgentEnvironment`, `AgentEnvironmentCapabilities`, `AgentEnvironmentEvent`, `AgentEnvironmentProvider`, `AgentEnvironmentQuery`, `AgentEnvironmentSummary`, `AgentGraph`, `AgenticOptions`, `AgenticRunResult`, `AgenticTask`, `AgenticTool`, `AgentSession`, `AgentSessionRef`, `AgentTurnResult`, `AllWorkersStalledOptions`, `AnalystRegistry`, `AnytimeReport`, `AnytimeStrategySummary`, `AnytimeTaskCurve`, `ArtifactHandle`, `AuditIntentInput`, `AuditIntentOptions`, `AuthoredHarness`, `AuthoredStrategy`, `AuthorStrategyOptions`, `BenchmarkConfig`, `BenchmarkLift`, `BenchmarkStrategySummary`, `BenchmarkTaskRow`, `BudgetPool`, `BusStats`, `ChampionPick`, `CheckpointRef`, `CheckpointRequest`, `CheckRunContext`, `CliWorktreeBridgeSeam`, `CoordinationMcpHandle`, `CopyOptions`, `CorpusReadbackOptions`, `CreateAgentEnvironmentInput`, `CreateTangleSandboxExactProcessProviderOptions`, `DefinedLeaderboard`, `DispatchReport`, `Driver`, `EvolutionArchiveNode`, `EvolutionAuthor`, `EvolutionBandInfo`, `EvolutionCandidate`, `EvolutionGeneration`, `EvolutionReport`, `ExecRequest`, `ExecResult`, `ExecutorResultMapping`, `ForkRequest`, `GitWorkspaceOptions`, `GraphResult`, `HarvestCorpusOptions`, `HarvestFailure`, `HarvestReport`, `Inbox`, `InProcessSandboxClientOptions`, `IntentAudit`, `Iteration`, `Leaderboard`, `LeaderboardOptions`, `LocalSandboxClientOptions`, `LoopDecisionPayload`, `LoopDispatchOptions`, `LoopEndedPayload`, `LoopIterationEndedPayload`, `LoopIterationStartedPayload`, `LoopPlanDescription`, `LoopResult`, `LoopSandboxPlacement`, `LoopStartedPayload`, `LoopTraceEmitter`, `LoopWinner`, `MaterializeLocalMcpOptions`, `McpEnvironmentOptions`, `McpToolDescriptor`, `NodeSnapshot`, `NoProgressForOptions`, `Observation`, `ObserveInput`, `ObserveOptions`, `OpenSandboxRunOptions`, `PairwiseOptions`, `PatchDeliverableOptions`, `PlacementInfo`, `PlateauOptions`, `ProgressTrackerOptions`, `PromotionGateOptions`, `PromotionVerdict`, `PublishOptions`, `ReproductionCheck`, `ResolveSandboxClientOptions`, `ResourceRequest`, `RollingDispatchOptions`, `RunAgenticOptions`, `RunAgentRoundsOptions`, `RunGraphOptions`, `SandboxRun`, `ShotSpec`, `SpawnOpts`, `StdioMcpConnection`, `StdioMcpServerSpec`, `SteerableSandboxArgs`, `Strategy`, `StrategyEvolutionConfig`, `StrategyResult`, `StreamAgentTurnOptions`, `StructuralRolloutConfig`, `SuperviseOptions`, `SuperviseSurfaceOptions`, `SupervisorAgentDeps`, `SupervisorOpts`, `SupervisorSpanOptions`, `SupervisorSpanRecorder`, `SurfaceScore`, `ToolSpec`, `ToolStepInput`, `TraceSource`, `TrajectoryAnalysis`, `UntrackedCopyStats`, `ValidationCtx`, `Validator`, `VerifierEnvironmentOptions`, `WatchTraceOptions`, `WaterfallCollector`, `WaterfallReport`, `WaterfallSpan`, `WorkerEvidenceInput`, `Workspace`, `WorkspaceRequest`, `WorkspaceRun`, `WorktreeCliExecutorOptions`, `WorktreeFanoutOptions`, `AgentEnvironmentStatus`, `AgentSessionStatus`, `ChampionPolicy`, `EdgeDeliveryOutcome`, `GraphEdge`, `LoopTraceEvent`, `MakeWorkerAgent`, `RepairStop`, `SandboxControlClient`, `WorkspaceCommit`. +**Undocumented supporting types** (add a TSDoc line at the declaration to earn a table row): `AcquireOptions`, `AgentEnvironment`, `AgentEnvironmentCapabilities`, `AgentEnvironmentEvent`, `AgentEnvironmentProvider`, `AgentEnvironmentQuery`, `AgentEnvironmentSummary`, `AgentGraph`, `AgenticOptions`, `AgenticRunResult`, `AgenticTask`, `AgenticTool`, `AgentSession`, `AgentSessionRef`, `AgentTurnInput`, `AgentTurnResult`, `AllWorkersStalledOptions`, `AnalystRegistry`, `AnytimeReport`, `AnytimeStrategySummary`, `AnytimeTaskCurve`, `ArtifactHandle`, `AuditIntentInput`, `AuditIntentOptions`, `AuthoredHarness`, `AuthoredStrategy`, `AuthorStrategyOptions`, `BenchmarkConfig`, `BenchmarkLift`, `BenchmarkStrategySummary`, `BenchmarkTaskRow`, `BudgetPool`, `BusStats`, `ChampionPick`, `CheckpointRef`, `CheckpointRequest`, `CheckRunContext`, `CliWorktreeBridgeSeam`, `CoordinationMcpHandle`, `CopyOptions`, `CorpusReadbackOptions`, `CreateAgentEnvironmentInput`, `CreateTangleSandboxExactProcessProviderOptions`, `DefinedLeaderboard`, `DispatchReport`, `Driver`, `EvolutionArchiveNode`, `EvolutionAuthor`, `EvolutionBandInfo`, `EvolutionCandidate`, `EvolutionGeneration`, `EvolutionReport`, `ExecRequest`, `ExecResult`, `ExecutorResultMapping`, `ForkRequest`, `GitWorkspaceOptions`, `GraphResult`, `HarvestCorpusOptions`, `HarvestFailure`, `HarvestReport`, `Inbox`, `InProcessSandboxClientOptions`, `IntentAudit`, `Iteration`, `Leaderboard`, `LeaderboardOptions`, `LocalSandboxClientOptions`, `LoopDecisionPayload`, `LoopDispatchOptions`, `LoopEndedPayload`, `LoopIterationEndedPayload`, `LoopIterationStartedPayload`, `LoopPlanDescription`, `LoopResult`, `LoopSandboxPlacement`, `LoopStartedPayload`, `LoopTraceEmitter`, `LoopWinner`, `MaterializeLocalMcpOptions`, `McpEnvironmentOptions`, `McpToolDescriptor`, `NodeSnapshot`, `NoProgressForOptions`, `Observation`, `ObserveInput`, `ObserveOptions`, `OpenSandboxRunOptions`, `PairwiseOptions`, `PatchDeliverableOptions`, `PlacementInfo`, `PlateauOptions`, `ProgressTrackerOptions`, `PromotionGateOptions`, `PromotionVerdict`, `PublishOptions`, `ReproductionCheck`, `ResolveSandboxClientOptions`, `ResourceRequest`, `RollingDispatchOptions`, `RunAgenticOptions`, `RunAgentRoundsOptions`, `RunGraphOptions`, `SandboxRun`, `ShotSpec`, `SpawnOpts`, `StdioMcpConnection`, `StdioMcpServerSpec`, `SteerableSandboxArgs`, `Strategy`, `StrategyEvolutionConfig`, `StrategyResult`, `StreamAgentTurnOptions`, `StructuralRolloutConfig`, `SuperviseOptions`, `SuperviseSurfaceOptions`, `SupervisorAgentDeps`, `SupervisorOpts`, `SupervisorSpanOptions`, `SupervisorSpanRecorder`, `SurfaceScore`, `ToolSpec`, `ToolStepInput`, `TraceSource`, `TrajectoryAnalysis`, `UntrackedCopyStats`, `ValidationCtx`, `Validator`, `VerifierEnvironmentOptions`, `WatchTraceOptions`, `WaterfallCollector`, `WaterfallReport`, `WaterfallSpan`, `WorkerEvidenceInput`, `Workspace`, `WorkspaceRequest`, `WorkspaceRun`, `WorktreeCliExecutorOptions`, `WorktreeFanoutOptions`, `AgentEnvironmentStatus`, `AgentSessionStatus`, `ChampionPolicy`, `EdgeDeliveryOutcome`, `GraphEdge`, `LoopTraceEvent`, `MakeWorkerAgent`, `RepairStop`, `SandboxControlClient`, `WorkspaceCommit`. ### Environment provider adapters — generic sandbox/compute bridge diff --git a/docs/api/runtime.md b/docs/api/runtime.md index ee2e434d..eabaa1f6 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -6048,6 +6048,12 @@ Reconstructable control of one provider-retained run. > `readonly` **controlRef**: `AgentExactRunControlRef` +##### capabilities + +> `readonly` **capabilities**: `AgentEnvironmentCapabilities` + +Capabilities measured from the exact environment that owns this run. + #### Methods ##### status() @@ -19765,14 +19771,6 @@ Model label stamped on cost-only `llm_call` events. Default `'agent'`. *** -### AgentTurnInput - -> **AgentTurnInput** = `string` \| \{ `messages`: `ReadonlyArray`\<`Readonly`\<`Record`\<`string`, `unknown`\>\>\>; \} - -One prompt or an exact OpenAI-compatible conversation carried as the turn input. - -*** - ### StructuralRolloutMessage > **StructuralRolloutMessage** = `Record`\<`string`, `unknown`\> @@ -24031,7 +24029,7 @@ timeout alike. The generator never throws; failures surface in-band as ##### input -[`AgentTurnInput`](#agentturninput) +`AgentTurnInput` ##### opts? diff --git a/docs/canonical-api.md b/docs/canonical-api.md index caf17b87..c9579188 100644 --- a/docs/canonical-api.md +++ b/docs/canonical-api.md @@ -8,7 +8,7 @@ Run pnpm docs:freshness after editing this file. --> > [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path. > `agent-eval` must satisfy `>=0.145.15 <0.146.0`. > `sandbox` must satisfy `>=0.27.0 <0.28.0`. -> Portable profile and tool-part types come from `@tangle-network/agent-interface` `>=0.53.0 <0.54.0`. +> Portable profile, turn-input, and tool-part types come from `@tangle-network/agent-interface` `>=0.54.0 <0.55.0`. > > **`./kernel` is the execution kernel**: `package.json` maps it to `src/runtime/index.ts`. Everything below labelled `/kernel` lives there — the recursive atom (`Scope`/`Supervisor`), the executor registry, budget conservation, the finalizer seam, analyst wiring, and the round-synchronous loop. > @@ -145,7 +145,8 @@ A general "loop" primitive is the single most common modelling error in this rep | Run a **recursive `supervise()` tree** through an agent-eval profile matrix | `superviseDispatch({ toTask, toSuperviseOptions, ... })`: `/kernel`; it admits the tree through Eval before Runtime spends, then records its receipt only when Runtime proves one model. Mixed or unknown trees fail instead of being relabelled. | a Lab receipt mapper, a second scheduler, or attaching a completed `SupervisedResult` after paid work already ran | | Run + **resume** ONE persistent box across turns | `openSandboxRun(client, opts, deliverable)`: `/kernel` | a per-domain `new Sandbox`+`box.fs.read`+delete copy | | Start a retry-safe detached run in a new environment, or a fresh harness chat in one existing environment | `startRetainedRun(...)` or `startRetainedRunInEnvironment(...)`: `/kernel`; both persist exact coordinates before and after dispatch; the existing-environment path also verifies its retained key through provider metadata; only `continueNative(...)` may claim same-chat continuity | calling `provider.create/get/dispatch` directly, reusing an environment as proof of chat continuity, or appending to an unverified native session | -| Run **ONE agent turn** on any substrate: box (`streamPrompt`), cli-bridge/router `Executor`, or in-process chat backend: as ONE normalized `RuntimeStreamEvent` stream with a guaranteed terminal result+usage event; opt into in-stream `tool_call`/`tool_result` with `preserveToolParts`, or tap the raw sandbox events with `onRawEvent` | `streamAgentTurn(backend, prompt, { signal, timeoutMs, preserveToolParts?, onRawEvent? })` + `collectAgentTurn(stream)`: `/kernel` | a per-provider stream→event mapper zoo, a hand-faked box around a non-box executor, or raw fetch leaking through the turn abstraction | +| Run **ONE agent turn** on any substrate: box (`streamPrompt`), cli-bridge/router `Executor`, or in-process chat backend: as ONE normalized `RuntimeStreamEvent` stream with a guaranteed terminal result+usage event; pass the shared `AgentTurnInput` so text, image, file, and provider parts stay intact; canonical Sandbox events win over legacy projections, while unknown provider payloads remain observer-only | `streamAgentTurn(backend, agentTurnInput, { signal, timeoutMs, preserveToolParts?, onRawEvent? })` + `collectAgentTurn(stream)`: `/kernel` | a second string/messages turn contract, a per-provider stream→event mapper zoo, a hand-faked box around a non-box executor, or raw fetch leaking through the turn abstraction | +| Adapt a Sandbox box to the neutral environment/session contract, or expose a neutral provider to existing Sandbox callers | `sandboxClientAsProvider(client)` / `providerAsSandboxClient(provider)`: `/kernel`; dispatch/reconnect carries the exact `executionId` and run-control reference, every session operation scopes to that execution, and detached interaction requests require declared kind support plus replay and response idempotency | reconstructing control coordinates from metadata, forwarding an unscoped cancel, dispatching unsupported durable interactions, or emitting arbitrary provider payloads into the public stream | | Use an exact profile and Runtime executor where `runAgentTaskStream` or a conversation expects an `AgentExecutionBackend` | `createProfileExecutionBackend({ profile, executor: createExecutor(config) })`: root `.`; the adapter preserves conversation authorization, recursion-depth, and trace headers | a provider-specific backend constructor or an adapter that reads a second model/prompt configuration | | Pick the **execution transport a driven loop runs on** (`sandbox` box / cli-bridge / router) from a product flag | `resolveSandboxClient({ backend })`: `/kernel` | a per-product `if (backend === 'router') …` branch re-wiring `createExecutor` + `inlineSandboxClient` | | Adapt an exact `AgentProfile` to agent-eval's `ChatClient` without moving credentials or execution policy into Eval | `profileChatClient({ profile, executor, context })`: `/kernel` | a provider fetch configured separately from the profile, or request fields that override the profile's model policy | diff --git a/examples/p1-parity/run-parity.ts b/examples/p1-parity/run-parity.ts index ec68df8a..cea02208 100644 --- a/examples/p1-parity/run-parity.ts +++ b/examples/p1-parity/run-parity.ts @@ -294,7 +294,11 @@ function completionsTransport( const turn = await collectAgentTurn( streamAgentTurn( { kind: 'executor', factory, profile }, - { messages: req.messages as Array> }, + { + providerOptions: { + messages: req.messages as Array>, + }, + }, req.signal ? { signal: req.signal } : {}, ), ) diff --git a/examples/stream-backends/stream-backends.ts b/examples/stream-backends/stream-backends.ts index f94cb76e..b5398c7e 100644 --- a/examples/stream-backends/stream-backends.ts +++ b/examples/stream-backends/stream-backends.ts @@ -170,7 +170,7 @@ async function main() { routerKey: apiKey, }), }, - 'Say hello.', + { prompt: 'Say hello.' }, )) { process.stdout.write(runtimeStreamServerSentEvent(event)) } diff --git a/examples/supervisor-loop/run-supervisor-mcp.ts b/examples/supervisor-loop/run-supervisor-mcp.ts index 9f0a74db..43f5eb6a 100644 --- a/examples/supervisor-loop/run-supervisor-mcp.ts +++ b/examples/supervisor-loop/run-supervisor-mcp.ts @@ -106,7 +106,7 @@ async function supervisorBridgeChat(opts: { const turn = await collectAgentTurn( streamAgentTurn( { kind: 'executor', factory, profile, agentRunName: profile.name }, - supervisorTask, + { prompt: supervisorTask }, timeoutMs === undefined ? {} : { timeoutMs }, ), ) diff --git a/package.json b/package.json index 532e17ef..aba8a25f 100644 --- a/package.json +++ b/package.json @@ -171,7 +171,7 @@ "packageManager": "pnpm@11.17.0", "peerDependencies": { "@tangle-network/agent-eval": ">=0.145.15 <0.146.0", - "@tangle-network/agent-interface": ">=0.53.0 <0.54.0", + "@tangle-network/agent-interface": ">=0.54.0 <0.55.0", "@tangle-network/sandbox": ">=0.27.0 <0.28.0" }, "peerDependenciesMeta": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0dc7ce7..3d332a74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,20 +13,20 @@ catalogs: specifier: 1.30.0 version: 1.30.0 '@tangle-network/agent-core': - specifier: 0.9.0 - version: 0.9.0 + specifier: 0.9.1 + version: 0.9.1 '@tangle-network/agent-eval': specifier: 0.145.15 version: 0.145.15 '@tangle-network/agent-interface': - specifier: 0.53.0 - version: 0.53.0 + specifier: 0.54.0 + version: 0.54.0 '@tangle-network/agent-knowledge': specifier: 8.0.1 version: 8.0.1 '@tangle-network/agent-profile-materialize': - specifier: 0.15.1 - version: 0.15.1 + specifier: 0.15.2 + version: 0.15.2 '@tangle-network/agent-trace-contract': specifier: ^1.0.2 version: 1.0.2 @@ -55,13 +55,13 @@ importers: dependencies: '@tangle-network/agent-core': specifier: 'catalog:' - version: 0.9.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + version: 0.9.1(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) '@tangle-network/agent-knowledge': specifier: 'catalog:' - version: 8.0.1(@tangle-network/agent-eval@0.145.15(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.53.0) + version: 8.0.1(@tangle-network/agent-eval@0.145.15(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.54.0) '@tangle-network/agent-profile-materialize': specifier: 'catalog:' - version: 0.15.1(@tangle-network/agent-interface@0.53.0) + version: 0.15.2(@tangle-network/agent-interface@0.54.0) '@tangle-network/agent-trace-contract': specifier: 'catalog:' version: 1.0.2 @@ -83,7 +83,7 @@ importers: version: 0.145.15(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) '@tangle-network/agent-interface': specifier: 'catalog:' - version: 0.53.0 + version: 0.54.0 '@tangle-network/sandbox': specifier: 'catalog:' version: 0.27.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(viem@2.54.6(typescript@6.0.3)(zod@4.4.3)) @@ -131,10 +131,10 @@ importers: version: 0.145.15(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) '@tangle-network/agent-interface': specifier: 'catalog:' - version: 0.53.0 + version: 0.54.0 '@tangle-network/agent-knowledge': specifier: 'catalog:' - version: 8.0.1(@tangle-network/agent-eval@0.145.15(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.53.0) + version: 8.0.1(@tangle-network/agent-eval@0.145.15(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.54.0) '@tangle-network/agent-runtime': specifier: workspace:* version: link:.. @@ -1132,6 +1132,14 @@ packages: '@modelcontextprotocol/sdk': optional: true + '@tangle-network/agent-core@0.9.1': + resolution: {integrity: sha512-Ke6Hh3PiiJjRdWEGkZSnAsSMcJgr6pYJXaV1qNqdh12t7vteTjBod6ehaFRrp7cIWkB3cAwKh0ADHByV2KONWA==} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.30.0 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@tangle-network/agent-eval@0.145.15': resolution: {integrity: sha512-VTvbAqhVKpXWsNwASZNcMCmYWTdsXicR3i8yeHa/ViIv1Uo3496VqWXs2FRzkMxrOB6wOI0s5DEnxCZ3qaoRzA==} engines: {node: '>=20'} @@ -1140,6 +1148,9 @@ packages: '@tangle-network/agent-interface@0.53.0': resolution: {integrity: sha512-XWH+4t9vPkVog9C9P+094USuoO//TJwaI+P6ntAm32wHqZ/kh5a9r6kSkHYMKsXgQjkY3IA5NO21w3rZ7M6U+g==} + '@tangle-network/agent-interface@0.54.0': + resolution: {integrity: sha512-KBWcP2KQzroyjbhsG65OJecyt7AXIV0MWQuqkjUDiSac8Gtach4+oeYlBba6i9euA/8PhlGv9VBgVNiC2D/YuQ==} + '@tangle-network/agent-knowledge@8.0.1': resolution: {integrity: sha512-H1zMyyNyVeTCijaPGMMbZX7QY7gjE1YyHEgtNtEJ9PgvTYNBuQSh938cIKNQBHM6MPB0Lq3xECgf9ELTVveTEQ==} engines: {node: '>=20.19.0'} @@ -1148,8 +1159,8 @@ packages: '@tangle-network/agent-eval': '>=0.145.14 <0.146.0' '@tangle-network/agent-interface': '>=0.53.0 <0.54.0' - '@tangle-network/agent-profile-materialize@0.15.1': - resolution: {integrity: sha512-+d6IvLnRurmNTpnGkiqBSHTXrFrXfFOjQQzeC74t9gl0+yZABOopqFibOAiX3njSy3WuHeZ04PpdFkZrz16Dvg==} + '@tangle-network/agent-profile-materialize@0.15.2': + resolution: {integrity: sha512-j9ld23ADJRbAIJEkQ0xk49+RYt47WzARquN5S3W5Pyb3rbD++gxf1AKJQ3O613wmkwdcc0V10a5jHruX0SZ1Vg==} peerDependencies: '@tangle-network/agent-interface': '>=0.47.0 <0.54.0' @@ -3177,6 +3188,13 @@ snapshots: optionalDependencies: '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@4.4.3) + '@tangle-network/agent-core@0.9.1(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))': + dependencies: + '@tangle-network/agent-interface': 0.54.0 + zod: 4.4.3 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@4.4.3) + '@tangle-network/agent-eval@0.145.15(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))': dependencies: '@asteasolutions/zod-to-openapi': 9.1.0(zod@4.4.3) @@ -3197,16 +3215,22 @@ snapshots: spdx-expression-parse: 5.0.0 zod: 4.4.3 - '@tangle-network/agent-knowledge@8.0.1(@tangle-network/agent-eval@0.145.15(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.53.0)': + '@tangle-network/agent-interface@0.54.0': + dependencies: + '@noble/hashes': 1.8.0 + spdx-expression-parse: 5.0.0 + zod: 4.4.3 + + '@tangle-network/agent-knowledge@8.0.1(@tangle-network/agent-eval@0.145.15(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.54.0)': dependencies: '@tangle-network/agent-eval': 0.145.15(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) - '@tangle-network/agent-interface': 0.53.0 + '@tangle-network/agent-interface': 0.54.0 proper-lockfile: 4.1.2 zod: 4.4.3 - '@tangle-network/agent-profile-materialize@0.15.1(@tangle-network/agent-interface@0.53.0)': + '@tangle-network/agent-profile-materialize@0.15.2(@tangle-network/agent-interface@0.54.0)': dependencies: - '@tangle-network/agent-interface': 0.53.0 + '@tangle-network/agent-interface': 0.54.0 '@tangle-network/agent-trace-contract@1.0.2': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 94158d3b..4e0dbd52 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,12 +18,12 @@ allowBuilds: catalog: '@arethetypeswrong/cli': 0.18.5 '@modelcontextprotocol/sdk': 1.30.0 - '@tangle-network/agent-core': 0.9.0 + '@tangle-network/agent-core': 0.9.1 '@types/node': 26.1.1 '@tangle-network/agent-eval': 0.145.15 - '@tangle-network/agent-interface': 0.53.0 + '@tangle-network/agent-interface': 0.54.0 '@tangle-network/agent-knowledge': 8.0.1 - '@tangle-network/agent-profile-materialize': 0.15.1 + '@tangle-network/agent-profile-materialize': 0.15.2 '@tangle-network/agent-trace-contract': ^1.0.2 '@tangle-network/sandbox': 0.27.0 publint: 0.3.22 diff --git a/src/backends.ts b/src/backends.ts index 96b3c83f..335d79df 100644 --- a/src/backends.ts +++ b/src/backends.ts @@ -7,6 +7,7 @@ * @stable */ +import { providerMessageText } from './runtime/turn-input' import { newRuntimeSession, nowIso, touchSession } from './sessions' import type { AgentBackendContext, @@ -54,7 +55,11 @@ export function createSandboxPromptBackend< }, async *stream(input, context) { const box = await options.getBox(input, context) - const message = input.message ?? input.messages?.at(-1)?.content ?? context.task.intent + const message = + input.message ?? + input.messages?.at(-1)?.content ?? + providerMessageText(input.providerOptions) ?? + context.task.intent for await (const event of options.streamPrompt(box, message, context)) { const mapped = options.mapEvent?.(event, context) ?? mapCommonBackendEvent(event, context) if (mapped) yield mapped diff --git a/src/conversation/conversation-backend.ts b/src/conversation/conversation-backend.ts index 0effc81c..dba8cd5d 100644 --- a/src/conversation/conversation-backend.ts +++ b/src/conversation/conversation-backend.ts @@ -14,6 +14,7 @@ * @stable */ +import { providerMessageText } from '../runtime/turn-input' import { newRuntimeSession, nowIso } from '../sessions' import type { AgentBackendContext, @@ -45,7 +46,11 @@ export function createConversationBackend(options: { input: AgentBackendInput, context: AgentBackendContext, ): AsyncIterable { - const seed = input.message ?? input.messages?.at(-1)?.content ?? context.task.intent + const seed = + input.message ?? + input.messages?.at(-1)?.content ?? + providerMessageText(input.providerOptions) ?? + context.task.intent const task = context.task const session = context.session diff --git a/src/improvement/agentic-generator.ts b/src/improvement/agentic-generator.ts index be6aa1ed..29a1e7ad 100644 --- a/src/improvement/agentic-generator.ts +++ b/src/improvement/agentic-generator.ts @@ -243,11 +243,15 @@ export function agenticGenerator(opts: AgenticGeneratorOptions): CandidateGenera callId?: string, ): Promise => { turn = await collectAgentTurn( - streamAgentTurn({ kind: 'executor', profile, factory }, taskPrompt, { - signal: executionSignal, - ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}), - ...(callId ? { callId } : {}), - }), + streamAgentTurn( + { kind: 'executor', profile, factory }, + { prompt: taskPrompt }, + { + signal: executionSignal, + ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}), + ...(callId ? { callId } : {}), + }, + ), ) const failure = shotFailure(turn) if (failure) throw failure diff --git a/src/index.ts b/src/index.ts index 1012a98f..a042e91d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -298,6 +298,7 @@ export type { OpenAIChatToolChoice, RunAgentTaskOptions, RunAgentTaskStreamOptions, + RuntimeCanonicalStreamEvent, RuntimeSession, RuntimeSessionStore, RuntimeStreamEvent, diff --git a/src/runtime/environment-provider.test.ts b/src/runtime/environment-provider.test.ts index 6b7b1e10..5585df33 100644 --- a/src/runtime/environment-provider.test.ts +++ b/src/runtime/environment-provider.test.ts @@ -1,5 +1,10 @@ import { HARNESS_NATIVE_MODEL } from '@tangle-network/agent-eval' -import type { AgentProfile } from '@tangle-network/agent-interface' +import { + type AgentExactRunControlRef, + type AgentProfile, + type AgentRunCancellationRequest, + agentRunCancellationRequestDigest, +} from '@tangle-network/agent-interface' import type { BackendType, CreateSandboxOptions, @@ -522,6 +527,336 @@ describe('environment provider adapters', () => { expect(interrupted).toBe(1) }) + it('preserves exact dispatch identity and scopes concurrent Sandbox sessions independently', async () => { + const requestDigest = `sha256:${'a'.repeat(64)}` as `sha256:${string}` + const controlRef = (sessionId: string, executionId: string): AgentExactRunControlRef => ({ + runId: `run-${sessionId}`, + provider: 'tangle-sandbox', + environmentId: 'sbx-durable', + sessionId, + executionId, + requestDigest, + }) + const firstRef = controlRef('session-a', 'execution-a') + const secondRef = controlRef('session-b', 'execution-b') + const executionReads: string[] = [] + const interrupts: string[] = [] + const cancelCalls: AgentRunCancellationRequest[] = [] + const makeSession = (ref: AgentExactRunControlRef) => ({ + id: ref.sessionId, + async status() { + executionReads.push(`status:${ref.executionId}`) + return { + id: ref.sessionId, + status: 'running', + activeExecutionId: ref.executionId, + latestExecutionId: ref.executionId, + runControlRef: ref, + } + }, + async *events(options?: { executionId?: string }): AsyncIterable { + executionReads.push(`events:${options?.executionId}`) + yield { type: 'status', data: { status: 'running' } } + }, + async result(options?: { executionId?: string }) { + executionReads.push(`result:${options?.executionId}`) + return { response: `result:${ref.executionId}`, success: true } + }, + async prompt() { + return { response: '', success: true } + }, + async interrupt(options?: { executionId?: string }) { + interrupts.push(options?.executionId ?? 'missing') + return { cancelled: true } + }, + async cancelRun(request: AgentRunCancellationRequest) { + cancelCalls.push(request) + return { + operationId: request.operationId, + requestDigest: request.requestDigest, + run: request.run, + status: 'accepted', + effect: 'cancel_requested', + } + }, + }) + const sessions = new Map([ + [firstRef.sessionId, makeSession(firstRef)], + [secondRef.sessionId, makeSession(secondRef)], + ]) + const box = { + id: 'sbx-durable', + status: 'running', + async *streamPrompt(): AsyncIterable {}, + async dispatchPrompt(_message: string, options?: { sessionId?: string }) { + const ref = sessions.get(options?.sessionId ?? firstRef.sessionId) + if (!ref) throw new Error('unknown test session') + return { + sessionId: ref.id, + executionId: ref.id === firstRef.sessionId ? firstRef.executionId : secondRef.executionId, + runControlRef: ref.id === firstRef.sessionId ? firstRef : secondRef, + status: 'running', + alreadyExisted: false, + dispatched: true, + } + }, + session(id: string) { + const session = sessions.get(id) + if (!session) throw new Error(`unknown test session ${id}`) + return session + }, + async delete(): Promise {}, + } as unknown as SandboxInstance + const client: SandboxClient = { + async create(): Promise { + return box + }, + } + const environment = await sandboxClientAsProvider(client).create({ + profile: { name: 'worker' }, + }) + + const dispatched = await environment.dispatch?.({ + prompt: 'detached', + sessionId: firstRef.sessionId, + executionId: firstRef.executionId, + }) + expect(dispatched).toMatchObject({ + id: firstRef.sessionId, + controlRef: firstRef, + metadata: { executionId: firstRef.executionId }, + }) + + // SandboxSession has no synchronous controlRef. The exact dispatch result + // is the persisted wrapper binding, and later status evidence is checked. + const firstSession = environment.session?.(firstRef.sessionId, { controlRef: firstRef }) + const secondSession = environment.session?.(secondRef.sessionId, { controlRef: secondRef }) + if (!firstSession || !secondSession) throw new Error('expected durable sessions') + await firstSession.status() + await collect(firstSession.events()) + await firstSession.result() + expect(executionReads).toEqual([ + `status:${firstRef.executionId}`, + `events:${firstRef.executionId}`, + `result:${firstRef.executionId}`, + ]) + + await Promise.all([firstSession.cancel(), secondSession.cancel()]) + expect(interrupts.sort()).toEqual([firstRef.executionId, secondRef.executionId].sort()) + + const mismatched = cancellationRequest(secondRef, 'cross-cancel') + await expect(firstSession.cancelRun?.(mismatched)).rejects.toThrow( + 'cancellation targeted a different execution', + ) + expect(cancelCalls).toHaveLength(0) + }) + + it('makes Sandbox result abortable even though Sandbox result accepts only executionId', async () => { + const controlRef: AgentExactRunControlRef = { + runId: 'result-abort-run', + provider: 'tangle-sandbox', + environmentId: 'sbx-result-abort', + sessionId: 'result-abort-session', + executionId: 'result-abort-execution', + requestDigest: `sha256:${'b'.repeat(64)}` as `sha256:${string}`, + } + const box = { + id: controlRef.environmentId, + status: 'running', + async *streamPrompt(): AsyncIterable {}, + session() { + return { + id: controlRef.sessionId, + async result() { + return await new Promise(() => {}) + }, + } + }, + async delete(): Promise {}, + } as unknown as SandboxInstance + const environment = await sandboxClientAsProvider({ + async create(): Promise { + return box + }, + }).create({ profile: { name: 'worker' } }) + const session = environment.session?.(controlRef.sessionId, { controlRef }) + if (!session) throw new Error('expected result session') + const controller = new AbortController() + const pending = session.result({ signal: controller.signal }) + controller.abort('stop waiting') + await expect(pending).rejects.toMatchObject({ + name: 'AbortError', + message: 'stop waiting', + }) + }) + + it('round-trips exact dispatch identity through both adapter directions', async () => { + const controlRef: AgentExactRunControlRef = { + runId: 'round-trip-run', + provider: 'fake-provider', + environmentId: 'environment-1', + sessionId: 'round-trip-session', + executionId: 'round-trip-execution', + requestDigest: `sha256:${'d'.repeat(64)}` as `sha256:${string}`, + } + const session: AgentSession = { + id: controlRef.sessionId, + controlRef, + status: async () => 'running', + async *events(): AsyncIterable {}, + result: async () => ({ text: 'done', success: true }), + prompt: async () => ({ text: 'continued', success: true }), + cancel: async () => {}, + } + const provider: AgentEnvironmentProvider = { + name: controlRef.provider, + capabilities: () => fakeCapabilities(), + async create() { + return fakeEnvironment({ + dispatch: async () => ({ + id: controlRef.sessionId, + provider: controlRef.provider, + controlRef, + metadata: { + status: 'running', + executionId: controlRef.executionId, + dispatched: true, + }, + }), + session: () => session, + stream: async function* () {}, + }) + }, + } + const box = await providerAsSandboxClient(provider).create({ + backend: { type: 'codex' as BackendType, profile: { name: 'worker' } }, + }) + await expect(box.dispatchPrompt?.('detached')).resolves.toMatchObject({ + sessionId: controlRef.sessionId, + executionId: controlRef.executionId, + runControlRef: controlRef, + dispatched: true, + }) + }) + + it.each([ + ['malformed', { runControlRef: { runId: 'malformed' } }], + [ + 'mismatched execution', + { + executionId: 'execution-a', + runControlRef: { + runId: 'run-a', + provider: 'tangle-sandbox', + environmentId: 'sandbox-dispatch-identity', + sessionId: 'session-a', + executionId: 'execution-b', + requestDigest: `sha256:${'e'.repeat(64)}` as `sha256:${string}`, + }, + }, + ], + ] as const)('rejects %s dispatch identity before exposing a session', async (_label, result) => { + const box = { + id: 'sandbox-dispatch-identity', + status: 'running', + async *streamPrompt(): AsyncIterable {}, + async dispatchPrompt() { + return { sessionId: 'session-a', status: 'running', alreadyExisted: false, ...result } + }, + async delete(): Promise {}, + } as unknown as SandboxInstance + const environment = await sandboxClientAsProvider({ + async create(): Promise { + return box + }, + }).create({ profile: { name: 'worker' } }) + + await expect(environment.dispatch?.({ prompt: 'detached' })).rejects.toThrow() + }) + + it('rejects malformed or mismatched control evidence and scopes cancelRun in both directions', async () => { + const ref: AgentExactRunControlRef = { + runId: 'scope-run', + provider: 'fake-provider', + environmentId: 'scope-environment', + sessionId: 'scope-session', + executionId: 'scope-execution', + requestDigest: `sha256:${'c'.repeat(64)}` as `sha256:${string}`, + } + const otherRef = { ...ref, executionId: 'other-execution' } + let neutralCancelCalls = 0 + const neutralSession: AgentSession = { + id: ref.sessionId, + controlRef: ref, + status: async () => 'running', + async *events(): AsyncIterable {}, + result: async () => ({ text: '', success: true }), + prompt: async () => ({ text: '', success: true }), + cancel: async () => {}, + async cancelRun(request) { + neutralCancelCalls += 1 + return { + operationId: request.operationId, + requestDigest: request.requestDigest, + run: request.run, + status: 'accepted', + effect: 'cancel_requested', + } + }, + } + const provider: AgentEnvironmentProvider = { + name: ref.provider, + capabilities: () => fakeCapabilities(), + async create() { + return fakeEnvironment({ session: () => neutralSession, stream: async function* () {} }) + }, + } + const box = await providerAsSandboxClient(provider).create({ + backend: { type: 'codex' as BackendType, profile: { name: 'worker' } }, + }) + const sandboxSession = box.session(ref.sessionId) + await expect( + sandboxSession.cancelRun?.(cancellationRequest(otherRef, 'wrong-execution')), + ).rejects.toThrow('cancellation targeted a different execution') + expect(neutralCancelCalls).toBe(0) + + const malformedProvider: AgentEnvironmentProvider = { + ...provider, + async create() { + return fakeEnvironment({ + session: () => + ({ ...neutralSession, controlRef: { runId: 'malformed' } }) as unknown as AgentSession, + stream: async function* () {}, + }) + }, + } + const malformedBox = await providerAsSandboxClient(malformedProvider).create({ + backend: { type: 'codex' as BackendType, profile: { name: 'worker' } }, + }) + expect(() => malformedBox.session(ref.sessionId)).toThrow(/expected string/) + + const mismatchBox = { + id: ref.environmentId, + status: 'running', + async *streamPrompt(): AsyncIterable {}, + session() { + return { + id: ref.sessionId, + controlRef: otherRef, + } + }, + async delete(): Promise {}, + } as unknown as SandboxInstance + const mismatchEnvironment = await sandboxClientAsProvider({ + async create(): Promise { + return mismatchBox + }, + }).create({ profile: { name: 'worker' } }) + expect(() => mismatchEnvironment.session?.(ref.sessionId, { controlRef: ref })).toThrow( + /different exact run control reference/, + ) + }) + it('fails closed when a neutral session only reports stopped', async () => { const session: AgentSession = { id: 'stopped-session', @@ -1326,3 +1661,14 @@ function deferred(): { promise: Promise; resolve: () => void } { }) return { promise, resolve } } + +function cancellationRequest( + run: AgentExactRunControlRef, + operationId: string, +): AgentRunCancellationRequest { + const material = { operationId, run } + return { + ...material, + requestDigest: agentRunCancellationRequestDigest(material), + } +} diff --git a/src/runtime/environment-provider.ts b/src/runtime/environment-provider.ts index 6f4b15b9..d75eb551 100644 --- a/src/runtime/environment-provider.ts +++ b/src/runtime/environment-provider.ts @@ -1,8 +1,15 @@ import { + type AgentExactRunControlRef, + AgentExactRunControlRefSchema, type AgentProfile, type AgentProfileValidationResult, + type AgentRunCancellationAcknowledgement, + type AgentRunCancellationRequest, + AgentRunCancellationRequestSchema, + type AgentRunControlRef, harnessSystemPromptIntents, - type InputPart, + type InteractionAcknowledgement, + type InteractionResponseCommand, type TokenUsage, } from '@tangle-network/agent-interface' import type { @@ -38,6 +45,8 @@ import type { ExecResult as SandboxExecResult, SandboxInstance, } from '@tangle-network/sandbox' +import { awaitAbortable, sameControlCoordinates } from './retained-run-binding' +import { canonicalStreamEventFromSandboxEvent } from './sandbox-events' import type { Executor, ExecutorContext, @@ -47,6 +56,7 @@ import type { Spend, UsageEvent, } from './supervise/types' +import { promptFromAgentTurnInput, promptOptionsFromAgentTurnInput } from './turn-input' import type { LoopSandboxPlacement, SandboxClient } from './types' import { zeroTokenUsage } from './util' @@ -570,13 +580,15 @@ function environmentAsSandboxInstance( if (cancellationStarted || !input.sessionId || !environment.session) return cancellationStarted = true try { - cancellation = environment - .session(input.sessionId) - .cancel() - .catch((error: unknown) => { - cancellationFailed = true - cancellationError = error - }) + const session = environment.session(input.sessionId, { + ...(input.controlRef === undefined ? {} : { controlRef: input.controlRef }), + ...(input.signal === undefined ? {} : { signal: input.signal }), + }) + assertScopedSessionForInput(session, input) + cancellation = session.cancel().catch((error: unknown) => { + cancellationFailed = true + cancellationError = error + }) } catch (error) { cancellationFailed = true cancellationError = error @@ -668,8 +680,11 @@ function environmentAsSandboxInstance( : {}), ...(environment.session ? { - session(id: string) { - return sandboxSessionFromAgentSession(environment.session?.(id)) + session(id: string, sessionOptions?: { controlRef?: AgentRunControlRef }) { + return sandboxSessionFromAgentSession( + environment.session?.(id, sessionOptions), + sessionOptions?.controlRef, + ) }, } : {}), @@ -714,14 +729,15 @@ function sandboxInstanceAsEnvironment( id: String(box.id), provider: providerName, ...(typeof box.name === 'string' ? { name: box.name } : {}), + ...(readBoxMetadata(box) ? { metadata: readBoxMetadata(box) } : {}), async status(): Promise { await maybeRefresh(box) return statusFromUnknown(readBoxStatus(box)) }, async *stream(input: AgentTurnInput): AsyncIterable { for await (const event of box.streamPrompt( - promptFromTurnInput(input), - promptOptionsFromTurnInput(input), + promptFromAgentTurnInput(input), + promptOptionsFromAgentTurnInput(input), )) { yield environmentEventFromSandboxEvent(event) } @@ -730,8 +746,8 @@ function sandboxInstanceAsEnvironment( ? { async dispatch(input: AgentTurnInput): Promise { const dispatched = await box.dispatchPrompt( - promptFromTurnInput(input), - promptOptionsFromTurnInput(input), + promptFromAgentTurnInput(input), + promptOptionsFromAgentTurnInput(input), ) return sessionRefFromSandboxDispatch(dispatched, providerName) }, @@ -739,8 +755,17 @@ function sandboxInstanceAsEnvironment( : {}), ...(hasSession(box) ? { - session(id: string): AgentSession { - return sandboxSessionAsAgentSession(box.session(id)) + session(id: string, sessionOptions?: { controlRef?: AgentRunControlRef }): AgentSession { + return sandboxSessionAsAgentSession(box.session(id), sessionOptions?.controlRef) + }, + async respondToInteraction( + command: InteractionResponseCommand, + options?: { signal?: AbortSignal }, + ): Promise { + const response = await box + .session(command.binding.sessionId) + .respondToInteraction(command, options) + return response.acknowledgement }, } : {}), @@ -785,39 +810,104 @@ function sandboxInstanceAsEnvironment( return environment } -function sandboxSessionAsAgentSession(session: SandboxSessionLike): AgentSession { +function sandboxSessionAsAgentSession( + session: SandboxSessionLike, + expectedControlRef?: AgentRunControlRef, +): AgentSession { + // SandboxSession has no synchronous controlRef property. The exact ref + // returned by dispatch is therefore the wrapper's persisted binding, while + // any ref later exposed by status is still validated below. + const controlRef = resolveSessionControlRef(session.controlRef, expectedControlRef, { + allowExpectedWhenActualAbsent: true, + }) return { id: session.id, + ...(controlRef === undefined ? {} : { controlRef }), async status(): Promise { const status = await session.status() if (!status) return null + assertSandboxStatusBinding(status, controlRef) return sessionStatusFromUnknown((status as { status?: unknown }).status) }, async *events(options?: { since?: string + executionId?: string signal?: AbortSignal }): AsyncIterable { - for await (const event of session.events(options)) + const executionId = scopedExecutionId(controlRef, options?.executionId) + for await (const event of session.events({ + ...(options?.since === undefined ? {} : { since: options.since }), + ...(executionId === undefined ? {} : { executionId }), + ...(options?.signal === undefined ? {} : { signal: options.signal }), + })) yield environmentEventFromSandboxEvent(event) }, - async result(): Promise { - return agentTurnResultFromPromptResult(await session.result()) + async result(options?: { signal?: AbortSignal }): Promise { + return agentTurnResultFromPromptResult( + await awaitAbortable( + Promise.resolve().then(() => + session.result({ + ...(controlRef?.executionId === undefined + ? {} + : { executionId: controlRef.executionId }), + }), + ), + options?.signal, + ), + ) }, async prompt(input: AgentTurnInput): Promise { return agentTurnResultFromPromptResult( - await session.prompt(promptFromTurnInput(input), promptOptionsFromTurnInput(input)), + await session.prompt( + promptFromAgentTurnInput(input), + promptOptionsFromAgentTurnInput(input), + ), ) }, + ...(session.respondToInteraction + ? { + async respondToInteraction( + command: InteractionResponseCommand, + options?: { signal?: AbortSignal }, + ): Promise { + assertInteractionCommandScope(command, controlRef) + const response = await session.respondToInteraction!(command, options) + return response.acknowledgement + }, + } + : {}), + ...(session.cancelRun + ? { + async cancelRun( + request: AgentRunCancellationRequest, + options?: { signal?: AbortSignal }, + ): Promise { + assertCancellationScope(request, controlRef) + return session.cancelRun!(request, options) + }, + } + : {}), cancel(): Promise { - return session.interrupt().then(() => undefined) + return session + .interrupt( + controlRef?.executionId === undefined + ? undefined + : { executionId: controlRef.executionId }, + ) + .then(() => undefined) }, } } -function sandboxSessionFromAgentSession(session: AgentSession | undefined): SandboxSessionLike { +function sandboxSessionFromAgentSession( + session: AgentSession | undefined, + expectedControlRef?: AgentRunControlRef, +): SandboxSessionLike { if (!session) throw new ValidationError('providerAsSandboxClient: session is unavailable') + const controlRef = resolveSessionControlRef(session.controlRef, expectedControlRef) return { id: session.id, + ...(controlRef === undefined ? {} : { controlRef }), async status() { const status = await session.status() if (!status) return null @@ -828,12 +918,19 @@ function sandboxSessionFromAgentSession(session: AgentSession | undefined): Sand }, async *events(options?: { since?: string + executionId?: string signal?: AbortSignal }): AsyncGenerator { - for await (const event of session.events(options)) + const executionId = scopedExecutionId(controlRef, options?.executionId) + for await (const event of session.events({ + ...(options?.since === undefined ? {} : { since: options.since }), + ...(executionId === undefined ? {} : { executionId }), + ...(options?.signal === undefined ? {} : { signal: options.signal }), + })) yield sandboxEventFromEnvironmentEvent(event) }, - async result(): Promise { + async result(options?: { executionId?: string }): Promise { + scopedExecutionId(controlRef, options?.executionId) return promptResultFromAgentTurnResult(await session.result()) }, async prompt( @@ -844,7 +941,32 @@ function sandboxSessionFromAgentSession(session: AgentSession | undefined): Sand await session.prompt(turnInputFromPrompt(message, options)), ) }, - async interrupt() { + ...(session.respondToInteraction + ? { + async respondToInteraction( + command: InteractionResponseCommand, + options?: { signal?: AbortSignal }, + ) { + assertInteractionCommandScope(command, controlRef) + return { + acknowledgement: await session.respondToInteraction!(command, options), + } + }, + } + : {}), + ...(session.cancelRun + ? { + async cancelRun( + request: AgentRunCancellationRequest, + options?: { signal?: AbortSignal }, + ): Promise { + assertCancellationScope(request, controlRef) + return session.cancelRun!(request, options) + }, + } + : {}), + async interrupt(options?: { executionId?: string }) { + scopedExecutionId(controlRef, options?.executionId) await session.cancel() return { cancelled: true } }, @@ -897,10 +1019,12 @@ function environmentEventFromSandboxEvent(event: SandboxEvent): AgentEnvironment event.data && typeof event.data === 'object' ? (event.data as Record) : ({} as Record) + const normalized = canonicalStreamEventFromSandboxEvent(event) return { type: String(event.type), data, ...(event.id ? { id: event.id } : {}), + ...(normalized ? { normalized } : {}), usage: tokenUsageFromData(data), providerEvent: event, } @@ -998,45 +1122,120 @@ function turnInputFromPrompt( ...(options?.turnId ? { turnId: options.turnId } : {}), ...(options?.detach !== undefined ? { detach: options.detach } : {}), ...(options?.context ? { context: options.context } : {}), + ...(options?.runControlRef ? { controlRef: options.runControlRef } : {}), + ...(options?.backend?.interactions ? { interactions: options.backend.interactions } : {}), ...(options?.signal ? { signal: options.signal } : {}), ...(options?.backend ? { providerOptions: { backend: options.backend } } : {}), } } -function promptFromTurnInput(input: AgentTurnInput): string | PromptInputPart[] { - if (input.parts) return input.parts.map(promptPartFromInputPart) - return input.prompt ?? '' +function resolveSessionControlRef( + actual: AgentRunControlRef | undefined, + expected: AgentRunControlRef | undefined, + options: { allowExpectedWhenActualAbsent?: boolean } = {}, +): AgentExactRunControlRef | undefined { + if (expected === undefined) { + if (actual === undefined) return undefined + return AgentExactRunControlRefSchema.parse(actual) + } + const expectedExact = AgentExactRunControlRefSchema.parse(expected) + if (actual === undefined) { + if (options.allowExpectedWhenActualAbsent === true) return expectedExact + throw new ValidationError('provider session omitted the required exact run control reference') + } + const actualExact = AgentExactRunControlRefSchema.safeParse(actual) + if (!actualExact.success) { + throw new ValidationError('provider session returned an invalid exact run control reference') + } + if (!sameControlCoordinates(actualExact.data, expectedExact)) { + throw new ValidationError('provider session returned a different exact run control reference') + } + return actualExact.data +} + +function assertSandboxStatusBinding( + status: unknown, + controlRef: AgentExactRunControlRef | undefined, +): void { + if (!status || typeof status !== 'object' || controlRef === undefined) return + const record = status as Record + if (record.runControlRef !== undefined) { + const statusControlRef = AgentExactRunControlRefSchema.parse(record.runControlRef) + if (!sameControlCoordinates(statusControlRef, controlRef)) { + throw new ValidationError('sandbox status returned a different exact run control reference') + } + } + for (const key of ['activeExecutionId', 'latestExecutionId']) { + const executionId = record[key] + if (executionId !== undefined && executionId !== controlRef.executionId) { + throw new ValidationError('sandbox status returned a different execution') + } + } +} + +function assertInteractionCommandScope( + command: InteractionResponseCommand, + controlRef: AgentRunControlRef | undefined, +): void { + if (controlRef === undefined) return + if ( + command.binding.runId !== controlRef.runId || + command.binding.provider !== controlRef.provider || + command.binding.environmentId !== controlRef.environmentId || + command.binding.sessionId !== controlRef.sessionId || + command.binding.executionId !== controlRef.executionId + ) { + throw new ValidationError('interaction response targeted a different execution') + } } -function promptPartFromInputPart(part: InputPart): PromptInputPart { - if (part.type === 'text' || part.type === 'image') return part - if (part.content !== undefined || part.path !== undefined) { +function assertCancellationScope( + request: AgentRunCancellationRequest, + controlRef: AgentExactRunControlRef | undefined, +): void { + if (controlRef === undefined) { throw new ValidationError( - 'Tangle Sandbox file prompt parts require a URL; inline content and local paths are not representable', + 'durable cancellation requires an exact wrapper run control reference', ) } - if (!part.filename || !part.url) { - throw new ValidationError('Tangle Sandbox file prompt parts require both filename and URL') + const exactRequest = AgentRunCancellationRequestSchema.parse(request) + if (!sameControlCoordinates(exactRequest.run, controlRef)) { + throw new ValidationError('cancellation targeted a different execution') } - return { - type: 'file', - filename: part.filename, - ...(part.mediaType ? { mediaType: part.mediaType } : {}), - url: part.url, +} + +function scopedExecutionId( + controlRef: AgentRunControlRef | undefined, + requested: string | undefined, +): string | undefined { + if ( + controlRef?.executionId !== undefined && + requested !== undefined && + controlRef.executionId !== requested + ) { + throw new ValidationError('session operation targeted a different execution') } + return controlRef?.executionId ?? requested } -function promptOptionsFromTurnInput(input: AgentTurnInput): PromptOptions { - return { - ...(input.sessionId ? { sessionId: input.sessionId } : {}), - ...(input.model ? { model: input.model } : {}), - ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), - ...(input.context ? { context: input.context } : {}), - ...(input.signal ? { signal: input.signal } : {}), - ...(input.executionId ? { executionId: input.executionId } : {}), - ...(input.lastEventId ? { lastEventId: input.lastEventId } : {}), - ...(input.turnId ? { turnId: input.turnId } : {}), - ...(input.detach !== undefined ? { detach: input.detach } : {}), +function assertScopedSessionForInput(session: AgentSession, input: AgentTurnInput): void { + if (input.executionId === undefined && input.controlRef === undefined) return + const sessionControlRef = AgentExactRunControlRefSchema.safeParse(session.controlRef) + if (!sessionControlRef.success) { + throw new ValidationError( + 'provider session did not expose an exact run control reference for scoped cancellation', + ) + } + const expectedControlRef = + input.controlRef === undefined + ? undefined + : AgentExactRunControlRefSchema.parse(input.controlRef) + if ( + (input.executionId !== undefined && sessionControlRef.data.executionId !== input.executionId) || + (expectedControlRef !== undefined && + !sameControlCoordinates(sessionControlRef.data, expectedControlRef)) + ) { + throw new ValidationError('provider session returned a different exact run control reference') } } @@ -1233,12 +1432,40 @@ function agentTurnResultFromPromptResult(result: PromptResult): AgentTurnResult } function sandboxDispatchResultFromSessionRef(session: AgentSessionRef): Record { + const controlRef = + session.controlRef === undefined + ? undefined + : AgentExactRunControlRefSchema.parse(session.controlRef) + const metadataExecutionId = optionalDispatchIdentity(session.metadata?.executionId, 'executionId') + if (controlRef !== undefined) { + if (controlRef.sessionId !== session.id) { + throw new ValidationError( + 'provider dispatch returned a control reference for another session', + ) + } + if (session.provider !== undefined && controlRef.provider !== session.provider) { + throw new ValidationError( + 'provider dispatch returned a control reference for another provider', + ) + } + if (metadataExecutionId !== undefined && metadataExecutionId !== controlRef.executionId) { + throw new ValidationError('provider dispatch returned conflicting execution identities') + } + } const hasStatus = session.metadata && Object.hasOwn(session.metadata, 'status') const status = hasStatus ? sessionStatusFromUnknown(session.metadata?.status) : 'running' return { sessionId: session.id, status, alreadyExisted: session.metadata?.alreadyExisted === true, + ...(session.metadata?.dispatched === undefined + ? {} + : { dispatched: session.metadata.dispatched === true }), + ...(controlRef === undefined + ? metadataExecutionId === undefined + ? {} + : { executionId: metadataExecutionId } + : { executionId: controlRef.executionId, runControlRef: controlRef }), } } @@ -1254,16 +1481,59 @@ function sessionRefFromSandboxDispatch(dispatched: unknown, providerName: string if (!record) { throw new ValidationError('sandboxClientAsProvider: dispatch returned no session record') } + const executionId = optionalDispatchIdentity(record.executionId, 'executionId') + const controlRef = + record.runControlRef === undefined + ? undefined + : AgentExactRunControlRefSchema.parse(record.runControlRef) + if (controlRef !== undefined) { + if (controlRef.sessionId !== id || controlRef.provider !== providerName) { + throw new ValidationError('sandbox dispatch returned a control reference for another session') + } + if (executionId !== undefined && controlRef.executionId !== executionId) { + throw new ValidationError('sandbox dispatch returned conflicting execution identities') + } + } + if (record.alreadyExisted !== undefined && typeof record.alreadyExisted !== 'boolean') { + throw new ValidationError('sandbox dispatch returned an invalid alreadyExisted flag') + } + if (record.dispatched !== undefined && typeof record.dispatched !== 'boolean') { + throw new ValidationError('sandbox dispatch returned an invalid dispatched flag') + } + if (record.status !== undefined && !isSandboxSessionStatus(record.status)) { + throw new ValidationError('sandbox dispatch returned an invalid session status') + } return { id, provider: providerName, + ...(controlRef === undefined ? {} : { controlRef }), metadata: { ...(record.status ? { status: record.status } : {}), + ...(executionId === undefined ? {} : { executionId }), ...(record.alreadyExisted !== undefined ? { alreadyExisted: record.alreadyExisted } : {}), + ...(record.dispatched !== undefined ? { dispatched: record.dispatched } : {}), }, } } +function optionalDispatchIdentity(value: unknown, label: string): string | undefined { + if (value === undefined) return undefined + if (typeof value !== 'string' || value.length === 0) { + throw new ValidationError(`sandbox dispatch returned an invalid ${label}`) + } + return value +} + +function isSandboxSessionStatus(value: unknown): boolean { + return ( + value === 'queued' || + value === 'running' || + value === 'completed' || + value === 'failed' || + value === 'cancelled' + ) +} + function execResultFromSandboxExecResult(result: SandboxExecResult): ExecResult { const record = result as unknown as Record const exitCode = finiteNumber(record.exitCode) ?? finiteNumber(record.code) @@ -1440,9 +1710,22 @@ function hasExec(box: SandboxInstance): box is SandboxInstance & { interface SandboxSessionLike { readonly id: string + readonly controlRef?: AgentRunControlRef status(): Promise - events(options?: { since?: string; signal?: AbortSignal }): AsyncIterable - result(): Promise + events(options?: { + since?: string + executionId?: string + signal?: AbortSignal + }): AsyncIterable + result(options?: { executionId?: string }): Promise prompt(message: string | PromptInputPart[], options?: PromptOptions): Promise - interrupt(): Promise + respondToInteraction?( + command: InteractionResponseCommand, + options?: { signal?: AbortSignal }, + ): Promise<{ acknowledgement: InteractionAcknowledgement }> + cancelRun?( + request: AgentRunCancellationRequest, + options?: { signal?: AbortSignal }, + ): Promise + interrupt(options?: { executionId?: string }): Promise } diff --git a/src/runtime/interaction-capabilities.ts b/src/runtime/interaction-capabilities.ts new file mode 100644 index 00000000..9103c049 --- /dev/null +++ b/src/runtime/interaction-capabilities.ts @@ -0,0 +1,36 @@ +import type { RequestedInteractions } from '@tangle-network/agent-interface' +import type { AgentEnvironmentCapabilities } from '@tangle-network/agent-interface/environment-provider' + +/** + * Check one turn's interaction request before the provider can dispatch it. + * + * A requested interaction is durable only when the provider can replay its + * event and safely replay the response command. An unsupported request fails + * before environment creation or dispatch begins. + */ +export function assertRequestedInteractionCapabilities( + providerName: string, + requested: RequestedInteractions | undefined, + capabilities: AgentEnvironmentCapabilities, +): void { + const requestedKinds = Object.entries(requested ?? {}).map(([kind]) => kind) + if (requestedKinds.length === 0) return + + const interactions = capabilities.interactions + if (!interactions) { + throw new Error( + `provider "${providerName}" does not support requested interactions: ${requestedKinds.join(', ')}`, + ) + } + const unsupportedKinds = requestedKinds.filter((kind) => !interactions.kinds.includes(kind)) + if (unsupportedKinds.length > 0) { + throw new Error( + `provider "${providerName}" does not support requested interaction kinds: ${unsupportedKinds.join(', ')}`, + ) + } + if (interactions.replay !== true || interactions.responseIdempotency !== true) { + throw new Error( + `provider "${providerName}" cannot safely dispatch requested interactions without replay and response idempotency`, + ) + } +} diff --git a/src/runtime/profile-chat-client.ts b/src/runtime/profile-chat-client.ts index 30ebcd8c..d94fbe92 100644 --- a/src/runtime/profile-chat-client.ts +++ b/src/runtime/profile-chat-client.ts @@ -174,7 +174,11 @@ export async function runBoundProfileChat( profile: turnProfile, factory: createExecutor(binding.executor), }, - { messages: req.messages as Array<{ role: string; content: unknown }> }, + { + providerOptions: { + messages: req.messages, + }, + }, { ...(req.timeoutMs !== undefined ? { timeoutMs: req.timeoutMs } : {}), ...(callOpts?.signal ? { signal: callOpts.signal } : {}), diff --git a/src/runtime/profile-execution-backend.ts b/src/runtime/profile-execution-backend.ts index 6e206cef..0a872818 100644 --- a/src/runtime/profile-execution-backend.ts +++ b/src/runtime/profile-execution-backend.ts @@ -1,4 +1,5 @@ import type { AgentProfile } from '@tangle-network/agent-interface' +import type { AgentTurnInput } from '@tangle-network/agent-interface/environment-provider' import { BackendTransportError, ValidationError } from '../errors' import type { AgentBackendContext, @@ -37,9 +38,13 @@ export function createProfileExecutionBackend(options: { ...executorContext, ...(propagatedHeaders === undefined ? {} : { propagatedHeaders }), }) - const turnInput = input.messages - ? { messages: input.messages.map((message) => ({ ...message })) } - : (input.message ?? context.task.intent) + const providerMessages = input.providerOptions?.messages + const turnInput: AgentTurnInput = + input.messages !== undefined + ? { providerOptions: { messages: input.messages.map((message) => ({ ...message })) } } + : Array.isArray(providerMessages) + ? { providerOptions: { messages: structuredClone(providerMessages) } } + : { prompt: input.message ?? context.task.intent } let terminal = false let emittedText = false diff --git a/src/runtime/retained-run-binding.ts b/src/runtime/retained-run-binding.ts index d79a5709..b004455e 100644 --- a/src/runtime/retained-run-binding.ts +++ b/src/runtime/retained-run-binding.ts @@ -10,6 +10,7 @@ import { canonicalCandidateDigest, type InteractionResponseCommand, type NativeContextBoundaryProof, + type StreamEvent, } from '@tangle-network/agent-interface' import type { AgentEnvironment, @@ -295,6 +296,25 @@ export function assertEventBinding( } } +/** Validate the nested coordinates carried by a canonical interaction request. */ +export function assertCanonicalEventBinding( + controlRef: AgentExactRunControlRef, + event: StreamEvent, +): void { + if (event.type !== 'interaction') return + const binding = event.request.binding + if ( + binding.runId !== controlRef.runId || + binding.provider !== controlRef.provider || + binding.environmentId !== controlRef.environmentId || + binding.sessionId !== controlRef.sessionId || + binding.executionId !== controlRef.executionId || + binding.interactionId !== event.request.id + ) { + throw new Error('provider returned an interaction for another retained execution') + } +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/src/runtime/retained-run-events.ts b/src/runtime/retained-run-events.ts index 408fb25e..eb4cc151 100644 --- a/src/runtime/retained-run-events.ts +++ b/src/runtime/retained-run-events.ts @@ -10,6 +10,7 @@ import type { } from '@tangle-network/agent-interface/environment-provider' import { abortError, + assertCanonicalEventBinding, assertEventBinding, assertSequence, assertStableText, @@ -54,6 +55,7 @@ export async function* retainedRunEvents( if (sourceCursor === after?.cursor) continue const event = canonicalEvent(source) if (!event) continue + assertCanonicalEventBinding(controlRef, event) if (firstAfterEvent && after !== undefined) { if (identity.sequence !== undefined && identity.sequence <= after.sequence) { throw new Error( diff --git a/src/runtime/retained-run-handle.ts b/src/runtime/retained-run-handle.ts index fd0fe15f..216f3908 100644 --- a/src/runtime/retained-run-handle.ts +++ b/src/runtime/retained-run-handle.ts @@ -55,6 +55,7 @@ export function createRetainedRunHandle( now: (() => number) | undefined, ): RetainedRunHandle { const clock = now ?? Date.now + const measuredCapabilities = structuredClone(capabilities) let activeControlRef = freezeControlRef(initialControlRef) const snapshot = async (reason?: string, signal?: AbortSignal): Promise => { if (signal?.aborted) throw abortError(signal.reason) @@ -88,6 +89,9 @@ export function createRetainedRunHandle( get controlRef() { return copyControlRef(activeControlRef) }, + get capabilities() { + return structuredClone(measuredCapabilities) + }, status: async (options) => { const waitMs = options?.waitMs ?? 0 assertWaitDuration(waitMs, 'retained status wait') @@ -114,7 +118,10 @@ export function createRetainedRunHandle( async respondToInteraction(command, options): Promise { const exactCommand = InteractionResponseCommandSchema.parse(command) assertInteractionBinding(activeControlRef, exactCommand) - if (capabilities.interactions?.responseIdempotency !== true) { + if ( + measuredCapabilities.interactions?.replay !== true || + measuredCapabilities.interactions.responseIdempotency !== true + ) { throw new Error( `provider "${activeControlRef.provider}" does not promise retry-safe interaction responses`, ) @@ -168,8 +175,8 @@ export function createRetainedRunHandle( throw new Error('native continuation request targets another user turn') } if ( - capabilities.nativeContinuation?.atomicBoundary !== true || - capabilities.nativeContinuation.requestIdempotency !== true || + measuredCapabilities.nativeContinuation?.atomicBoundary !== true || + measuredCapabilities.nativeContinuation.requestIdempotency !== true || !session.continueNative ) { throw new Error( diff --git a/src/runtime/retained-run-start.ts b/src/runtime/retained-run-start.ts index 7092acfe..272b2df1 100644 --- a/src/runtime/retained-run-start.ts +++ b/src/runtime/retained-run-start.ts @@ -9,6 +9,7 @@ import type { AgentSession, } from '@tangle-network/agent-interface/environment-provider' import { RetainedRunAdmissionError, RetainedRunDispatchBindingError } from '../errors' +import { assertRequestedInteractionCapabilities } from './interaction-capabilities' import { assertStableText, exactControlRef, @@ -77,7 +78,12 @@ export async function startRetainedRun( const identity = options.identity ?? mintRetainedIdentity(options.environment.idempotencyKey, options.turn.turnId) - const capabilities = await assertRetainedCapabilities(options.provider) + const providerCapabilities = await assertRetainedCapabilities(options.provider) + assertRequestedInteractionCapabilities( + options.provider.name, + options.turn.interactions, + providerCapabilities, + ) if (!options.provider.get) { throw new Error(`provider "${options.provider.name}" cannot reconstruct an environment by id`) } @@ -94,6 +100,29 @@ export async function startRetainedRun( executionId: identity.executionId, }, }) + let capabilities: AgentEnvironmentCapabilities + try { + capabilities = retainedCapabilitiesForEnvironment( + options.provider.name, + providerCapabilities, + environment, + ) + assertRequestedInteractionCapabilities( + options.provider.name, + options.turn.interactions, + capabilities, + ) + } catch (error) { + try { + await environment.destroy?.() + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + 'retained run environment published invalid capabilities and could not be destroyed', + ) + } + throw error + } if (!environment.dispatch || !environment.session) { try { await environment.destroy?.() @@ -146,7 +175,7 @@ export async function startRetainedRunInEnvironment( const identity = options.identity ?? mintRetainedIdentity(options.environment.idempotencyKey, options.turn.turnId) - const capabilities = await assertRetainedCapabilities(options.provider) + const providerCapabilities = await assertRetainedCapabilities(options.provider) if (!options.provider.get) { throw new Error(`provider "${options.provider.name}" cannot reconstruct an environment by id`) } @@ -159,6 +188,16 @@ export async function startRetainedRunInEnvironment( if (environment.id !== options.environment.id || environment.provider !== options.provider.name) { throw new Error('provider reconstructed a different retained environment') } + const capabilities = retainedCapabilitiesForEnvironment( + options.provider.name, + providerCapabilities, + environment, + ) + assertRequestedInteractionCapabilities( + options.provider.name, + options.turn.interactions, + capabilities, + ) if (!environment.dispatch || !environment.session) { throw new Error(`provider "${options.provider.name}" does not expose detached session control`) } @@ -218,6 +257,11 @@ async function dispatchRetainedRun( options: DispatchRetainedRunOptions, ): Promise { const { environment, identity } = options + assertRequestedInteractionCapabilities( + options.provider.name, + options.turn.interactions, + options.capabilities, + ) // The environment admission fires only for a dispatch-capable environment. await admitDurably(options.onAdmission, { phase: 'environment', @@ -329,12 +373,17 @@ export async function recoverRetainedRun( assertStableText(options.environmentId, 'retained environment id') assertStableText(options.sessionId, 'retained session id') assertStableText(options.executionId, 'retained execution id') - const capabilities = await assertRetainedCapabilities(options.provider) + const providerCapabilities = await assertRetainedCapabilities(options.provider) if (!options.provider.get) { throw new Error(`provider "${options.provider.name}" cannot reconstruct an environment by id`) } const environment = await options.provider.get(options.environmentId) if (!environment) return { outcome: 'not_found' } + const capabilities = retainedCapabilitiesForEnvironment( + options.provider.name, + providerCapabilities, + environment, + ) if (!environment.session) return { outcome: 'unverifiable', environment } let session: AgentSession try { @@ -379,12 +428,17 @@ export async function reconnectRetainedRun( `run provider "${controlRef.provider}" does not match "${options.provider.name}"`, ) } - const capabilities = await assertRetainedCapabilities(options.provider) + const providerCapabilities = await assertRetainedCapabilities(options.provider) if (!options.provider.get) { throw new Error(`provider "${options.provider.name}" cannot reconstruct an environment by id`) } const environment = await options.provider.get(controlRef.environmentId) if (!environment) return null + const capabilities = retainedCapabilitiesForEnvironment( + options.provider.name, + providerCapabilities, + environment, + ) const exact = exactSession(environment, controlRef) return createRetainedRunHandle( environment, @@ -399,6 +453,32 @@ export async function assertRetainedCapabilities( provider: AgentEnvironmentProvider, ): Promise { const capabilities = AgentEnvironmentCapabilitiesSchema.parse(await provider.capabilities()) + assertRetainedCapabilityRequirements(provider.name, capabilities) + return capabilities +} + +/** + * Select the capability document for one concrete environment. + * + * A measured environment document is authoritative. An absent document uses + * the provider document because that provider claims the same guarantee for + * every environment it creates. + */ +function retainedCapabilitiesForEnvironment( + providerName: string, + providerCapabilities: AgentEnvironmentCapabilities, + environment: AgentEnvironment, +): AgentEnvironmentCapabilities { + if (environment.capabilities === undefined) return providerCapabilities + const measured = AgentEnvironmentCapabilitiesSchema.parse(environment.capabilities) + assertRetainedCapabilityRequirements(providerName, measured) + return measured +} + +function assertRetainedCapabilityRequirements( + providerName: string, + capabilities: AgentEnvironmentCapabilities, +): void { const retained = capabilities.retainedControl if ( retained?.exactRunIdentity !== true || @@ -409,7 +489,6 @@ export async function assertRetainedCapabilities( !capabilities.streaming.replay || !capabilities.streaming.turnIdempotency ) { - throw new Error(`provider "${provider.name}" cannot control a retry-safe retained run`) + throw new Error(`provider "${providerName}" cannot control a retry-safe retained run`) } - return capabilities } diff --git a/src/runtime/retained-run-types.ts b/src/runtime/retained-run-types.ts index 46c693c3..24a1ba07 100644 --- a/src/runtime/retained-run-types.ts +++ b/src/runtime/retained-run-types.ts @@ -13,6 +13,7 @@ import type { } from '@tangle-network/agent-interface' import type { AgentEnvironment, + AgentEnvironmentCapabilities, AgentEnvironmentProvider, AgentTurnInput, AgentTurnResult, @@ -73,6 +74,8 @@ export type NativeContextContinuationExecution = AgentNativeContextContinuationR /** Reconstructable control of one provider-retained run. @stable */ export interface RetainedRunHandle { readonly controlRef: AgentExactRunControlRef + /** Capabilities measured from the exact environment that owns this run. */ + readonly capabilities: AgentEnvironmentCapabilities status(options?: { waitMs?: number; signal?: AbortSignal }): Promise events(options?: RetainedRunEventOptions): AsyncIterable result(): Promise diff --git a/src/runtime/retained-run.test.ts b/src/runtime/retained-run.test.ts index 04f9851e..fc623f1b 100644 --- a/src/runtime/retained-run.test.ts +++ b/src/runtime/retained-run.test.ts @@ -7,6 +7,8 @@ import { type AgentRunCancellationAcknowledgement, type AgentRunCancellationRequest, AgentRunCancellationRequestSchema, + type InteractionCapabilities, + interactionRequestDigest, interactionResponseCommandDigest, type RuntimeEventEnvelope, } from '@tangle-network/agent-interface' @@ -497,6 +499,8 @@ describe('retained runtime run control', () => { expect(recorded).toEqual({ prompt: 'fresh prompt', + controlRef: { ...controlRef, runId: 'old-run' }, + nativeContinuation: { stale: true }, turnId: 'fresh-turn', detach: true, sessionId: controlRef.sessionId, @@ -961,6 +965,71 @@ describe('retained runtime run control', () => { expect(creates).toBe(0) }) + it.each([true, false])( + 'rejects an unsupported declared interaction before create or dispatch (%s)', + async (enabled) => { + let creates = 0 + let dispatches = 0 + const provider = providerWithEnvironment({ + async dispatch() { + dispatches += 1 + throw new Error('dispatch must not run') + }, + }) + const create = provider.create + provider.create = async (input) => { + creates += 1 + return create(input) + } + + await expect( + startRetainedRun({ + provider, + environment: { profile: { name: 'worker' }, idempotencyKey: `unsupported-${enabled}` }, + turn: { + prompt: 'go', + turnId: `unsupported-turn-${enabled}`, + interactions: { question: enabled }, + }, + onAdmission: recordedAdmissions().onAdmission, + }), + ).rejects.toThrow('does not support requested interactions: question') + expect({ creates, dispatches }).toEqual({ creates: 0, dispatches: 0 }) + }, + ) + + it.each([ + ['replay', { replay: false, responseIdempotency: true }], + ['response idempotency', { replay: true, responseIdempotency: false }], + ] as const)('requires %s for an interaction dispatch', async (_missing, override) => { + let creates = 0 + const provider = providerWithEnvironment({}) + const baseCapabilities = provider.capabilities + provider.capabilities = async () => ({ + ...(await baseCapabilities()), + interactions: interactionCapabilities(override), + }) + const create = provider.create + provider.create = async (input) => { + creates += 1 + return create(input) + } + + await expect( + startRetainedRun({ + provider, + environment: { profile: { name: 'worker' }, idempotencyKey: `unsafe-${_missing}` }, + turn: { + prompt: 'go', + turnId: `unsafe-${_missing}-turn`, + interactions: { question: true }, + }, + onAdmission: recordedAdmissions().onAdmission, + }), + ).rejects.toThrow('without replay and response idempotency') + expect(creates).toBe(0) + }) + it('rejects legacy replay flags without exact retained identity promises', async () => { let creates = 0 const controlRef = { @@ -1538,6 +1607,80 @@ describe('retained runtime run control', () => { await expect(collectRetainedEvents(run.events())).rejects.toThrow('another retained session') }) + it.each(['live', 'replay'] as const)( + 'validates nested interaction binding on the %s event path', + async (path) => { + const controlRef = { + runId: `nested-interaction-${path}-run`, + provider: 'test-provider', + environmentId: 'environment-1', + sessionId: `nested-interaction-${path}-session`, + executionId: `nested-interaction-${path}-execution`, + requestDigest: retainedRequestDigest, + } + const requestMaterial = { + id: `nested-interaction-${path}`, + kind: 'question', + title: 'Need input', + answerSpec: { + fields: [{ type: 'text' as const, name: 'answer', label: 'Answer' }], + }, + binding: { + runId: controlRef.runId, + provider: controlRef.provider, + environmentId: controlRef.environmentId, + sessionId: controlRef.sessionId, + executionId: 'foreign-execution', + interactionId: `nested-interaction-${path}`, + }, + } + const session: AgentSession = { + id: controlRef.sessionId, + controlRef, + status: async () => 'running', + async *events(): AsyncIterable { + yield { + id: `nested-interaction-${path}-event`, + type: 'interaction', + data: {}, + normalized: { + type: 'interaction', + request: { + ...requestMaterial, + requestDigest: interactionRequestDigest(requestMaterial), + }, + }, + } + }, + result: async () => ({ text: 'done', success: true }), + prompt: async () => ({ text: 'continued', success: true }), + cancel: async () => {}, + } + const provider = providerWithEnvironment({ + dispatch: async () => ({ id: session.id, provider: 'test-provider', controlRef }), + session: () => session, + }) + const run = await startRetainedRun({ + provider, + environment: { + profile: { name: 'worker' }, + idempotencyKey: `nested-interaction-${path}-environment`, + }, + turn: { prompt: 'go', turnId: `nested-interaction-${path}-turn` }, + onAdmission: recordedAdmissions().onAdmission, + identity: { sessionId: controlRef.sessionId, executionId: controlRef.executionId }, + }) + + const events = + path === 'live' + ? run.events() + : run.events({ after: { cursor: 'before-event', sequence: 0 } }) + await expect(collectRetainedEvents(events)).rejects.toThrow( + 'provider returned an interaction for another retained execution', + ) + }, + ) + it('validates a replay anchor before skipping it', async () => { const controlRef = { runId: 'anchor-run', @@ -2151,3 +2294,18 @@ function providerWithEnvironment( }, } } + +function interactionCapabilities( + overrides: Partial = {}, +): InteractionCapabilities { + return { + kinds: ['question'], + answerFieldTypes: ['text'], + responseScopes: ['interaction'], + secretAnswers: false, + concurrentRequests: false, + replay: true, + responseIdempotency: true, + ...overrides, + } +} diff --git a/src/runtime/sandbox-events.ts b/src/runtime/sandbox-events.ts index 047b75b6..8f6371ab 100644 --- a/src/runtime/sandbox-events.ts +++ b/src/runtime/sandbox-events.ts @@ -12,8 +12,37 @@ * Both live here so the empirically-observed `type` vocabulary has one home. */ +import type { StreamEvent } from '@tangle-network/agent-interface' import type { SandboxEvent } from '@tangle-network/sandbox' import type { RuntimeStreamEvent } from '../types' +import { parseCanonicalTransportEvent } from './sandbox-transport-events' + +const CANONICAL_STREAM_EVENT_TYPES: ReadonlySet = new Set([ + 'message.part.updated', + 'tool-heartbeat', + 'tool-slow', + 'model-processing', + 'status', + 'warning', + 'raw', + 'session.updated', + 'interaction', + 'interaction.cancel', + 'plan.submitted', +]) + +/** Decode one known Agent Interface event from a Sandbox event. */ +export function canonicalStreamEventFromSandboxEvent(event: SandboxEvent): StreamEvent | undefined { + if (!event || typeof event !== 'object') return undefined + const type = String(event.type ?? '') + const data = + event.data && typeof event.data === 'object' + ? (event.data as Record) + : ({} as Record) + const normalized = data.normalized + if (normalized === undefined && !CANONICAL_STREAM_EVENT_TYPES.has(type)) return undefined + return parseCanonicalTransportEvent(type, data, normalized, 'sandbox') +} /** * Forward a sandbox event to an optional observer without letting observer diff --git a/src/runtime/strategy.ts b/src/runtime/strategy.ts index ead18751..50a71bba 100644 --- a/src/runtime/strategy.ts +++ b/src/runtime/strategy.ts @@ -263,7 +263,11 @@ async function runShot( const turn = await collectAgentTurn( streamAgentTurn( { kind: 'executor', profile, factory }, - { messages: messages as Array<{ role: string; content: unknown }> }, + { + providerOptions: { + messages, + }, + }, ), ) if (turn.status !== 'completed') { diff --git a/src/runtime/stream-agent-turn.test.ts b/src/runtime/stream-agent-turn.test.ts index e366761c..984edd5c 100644 --- a/src/runtime/stream-agent-turn.test.ts +++ b/src/runtime/stream-agent-turn.test.ts @@ -43,7 +43,10 @@ describe('streamAgentTurn: box backend', () => { ] as SandboxEvent[]) const seen: RuntimeStreamEvent[] = [] - for await (const event of streamObservedAgentTurn({ kind: 'box', box }, 'say hello')) { + for await (const event of streamObservedAgentTurn( + { kind: 'box', box }, + { prompt: 'say hello' }, + )) { seen.push(event) } // Incremental events surface in order, before the terminal event. @@ -70,7 +73,9 @@ describe('streamAgentTurn: box backend', () => { { type: 'done', data: { tokenUsage: { inputTokens: 7, outputTokens: 3 } } }, ] as SandboxEvent[]) - const turn = await collectAgentTurn(streamObservedAgentTurn({ kind: 'box', box }, 'answer')) + const turn = await collectAgentTurn( + streamObservedAgentTurn({ kind: 'box', box }, { prompt: 'answer' }), + ) expect(turn.finalText).toBe('42') expect(turn.usage).toEqual({ input: 7, output: 3, usdKnown: false }) expect(turn.status).toBe('completed') @@ -90,7 +95,9 @@ describe('streamAgentTurn: box backend', () => { }, }) const box = await client.create() - const turn = await collectAgentTurn(streamObservedAgentTurn({ kind: 'box', box }, 'boom')) + const turn = await collectAgentTurn( + streamObservedAgentTurn({ kind: 'box', box }, { prompt: 'boom' }), + ) expect(turn.status).toBe('failed') expect(turn.error).toMatchObject({ kind: 'backend', message: 'box exploded' }) const types = turn.events.map((e) => e.type) @@ -127,7 +134,7 @@ describe('streamAgentTurn: current Sandbox prompt options', () => { box, options: { sessionId: 'sess-1', model: 'kimi-k2' }, }, - 'do the task', + { prompt: 'do the task' }, ), ) // The options passthrough arrives verbatim at the current prompt verb. @@ -158,11 +165,138 @@ describe('streamAgentTurn: current Sandbox prompt options', () => { }) const box = await client.create() const turn = await collectAgentTurn( - streamObservedAgentTurn({ kind: 'box', box }, 'hang', { timeoutMs: 25 }), + streamObservedAgentTurn({ kind: 'box', box }, { prompt: 'hang' }, { timeoutMs: 25 }), ) expect(turn.status).toBe('failed') expect(turn.error?.message).toContain('timed out after 25ms') }) + + it('does not classify a silent iterator close after caller abort as completed', async () => { + const controller = new AbortController() + const client = inProcessSandboxClient({ + onPrompt: async function* (_prompt, ctx): AsyncIterable { + await new Promise((resolve) => { + if (ctx.signal.aborted) { + resolve() + return + } + ctx.signal.addEventListener('abort', () => resolve(), { once: true }) + }) + }, + }) + const box = await client.create() + const pending = collectAgentTurn( + streamObservedAgentTurn( + { kind: 'box', box }, + { prompt: 'stop' }, + { signal: controller.signal }, + ), + ) + controller.abort(new Error('caller stopped')) + const turn = await pending + expect(turn.status).toBe('aborted') + expect(turn.error?.message).toBe('caller stopped') + expect(turn.events.at(-1)?.type).toBe('final') + }) +}) + +describe('streamAgentTurn: canonical event precedence', () => { + async function makeBox(events: SandboxEvent[]) { + const client = inProcessSandboxClient({ onPrompt: () => events }) + return client.create() + } + + it.each([ + [ + 'message part', + { + type: 'message.part.updated', + data: { + part: { + id: 'part-text', + sessionID: 'session-1', + messageID: 'message-1', + type: 'text', + text: 'canonical text', + }, + delta: 'canonical text', + }, + }, + 'message.part.updated', + ], + ['status', { type: 'status', data: { status: 'processing' } }, 'status'], + [ + 'raw', + { type: 'raw', data: { backend: 'opencode', event: { providerSecret: 'observer-only' } } }, + 'raw', + ], + ] as const)( + 'emits one canonical semantic event for one %s source frame', + async (_label, source, type) => { + const box = await makeBox([ + source, + { + type: 'done', + data: { finalText: 'finished', tokenUsage: { inputTokens: 1, outputTokens: 1 } }, + }, + ] as SandboxEvent[]) + const turn = await collectAgentTurn( + streamObservedAgentTurn({ kind: 'box', box }, { prompt: 'canonical' }), + ) + expect(turn.events.filter((event) => event.type === type)).toHaveLength(1) + expect(turn.events.filter((event) => event.type === 'text_delta')).toHaveLength(0) + }, + ) + + it('does not expand a canonical tool part into a second tool frame', async () => { + const box = await makeBox([ + { + type: 'message.part.updated', + data: { + part: { + id: 'part-tool', + sessionID: 'session-1', + messageID: 'message-1', + type: 'tool', + callID: 'call-1', + tool: 'bash', + state: { status: 'running', input: { command: 'pwd' } }, + }, + }, + }, + { type: 'done', data: { tokenUsage: { inputTokens: 1, outputTokens: 1 } } }, + ] as SandboxEvent[]) + const turn = await collectAgentTurn( + streamObservedAgentTurn( + { kind: 'box', box }, + { prompt: 'run pwd' }, + { preserveToolParts: true }, + ), + ) + expect(turn.events.filter((event) => event.type === 'message.part.updated')).toHaveLength(1) + expect(turn.events.filter((event) => event.type === 'tool_call')).toHaveLength(0) + }) + + it('keeps an unknown provider payload on the observer path only', async () => { + const observed: SandboxEvent[] = [] + const box = await makeBox([ + { type: 'provider.secret', data: { token: 'do-not-persist' } }, + { type: 'done', data: { tokenUsage: { inputTokens: 1, outputTokens: 1 } } }, + ] as SandboxEvent[]) + const turn = await collectAgentTurn( + streamObservedAgentTurn( + { kind: 'box', box }, + { prompt: 'observe' }, + { + onRawEvent: (event) => { + observed.push(event) + }, + }, + ), + ) + expect(observed.map((event) => event.type)).toContain('provider.secret') + expect(turn.events.some((event) => event.type === 'raw')).toBe(false) + }) }) describe('streamAgentTurn: tool-part preservation (opt-in)', () => { @@ -213,7 +347,11 @@ describe('streamAgentTurn: tool-part preservation (opt-in)', () => { it('preserveToolParts: true surfaces deduped tool_call/tool_result in-stream', async () => { const box = await makeBox(toolFrames) const turn = await collectAgentTurn( - streamObservedAgentTurn({ kind: 'box', box }, 'list files', { preserveToolParts: true }), + streamObservedAgentTurn( + { kind: 'box', box }, + { prompt: 'list files' }, + { preserveToolParts: true }, + ), ) expect(turn.events.map((e) => e.type)).toEqual([ 'backend_start', @@ -236,7 +374,9 @@ describe('streamAgentTurn: tool-part preservation (opt-in)', () => { it('default (off) leaves the stream vocabulary unchanged — no tool events', async () => { const box = await makeBox(toolFrames) - const turn = await collectAgentTurn(streamObservedAgentTurn({ kind: 'box', box }, 'list files')) + const turn = await collectAgentTurn( + streamObservedAgentTurn({ kind: 'box', box }, { prompt: 'list files' }), + ) expect(turn.events.map((e) => e.type)).toEqual([ 'backend_start', 'text_delta', @@ -261,7 +401,11 @@ describe('streamAgentTurn: tool-part preservation (opt-in)', () => { { type: 'done', data: { tokenUsage: { inputTokens: 1, outputTokens: 1 } } }, ] as SandboxEvent[]) const turn = await collectAgentTurn( - streamObservedAgentTurn({ kind: 'box', box }, 'fetch', { preserveToolParts: true }), + streamObservedAgentTurn( + { kind: 'box', box }, + { prompt: 'fetch' }, + { preserveToolParts: true }, + ), ) const types = turn.events.map((e) => e.type) expect(types).toEqual(['backend_start', 'tool_call', 'tool_result', 'llm_call', 'final']) @@ -281,7 +425,11 @@ describe('streamAgentTurn: tool-part preservation (opt-in)', () => { }) const box = await client.create() const turn = await collectAgentTurn( - streamObservedAgentTurn({ kind: 'box', box }, 'search', { preserveToolParts: true }), + streamObservedAgentTurn( + { kind: 'box', box }, + { prompt: 'search' }, + { preserveToolParts: true }, + ), ) expect(turn.events.map((e) => e.type)).toEqual([ 'backend_start', @@ -308,13 +456,17 @@ describe('streamAgentTurn: raw-event tap (onRawEvent)', () => { ] as SandboxEvent[], }) const box = await client.create() - const stream = streamObservedAgentTurn({ kind: 'box', box }, 'go', { - onRawEvent: async (event) => { - // Async on purpose: the drive must AWAIT the tap before projecting. - await Promise.resolve() - log.push(`raw:${String(event.type)}`) + const stream = streamObservedAgentTurn( + { kind: 'box', box }, + { prompt: 'go' }, + { + onRawEvent: async (event) => { + // Async on purpose: the drive must AWAIT the tap before projecting. + await Promise.resolve() + log.push(`raw:${String(event.type)}`) + }, }, - }) + ) for await (const event of stream) log.push(`mapped:${event.type}`) expect(log).toEqual([ 'mapped:backend_start', @@ -351,7 +503,7 @@ describe('streamAgentTurn: mid-stream lifecycle (pull-based, no extra API)', () }, }) const box = await client.create() - for await (const event of streamObservedAgentTurn({ kind: 'box', box }, 'go')) { + for await (const event of streamObservedAgentTurn({ kind: 'box', box }, { prompt: 'go' })) { log.push(`consumed:${event.type}`) // The mid-stream escape: arbitrary awaited work (a vault sync, a retry // decision) runs here while the producer is suspended. @@ -397,13 +549,15 @@ describe('streamAgentTurn: mid-stream lifecycle (pull-based, no extra API)', () let synced = false async function* withLifecycle(): AsyncGenerator { - const first = await collectAgentTurn(streamObservedAgentTurn({ kind: 'box', box }, 'attempt')) + const first = await collectAgentTurn( + streamObservedAgentTurn({ kind: 'box', box }, { prompt: 'attempt' }), + ) const noop = first.finalText === '' && first.status === 'completed' if (noop) { // Retry with a steering prompt — the first `final` is never forwarded. for await (const event of streamObservedAgentTurn( { kind: 'box', box }, - 'attempt (retry)', + { prompt: 'attempt (retry)' }, )) { if (event.type === 'final') { synced = true // pre-done lifecycle work completes before forwarding @@ -442,9 +596,13 @@ describe('streamAgentTurn: executor backend', () => { if (ctx.signal.aborted) onAbort() }) } + const prompt = + task && typeof task === 'object' && 'prompt' in task && typeof task.prompt === 'string' + ? task.prompt + : String(task) return { outRef: 'stub-1', - out: { content: `echo: ${String(task)}`, transportAttempts: 2 }, + out: { content: `echo: ${prompt}`, transportAttempts: 2 }, spent: { iterations: 1, tokens: { input: 11, output: 6 }, usd: 0.005, ms: 1 }, } }, @@ -490,7 +648,7 @@ describe('streamAgentTurn: executor backend', () => { factory, profile: { name: 'incomplete', model: { default: 'offline-test-model' } }, }, - 'must not run', + { prompt: 'must not run' }, ), ), ).rejects.toThrow(/AgentProfile\.harness must be explicit/u) @@ -505,7 +663,7 @@ describe('streamAgentTurn: executor backend', () => { factory: stubFactory({ onTeardown: () => toreDown++ }), profile: TEST_PROFILE, }, - 'ping', + { prompt: 'ping' }, ) const turn = await collectAgentTurn(stream) expect(turn.finalText).toBe('echo: ping') @@ -560,7 +718,7 @@ describe('streamAgentTurn: executor backend', () => { }, }), }, - 'ping', + { prompt: 'ping' }, ), ) @@ -578,7 +736,7 @@ describe('streamAgentTurn: executor backend', () => { factory: stubFactory({ hangUntilAbort: true, onTeardown: () => toreDown++ }), profile: TEST_PROFILE, }, - 'hang', + { prompt: 'hang' }, { signal: controller.signal }, ) setTimeout(() => controller.abort(new Error('caller cancelled')), 20) @@ -613,7 +771,7 @@ describe('streamAgentTurn: chat backend', () => { const seen: RuntimeStreamEvent[] = [] for await (const event of streamObservedAgentTurn( { kind: 'chat', backend: stubChatBackend() }, - 'hi', + { prompt: 'hi' }, )) { seen.push(event) } @@ -643,7 +801,7 @@ describe('streamAgentTurn: chat backend', () => { const controller = new AbortController() const stream = streamObservedAgentTurn( { kind: 'chat', backend: stubChatBackend({ hangUntilAbort: true }) }, - 'hang', + { prompt: 'hang' }, { signal: controller.signal }, ) setTimeout(() => controller.abort(new Error('user stopped')), 20) @@ -667,7 +825,7 @@ describe('streamAgentTurn: chat backend', () => { kind: 'chat', backend: stubChatBackend({ hangUntilAbort: true }), }, - 'slow', + { prompt: 'slow' }, { timeoutMs: 25, }, diff --git a/src/runtime/stream-agent-turn.ts b/src/runtime/stream-agent-turn.ts index af6113f2..ba235527 100644 --- a/src/runtime/stream-agent-turn.ts +++ b/src/runtime/stream-agent-turn.ts @@ -48,7 +48,12 @@ */ import { scoreKnowledgeReadiness } from '@tangle-network/agent-eval' -import { type AgentProfile, canonicalCandidateDigest } from '@tangle-network/agent-interface' +import { + type AgentProfile, + canonicalCandidateDigest, + renderInputPartsAsText, +} from '@tangle-network/agent-interface' +import type { AgentTurnInput } from '@tangle-network/agent-interface/environment-provider' import type { PromptOptions, SandboxEvent, SandboxInstance } from '@tangle-network/sandbox' import { normalizeBackendStreamEvent } from '../backends' import { BackendTransportError, ValidationError } from '../errors' @@ -62,7 +67,12 @@ import type { RuntimeSession, RuntimeStreamEvent, } from '../types' -import { createSandboxToolPartState, mapSandboxEvent, mapSandboxToolEvent } from './sandbox-events' +import { + canonicalStreamEventFromSandboxEvent, + createSandboxToolPartState, + mapSandboxEvent, + mapSandboxToolEvent, +} from './sandbox-events' import { executableAgentProfileSnapshot } from './supervise/executable-spec' import { authoredProfileDigest, @@ -88,6 +98,7 @@ import type { ProfileMaterializationReceipt, UsageEvent, } from './supervise/types' +import { promptFromAgentTurnInput, promptOptionsFromAgentTurnInput } from './turn-input' /** * The execution substrate one turn runs on — a closed discriminated union over @@ -132,10 +143,7 @@ type ObservedAgentTurnBackend = backend: AgentExecutionBackend } -/** One prompt or an exact OpenAI-compatible conversation carried as the turn input. */ -export type AgentTurnInput = - | string - | { readonly messages: ReadonlyArray>> } +export type { AgentTurnInput } from '@tangle-network/agent-interface/environment-provider' /** @stable */ export interface StreamAgentTurnOptions { @@ -173,19 +181,20 @@ export interface StreamAgentTurnOptions { } function turnIntent(input: AgentTurnInput): string { - if (typeof input === 'string') return input - for (let index = input.messages.length - 1; index >= 0; index -= 1) { - const message = input.messages[index] - if (message?.role === 'user' && typeof message.content === 'string') return message.content - } + if (input.prompt !== undefined) return input.prompt + if (input.parts !== undefined) return renderInputPartsAsText(input.parts) return 'structured agent turn' } function turnBackendInput(task: AgentTaskSpec, input: AgentTurnInput): AgentBackendInput { - if (typeof input === 'string') return { task, message: input } return { task, - messages: input.messages.map((message) => ({ ...message })) as AgentBackendInput['messages'], + ...(input.prompt === undefined ? {} : { message: input.prompt }), + ...(input.parts === undefined ? {} : { parts: structuredClone(input.parts) }), + ...(input.interactions === undefined ? {} : { interactions: input.interactions }), + ...(input.providerOptions === undefined + ? {} + : { providerOptions: structuredClone(input.providerOptions) }), } } @@ -617,7 +626,7 @@ async function* streamAgentTurnInternal( ) : driveBoxTurn( backend.box, - turnIntent(input), + input, deadline.signal, backend.agentRunName ?? 'agent', acc, @@ -631,6 +640,9 @@ async function* streamAgentTurnInternal( yield event throwIfAborted(deadline.signal) } + // Some providers close their iterator instead of throwing after an abort + // or deadline. Never turn that silent close into a successful completion. + throwIfAborted(deadline.signal) if (backend.kind === 'executor') { ;({ materialization, executionBinding } = executorEvidence(executor!, profile!, attemptId)) @@ -837,29 +849,45 @@ interface BoxTurnConfig { */ async function* driveBoxTurn( box: SandboxInstance, - prompt: string, + input: AgentTurnInput, signal: AbortSignal, agentRunName: string, acc: TurnAccumulator, cfg: BoxTurnConfig, ): AsyncGenerator { const callOptions: PromptOptions = { ...(cfg.options ?? {}), signal } - const stream = box.streamPrompt(prompt, callOptions) + const stream = box.streamPrompt(promptFromAgentTurnInput(input), { + ...callOptions, + ...promptOptionsFromAgentTurnInput(input), + signal, + }) const toolParts = cfg.preserveToolParts ? createSandboxToolPartState() : undefined for await (const event of stream) { if (cfg.onRawEvent) await cfg.onRawEvent(event) const terminalText = terminalTextFromSandboxEvent(event) if (terminalText !== undefined) acc.terminalText = terminalText + const canonical = canonicalStreamEventFromSandboxEvent(event) + if (canonical) { + yield canonical + continue + } + if (toolParts) { - for (const toolEvent of mapSandboxToolEvent(event, toolParts)) yield toolEvent + for (const toolEvent of mapSandboxToolEvent(event, toolParts)) { + yield toolEvent + } } const mapped = mapSandboxEvent(event, { agentRunName }) - if (!mapped) continue - // `mapSandboxEvent` stamps `agentRunName` as the model label when the - // event carried none — a run label, not a reported model. Exclude it from - // the terminal usage so `usage.model` is never a fabricated value. - foldEvent(mapped, acc, agentRunName) - yield mapped + if (mapped) { + // `mapSandboxEvent` stamps `agentRunName` as the model label when the + // event carried none — a run label, not a reported model. Exclude it from + // the terminal usage so `usage.model` is never a fabricated value. + foldEvent(mapped, acc, agentRunName) + yield mapped + } + + // Unknown provider events stay available through onRawEvent. Do not copy + // arbitrary provider payloads into the public stream. } } @@ -891,7 +919,7 @@ async function* driveExecutorTurn( acc: TurnAccumulator, declaredModel: string | undefined, ): AsyncGenerator { - const taskValue = typeof input === 'string' ? input : { messages: input.messages } + const taskValue = executorTaskValue(input) const run = executor.execute(taskValue, signal) let result: ExecutorResult if (isAsyncIterable(run)) { @@ -965,6 +993,18 @@ async function* driveExecutorTurn( } } +function executorTaskValue(input: AgentTurnInput): unknown { + const messages = input.providerOptions?.messages + if (Array.isArray(messages)) { + return { messages: structuredClone(messages) } + } + return { + ...(input.prompt === undefined ? {} : { prompt: input.prompt }), + ...(input.parts === undefined ? {} : { parts: structuredClone(input.parts) }), + ...(input.interactions === undefined ? {} : { interactions: input.interactions }), + } +} + function isAsyncIterable(value: unknown): value is AsyncIterable { return typeof value === 'object' && value !== null && Symbol.asyncIterator in value } diff --git a/src/runtime/supervise/runtime.ts b/src/runtime/supervise/runtime.ts index 59003c76..648f8e9e 100644 --- a/src/runtime/supervise/runtime.ts +++ b/src/runtime/supervise/runtime.ts @@ -33,11 +33,13 @@ import { estimateCost, isModelPriced } from '@tangle-network/agent-eval' import { type AgentProfile, type AgentProfileResourceRef, + AgentTurnInputSchema, agentProfileSchema, canonicalAgentProfileDigest, profileMaterializationAxes, REASONING_EFFORTS, type ReasoningEffort, + renderInputPartsAsText, } from '@tangle-network/agent-interface' import type { BackendType, SandboxEvent } from '@tangle-network/sandbox' import { @@ -4761,6 +4763,10 @@ export function taskToPrompt(task: unknown): string { for (const k of ['prompt', 'content', 'task', 'message']) { if (typeof obj[k] === 'string') return obj[k] as string } + if (Array.isArray(obj.parts)) { + const parsed = AgentTurnInputSchema.safeParse({ parts: obj.parts }) + if (parsed.success && parsed.data.parts) return renderInputPartsAsText(parsed.data.parts) + } } return JSON.stringify(task) } diff --git a/src/runtime/turn-input.ts b/src/runtime/turn-input.ts index 1f54448b..5ce2c9f8 100644 --- a/src/runtime/turn-input.ts +++ b/src/runtime/turn-input.ts @@ -1,5 +1,10 @@ -import type { ContextTransferRequest } from '@tangle-network/agent-interface' +import { + AgentExactRunControlRefSchema, + type ContextTransferRequest, + type InputPart, +} from '@tangle-network/agent-interface' import type { AgentTurnInput } from '@tangle-network/agent-interface/environment-provider' +import type { PromptInputPart, PromptOptions } from '@tangle-network/sandbox' /** * Copy only fields that describe a new provider turn. @@ -24,6 +29,11 @@ export function freshTurnInput( ...(input.model === undefined ? {} : { model: input.model }), ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), ...(input.context === undefined ? {} : { context: input.context }), + ...(input.controlRef === undefined ? {} : { controlRef: input.controlRef }), + ...(input.interactions === undefined ? {} : { interactions: input.interactions }), + ...(input.nativeContinuation === undefined + ? {} + : { nativeContinuation: input.nativeContinuation }), ...(input.providerOptions === undefined ? {} : { providerOptions: input.providerOptions }), ...(input.signal === undefined ? {} : { signal: input.signal }), turnId: runtime.turnId, @@ -34,3 +44,71 @@ export function freshTurnInput( } return fresh } + +/** Project canonical turn parts onto the Sandbox prompt vocabulary once. */ +export function promptFromAgentTurnInput(input: AgentTurnInput): string | PromptInputPart[] { + if (input.parts !== undefined) return input.parts.map(promptPartFromInputPart) + return input.prompt ?? '' +} + +/** Project canonical turn controls onto the Sandbox prompt options once. */ +export function promptOptionsFromAgentTurnInput(input: AgentTurnInput): PromptOptions { + const providerBackend = + input.providerOptions?.backend && + typeof input.providerOptions.backend === 'object' && + !Array.isArray(input.providerOptions.backend) + ? (input.providerOptions.backend as NonNullable) + : undefined + const backend = { + ...(providerBackend ?? {}), + ...(input.interactions === undefined ? {} : { interactions: input.interactions }), + } + const runControlRef = + input.controlRef === undefined + ? undefined + : AgentExactRunControlRefSchema.parse(input.controlRef) + return { + ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), + ...(input.model === undefined ? {} : { model: input.model }), + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + ...(input.context === undefined ? {} : { context: input.context }), + ...(input.signal === undefined ? {} : { signal: input.signal }), + ...(input.executionId === undefined ? {} : { executionId: input.executionId }), + ...(input.lastEventId === undefined ? {} : { lastEventId: input.lastEventId }), + ...(input.turnId === undefined ? {} : { turnId: input.turnId }), + ...(input.detach === undefined ? {} : { detach: input.detach }), + ...(runControlRef === undefined ? {} : { runControlRef }), + ...(Object.keys(backend).length === 0 ? {} : { backend }), + } +} + +/** Read a text fallback from provider-specific conversation options. */ +export function providerMessageText( + providerOptions: Record | undefined, +): string | undefined { + const messages = providerOptions?.messages + if (!Array.isArray(messages)) return undefined + const last = messages.at(-1) + if (!last || typeof last !== 'object' || Array.isArray(last)) return undefined + if (!('content' in last)) return undefined + const content = last.content + return typeof content === 'string' ? content : undefined +} + +function promptPartFromInputPart(part: InputPart): PromptInputPart { + if (part.type === 'text' || part.type === 'image') return part + if (part.content !== undefined || part.path !== undefined) { + throw new Error( + 'Sandbox file prompt parts require a URL; inline content and local paths are not representable', + ) + } + if (!part.filename || !part.url) { + throw new Error('Sandbox file prompt parts require both filename and URL') + } + return { + type: 'file', + filename: part.filename, + ...(part.mediaType === undefined ? {} : { mediaType: part.mediaType }), + url: part.url, + } +} diff --git a/src/types.ts b/src/types.ts index 7ab74e0d..bdc4e704 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,7 @@ import type { TraceStore, UserQuestion, } from '@tangle-network/agent-eval' +import type { InputPart, RequestedInteractions, StreamEvent } from '@tangle-network/agent-interface' /** @stable */ export interface AgentTaskSpec { @@ -276,8 +277,16 @@ export type OpenAIChatResponseFormat = | { type: 'json_object' } | { type: 'json_schema'; json_schema: Record } +/** Agent Interface events that do not belong to Runtime's task vocabulary. */ +export type RuntimeCanonicalStreamEvent = StreamEvent & { + task?: AgentTaskSpec + session?: RuntimeSession + timestamp?: string +} + /** @stable */ export type RuntimeStreamEvent = + | RuntimeCanonicalStreamEvent | { type: 'task_start'; task: AgentTaskSpec; timestamp: string } | { type: 'readiness_start'; task: AgentTaskSpec; timestamp: string } | { @@ -482,6 +491,9 @@ export interface AgentBackendInput { task: AgentTaskSpec message?: string messages?: Array<{ role: string; content: string }> + parts?: InputPart[] + interactions?: RequestedInteractions + providerOptions?: Record inputs?: Record } diff --git a/tests/helpers/durable-retained-provider.ts b/tests/helpers/durable-retained-provider.ts index 41f651de..74600d7b 100644 --- a/tests/helpers/durable-retained-provider.ts +++ b/tests/helpers/durable-retained-provider.ts @@ -158,7 +158,10 @@ function sessionFor( return sessionState(stateFile, environmentId, initial.id).status }, async *events(options): AsyncIterable { - const events = sessionState(stateFile, environmentId, initial.id).events + const current = sessionState(stateFile, environmentId, initial.id) + const latestControlRef = + Object.values(current.nativeOperations).at(-1)?.result.controlRef ?? current.controlRef + const events = rebindInteractionEvents(current.events, latestControlRef) const start = options?.since === undefined ? 0 @@ -480,6 +483,38 @@ function retainedEvents(controlRef: AgentExactRunControlRef): AgentEnvironmentEv ] } +function rebindInteractionEvents( + events: readonly AgentEnvironmentEvent[], + controlRef: AgentExactRunControlRef, +): AgentEnvironmentEvent[] { + return events.map((event) => { + if (event.normalized?.type !== 'interaction') return structuredClone(event) + const { requestDigest: _requestDigest, ...requestMaterial } = event.normalized.request + const material = { + ...requestMaterial, + binding: { + ...event.normalized.request.binding, + runId: controlRef.runId, + provider: controlRef.provider, + environmentId: controlRef.environmentId, + sessionId: controlRef.sessionId, + executionId: controlRef.executionId, + interactionId: event.normalized.request.id, + }, + } + return { + ...structuredClone(event), + normalized: { + type: 'interaction', + request: { + ...material, + requestDigest: interactionRequestDigest(material), + }, + }, + } + }) +} + function serializableTurn(input: AgentTurnInput): Record { const { signal: _signal, controlRef, nativeContinuation, contextTransfer, ...rest } = input return { From d851f1fcdf26041caafc0cb7ccd318870725cd0b Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 00:09:56 -0600 Subject: [PATCH 02/16] fix(runtime): enforce deadlines and owned turn identity --- src/runtime/interaction-capabilities.ts | 4 +- src/runtime/retained-run-binding.ts | 4 +- src/runtime/retained-run.test.ts | 110 ++++++++++++++------ src/runtime/stream-agent-turn.test.ts | 129 +++++++++++++++++++++++- src/runtime/stream-agent-turn.ts | 73 ++++++++++++-- src/runtime/turn-input.ts | 4 - 6 files changed, 275 insertions(+), 49 deletions(-) diff --git a/src/runtime/interaction-capabilities.ts b/src/runtime/interaction-capabilities.ts index 9103c049..e1d32f7c 100644 --- a/src/runtime/interaction-capabilities.ts +++ b/src/runtime/interaction-capabilities.ts @@ -13,7 +13,9 @@ export function assertRequestedInteractionCapabilities( requested: RequestedInteractions | undefined, capabilities: AgentEnvironmentCapabilities, ): void { - const requestedKinds = Object.entries(requested ?? {}).map(([kind]) => kind) + const requestedKinds = Object.entries(requested ?? {}) + .filter(([, enabled]) => enabled === true) + .map(([kind]) => kind) if (requestedKinds.length === 0) return const interactions = capabilities.interactions diff --git a/src/runtime/retained-run-binding.ts b/src/runtime/retained-run-binding.ts index b004455e..6ec29547 100644 --- a/src/runtime/retained-run-binding.ts +++ b/src/runtime/retained-run-binding.ts @@ -326,7 +326,9 @@ export function assertStableText(value: string, label: string): void { } export function abortError(reason: unknown): Error { - const error = new Error(reason === undefined ? 'aborted' : String(reason)) + const error = new Error( + reason instanceof Error ? reason.message : reason === undefined ? 'aborted' : String(reason), + ) error.name = 'AbortError' return error } diff --git a/src/runtime/retained-run.test.ts b/src/runtime/retained-run.test.ts index fc623f1b..3da9cbd6 100644 --- a/src/runtime/retained-run.test.ts +++ b/src/runtime/retained-run.test.ts @@ -499,8 +499,6 @@ describe('retained runtime run control', () => { expect(recorded).toEqual({ prompt: 'fresh prompt', - controlRef: { ...controlRef, runId: 'old-run' }, - nativeContinuation: { stale: true }, turnId: 'fresh-turn', detach: true, sessionId: controlRef.sessionId, @@ -965,38 +963,88 @@ describe('retained runtime run control', () => { expect(creates).toBe(0) }) - it.each([true, false])( - 'rejects an unsupported declared interaction before create or dispatch (%s)', - async (enabled) => { - let creates = 0 - let dispatches = 0 - const provider = providerWithEnvironment({ - async dispatch() { - dispatches += 1 - throw new Error('dispatch must not run') + it('rejects an unsupported enabled interaction before create or dispatch', async () => { + let creates = 0 + let dispatches = 0 + const provider = providerWithEnvironment({ + async dispatch() { + dispatches += 1 + throw new Error('dispatch must not run') + }, + }) + const create = provider.create + provider.create = async (input) => { + creates += 1 + return create(input) + } + + await expect( + startRetainedRun({ + provider, + environment: { profile: { name: 'worker' }, idempotencyKey: 'unsupported-true' }, + turn: { + prompt: 'go', + turnId: 'unsupported-turn-true', + interactions: { question: true }, }, - }) - const create = provider.create - provider.create = async (input) => { - creates += 1 - return create(input) - } + onAdmission: recordedAdmissions().onAdmission, + }), + ).rejects.toThrow('does not support requested interactions: question') + expect({ creates, dispatches }).toEqual({ creates: 0, dispatches: 0 }) + }) - await expect( - startRetainedRun({ - provider, - environment: { profile: { name: 'worker' }, idempotencyKey: `unsupported-${enabled}` }, - turn: { - prompt: 'go', - turnId: `unsupported-turn-${enabled}`, - interactions: { question: enabled }, + it('treats a false interaction posture as explicitly disabled', async () => { + let dispatches = 0 + let dispatchedControlRef: AgentSession['controlRef'] + const provider = providerWithEnvironment({ + async dispatch(input) { + dispatches += 1 + const sessionId = input.sessionId ?? 'missing-session' + const executionId = input.executionId ?? 'missing-execution' + dispatchedControlRef = { + runId: executionId, + provider: 'test-provider', + environmentId: 'environment-1', + sessionId, + executionId, + requestDigest: retainedRequestDigest, + } + return { + id: sessionId, + provider: 'test-provider', + controlRef: dispatchedControlRef, + } + }, + session(id) { + if (!dispatchedControlRef) throw new Error('dispatch did not bind a control reference') + return { + id, + controlRef: dispatchedControlRef, + status: async () => 'running', + async *events() { + yield* [] }, - onAdmission: recordedAdmissions().onAdmission, - }), - ).rejects.toThrow('does not support requested interactions: question') - expect({ creates, dispatches }).toEqual({ creates: 0, dispatches: 0 }) - }, - ) + result: async () => ({ text: 'done', success: true }), + prompt: async () => ({ text: 'continued', success: true }), + cancel: async () => {}, + } + }, + }) + + await expect( + startRetainedRun({ + provider, + environment: { profile: { name: 'worker' }, idempotencyKey: 'disabled-question' }, + turn: { + prompt: 'go', + turnId: 'disabled-question-turn', + interactions: { question: false }, + }, + onAdmission: recordedAdmissions().onAdmission, + }), + ).resolves.toBeDefined() + expect(dispatches).toBe(1) + }) it.each([ ['replay', { replay: false, responseIdempotency: true }], diff --git a/src/runtime/stream-agent-turn.test.ts b/src/runtime/stream-agent-turn.test.ts index 984edd5c..df357684 100644 --- a/src/runtime/stream-agent-turn.test.ts +++ b/src/runtime/stream-agent-turn.test.ts @@ -7,7 +7,7 @@ * paths. No network, no credentials. */ -import type { SandboxEvent } from '@tangle-network/sandbox' +import type { SandboxEvent, SandboxInstance } from '@tangle-network/sandbox' import { describe, expect, it } from 'vitest' import type { AgentExecutionBackend, RuntimeStreamEvent } from '../types' import { inProcessSandboxClient } from './in-process-sandbox-client' @@ -171,6 +171,33 @@ describe('streamAgentTurn: current Sandbox prompt options', () => { expect(turn.error?.message).toContain('timed out after 25ms') }) + it('enforces its deadline when a Sandbox iterator ignores cancellation', async () => { + let returnCalls = 0 + const events: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next: async () => await new Promise>(() => {}), + return: async () => { + returnCalls += 1 + return { done: true, value: undefined } + }, + } + }, + } + const box = { + streamPrompt: () => events, + } as unknown as SandboxInstance + + const turn = await collectAgentTurn( + streamObservedAgentTurn({ kind: 'box', box }, { prompt: 'hang' }, { timeoutMs: 25 }), + ) + + expect(turn.status).toBe('failed') + expect(turn.error?.message).toContain('timed out after 25ms') + await Promise.resolve() + expect(returnCalls).toBe(1) + }) + it('does not classify a silent iterator close after caller abort as completed', async () => { const controller = new AbortController() const client = inProcessSandboxClient({ @@ -745,6 +772,52 @@ describe('streamAgentTurn: executor backend', () => { expect(turn.error?.message).toBe('caller cancelled') expect(toreDown).toBe(1) }) + + it('enforces its deadline when an executor promise ignores cancellation', async () => { + let toreDown = 0 + const factory: ExecutorFactory = (spec, ctx) => { + const attemptId = ctx.node?.attemptId ?? 'uncooperative-attempt' + return attestRuntimeOwnedExecutor( + { + runtime: 'uncooperative', + execute: async () => await new Promise>(() => {}), + async teardown() { + toreDown += 1 + return { destroyed: true } + }, + resultArtifact() { + throw new Error('uncooperative executor has no result') + }, + }, + { + effectiveProfile: spec.profile, + backend: 'inline-test', + model: { status: 'known', id: 'offline-test-model' }, + execution: { kind: 'request', id: attemptId }, + materializer: 'offline-test-executor', + plan: { kind: 'offline-test' }, + }, + { + attemptId, + binding: { kind: 'offline-test', attemptId }, + descriptor: { kind: 'offline-test', transport: 'in-process' }, + }, + ) + } + + const turn = await collectAgentTurn( + streamAgentTurn( + { kind: 'executor', factory, profile: TEST_PROFILE }, + { prompt: 'hang' }, + { timeoutMs: 25 }, + ), + ) + + expect(turn.status).toBe('failed') + expect(turn.error?.message).toContain('timed out after 25ms') + await Promise.resolve() + expect(toreDown).toBe(1) + }) }) describe('streamAgentTurn: chat backend', () => { @@ -834,6 +907,60 @@ describe('streamAgentTurn: chat backend', () => { expect(turn.status).toBe('failed') expect(turn.error?.message).toContain('timed out after 25ms') }) + + it('enforces its deadline when chat startup ignores cancellation', async () => { + const backend: AgentExecutionBackend = { + kind: 'uncooperative-start', + start: async () => await new Promise(() => {}), + async *stream() { + yield* [] + }, + } + + const turn = await collectAgentTurn( + streamObservedAgentTurn( + { kind: 'chat', backend }, + { prompt: 'hang before start' }, + { timeoutMs: 25 }, + ), + ) + + expect(turn.status).toBe('failed') + expect(turn.error?.message).toContain('timed out after 25ms') + expect(turn.events.map((event) => event.type)).toEqual(['backend_error', 'final']) + }) + + it('closes a chat iterator that ignores cancellation', async () => { + let returnCalls = 0 + const events: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next: async () => await new Promise>(() => {}), + return: async () => { + returnCalls += 1 + return { done: true, value: undefined } + }, + } + }, + } + const backend: AgentExecutionBackend = { + kind: 'uncooperative-stream', + stream: () => events, + } + + const turn = await collectAgentTurn( + streamObservedAgentTurn( + { kind: 'chat', backend }, + { prompt: 'hang after start' }, + { timeoutMs: 25 }, + ), + ) + + expect(turn.status).toBe('failed') + expect(turn.error?.message).toContain('timed out after 25ms') + await Promise.resolve() + expect(returnCalls).toBe(1) + }) }) describe('collectAgentTurn contract', () => { diff --git a/src/runtime/stream-agent-turn.ts b/src/runtime/stream-agent-turn.ts index ba235527..0905dd90 100644 --- a/src/runtime/stream-agent-turn.ts +++ b/src/runtime/stream-agent-turn.ts @@ -67,6 +67,7 @@ import type { RuntimeSession, RuntimeStreamEvent, } from '../types' +import { awaitAbortable } from './retained-run-binding' import { canonicalStreamEventFromSandboxEvent, createSandboxToolPartState, @@ -587,11 +588,15 @@ async function* streamAgentTurnInternal( 'executor-did-not-report', ) } - session = await startTurnSession(backend, task, input, deadline.signal, label) + const startedSession = await awaitAbortable( + Promise.resolve().then(() => startTurnSession(backend, task, input, deadline.signal, label)), + deadline.signal, + ) + session = startedSession yield { type: 'backend_start', task, - session, + session: startedSession, backend: executor?.runtime ?? label, metadata: { ...(profileDigest @@ -613,12 +618,12 @@ async function* streamAgentTurnInternal( const inner = backend.kind === 'chat' - ? driveChatTurn(backend.backend, task, session, input, deadline.signal, acc) + ? driveChatTurn(backend.backend, task, startedSession, input, deadline.signal, acc) : backend.kind === 'executor' ? driveExecutorTurn( executor!, task, - session, + startedSession, input, deadline.signal, acc, @@ -636,7 +641,7 @@ async function* streamAgentTurnInternal( ...(opts.onRawEvent ? { onRawEvent: opts.onRawEvent } : {}), }, ) - for await (const event of inner) { + for await (const event of abortableValues(inner, deadline.signal)) { yield event throwIfAborted(deadline.signal) } @@ -720,7 +725,12 @@ async function* streamAgentTurnInternal( ), ) } finally { - await executor?.teardown('brutalKill').catch(() => undefined) + if (executor) { + await awaitAbortable( + Promise.resolve().then(() => executor!.teardown('brutalKill')), + deadline.signal, + ).catch(() => undefined) + } deadline.dispose() } } @@ -862,8 +872,13 @@ async function* driveBoxTurn( signal, }) const toolParts = cfg.preserveToolParts ? createSandboxToolPartState() : undefined - for await (const event of stream) { - if (cfg.onRawEvent) await cfg.onRawEvent(event) + for await (const event of abortableValues(stream, signal)) { + if (cfg.onRawEvent) { + await awaitAbortable( + Promise.resolve().then(() => cfg.onRawEvent!(event)), + signal, + ) + } const terminalText = terminalTextFromSandboxEvent(event) if (terminalText !== undefined) acc.terminalText = terminalText const canonical = canonicalStreamEventFromSandboxEvent(event) @@ -903,7 +918,7 @@ async function* driveChatTurn( ): AsyncGenerator { const backendInput = turnBackendInput(task, input) const context = { task, knowledge: emptyReadiness(task), session, signal } - for await (const raw of backend.stream(backendInput, context)) { + for await (const raw of abortableValues(backend.stream(backendInput, context), signal)) { const event = normalizeBackendStreamEvent(raw, task, session) foldEvent(event, acc) yield event @@ -923,12 +938,12 @@ async function* driveExecutorTurn( const run = executor.execute(taskValue, signal) let result: ExecutorResult if (isAsyncIterable(run)) { - for await (const _usage of run) { + for await (const _usage of abortableValues(run, signal)) { throwIfAborted(signal) } result = executor.resultArtifact() } else { - result = await run + result = await awaitAbortable(run, signal) } acc.result = result acc.terminalText = executorResultText(result.out) @@ -1009,6 +1024,42 @@ function isAsyncIterable(value: unknown): value is AsyncIterable { return typeof value === 'object' && value !== null && Symbol.asyncIterator in value } +/** + * Pull one provider iterator under Runtime's deadline. + * + * Providers still receive the signal for native cancellation, but correctness + * does not depend on them observing it. A losing iterator is asked to close; + * its late rejection is observed and cannot delay the terminal Runtime event. + */ +async function* abortableValues( + source: AsyncIterable, + signal: AbortSignal, +): AsyncGenerator { + const iterator = source[Symbol.asyncIterator]() + let completed = false + try { + for (;;) { + const next = await awaitAbortable( + Promise.resolve().then(() => iterator.next()), + signal, + ) + if (next.done) { + completed = true + return + } + yield next.value + } + } finally { + if (!completed) { + try { + void Promise.resolve(iterator.return?.()).catch(() => undefined) + } catch { + // The Runtime deadline already owns the observable terminal result. + } + } + } +} + function executorResultText(value: unknown): string { if (typeof value === 'string') return value if (!value || typeof value !== 'object') return '' diff --git a/src/runtime/turn-input.ts b/src/runtime/turn-input.ts index 5ce2c9f8..aef733aa 100644 --- a/src/runtime/turn-input.ts +++ b/src/runtime/turn-input.ts @@ -29,11 +29,7 @@ export function freshTurnInput( ...(input.model === undefined ? {} : { model: input.model }), ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), ...(input.context === undefined ? {} : { context: input.context }), - ...(input.controlRef === undefined ? {} : { controlRef: input.controlRef }), ...(input.interactions === undefined ? {} : { interactions: input.interactions }), - ...(input.nativeContinuation === undefined - ? {} - : { nativeContinuation: input.nativeContinuation }), ...(input.providerOptions === undefined ? {} : { providerOptions: input.providerOptions }), ...(input.signal === undefined ? {} : { signal: input.signal }), turnId: runtime.turnId, From 0c6166e67065ec24cedd0af89969db0de11f300d Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 00:16:18 -0600 Subject: [PATCH 03/16] fix(bench): adapt calls to canonical turn input --- bench/src/benchmarks/appworld.ts | 2 +- bench/src/router-turn.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/bench/src/benchmarks/appworld.ts b/bench/src/benchmarks/appworld.ts index f76d5804..21956dc5 100644 --- a/bench/src/benchmarks/appworld.ts +++ b/bench/src/benchmarks/appworld.ts @@ -440,7 +440,7 @@ export function appworldToolLoopClient(cfg: { const loop = await collectAgentTurn( streamAgentTurn( { kind: 'executor', factory, profile }, - `Task: ${instruction}`, + { prompt: `Task: ${instruction}` }, { signal }, ), ) diff --git a/bench/src/router-turn.ts b/bench/src/router-turn.ts index 6021ec60..6d10dc0b 100644 --- a/bench/src/router-turn.ts +++ b/bench/src/router-turn.ts @@ -7,6 +7,7 @@ import { collectAgentTurn, createExecutor, streamAgentTurn, + type AgentTurnInput, type CollectedAgentTurn, type ToolSpec, } from '@tangle-network/agent-runtime/kernel' @@ -118,10 +119,18 @@ export async function runBenchRouterTurn( routerKey: config.routerKey, ...(config.tools ? { tools: config.tools } : {}), }) + const turnInput: AgentTurnInput = + typeof input === 'string' + ? { prompt: input } + : { + providerOptions: { + messages: input.messages.map((message) => ({ ...message })), + }, + } const turn = await collectAgentTurn( streamAgentTurn( { kind: 'executor', factory, profile: config.profile }, - input, + turnInput, { ...(config.timeoutMs === undefined ? {} : { timeoutMs: config.timeoutMs }), ...(config.signal ? { signal: config.signal } : {}), From d424eea4d3405ad4cb108a24e42d85db4c225312 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 01:41:45 -0600 Subject: [PATCH 04/16] fix(runtime): harden retained interactive controls --- src/errors.ts | 66 +- src/index.ts | 2 + src/runtime/index.ts | 12 + src/runtime/retained-interactive-handle.ts | 134 ++++ src/runtime/retained-interactive-types.ts | 62 ++ src/runtime/retained-interactive.test.ts | 675 +++++++++++++++++++++ src/runtime/retained-interactive.ts | 364 +++++++++++ src/runtime/retained-run-start.ts | 31 +- src/runtime/retained-run-types.ts | 34 +- src/runtime/retained-run.test.ts | 13 + src/runtime/retained-run.ts | 16 + 11 files changed, 1388 insertions(+), 21 deletions(-) create mode 100644 src/runtime/retained-interactive-handle.ts create mode 100644 src/runtime/retained-interactive-types.ts create mode 100644 src/runtime/retained-interactive.test.ts create mode 100644 src/runtime/retained-interactive.ts diff --git a/src/errors.ts b/src/errors.ts index 2cbd740a..b1d10a68 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -22,7 +22,15 @@ */ import { AgentEvalError } from '@tangle-network/agent-eval' -import type { RetainedRunAdmission } from './runtime/retained-run-types' +import type { + AgentInteractiveSessionRef, + AgentInteractiveSessionStart, + AgentInteractiveSessionStatus, +} from '@tangle-network/agent-interface' +import type { + RetainedInteractiveAdmission, + RetainedRunAdmission, +} from './runtime/retained-run-types' export { AgentEvalError, @@ -149,18 +157,20 @@ export class AnalystError extends AgentEvalError { * The caller's `onAdmission` durability hook rejected, so a retained run's * admission record is not durable while provider work may already be live. * Distinct from a provider failure: the provider call succeeded, and the - * environment is intentionally kept so `recoverRetainedRun` (or a provider - * metadata lookup) can rebuild or disprove the run. Carries `capture_integrity` - * because the durable record a later recovery requires was not written. + * environment is intentionally kept so the matching recovery API or a + * provider metadata lookup can rebuild or disprove the run. Carries + * `capture_integrity` because the required recovery record was not written. * * @stable */ -export class RetainedRunAdmissionError extends AgentEvalError { - readonly phase: RetainedRunAdmission['phase'] +abstract class RetainedAdmissionError< + TAdmission extends RetainedRunAdmission | RetainedInteractiveAdmission, +> extends AgentEvalError { + readonly phase: TAdmission['phase'] /** The exact record the hook failed to persist, for direct recovery. */ - readonly admission: RetainedRunAdmission + readonly admission: TAdmission - constructor(admission: RetainedRunAdmission, options?: { cause?: unknown }) { + constructor(admission: TAdmission, options?: { cause?: unknown }) { super( 'capture_integrity', `retained run admission (${admission.phase}) was not persisted; the environment is kept for recovery`, @@ -171,6 +181,46 @@ export class RetainedRunAdmissionError extends AgentEvalError { } } +/** The caller could not persist one detached-run recovery record. @stable */ +export class RetainedRunAdmissionError extends RetainedAdmissionError {} + +/** The caller could not persist one exact interactive-process recovery record. @stable */ +export class RetainedInteractiveAdmissionError extends RetainedAdmissionError {} + +/** + * A provider returned a valid interactive reference that does not bind to the + * exact start request, or returned data that could not be parsed as one. + * + * The requested start and any valid provider reference are detached snapshots. + * Malformed provider data is never copied into the error, so the error remains + * safe to persist while the environment remains available for orphan cleanup. + * + * @stable + */ +export class RetainedInteractiveBindingError extends AgentEvalError { + /** The exact native-process start request sent to the provider. */ + readonly requested: AgentInteractiveSessionStart + /** The valid provider data, when the provider returned a parseable value. */ + readonly returned: { + readonly ref?: AgentInteractiveSessionRef + readonly status?: AgentInteractiveSessionStatus + } + + constructor( + requested: AgentInteractiveSessionStart, + returned: RetainedInteractiveBindingError['returned'], + options?: { cause?: unknown }, + ) { + super( + 'backend_integrity', + 'provider returned interactive data that does not bind to the requested start; the environment is kept for diagnosis and cleanup', + options, + ) + this.requested = Object.freeze(requested) + this.returned = Object.freeze(returned) + } +} + /** * * A retained dispatch answered with coordinates that do not bind to the diff --git a/src/index.ts b/src/index.ts index a042e91d..26c3df40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,6 +102,8 @@ export { JudgeError, NotFoundError, PlannerError, + RetainedInteractiveAdmissionError, + RetainedInteractiveBindingError, RetainedRunAdmissionError, RetainedRunDispatchBindingError, RuntimeRunStateError, diff --git a/src/runtime/index.ts b/src/runtime/index.ts index f9592eb0..256a2ba5 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -347,9 +347,17 @@ export { export { type NativeContextContinuationExecution, type NativeContextContinuationInput, + type ReconnectRetainedInteractiveRunOptions, type ReconnectRetainedRunOptions, + type RecoverRetainedInteractiveRunOptions, type RecoverRetainedRunOptions, type RecoverRetainedRunResult, + type RetainedInteractiveAdmission, + type RetainedInteractiveAdmissionHook, + type RetainedInteractiveEnvironmentAdmission, + type RetainedInteractiveEnvironmentInput, + type RetainedInteractiveRunHandle, + type RetainedInteractiveStartedAdmission, type RetainedRunAdmission, type RetainedRunAdmissionHook, type RetainedRunCancellation, @@ -361,10 +369,14 @@ export { type RetainedRunHandle, type RetainedRunReplayPoint, type RetainedRunSnapshot, + reconnectRetainedInteractiveRun, reconnectRetainedRun, + recoverRetainedInteractiveRun, recoverRetainedRun, + type StartRetainedInteractiveRunOptions, type StartRetainedRunInEnvironmentOptions, type StartRetainedRunOptions, + startRetainedInteractiveRun, startRetainedRun, startRetainedRunInEnvironment, } from './retained-run' diff --git a/src/runtime/retained-interactive-handle.ts b/src/runtime/retained-interactive-handle.ts new file mode 100644 index 00000000..716a0e73 --- /dev/null +++ b/src/runtime/retained-interactive-handle.ts @@ -0,0 +1,134 @@ +import type { + AgentInteractiveSessionRef, + AgentInteractiveSessionStart, + AgentInteractiveSessionStatus, + AgentTerminalSession, +} from '@tangle-network/agent-interface' +import { + AgentInteractiveSessionRefSchema, + AgentInteractiveSessionStatusSchema, + agentInteractiveSessionStatusMatchesRef, + canonicalCandidateDigest, + TerminalReplayWindowSchema, + TerminalSessionRefSchema, +} from '@tangle-network/agent-interface' +import type { + AgentEnvironment, + AgentEnvironmentCapabilities, +} from '@tangle-network/agent-interface/environment-provider' +import { RetainedInteractiveBindingError } from '../errors' +import type { RetainedInteractiveRunHandle } from './retained-interactive-types' +import { awaitAbortable } from './retained-run-binding' +import { detachedSnapshot } from './supervise/snapshot' + +export function createRetainedInteractiveRunHandle( + environment: AgentEnvironment, + inputRef: AgentInteractiveSessionRef, + capabilities: AgentEnvironmentCapabilities, + requestedStart?: AgentInteractiveSessionStart, +): RetainedInteractiveRunHandle { + const ref = freezeInteractiveRef(inputRef) + const source = environment.interactive!(ref) + if (!sameInteractiveRef(source.ref, ref)) { + throw new Error('provider reconstructed a different interactive process') + } + if (!source.sendPrompt) { + throw new Error('provider declared interactive prompt support but exposed no prompt method') + } + return Object.freeze({ + ref, + capabilities, + status: async (options?: { signal?: AbortSignal }) => + exactStatus( + ref, + await awaitAbortable( + Promise.resolve().then(() => source.status(options)), + options?.signal, + ), + requestedStart, + ), + attach: async ( + request?: { cols?: number; rows?: number }, + options?: { signal?: AbortSignal }, + ) => + exactTerminal( + ref, + await awaitAbortable( + Promise.resolve().then(() => source.attach(request, options)), + options?.signal, + ), + ), + sendPrompt: async (prompt: string, options?: { signal?: AbortSignal }) => + await awaitAbortable( + Promise.resolve().then(() => source.sendPrompt!(prompt, options)), + options?.signal, + ), + stop: async (options?: { signal?: AbortSignal }) => + exactStatus( + ref, + await awaitAbortable( + Promise.resolve().then(() => source.stop(options)), + options?.signal, + ), + requestedStart, + ), + }) +} + +export function freezeInteractiveRef( + value: AgentInteractiveSessionRef, +): AgentInteractiveSessionRef { + const ref = AgentInteractiveSessionRefSchema.parse(value) + return Object.freeze({ ...ref, run: Object.freeze({ ...ref.run }) }) +} + +function exactStatus( + ref: AgentInteractiveSessionRef, + value: AgentInteractiveSessionStatus, + requestedStart?: AgentInteractiveSessionStart, +): AgentInteractiveSessionStatus { + let status: AgentInteractiveSessionStatus + try { + status = AgentInteractiveSessionStatusSchema.parse(value) + } catch (error) { + if (requestedStart === undefined) throw error + throw new RetainedInteractiveBindingError( + detachedSnapshot(requestedStart, 'interactive status binding request'), + {}, + { cause: error }, + ) + } + if (!agentInteractiveSessionStatusMatchesRef(ref, status)) { + if (requestedStart !== undefined) { + throw new RetainedInteractiveBindingError( + detachedSnapshot(requestedStart, 'interactive status binding request'), + detachedSnapshot( + { status: detachedSnapshot(status, 'interactive provider status') }, + 'interactive provider status result', + ), + { cause: new Error('provider returned status for another interactive process') }, + ) + } + throw new Error('provider returned status for another interactive process') + } + return status +} + +function exactTerminal( + ref: AgentInteractiveSessionRef, + terminal: AgentTerminalSession, +): AgentTerminalSession { + const terminalRef = TerminalSessionRefSchema.parse(terminal.ref) + TerminalReplayWindowSchema.parse(terminal.cursors) + if (terminalRef.parentExecutionId !== ref.run.executionId) { + throw new Error('provider attached a terminal from another interactive run') + } + return terminal +} + +function sameInteractiveRef( + left: AgentInteractiveSessionRef, + right: AgentInteractiveSessionRef, +): boolean { + return canonicalCandidateDigest(left) === canonicalCandidateDigest(right) +} diff --git a/src/runtime/retained-interactive-types.ts b/src/runtime/retained-interactive-types.ts new file mode 100644 index 00000000..cea8db86 --- /dev/null +++ b/src/runtime/retained-interactive-types.ts @@ -0,0 +1,62 @@ +import type { + AgentInteractiveSession, + AgentInteractiveSessionRef, + AgentProfile, +} from '@tangle-network/agent-interface' +import type { + AgentEnvironmentCapabilities, + AgentEnvironmentProvider, + CreateAgentEnvironmentInput, +} from '@tangle-network/agent-interface/environment-provider' +import type { + RetainedInteractiveAdmission, + RetainedInteractiveEnvironmentAdmission, +} from './retained-run-types' + +/** Environment and exact AgentProfile used to start one native coding-agent process. @stable */ +export type RetainedInteractiveEnvironmentInput = Omit< + CreateAgentEnvironmentInput, + 'idempotencyKey' | 'profile' | 'signal' +> & { + readonly idempotencyKey: string + readonly profile: AgentProfile +} + +/** Start one retry-safe native coding-agent TUI in a new environment. @stable */ +export interface StartRetainedInteractiveRunOptions { + readonly provider: AgentEnvironmentProvider + readonly environment: RetainedInteractiveEnvironmentInput + readonly interactiveIdempotencyKey: string + readonly initialPrompt?: string + readonly cwd?: string + readonly cols?: number + readonly rows?: number + readonly onAdmission: RetainedInteractiveAdmissionHook + readonly signal?: AbortSignal +} + +/** Persist each exact interactive record before the runtime proceeds. @stable */ +export type RetainedInteractiveAdmissionHook = ( + admission: RetainedInteractiveAdmission, +) => Promise + +/** Reconstruct one exact provider-owned native coding-agent process. @stable */ +export interface ReconnectRetainedInteractiveRunOptions { + readonly provider: AgentEnvironmentProvider + readonly ref: AgentInteractiveSessionRef + readonly signal?: AbortSignal +} + +/** Recover a start whose provider response may have been lost. @stable */ +export interface RecoverRetainedInteractiveRunOptions { + readonly provider: AgentEnvironmentProvider + readonly admission: RetainedInteractiveEnvironmentAdmission + readonly onAdmission: RetainedInteractiveAdmissionHook + readonly signal?: AbortSignal +} + +/** Exact interactive process controls plus measured environment capabilities. @stable */ +export interface RetainedInteractiveRunHandle extends AgentInteractiveSession { + readonly capabilities: AgentEnvironmentCapabilities + sendPrompt(prompt: string, options?: { signal?: AbortSignal }): Promise +} diff --git a/src/runtime/retained-interactive.test.ts b/src/runtime/retained-interactive.test.ts new file mode 100644 index 00000000..94ba4ed6 --- /dev/null +++ b/src/runtime/retained-interactive.test.ts @@ -0,0 +1,675 @@ +import { + type AgentInteractiveSession, + type AgentInteractiveSessionRef, + AgentInteractiveSessionRefSchema, + type AgentInteractiveSessionStart, + type AgentProfile, + type AgentTerminalSession, +} from '@tangle-network/agent-interface' +import type { + AgentEnvironment, + AgentEnvironmentCapabilities, + AgentEnvironmentProvider, +} from '@tangle-network/agent-interface/environment-provider' +import { describe, expect, it } from 'vitest' +import { RetainedInteractiveAdmissionError, RetainedInteractiveBindingError } from '../errors' +import { + reconnectRetainedInteractiveRun, + recoverRetainedInteractiveRun, + startRetainedInteractiveRun, +} from './retained-interactive' +import type { RetainedInteractiveAdmission } from './retained-run-types' + +const profile: AgentProfile = { + name: 'Braid product engineer', + harness: 'pi', + model: { + provider: 'tangle-router', + default: 'deepseek/deepseek-v4-pro', + reasoningEffort: 'high', + }, +} + +describe('retained interactive runs', () => { + it('starts one exact native TUI without dispatching a headless turn', async () => { + const fixture = interactiveProvider() + const admissions: RetainedInteractiveAdmission[] = [] + + const handle = await startRetainedInteractiveRun({ + provider: fixture.provider, + environment: { profile, idempotencyKey: 'workspace-1' }, + interactiveIdempotencyKey: 'native-turn-1', + initialPrompt: 'Inspect this workspace.', + cols: 120, + rows: 40, + onAdmission: async (admission) => { + admissions.push(admission) + }, + }) + + expect(fixture.createCalls).toBe(1) + expect(fixture.dispatchCalls).toBe(0) + expect(fixture.processStarts).toBe(1) + expect(admissions.map((admission) => admission.phase)).toEqual([ + 'interactive_environment', + 'interactive_started', + ]) + expect(admissions[0]).toMatchObject({ + phase: 'interactive_environment', + request: { initialPrompt: 'Inspect this workspace.', cols: 120, rows: 40 }, + }) + expect(handle.ref.run.sessionId).toBe('retained-session:workspace-1:native-turn-1') + expect((await handle.status()).state).toBe('running') + await handle.sendPrompt('Run tests.') + expect(fixture.prompts).toEqual(['Run tests.']) + expect((await handle.attach()).ref.parentExecutionId).toBe(handle.ref.run.executionId) + }) + + it('recovers a lost start response by replaying the same process identity', async () => { + const fixture = interactiveProvider({ loseFirstStartResponse: true }) + const admissions: RetainedInteractiveAdmission[] = [] + const onAdmission = async (admission: RetainedInteractiveAdmission): Promise => { + admissions.push(admission) + } + + await expect( + startRetainedInteractiveRun({ + provider: fixture.provider, + environment: { profile, idempotencyKey: 'workspace-loss' }, + interactiveIdempotencyKey: 'native-loss', + onAdmission, + }), + ).rejects.toThrow('start response lost') + const environmentAdmission = admissions.find( + (admission) => admission.phase === 'interactive_environment', + ) + if (environmentAdmission?.phase !== 'interactive_environment') { + throw new Error('expected interactive environment admission') + } + + const recovered = await recoverRetainedInteractiveRun({ + provider: fixture.provider, + admission: environmentAdmission, + onAdmission, + }) + + expect(recovered?.ref.run).toEqual(environmentAdmission.request.run) + expect(fixture.startCalls).toBe(2) + expect(fixture.processStarts).toBe(1) + expect(admissions.at(-1)).toMatchObject({ + phase: 'interactive_started', + ref: { incarnationId: 'incarnation-1' }, + }) + }) + + it('keeps the environment and does not start when its admission cannot persist', async () => { + const fixture = interactiveProvider() + let failure: unknown + + try { + await startRetainedInteractiveRun({ + provider: fixture.provider, + environment: { profile, idempotencyKey: 'workspace-environment-admission' }, + interactiveIdempotencyKey: 'native-environment-admission', + onAdmission: async () => { + throw new Error('journal unavailable') + }, + }) + } catch (error) { + failure = error + } + + expect(failure).toBeInstanceOf(RetainedInteractiveAdmissionError) + expect((failure as RetainedInteractiveAdmissionError).phase).toBe('interactive_environment') + expect(fixture.createCalls).toBe(1) + expect(fixture.startCalls).toBe(0) + expect(fixture.processStarts).toBe(0) + expect(fixture.destroyCalls).toBe(0) + }) + + it('detaches and deeply freezes the exact record before the durability hook sees it', async () => { + const fixture = interactiveProvider() + let failure: unknown + + try { + await startRetainedInteractiveRun({ + provider: fixture.provider, + environment: { profile, idempotencyKey: 'workspace-immutable-admission' }, + interactiveIdempotencyKey: 'native-immutable-admission', + onAdmission: async (admission) => { + if (admission.phase !== 'interactive_environment') return + expect(Object.isFrozen(admission)).toBe(true) + expect(Object.isFrozen(admission.request)).toBe(true) + expect(Object.isFrozen(admission.request.profile)).toBe(true) + expect(Object.isFrozen(admission.request.profile.model)).toBe(true) + ;(admission.request.profile as { name: string }).name = 'mutated by hook' + }, + }) + } catch (error) { + failure = error + } + + expect(failure).toBeInstanceOf(RetainedInteractiveAdmissionError) + const admissionFailure = failure as RetainedInteractiveAdmissionError + expect(admissionFailure.admission).toMatchObject({ + phase: 'interactive_environment', + request: { profile: { name: 'Braid product engineer' } }, + }) + expect(Object.isFrozen(admissionFailure.admission)).toBe(true) + if (admissionFailure.admission.phase === 'interactive_environment') { + expect(Object.isFrozen(admissionFailure.admission.request.profile)).toBe(true) + } + expect(fixture.startCalls).toBe(0) + expect(fixture.processStarts).toBe(0) + }) + + it('keeps and recovers one process when its started admission cannot persist', async () => { + const fixture = interactiveProvider() + const admissions: RetainedInteractiveAdmission[] = [] + let failure: unknown + + try { + await startRetainedInteractiveRun({ + provider: fixture.provider, + environment: { profile, idempotencyKey: 'workspace-started-admission' }, + interactiveIdempotencyKey: 'native-started-admission', + onAdmission: async (admission) => { + admissions.push(admission) + if (admission.phase === 'interactive_started') { + throw new Error('journal unavailable') + } + }, + }) + } catch (error) { + failure = error + } + + expect(failure).toBeInstanceOf(RetainedInteractiveAdmissionError) + expect((failure as RetainedInteractiveAdmissionError).phase).toBe('interactive_started') + expect(fixture.processStarts).toBe(1) + expect(fixture.destroyCalls).toBe(0) + const environmentAdmission = admissions.find( + (admission) => admission.phase === 'interactive_environment', + ) + if (environmentAdmission?.phase !== 'interactive_environment') { + throw new Error('expected interactive environment admission') + } + + const recoveredAdmissions: RetainedInteractiveAdmission[] = [] + const recovered = await recoverRetainedInteractiveRun({ + provider: fixture.provider, + admission: environmentAdmission, + onAdmission: async (admission) => { + recoveredAdmissions.push(admission) + }, + }) + + expect(recovered?.ref.run).toEqual(environmentAdmission.request.run) + expect(fixture.startCalls).toBe(2) + expect(fixture.processStarts).toBe(1) + expect(recoveredAdmissions).toHaveLength(1) + expect(recoveredAdmissions[0]).toMatchObject({ + phase: 'interactive_started', + ref: { incarnationId: 'incarnation-1' }, + }) + }) + + it('rejects corrupted recovery coordinates before contacting the process', async () => { + const fixture = interactiveProvider() + const admissions: RetainedInteractiveAdmission[] = [] + await startRetainedInteractiveRun({ + provider: fixture.provider, + environment: { profile, idempotencyKey: 'workspace-recovery-binding' }, + interactiveIdempotencyKey: 'native-recovery-binding', + onAdmission: async (admission) => { + admissions.push(admission) + }, + }) + const environmentAdmission = admissions.find( + (admission) => admission.phase === 'interactive_environment', + ) + if (environmentAdmission?.phase !== 'interactive_environment') { + throw new Error('expected interactive environment admission') + } + const startsBeforeRecovery = fixture.startCalls + + await expect( + recoverRetainedInteractiveRun({ + provider: fixture.provider, + admission: { ...environmentAdmission, environmentId: 'sandbox-other' }, + onAdmission: async () => {}, + }), + ).rejects.toThrow('does not match its recovery coordinates') + await expect( + recoverRetainedInteractiveRun({ + provider: fixture.provider, + admission: { + ...environmentAdmission, + interactiveIdempotencyKey: 'native-other', + }, + onAdmission: async () => {}, + }), + ).rejects.toThrow('does not match its recovery coordinates') + expect(fixture.startCalls).toBe(startsBeforeRecovery) + }) + + it('reconnects only after the provider proves the exact incarnation', async () => { + const fixture = interactiveProvider() + const handle = await start(fixture.provider) + const reconnected = await reconnectRetainedInteractiveRun({ + provider: fixture.provider, + ref: handle.ref, + }) + expect(reconnected?.ref).toEqual(handle.ref) + + fixture.statusRef = { ...handle.ref, incarnationId: 'replacement-incarnation' } + let statusFailure: unknown + try { + await handle.status() + } catch (error) { + statusFailure = error + } + expect(statusFailure).toBeInstanceOf(RetainedInteractiveBindingError) + const statusBinding = statusFailure as RetainedInteractiveBindingError + expect(statusBinding.returned.status?.ref.incarnationId).toBe('replacement-incarnation') + expect(Object.isFrozen(statusBinding.returned)).toBe(true) + expect(Object.isFrozen(statusBinding.returned.status)).toBe(true) + await expect( + reconnectRetainedInteractiveRun({ provider: fixture.provider, ref: handle.ref }), + ).rejects.toThrow('status for another interactive process') + }) + + it('rejects provider substitution and a terminal from another execution', async () => { + const wrongStart = interactiveProvider({ returnWrongRun: true }) + let bindingFailure: unknown + try { + await start(wrongStart.provider) + } catch (error) { + bindingFailure = error + } + expect(bindingFailure).toBeInstanceOf(RetainedInteractiveBindingError) + const binding = bindingFailure as RetainedInteractiveBindingError + expect(binding.requested).toMatchObject({ profile }) + expect(binding.returned.ref?.run.runId).toBe(`${binding.requested.run.runId}-other`) + expect(Object.isFrozen(binding.requested)).toBe(true) + expect(Object.isFrozen(binding.requested.profile)).toBe(true) + expect(Object.isFrozen(binding.returned)).toBe(true) + expect(Object.isFrozen(binding.returned.ref)).toBe(true) + expect(wrongStart.destroyCalls).toBe(0) + + const wrongTerminal = interactiveProvider({ returnWrongTerminal: true }) + const handle = await start(wrongTerminal.provider) + await expect(handle.attach()).rejects.toThrow( + 'attached a terminal from another interactive run', + ) + }) + + it('does not copy malformed provider start data or destroy the environment', async () => { + const malformed = interactiveProvider({ returnMalformedRef: true }) + let failure: unknown + try { + await start(malformed.provider) + } catch (error) { + failure = error + } + + expect(failure).toBeInstanceOf(RetainedInteractiveBindingError) + const binding = failure as RetainedInteractiveBindingError + expect(binding.returned).toEqual({}) + expect(binding.requested.profile).toEqual(profile) + expect(malformed.destroyCalls).toBe(0) + }) + + it.each(['capabilities', 'create', 'start'] as const)( + 'cancels a hanging provider %s call', + async (hangAt) => { + const fixture = interactiveProvider({ hangAt }) + const controller = new AbortController() + const pending = start(fixture.provider, controller.signal) + await waitForHangingCall(fixture, 1) + controller.abort(`cancel ${hangAt}`) + + await expect(pending).rejects.toMatchObject({ + name: 'AbortError', + message: `cancel ${hangAt}`, + }) + expect(fixture.hangingCalls).toBe(1) + }, + ) + + it.each(['status', 'attach', 'sendPrompt', 'stop'] as const)( + 'cancels a hanging interactive %s call', + async (hangAt) => { + const fixture = interactiveProvider({ hangAt }) + const handle = await start(fixture.provider) + const controller = new AbortController() + const pending = + hangAt === 'status' + ? handle.status({ signal: controller.signal }) + : hangAt === 'attach' + ? handle.attach(undefined, { signal: controller.signal }) + : hangAt === 'sendPrompt' + ? handle.sendPrompt('continue', { signal: controller.signal }) + : handle.stop({ signal: controller.signal }) + await waitForHangingCall(fixture, 1) + controller.abort(`cancel ${hangAt}`) + + await expect(pending).rejects.toMatchObject({ + name: 'AbortError', + message: `cancel ${hangAt}`, + }) + expect(fixture.hangingCalls).toBe(1) + }, + ) + + it('cancels hanging provider lookup during recovery and reconnect', async () => { + const fixture = interactiveProvider() + const admissions: RetainedInteractiveAdmission[] = [] + const handle = await startRetainedInteractiveRun({ + provider: fixture.provider, + environment: { profile, idempotencyKey: 'workspace-lookup-cancel' }, + interactiveIdempotencyKey: 'native-lookup-cancel', + onAdmission: async (admission) => { + admissions.push(admission) + }, + }) + const admission = admissions.find((candidate) => candidate.phase === 'interactive_environment') + if (admission?.phase !== 'interactive_environment') { + throw new Error('expected interactive environment admission') + } + fixture.hangAt = 'get' + + const recoverController = new AbortController() + const recovering = recoverRetainedInteractiveRun({ + provider: fixture.provider, + admission, + onAdmission: async () => {}, + signal: recoverController.signal, + }) + await waitForHangingCall(fixture, 1) + recoverController.abort('cancel recover') + await expect(recovering).rejects.toMatchObject({ + name: 'AbortError', + message: 'cancel recover', + }) + + const reconnectController = new AbortController() + const reconnecting = reconnectRetainedInteractiveRun({ + provider: fixture.provider, + ref: handle.ref, + signal: reconnectController.signal, + }) + await waitForHangingCall(fixture, 2) + reconnectController.abort('cancel reconnect') + await expect(reconnecting).rejects.toMatchObject({ + name: 'AbortError', + message: 'cancel reconnect', + }) + expect(fixture.hangingCalls).toBe(2) + }) + + it('fails before allocation when capability or caller authority is absent', async () => { + const incomplete = interactiveProvider({ completeCapabilities: false }) + await expect(start(incomplete.provider)).rejects.toThrow( + 'cannot control an exact interactive agent', + ) + expect(incomplete.createCalls).toBe(0) + + const aborted = interactiveProvider() + const controller = new AbortController() + controller.abort(new Error('caller stopped')) + await expect(start(aborted.provider, controller.signal)).rejects.toThrow('caller stopped') + expect(aborted.createCalls).toBe(0) + }) +}) + +async function start(provider: AgentEnvironmentProvider, signal?: AbortSignal) { + return startRetainedInteractiveRun({ + provider, + environment: { profile, idempotencyKey: 'workspace-default' }, + interactiveIdempotencyKey: 'native-default', + onAdmission: async () => {}, + signal, + }) +} + +interface ProviderFixture { + readonly provider: AgentEnvironmentProvider + readonly prompts: string[] + readonly createCalls: number + readonly dispatchCalls: number + readonly startCalls: number + readonly processStarts: number + readonly destroyCalls: number + readonly hangingCalls: number + hangAt?: HangPoint + statusRef?: AgentInteractiveSessionRef +} + +type HangPoint = + | 'capabilities' + | 'create' + | 'get' + | 'start' + | 'status' + | 'attach' + | 'sendPrompt' + | 'stop' + +function interactiveProvider( + options: { + completeCapabilities?: boolean + hangAt?: HangPoint + loseFirstStartResponse?: boolean + returnMalformedRef?: boolean + returnWrongRun?: boolean + returnWrongTerminal?: boolean + } = {}, +): ProviderFixture { + const fixture = { + prompts: [] as string[], + createCalls: 0, + dispatchCalls: 0, + startCalls: 0, + processStarts: 0, + destroyCalls: 0, + hangingCalls: 0, + statusRef: undefined as AgentInteractiveSessionRef | undefined, + } + let hangAt = options.hangAt + let ref: AgentInteractiveSessionRef | undefined + let lost = false + const terminal = (): AgentTerminalSession => ({ + ref: { + terminalSessionId: 'terminal-1', + parentExecutionId: options.returnWrongTerminal ? 'another-execution' : ref!.run.executionId, + name: 'pi', + shell: '/bin/sh', + command: 'pi', + cwd: '/workspace', + cols: 120, + rows: 40, + createdAt: '2026-08-16T00:00:00.000Z', + lastActivityAt: '2026-08-16T00:00:00.000Z', + expiresAt: '2026-08-17T00:00:00.000Z', + isRunning: true, + attachCount: 1, + }, + cursors: { earliest: 0, latest: 0 }, + input: async () => {}, + resize: async () => {}, + detach: async () => ({ status: 'detached', terminalSessionId: 'terminal-1' }), + close: async () => ({ status: 'closed', terminalSessionId: 'terminal-1' }), + async *events() {}, + }) + const session = (): AgentInteractiveSession => ({ + ref: ref!, + status: async () => { + if (hangAt === 'status') { + fixture.hangingCalls += 1 + return neverPending() + } + return { state: 'running' as const, ref: fixture.statusRef ?? ref! } + }, + attach: async () => { + if (hangAt === 'attach') { + fixture.hangingCalls += 1 + return neverPending() + } + return terminal() + }, + sendPrompt: async (prompt: string) => { + if (hangAt === 'sendPrompt') { + fixture.hangingCalls += 1 + return neverPending() + } + fixture.prompts.push(prompt) + }, + stop: async () => { + if (hangAt === 'stop') { + fixture.hangingCalls += 1 + return neverPending() + } + return { + state: 'exited' as const, + ref: fixture.statusRef ?? ref!, + endedAt: '2026-08-16T01:00:00.000Z', + reason: 'stopped' as const, + } + }, + }) + const environment: AgentEnvironment = { + id: 'sandbox-1', + provider: 'test-provider', + status: async () => 'running', + async *stream() {}, + dispatch: async () => { + fixture.dispatchCalls += 1 + throw new Error('headless dispatch must not run') + }, + startInteractive: async (request: AgentInteractiveSessionStart) => { + if (hangAt === 'start') { + fixture.hangingCalls += 1 + return neverPending() + } + fixture.startCalls += 1 + if (!ref) { + fixture.processStarts += 1 + const run = options.returnWrongRun + ? { ...request.run, runId: `${request.run.runId}-other` } + : request.run + ref = AgentInteractiveSessionRefSchema.parse({ + run, + requestedProfileDigest: request.requestedProfileDigest, + admittedProfileDigest: request.requestedProfileDigest, + incarnationId: 'incarnation-1', + harness: request.profile.harness, + startedAt: '2026-08-16T00:00:00.000Z', + }) + } + if (options.returnMalformedRef) return 42 as unknown as AgentInteractiveSessionRef + if (options.loseFirstStartResponse && !lost) { + lost = true + throw new Error('start response lost') + } + return ref + }, + interactive: () => session(), + destroy: async () => { + fixture.destroyCalls += 1 + }, + } + const provider: AgentEnvironmentProvider = { + name: 'test-provider', + capabilities: async () => { + if (hangAt === 'capabilities') { + fixture.hangingCalls += 1 + return neverPending() + } + return interactiveCapabilities(options.completeCapabilities !== false) + }, + create: async () => { + if (hangAt === 'create') { + fixture.hangingCalls += 1 + return neverPending() + } + fixture.createCalls += 1 + return environment + }, + get: async (id) => { + if (hangAt === 'get') { + fixture.hangingCalls += 1 + return neverPending() + } + return id === environment.id ? environment : null + }, + } + return Object.defineProperties( + { provider, prompts: fixture.prompts }, + { + createCalls: { get: () => fixture.createCalls }, + dispatchCalls: { get: () => fixture.dispatchCalls }, + startCalls: { get: () => fixture.startCalls }, + processStarts: { get: () => fixture.processStarts }, + destroyCalls: { get: () => fixture.destroyCalls }, + hangingCalls: { get: () => fixture.hangingCalls }, + hangAt: { + get: () => hangAt, + set: (value: HangPoint | undefined) => { + hangAt = value + }, + }, + statusRef: { + get: () => fixture.statusRef, + set: (value: AgentInteractiveSessionRef | undefined) => { + fixture.statusRef = value + }, + }, + }, + ) as ProviderFixture +} + +function neverPending(): Promise { + return new Promise(() => {}) +} + +async function waitForHangingCall(fixture: ProviderFixture, expected: number): Promise { + for (let attempt = 0; attempt < 20 && fixture.hangingCalls < expected; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + expect(fixture.hangingCalls).toBeGreaterThanOrEqual(expected) +} + +function interactiveCapabilities(complete: boolean): AgentEnvironmentCapabilities { + return { + profile: { + namedProfiles: true, + systemPrompt: { replace: true, append: true }, + instructions: true, + tools: true, + permissions: true, + mcp: true, + subagents: true, + resources: { files: true, instructions: true }, + runtimeUpdate: true, + validation: true, + }, + streaming: { live: true, replay: true, detach: true, turnIdempotency: true }, + sessions: { continue: true, list: true, messages: true }, + workspace: { read: true, write: true, exec: true, git: true, upload: true, download: true }, + branching: { checkpoint: true, fork: true }, + placement: true, + usage: true, + confidential: false, + interactiveAgent: { + start: true, + status: true, + attach: true, + reattach: complete, + sendPrompt: true, + input: true, + resize: true, + stop: true, + }, + } +} diff --git a/src/runtime/retained-interactive.ts b/src/runtime/retained-interactive.ts new file mode 100644 index 00000000..32efb878 --- /dev/null +++ b/src/runtime/retained-interactive.ts @@ -0,0 +1,364 @@ +import type { + AgentInteractiveSessionRef, + AgentInteractiveSessionStart, +} from '@tangle-network/agent-interface' +import { + AgentEnvironmentCapabilitiesSchema, + AgentInteractiveSessionRefSchema, + agentInteractiveSessionRefMatchesStart, + agentInteractiveSessionRunRef, + agentProfileSchema, + canonicalAgentProfileDigest, + exactAgentInteractiveSessionStart, +} from '@tangle-network/agent-interface' +import type { + AgentEnvironment, + AgentEnvironmentCapabilities, +} from '@tangle-network/agent-interface/environment-provider' +import { RetainedInteractiveBindingError } from '../errors' +import { + createRetainedInteractiveRunHandle, + freezeInteractiveRef, +} from './retained-interactive-handle' +import type { + ReconnectRetainedInteractiveRunOptions, + RecoverRetainedInteractiveRunOptions, + RetainedInteractiveRunHandle, + StartRetainedInteractiveRunOptions, +} from './retained-interactive-types' +import { assertStableText, awaitAbortable } from './retained-run-binding' +import { admitDurably, mintRetainedIdentity } from './retained-run-start' +import { detachedSnapshot } from './supervise/snapshot' + +/** Start one retry-safe native coding-agent TUI without dispatching a headless turn. @stable */ +export async function startRetainedInteractiveRun( + options: StartRetainedInteractiveRunOptions, +): Promise { + options.signal?.throwIfAborted() + assertStableText(options.environment.idempotencyKey, 'environment idempotency key') + assertStableText(options.interactiveIdempotencyKey, 'interactive idempotency key') + if (typeof options.onAdmission !== 'function') { + throw new Error('startRetainedInteractiveRun requires an awaited onAdmission durability hook') + } + if (!options.provider.get) { + throw new Error(`provider "${options.provider.name}" cannot reconstruct an environment by id`) + } + const profile = agentProfileSchema.parse(options.environment.profile) + if (profile.harness === undefined) { + throw new Error('retained interactive runs require AgentProfile.harness') + } + const requestedProfileDigest = canonicalAgentProfileDigest(profile) + const identity = mintRetainedIdentity( + options.environment.idempotencyKey, + options.interactiveIdempotencyKey, + ) + const providerCapabilities = AgentEnvironmentCapabilitiesSchema.parse( + await awaitAbortable( + Promise.resolve().then(() => options.provider.capabilities()), + options.signal, + ), + ) + assertInteractiveCapabilities(options.provider.name, providerCapabilities) + + const environment = await awaitAbortable( + Promise.resolve().then(() => + options.provider.create({ + ...options.environment, + profile, + signal: options.signal, + metadata: { + ...options.environment.metadata, + retainedIdempotencyKey: options.environment.idempotencyKey, + interactiveIdempotencyKey: options.interactiveIdempotencyKey, + requestedProfileDigest, + sessionId: identity.sessionId, + executionId: identity.executionId, + }, + }), + ), + options.signal, + ) + let capabilities: AgentEnvironmentCapabilities + try { + assertExactEnvironment(options.provider.name, environment) + options.signal?.throwIfAborted() + capabilities = interactiveCapabilitiesForEnvironment( + options.provider.name, + providerCapabilities, + environment, + ) + assertInteractiveMethods(options.provider.name, environment) + } catch (error) { + await destroyUnusedEnvironment(environment, error, options.signal) + throw error + } + + const request = interactiveRequest( + options, + environment, + profile, + requestedProfileDigest, + identity, + ) + await admitDurably(options.onAdmission, { + phase: 'interactive_environment', + provider: options.provider.name, + environmentId: environment.id, + idempotencyKey: options.environment.idempotencyKey, + interactiveIdempotencyKey: options.interactiveIdempotencyKey, + request, + }) + + const ref = exactStartedRef( + request, + await awaitAbortable( + Promise.resolve().then(() => + environment.startInteractive!(request, { + signal: options.signal, + }), + ), + options.signal, + ), + ) + await admitDurably(options.onAdmission, { + phase: 'interactive_started', + idempotencyKey: options.environment.idempotencyKey, + interactiveIdempotencyKey: options.interactiveIdempotencyKey, + ref, + }) + return createRetainedInteractiveRunHandle(environment, ref, capabilities, request) +} + +/** Retry one exact start after its provider response may have been lost. @stable */ +export async function recoverRetainedInteractiveRun( + options: RecoverRetainedInteractiveRunOptions, +): Promise { + options.signal?.throwIfAborted() + if (typeof options.onAdmission !== 'function') { + throw new Error('recoverRetainedInteractiveRun requires an awaited onAdmission durability hook') + } + const admission = options.admission + if (admission.provider !== options.provider.name) { + throw new Error('interactive admission belongs to another provider') + } + const request = exactRecoveryRequest(admission) + const { environment, capabilities } = await reconstructEnvironment( + options.provider, + admission.environmentId, + options.signal, + ) + if (!environment || !capabilities) return null + const ref = exactStartedRef( + request, + await awaitAbortable( + Promise.resolve().then(() => + environment.startInteractive!(request, { + signal: options.signal, + }), + ), + options.signal, + ), + ) + await admitDurably(options.onAdmission, { + phase: 'interactive_started', + idempotencyKey: admission.idempotencyKey, + interactiveIdempotencyKey: admission.interactiveIdempotencyKey, + ref, + }) + return createRetainedInteractiveRunHandle(environment, ref, capabilities, request) +} + +function exactRecoveryRequest( + admission: RecoverRetainedInteractiveRunOptions['admission'], +): AgentInteractiveSessionStart { + assertStableText(admission.environmentId, 'interactive environment id') + assertStableText(admission.idempotencyKey, 'environment idempotency key') + assertStableText(admission.interactiveIdempotencyKey, 'interactive idempotency key') + const request = exactAgentInteractiveSessionStart(admission.request) + const identity = mintRetainedIdentity( + admission.idempotencyKey, + admission.interactiveIdempotencyKey, + ) + if ( + request.run.provider !== admission.provider || + request.run.environmentId !== admission.environmentId || + request.run.sessionId !== identity.sessionId || + request.run.executionId !== identity.executionId + ) { + throw new Error('interactive admission does not match its recovery coordinates') + } + return request +} + +/** Rebuild controls for one exact provider-owned coding-agent process. @stable */ +export async function reconnectRetainedInteractiveRun( + options: ReconnectRetainedInteractiveRunOptions, +): Promise { + options.signal?.throwIfAborted() + const ref = AgentInteractiveSessionRefSchema.parse(options.ref) + if (ref.run.provider !== options.provider.name) { + throw new Error('interactive session reference belongs to another provider') + } + const { environment, capabilities } = await reconstructEnvironment( + options.provider, + ref.run.environmentId, + options.signal, + ) + if (!environment || !capabilities) return null + const handle = createRetainedInteractiveRunHandle(environment, ref, capabilities) + await handle.status({ signal: options.signal }) + return handle +} + +function interactiveRequest( + options: StartRetainedInteractiveRunOptions, + environment: AgentEnvironment, + profile: StartRetainedInteractiveRunOptions['environment']['profile'], + requestedProfileDigest: `sha256:${string}`, + identity: { readonly sessionId: string; readonly executionId: string }, +): AgentInteractiveSessionStart { + const start = { + profile, + requestedProfileDigest, + ...(options.initialPrompt === undefined ? {} : { initialPrompt: options.initialPrompt }), + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + ...(options.cols === undefined ? {} : { cols: options.cols }), + ...(options.rows === undefined ? {} : { rows: options.rows }), + } + const run = agentInteractiveSessionRunRef( + { + provider: options.provider.name, + environmentId: environment.id, + ...identity, + }, + start, + ) + return exactAgentInteractiveSessionStart({ run, ...start }) +} + +async function reconstructEnvironment( + provider: ReconnectRetainedInteractiveRunOptions['provider'], + environmentId: string, + signal?: AbortSignal, +): Promise<{ + environment: AgentEnvironment | null + capabilities: AgentEnvironmentCapabilities | null +}> { + if (!provider.get) { + throw new Error(`provider "${provider.name}" cannot reconstruct an environment by id`) + } + const providerCapabilities = AgentEnvironmentCapabilitiesSchema.parse( + await awaitAbortable( + Promise.resolve().then(() => provider.capabilities()), + signal, + ), + ) + assertInteractiveCapabilities(provider.name, providerCapabilities) + const environment = await awaitAbortable( + Promise.resolve().then(() => provider.get!(environmentId, { signal })), + signal, + ) + if (!environment) return { environment: null, capabilities: null } + assertExactEnvironment(provider.name, environment, environmentId) + const capabilities = interactiveCapabilitiesForEnvironment( + provider.name, + providerCapabilities, + environment, + ) + assertInteractiveMethods(provider.name, environment) + return { environment, capabilities } +} + +function exactStartedRef( + request: AgentInteractiveSessionStart, + value: AgentInteractiveSessionRef, +): AgentInteractiveSessionRef { + const stableRequest = detachedSnapshot(request, 'interactive start binding request') + let ref: AgentInteractiveSessionRef + try { + ref = AgentInteractiveSessionRefSchema.parse(value) + } catch (error) { + throw new RetainedInteractiveBindingError(stableRequest, {}, { cause: error }) + } + if (!agentInteractiveSessionRefMatchesStart(request, ref)) { + throw new RetainedInteractiveBindingError( + stableRequest, + detachedSnapshot( + { ref: detachedSnapshot(ref, 'interactive provider start reference') }, + 'interactive provider start result', + ), + { cause: new Error('provider started another interactive run than requested') }, + ) + } + return freezeInteractiveRef(ref) +} + +function assertExactEnvironment( + providerName: string, + environment: AgentEnvironment, + environmentId?: string, +): void { + if ( + environment.provider !== providerName || + (environmentId && environment.id !== environmentId) + ) { + throw new Error('provider returned another interactive environment') + } + assertStableText(environment.id, 'interactive environment id') +} + +function interactiveCapabilitiesForEnvironment( + providerName: string, + providerCapabilities: AgentEnvironmentCapabilities, + environment: AgentEnvironment, +): AgentEnvironmentCapabilities { + const capabilities = + environment.capabilities === undefined + ? providerCapabilities + : AgentEnvironmentCapabilitiesSchema.parse(environment.capabilities) + assertInteractiveCapabilities(providerName, capabilities) + return capabilities +} + +function assertInteractiveCapabilities( + providerName: string, + capabilities: AgentEnvironmentCapabilities, +): void { + const interactive = capabilities.interactiveAgent + if ( + !interactive?.start || + !interactive.status || + !interactive.attach || + !interactive.reattach || + !interactive.sendPrompt || + !interactive.input || + !interactive.resize || + !interactive.stop + ) { + throw new Error(`provider "${providerName}" cannot control an exact interactive agent`) + } +} + +function assertInteractiveMethods(providerName: string, environment: AgentEnvironment): void { + if (!environment.startInteractive || !environment.interactive) { + throw new Error(`provider "${providerName}" exposes incomplete interactive agent controls`) + } +} + +async function destroyUnusedEnvironment( + environment: AgentEnvironment, + cause: unknown, + signal?: AbortSignal, +): Promise { + try { + await awaitAbortable( + Promise.resolve().then(() => environment.destroy?.({ signal })), + signal, + ) + } catch (cleanupError) { + throw new AggregateError( + [cause, cleanupError], + 'interactive environment was invalid and could not be destroyed', + ) + } +} diff --git a/src/runtime/retained-run-start.ts b/src/runtime/retained-run-start.ts index 272b2df1..dc87d571 100644 --- a/src/runtime/retained-run-start.ts +++ b/src/runtime/retained-run-start.ts @@ -8,7 +8,11 @@ import type { AgentEnvironmentProvider, AgentSession, } from '@tangle-network/agent-interface/environment-provider' -import { RetainedRunAdmissionError, RetainedRunDispatchBindingError } from '../errors' +import { + RetainedInteractiveAdmissionError, + RetainedRunAdmissionError, + RetainedRunDispatchBindingError, +} from '../errors' import { assertRequestedInteractionCapabilities } from './interaction-capabilities' import { assertStableText, @@ -21,12 +25,14 @@ import type { ReconnectRetainedRunOptions, RecoverRetainedRunOptions, RecoverRetainedRunResult, + RetainedInteractiveAdmission, RetainedRunAdmission, RetainedRunAdmissionHook, RetainedRunHandle, StartRetainedRunInEnvironmentOptions, StartRetainedRunOptions, } from './retained-run-types' +import { detachedSnapshot } from './supervise/snapshot' import { freshTurnInput } from './turn-input' /** @@ -339,17 +345,26 @@ async function dispatchRetainedRun( * record is the recovery path — and surfaces as `RetainedRunAdmissionError` so * callers can distinguish a persistence failure from a provider failure. */ -async function admitDurably( - onAdmission: RetainedRunAdmissionHook, - admission: RetainedRunAdmission, -): Promise { +export async function admitDurably< + TAdmission extends RetainedRunAdmission | RetainedInteractiveAdmission, +>(onAdmission: (admission: TAdmission) => Promise, admission: TAdmission): Promise { + const stableAdmission = detachedSnapshot(admission, 'retained run admission') try { - await onAdmission(Object.freeze(admission)) + await onAdmission(stableAdmission) } catch (error) { - throw new RetainedRunAdmissionError(admission, { cause: error }) + if (isInteractiveAdmission(stableAdmission)) { + throw new RetainedInteractiveAdmissionError(stableAdmission, { cause: error }) + } + throw new RetainedRunAdmissionError(stableAdmission, { cause: error }) } } +function isInteractiveAdmission( + admission: RetainedRunAdmission | RetainedInteractiveAdmission, +): admission is RetainedInteractiveAdmission { + return admission.phase === 'interactive_environment' || admission.phase === 'interactive_started' +} + /** * Rebuild the exact run named by pre-dispatch admission coordinates, or * report why the provider cannot prove it. @@ -464,7 +479,7 @@ export async function assertRetainedCapabilities( * the provider document because that provider claims the same guarantee for * every environment it creates. */ -function retainedCapabilitiesForEnvironment( +export function retainedCapabilitiesForEnvironment( providerName: string, providerCapabilities: AgentEnvironmentCapabilities, environment: AgentEnvironment, diff --git a/src/runtime/retained-run-types.ts b/src/runtime/retained-run-types.ts index 24a1ba07..29197150 100644 --- a/src/runtime/retained-run-types.ts +++ b/src/runtime/retained-run-types.ts @@ -1,5 +1,7 @@ import type { AgentExactRunControlRef, + AgentInteractiveSessionRef, + AgentInteractiveSessionStart, AgentNativeContextContinuationOptions, AgentNativeContextContinuationResult, AgentSessionStatus, @@ -112,16 +114,38 @@ export interface RetainedRunDispatchedAdmission { readonly turnId: string } -/** One admission record the runtime persists through the caller before proceeding. @stable */ +/** Exact interactive start request durable before provider work begins. @stable */ +export interface RetainedInteractiveEnvironmentAdmission { + readonly phase: 'interactive_environment' + readonly provider: string + readonly environmentId: string + readonly idempotencyKey: string + readonly interactiveIdempotencyKey: string + readonly request: AgentInteractiveSessionStart +} + +/** Provider-issued interactive process reference durable before start returns. @stable */ +export interface RetainedInteractiveStartedAdmission { + readonly phase: 'interactive_started' + readonly idempotencyKey: string + readonly interactiveIdempotencyKey: string + readonly ref: AgentInteractiveSessionRef +} + +/** Durable records for one exact native coding-agent process. @stable */ +export type RetainedInteractiveAdmission = + | RetainedInteractiveEnvironmentAdmission + | RetainedInteractiveStartedAdmission + +/** One detached-run admission record the runtime persists before dispatch proceeds. @stable */ export type RetainedRunAdmission = RetainedRunEnvironmentAdmission | RetainedRunDispatchedAdmission /** * Awaited durability hook for retained admission records. * - * The runtime blocks after environment creation and again after dispatch until - * the hook resolves, so no retained run becomes caller-visible before its - * recovery record is durable. A rejection fails the start without destroying - * the environment; the persisted record or provider state is the recovery path. + * The runtime blocks after environment creation and after provider work until + * the hook resolves. No retained run becomes caller-visible before its exact + * recovery record is durable. A rejection keeps the environment for recovery. * * @stable */ diff --git a/src/runtime/retained-run.test.ts b/src/runtime/retained-run.test.ts index 3da9cbd6..8f54803b 100644 --- a/src/runtime/retained-run.test.ts +++ b/src/runtime/retained-run.test.ts @@ -44,11 +44,24 @@ function recordedAdmissions(): { return { admissions, onAdmission: async (admission) => { + assertDetachedAdmissionPhase(admission) admissions.push(admission) }, } } +function assertDetachedAdmissionPhase(admission: RetainedRunAdmission): void { + switch (admission.phase) { + case 'environment': + case 'dispatched': + return + default: { + const exhaustive: never = admission + throw new Error(`unexpected detached admission: ${JSON.stringify(exhaustive)}`) + } + } +} + interface ChildExit { readonly code: number | null readonly signal: NodeJS.Signals | null diff --git a/src/runtime/retained-run.ts b/src/runtime/retained-run.ts index 1fff575f..54f077d3 100644 --- a/src/runtime/retained-run.ts +++ b/src/runtime/retained-run.ts @@ -5,6 +5,19 @@ * startup, replay, binding checks, and handle operations. */ +export { + reconnectRetainedInteractiveRun, + recoverRetainedInteractiveRun, + startRetainedInteractiveRun, +} from './retained-interactive' +export type { + ReconnectRetainedInteractiveRunOptions, + RecoverRetainedInteractiveRunOptions, + RetainedInteractiveAdmissionHook, + RetainedInteractiveEnvironmentInput, + RetainedInteractiveRunHandle, + StartRetainedInteractiveRunOptions, +} from './retained-interactive-types' export { reconnectRetainedRun, recoverRetainedRun, @@ -17,6 +30,9 @@ export type { ReconnectRetainedRunOptions, RecoverRetainedRunOptions, RecoverRetainedRunResult, + RetainedInteractiveAdmission, + RetainedInteractiveEnvironmentAdmission, + RetainedInteractiveStartedAdmission, RetainedRunAdmission, RetainedRunAdmissionHook, RetainedRunCancellation, From 7af4eb4770629f14239d234f60726215a7a5bf80 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 02:07:55 -0600 Subject: [PATCH 05/16] fix(runtime): admit interactive intent before create --- docs/canonical-api.md | 1 + src/errors.ts | 15 +- src/runtime/index.ts | 2 + src/runtime/retained-interactive-types.ts | 19 ++- src/runtime/retained-interactive.test.ts | 180 +++++++++++++++++++++- src/runtime/retained-interactive.ts | 157 ++++++++++++++++++- src/runtime/retained-run-start.ts | 6 +- src/runtime/retained-run-types.ts | 29 +++- src/runtime/retained-run.ts | 2 + 9 files changed, 390 insertions(+), 21 deletions(-) diff --git a/docs/canonical-api.md b/docs/canonical-api.md index c9579188..0b9c1faa 100644 --- a/docs/canonical-api.md +++ b/docs/canonical-api.md @@ -145,6 +145,7 @@ A general "loop" primitive is the single most common modelling error in this rep | Run a **recursive `supervise()` tree** through an agent-eval profile matrix | `superviseDispatch({ toTask, toSuperviseOptions, ... })`: `/kernel`; it admits the tree through Eval before Runtime spends, then records its receipt only when Runtime proves one model. Mixed or unknown trees fail instead of being relabelled. | a Lab receipt mapper, a second scheduler, or attaching a completed `SupervisedResult` after paid work already ran | | Run + **resume** ONE persistent box across turns | `openSandboxRun(client, opts, deliverable)`: `/kernel` | a per-domain `new Sandbox`+`box.fs.read`+delete copy | | Start a retry-safe detached run in a new environment, or a fresh harness chat in one existing environment | `startRetainedRun(...)` or `startRetainedRunInEnvironment(...)`: `/kernel`; both persist exact coordinates before and after dispatch; the existing-environment path also verifies its retained key through provider metadata; only `continueNative(...)` may claim same-chat continuity | calling `provider.create/get/dispatch` directly, reusing an environment as proof of chat continuity, or appending to an unverified native session | +| Start, recover, or reconnect one native coding-agent TUI | `startRetainedInteractiveRun(...)`, `recoverRetainedInteractiveRun(...)`, or `reconnectRetainedInteractiveRun(...)`: `/kernel`; Runtime persists a sanitized interactive intent before environment creation, exact environment coordinates before process start, and the server-issued process incarnation before returning | calling `environment.startInteractive` directly, replacing the process with a generic shell, or treating a detached headless turn as attachable | | Run **ONE agent turn** on any substrate: box (`streamPrompt`), cli-bridge/router `Executor`, or in-process chat backend: as ONE normalized `RuntimeStreamEvent` stream with a guaranteed terminal result+usage event; pass the shared `AgentTurnInput` so text, image, file, and provider parts stay intact; canonical Sandbox events win over legacy projections, while unknown provider payloads remain observer-only | `streamAgentTurn(backend, agentTurnInput, { signal, timeoutMs, preserveToolParts?, onRawEvent? })` + `collectAgentTurn(stream)`: `/kernel` | a second string/messages turn contract, a per-provider stream→event mapper zoo, a hand-faked box around a non-box executor, or raw fetch leaking through the turn abstraction | | Adapt a Sandbox box to the neutral environment/session contract, or expose a neutral provider to existing Sandbox callers | `sandboxClientAsProvider(client)` / `providerAsSandboxClient(provider)`: `/kernel`; dispatch/reconnect carries the exact `executionId` and run-control reference, every session operation scopes to that execution, and detached interaction requests require declared kind support plus replay and response idempotency | reconstructing control coordinates from metadata, forwarding an unscoped cancel, dispatching unsupported durable interactions, or emitting arbitrary provider payloads into the public stream | | Use an exact profile and Runtime executor where `runAgentTaskStream` or a conversation expects an `AgentExecutionBackend` | `createProfileExecutionBackend({ profile, executor: createExecutor(config) })`: root `.`; the adapter preserves conversation authorization, recursion-depth, and trace headers | a provider-specific backend constructor or an adapter that reads a second model/prompt configuration | diff --git a/src/errors.ts b/src/errors.ts index b1d10a68..60d46d31 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -155,11 +155,10 @@ export class AnalystError extends AgentEvalError { /** * * The caller's `onAdmission` durability hook rejected, so a retained run's - * admission record is not durable while provider work may already be live. - * Distinct from a provider failure: the provider call succeeded, and the - * environment is intentionally kept so the matching recovery API or a - * provider metadata lookup can rebuild or disprove the run. Carries - * `capture_integrity` because the required recovery record was not written. + * admission record is not durable. For a pre-create intent, no provider work + * has started. For a later record, provider state may already be live and the + * environment remains available for recovery. Carries `capture_integrity` + * because the required recovery record was not written. * * @stable */ @@ -171,9 +170,13 @@ abstract class RetainedAdmissionError< readonly admission: TAdmission constructor(admission: TAdmission, options?: { cause?: unknown }) { + const recovery = + admission.phase === 'interactive_intent' + ? 'no provider work has started' + : 'the environment is kept for recovery' super( 'capture_integrity', - `retained run admission (${admission.phase}) was not persisted; the environment is kept for recovery`, + `retained run admission (${admission.phase}) was not persisted; ${recovery}`, options, ) this.phase = admission.phase diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 256a2ba5..43209f80 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -356,8 +356,10 @@ export { type RetainedInteractiveAdmissionHook, type RetainedInteractiveEnvironmentAdmission, type RetainedInteractiveEnvironmentInput, + type RetainedInteractiveIntentAdmission, type RetainedInteractiveRunHandle, type RetainedInteractiveStartedAdmission, + type RetainedInteractiveStartMaterial, type RetainedRunAdmission, type RetainedRunAdmissionHook, type RetainedRunCancellation, diff --git a/src/runtime/retained-interactive-types.ts b/src/runtime/retained-interactive-types.ts index cea8db86..b7f19cf5 100644 --- a/src/runtime/retained-interactive-types.ts +++ b/src/runtime/retained-interactive-types.ts @@ -11,6 +11,7 @@ import type { import type { RetainedInteractiveAdmission, RetainedInteractiveEnvironmentAdmission, + RetainedInteractiveIntentAdmission, } from './retained-run-types' /** Environment and exact AgentProfile used to start one native coding-agent process. @stable */ @@ -22,15 +23,21 @@ export type RetainedInteractiveEnvironmentInput = Omit< readonly profile: AgentProfile } -/** Start one retry-safe native coding-agent TUI in a new environment. @stable */ -export interface StartRetainedInteractiveRunOptions { - readonly provider: AgentEnvironmentProvider +/** Material used to create and start one native coding-agent TUI. @stable */ +export interface RetainedInteractiveStartMaterial { readonly environment: RetainedInteractiveEnvironmentInput readonly interactiveIdempotencyKey: string readonly initialPrompt?: string readonly cwd?: string readonly cols?: number readonly rows?: number +} + +/** Start one retry-safe native coding-agent TUI in a new environment. @stable */ +export interface StartRetainedInteractiveRunOptions extends RetainedInteractiveStartMaterial { + readonly provider: AgentEnvironmentProvider + /** A previously persisted intent used to replay the exact create operation. */ + readonly intent?: RetainedInteractiveIntentAdmission readonly onAdmission: RetainedInteractiveAdmissionHook readonly signal?: AbortSignal } @@ -47,10 +54,12 @@ export interface ReconnectRetainedInteractiveRunOptions { readonly signal?: AbortSignal } -/** Recover a start whose provider response may have been lost. @stable */ +/** Recover a start after a pre-create crash or a lost provider response. @stable */ export interface RecoverRetainedInteractiveRunOptions { readonly provider: AgentEnvironmentProvider - readonly admission: RetainedInteractiveEnvironmentAdmission + readonly admission: RetainedInteractiveIntentAdmission | RetainedInteractiveEnvironmentAdmission + /** Required when recovering from an intent before an environment existed. */ + readonly replay?: RetainedInteractiveStartMaterial readonly onAdmission: RetainedInteractiveAdmissionHook readonly signal?: AbortSignal } diff --git a/src/runtime/retained-interactive.test.ts b/src/runtime/retained-interactive.test.ts index 94ba4ed6..83e2010f 100644 --- a/src/runtime/retained-interactive.test.ts +++ b/src/runtime/retained-interactive.test.ts @@ -51,10 +51,22 @@ describe('retained interactive runs', () => { expect(fixture.dispatchCalls).toBe(0) expect(fixture.processStarts).toBe(1) expect(admissions.map((admission) => admission.phase)).toEqual([ + 'interactive_intent', 'interactive_environment', 'interactive_started', ]) expect(admissions[0]).toMatchObject({ + phase: 'interactive_intent', + provider: 'test-provider', + idempotencyKey: 'workspace-1', + interactiveIdempotencyKey: 'native-turn-1', + sessionId: 'retained-session:workspace-1:native-turn-1', + executionId: 'retained-execution:workspace-1:native-turn-1', + runId: expect.stringMatching(/^interactive-intent-run:/u), + requestedProfileDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u), + requestDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u), + }) + expect(admissions[1]).toMatchObject({ phase: 'interactive_environment', request: { initialPrompt: 'Inspect this workspace.', cols: 120, rows: 40 }, }) @@ -65,6 +77,158 @@ describe('retained interactive runs', () => { expect((await handle.attach()).ref.parentExecutionId).toBe(handle.ref.run.executionId) }) + it('replays a persisted intent after a crash before environment create', async () => { + const fixture = interactiveProvider() + const admissions: RetainedInteractiveAdmission[] = [] + let crash = true + const onAdmission = async (admission: RetainedInteractiveAdmission): Promise => { + admissions.push(admission) + if (crash && admission.phase === 'interactive_intent') { + throw new Error('simulated crash after intent') + } + } + + await expect( + startRetainedInteractiveRun({ + provider: fixture.provider, + environment: { + profile, + idempotencyKey: 'workspace-intent-crash', + env: { API_TOKEN: 'secret-value' }, + secrets: ['sandbox-secret'], + providerOptions: { credential: 'provider-secret' }, + }, + interactiveIdempotencyKey: 'native-intent-crash', + initialPrompt: 'Inspect this workspace.', + onAdmission, + }), + ).rejects.toMatchObject({ phase: 'interactive_intent' }) + + const intent = admissions.find((admission) => admission.phase === 'interactive_intent') + if (intent?.phase !== 'interactive_intent') { + throw new Error('expected interactive intent admission') + } + expect(fixture.createCalls).toBe(0) + expect(fixture.environmentCreations).toBe(0) + expect(fixture.processStarts).toBe(0) + expect(JSON.stringify(intent)).not.toContain('secret-value') + expect(JSON.stringify(intent)).not.toContain('sandbox-secret') + expect(JSON.stringify(intent)).not.toContain('provider-secret') + + crash = false + const recovered = await recoverRetainedInteractiveRun({ + provider: fixture.provider, + admission: intent, + replay: { + environment: { + profile, + idempotencyKey: 'workspace-intent-crash', + env: { API_TOKEN: 'secret-value' }, + secrets: ['sandbox-secret'], + providerOptions: { credential: 'provider-secret' }, + }, + interactiveIdempotencyKey: 'native-intent-crash', + initialPrompt: 'Inspect this workspace.', + }, + onAdmission, + }) + + expect(recovered?.ref.run.sessionId).toBe( + 'retained-session:workspace-intent-crash:native-intent-crash', + ) + expect(fixture.createCalls).toBe(1) + expect(fixture.environmentCreations).toBe(1) + expect(fixture.processStarts).toBe(1) + expect(admissions.map((admission) => admission.phase)).toEqual([ + 'interactive_intent', + 'interactive_environment', + 'interactive_started', + ]) + }) + + it('reuses one environment after a crash after create but before environment admission', async () => { + const fixture = interactiveProvider() + const admissions: RetainedInteractiveAdmission[] = [] + let crash = true + const onAdmission = async (admission: RetainedInteractiveAdmission): Promise => { + admissions.push(admission) + if (crash && admission.phase === 'interactive_environment') { + throw new Error('simulated crash after create') + } + } + + await expect( + startRetainedInteractiveRun({ + provider: fixture.provider, + environment: { profile, idempotencyKey: 'workspace-create-crash' }, + interactiveIdempotencyKey: 'native-create-crash', + onAdmission, + }), + ).rejects.toMatchObject({ phase: 'interactive_environment' }) + + const intent = admissions.find((admission) => admission.phase === 'interactive_intent') + if (intent?.phase !== 'interactive_intent') { + throw new Error('expected interactive intent admission') + } + expect(fixture.createCalls).toBe(1) + expect(fixture.environmentCreations).toBe(1) + expect(fixture.processStarts).toBe(0) + + crash = false + const recovered = await recoverRetainedInteractiveRun({ + provider: fixture.provider, + admission: intent, + replay: { + environment: { profile, idempotencyKey: 'workspace-create-crash' }, + interactiveIdempotencyKey: 'native-create-crash', + }, + onAdmission, + }) + + expect(recovered).toBeDefined() + expect(fixture.createCalls).toBe(2) + expect(fixture.environmentCreations).toBe(1) + expect(fixture.processStarts).toBe(1) + expect(admissions.filter((admission) => admission.phase === 'interactive_intent')).toHaveLength( + 1, + ) + }) + + it('rejects changed replay material before provider create', async () => { + const fixture = interactiveProvider() + const admissions: RetainedInteractiveAdmission[] = [] + await startRetainedInteractiveRun({ + provider: fixture.provider, + environment: { profile, idempotencyKey: 'workspace-replay-conflict' }, + interactiveIdempotencyKey: 'native-replay-conflict', + initialPrompt: 'Original request.', + onAdmission: async (admission) => { + admissions.push(admission) + }, + }) + const intent = admissions.find((admission) => admission.phase === 'interactive_intent') + if (intent?.phase !== 'interactive_intent') { + throw new Error('expected interactive intent admission') + } + const createCallsBeforeReplay = fixture.createCalls + const processStartsBeforeReplay = fixture.processStarts + + await expect( + recoverRetainedInteractiveRun({ + provider: fixture.provider, + admission: intent, + replay: { + environment: { profile, idempotencyKey: 'workspace-replay-conflict' }, + interactiveIdempotencyKey: 'native-replay-conflict', + initialPrompt: 'Changed request.', + }, + onAdmission: async () => {}, + }), + ).rejects.toThrow('interactive intent conflicts with replay material') + expect(fixture.createCalls).toBe(createCallsBeforeReplay) + expect(fixture.processStarts).toBe(processStartsBeforeReplay) + }) + it('recovers a lost start response by replaying the same process identity', async () => { const fixture = interactiveProvider({ loseFirstStartResponse: true }) const admissions: RetainedInteractiveAdmission[] = [] @@ -111,8 +275,10 @@ describe('retained interactive runs', () => { provider: fixture.provider, environment: { profile, idempotencyKey: 'workspace-environment-admission' }, interactiveIdempotencyKey: 'native-environment-admission', - onAdmission: async () => { - throw new Error('journal unavailable') + onAdmission: async (admission) => { + if (admission.phase === 'interactive_environment') { + throw new Error('journal unavailable') + } }, }) } catch (error) { @@ -437,6 +603,7 @@ interface ProviderFixture { readonly provider: AgentEnvironmentProvider readonly prompts: string[] readonly createCalls: number + readonly environmentCreations: number readonly dispatchCalls: number readonly startCalls: number readonly processStarts: number @@ -469,6 +636,7 @@ function interactiveProvider( const fixture = { prompts: [] as string[], createCalls: 0, + environmentCreations: 0, dispatchCalls: 0, startCalls: 0, processStarts: 0, @@ -476,6 +644,7 @@ function interactiveProvider( hangingCalls: 0, statusRef: undefined as AgentInteractiveSessionRef | undefined, } + const environmentKeys = new Set() let hangAt = options.hangAt let ref: AgentInteractiveSessionRef | undefined let lost = false @@ -588,12 +757,16 @@ function interactiveProvider( } return interactiveCapabilities(options.completeCapabilities !== false) }, - create: async () => { + create: async (input) => { if (hangAt === 'create') { fixture.hangingCalls += 1 return neverPending() } fixture.createCalls += 1 + if (input.idempotencyKey !== undefined && !environmentKeys.has(input.idempotencyKey)) { + environmentKeys.add(input.idempotencyKey) + fixture.environmentCreations += 1 + } return environment }, get: async (id) => { @@ -608,6 +781,7 @@ function interactiveProvider( { provider, prompts: fixture.prompts }, { createCalls: { get: () => fixture.createCalls }, + environmentCreations: { get: () => fixture.environmentCreations }, dispatchCalls: { get: () => fixture.dispatchCalls }, startCalls: { get: () => fixture.startCalls }, processStarts: { get: () => fixture.processStarts }, diff --git a/src/runtime/retained-interactive.ts b/src/runtime/retained-interactive.ts index 32efb878..388cd0bb 100644 --- a/src/runtime/retained-interactive.ts +++ b/src/runtime/retained-interactive.ts @@ -1,6 +1,7 @@ import type { AgentInteractiveSessionRef, AgentInteractiveSessionStart, + Sha256Digest, } from '@tangle-network/agent-interface' import { AgentEnvironmentCapabilitiesSchema, @@ -9,6 +10,7 @@ import { agentInteractiveSessionRunRef, agentProfileSchema, canonicalAgentProfileDigest, + canonicalCandidateDigest, exactAgentInteractiveSessionStart, } from '@tangle-network/agent-interface' import type { @@ -24,13 +26,23 @@ import type { ReconnectRetainedInteractiveRunOptions, RecoverRetainedInteractiveRunOptions, RetainedInteractiveRunHandle, + RetainedInteractiveStartMaterial, StartRetainedInteractiveRunOptions, } from './retained-interactive-types' import { assertStableText, awaitAbortable } from './retained-run-binding' import { admitDurably, mintRetainedIdentity } from './retained-run-start' +import type { + RetainedInteractiveEnvironmentAdmission, + RetainedInteractiveIntentAdmission, +} from './retained-run-types' import { detachedSnapshot } from './supervise/snapshot' -/** Start one retry-safe native coding-agent TUI without dispatching a headless turn. @stable */ +/** + * Start one retry-safe native coding-agent TUI without dispatching a headless turn. + * The intent admission is durable before provider.create; the environment and + * process admissions follow only after their exact provider coordinates exist. + * @stable + */ export async function startRetainedInteractiveRun( options: StartRetainedInteractiveRunOptions, ): Promise { @@ -52,6 +64,14 @@ export async function startRetainedInteractiveRun( options.environment.idempotencyKey, options.interactiveIdempotencyKey, ) + const intent = interactiveIntent(options, profile, identity) + if (options.intent === undefined) { + // This is the only admission that can be written before provider.create. + // A replay supplies the same record and therefore skips a duplicate write. + await admitDurably(options.onAdmission, intent) + } else { + assertExactInteractiveIntent(options.intent, intent) + } const providerCapabilities = AgentEnvironmentCapabilitiesSchema.parse( await awaitAbortable( Promise.resolve().then(() => options.provider.capabilities()), @@ -71,6 +91,8 @@ export async function startRetainedInteractiveRun( retainedIdempotencyKey: options.environment.idempotencyKey, interactiveIdempotencyKey: options.interactiveIdempotencyKey, requestedProfileDigest, + interactiveIntentDigest: intent.requestDigest, + interactiveRunId: intent.runId, sessionId: identity.sessionId, executionId: identity.executionId, }, @@ -141,6 +163,20 @@ export async function recoverRetainedInteractiveRun( if (admission.provider !== options.provider.name) { throw new Error('interactive admission belongs to another provider') } + if (admission.phase === 'interactive_intent') { + if (options.replay === undefined) { + throw new Error( + 'recoverRetainedInteractiveRun requires the original start material for an interactive intent', + ) + } + return startRetainedInteractiveRun({ + provider: options.provider, + ...options.replay, + intent: admission, + onAdmission: options.onAdmission, + signal: options.signal, + }) + } const request = exactRecoveryRequest(admission) const { environment, capabilities } = await reconstructEnvironment( options.provider, @@ -169,7 +205,7 @@ export async function recoverRetainedInteractiveRun( } function exactRecoveryRequest( - admission: RecoverRetainedInteractiveRunOptions['admission'], + admission: RetainedInteractiveEnvironmentAdmission, ): AgentInteractiveSessionStart { assertStableText(admission.environmentId, 'interactive environment id') assertStableText(admission.idempotencyKey, 'environment idempotency key') @@ -236,6 +272,123 @@ function interactiveRequest( return exactAgentInteractiveSessionStart({ run, ...start }) } +function interactiveIntent( + options: StartRetainedInteractiveRunOptions, + profile: StartRetainedInteractiveRunOptions['environment']['profile'], + identity: { readonly sessionId: string; readonly executionId: string }, +): RetainedInteractiveIntentAdmission { + const requestDigest = canonicalCandidateDigest({ + kind: 'retained-interactive-intent.v1', + provider: options.provider.name, + idempotencyKey: options.environment.idempotencyKey, + interactiveIdempotencyKey: options.interactiveIdempotencyKey, + sessionId: identity.sessionId, + executionId: identity.executionId, + requestedProfileDigest: canonicalAgentProfileDigest(profile), + create: sanitizedCreateMaterial(options.environment), + start: sanitizedStartMaterial(options), + }) + return { + phase: 'interactive_intent', + provider: options.provider.name, + idempotencyKey: options.environment.idempotencyKey, + interactiveIdempotencyKey: options.interactiveIdempotencyKey, + sessionId: identity.sessionId, + executionId: identity.executionId, + runId: `interactive-intent-run:${requestDigest.slice('sha256:'.length)}`, + requestedProfileDigest: canonicalAgentProfileDigest(profile), + requestDigest, + } +} + +function sanitizedCreateMaterial( + environment: StartRetainedInteractiveRunOptions['environment'], +): Record { + return { + ...(environment.backend === undefined ? {} : { backend: environment.backend }), + ...(environment.workspace === undefined + ? {} + : { workspaceDigest: canonicalCandidateDigest(environment.workspace) }), + ...(environment.resources === undefined + ? {} + : { resourcesDigest: canonicalCandidateDigest(environment.resources) }), + ...(environment.name === undefined ? {} : { name: environment.name }), + ...(environment.env === undefined + ? {} + : { envDigest: canonicalCandidateDigest(environment.env) }), + ...(environment.secrets === undefined + ? {} + : { secretsDigest: canonicalCandidateDigest(environment.secrets) }), + ...(environment.metadata === undefined + ? {} + : { metadataDigest: canonicalCandidateDigest(environment.metadata) }), + ...(environment.providerOptions === undefined + ? {} + : { providerOptionsDigest: canonicalCandidateDigest(environment.providerOptions) }), + } +} + +function sanitizedStartMaterial( + options: RetainedInteractiveStartMaterial, +): Record { + return { + ...(options.initialPrompt === undefined ? {} : { initialPrompt: options.initialPrompt }), + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + ...(options.cols === undefined ? {} : { cols: options.cols }), + ...(options.rows === undefined ? {} : { rows: options.rows }), + } +} + +function assertExactInteractiveIntent( + received: RetainedInteractiveIntentAdmission, + expected: RetainedInteractiveIntentAdmission, +): void { + const stableReceived = parseInteractiveIntent(received) + if (canonicalCandidateDigest(stableReceived) !== canonicalCandidateDigest(expected)) { + throw new Error('interactive intent conflicts with replay material') + } +} + +function parseInteractiveIntent(value: unknown): RetainedInteractiveIntentAdmission { + const stable = detachedSnapshot(value, 'interactive intent') + if (stable === null || typeof stable !== 'object' || Array.isArray(stable)) { + throw new Error('interactive intent is malformed') + } + const record = stable as Record + const allowed = new Set([ + 'phase', + 'provider', + 'idempotencyKey', + 'interactiveIdempotencyKey', + 'sessionId', + 'executionId', + 'runId', + 'requestedProfileDigest', + 'requestDigest', + ]) + if (Object.keys(record).some((key) => !allowed.has(key))) { + throw new Error('interactive intent contains unsupported material') + } + if (record.phase !== 'interactive_intent') { + throw new Error('interactive intent has an invalid phase') + } + for (const [key, value] of Object.entries(record)) { + if (key === 'phase') continue + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`interactive intent field "${key}" is invalid`) + } + } + assertDigest(record.requestedProfileDigest, 'interactive intent profile digest') + assertDigest(record.requestDigest, 'interactive intent request digest') + return stable as RetainedInteractiveIntentAdmission +} + +function assertDigest(value: unknown, label: string): asserts value is Sha256Digest { + if (typeof value !== 'string' || !/^sha256:[a-f0-9]{64}$/u.test(value)) { + throw new Error(`${label} is invalid`) + } +} + async function reconstructEnvironment( provider: ReconnectRetainedInteractiveRunOptions['provider'], environmentId: string, diff --git a/src/runtime/retained-run-start.ts b/src/runtime/retained-run-start.ts index dc87d571..7a2c9916 100644 --- a/src/runtime/retained-run-start.ts +++ b/src/runtime/retained-run-start.ts @@ -362,7 +362,11 @@ export async function admitDurably< function isInteractiveAdmission( admission: RetainedRunAdmission | RetainedInteractiveAdmission, ): admission is RetainedInteractiveAdmission { - return admission.phase === 'interactive_environment' || admission.phase === 'interactive_started' + return ( + admission.phase === 'interactive_intent' || + admission.phase === 'interactive_environment' || + admission.phase === 'interactive_started' + ) } /** diff --git a/src/runtime/retained-run-types.ts b/src/runtime/retained-run-types.ts index 29197150..6e54f0f9 100644 --- a/src/runtime/retained-run-types.ts +++ b/src/runtime/retained-run-types.ts @@ -114,7 +114,26 @@ export interface RetainedRunDispatchedAdmission { readonly turnId: string } -/** Exact interactive start request durable before provider work begins. @stable */ +/** + * Sanitized intent durable before an interactive environment create begins. + * + * The digest covers the exact start and create material without retaining that + * material. It never carries environment variables, secrets, or provider options. + * @stable + */ +export interface RetainedInteractiveIntentAdmission { + readonly phase: 'interactive_intent' + readonly provider: string + readonly idempotencyKey: string + readonly interactiveIdempotencyKey: string + readonly sessionId: string + readonly executionId: string + readonly runId: string + readonly requestedProfileDigest: Sha256Digest + readonly requestDigest: Sha256Digest +} + +/** Exact interactive start request durable after environment creation. @stable */ export interface RetainedInteractiveEnvironmentAdmission { readonly phase: 'interactive_environment' readonly provider: string @@ -134,6 +153,7 @@ export interface RetainedInteractiveStartedAdmission { /** Durable records for one exact native coding-agent process. @stable */ export type RetainedInteractiveAdmission = + | RetainedInteractiveIntentAdmission | RetainedInteractiveEnvironmentAdmission | RetainedInteractiveStartedAdmission @@ -143,9 +163,10 @@ export type RetainedRunAdmission = RetainedRunEnvironmentAdmission | RetainedRun /** * Awaited durability hook for retained admission records. * - * The runtime blocks after environment creation and after provider work until - * the hook resolves. No retained run becomes caller-visible before its exact - * recovery record is durable. A rejection keeps the environment for recovery. + * The runtime blocks after the pre-create intent, environment creation, and + * provider work until the hook resolves. No retained run becomes caller-visible + * before its exact recovery record is durable. A rejection keeps provider state + * for recovery when provider work has already started. * * @stable */ diff --git a/src/runtime/retained-run.ts b/src/runtime/retained-run.ts index 54f077d3..28fce61b 100644 --- a/src/runtime/retained-run.ts +++ b/src/runtime/retained-run.ts @@ -16,6 +16,7 @@ export type { RetainedInteractiveAdmissionHook, RetainedInteractiveEnvironmentInput, RetainedInteractiveRunHandle, + RetainedInteractiveStartMaterial, StartRetainedInteractiveRunOptions, } from './retained-interactive-types' export { @@ -32,6 +33,7 @@ export type { RecoverRetainedRunResult, RetainedInteractiveAdmission, RetainedInteractiveEnvironmentAdmission, + RetainedInteractiveIntentAdmission, RetainedInteractiveStartedAdmission, RetainedRunAdmission, RetainedRunAdmissionHook, From 2ce7bed523c6e20ee103a20d8d812bcd61a51e66 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 02:26:54 -0600 Subject: [PATCH 06/16] feat(runtime): adopt interface 0.56 interactive controls --- docs/api/agent.md | 2 +- docs/api/index.md | 162 +++++++- docs/api/intelligence.md | 14 +- docs/api/mcp.md | 2 +- docs/api/primitive-catalog.md | 26 +- docs/api/runtime.md | 455 ++++++++++++++++++++- docs/api/testing.md | 8 +- docs/api/tui.md | 2 +- docs/canonical-api.md | 4 +- package.json | 4 +- pnpm-lock.yaml | 83 ++-- pnpm-workspace.yaml | 8 +- src/runtime/retained-interactive-handle.ts | 164 +++++++- src/runtime/retained-interactive-types.ts | 7 +- src/runtime/retained-interactive.test.ts | 194 +++++++-- src/runtime/retained-interactive.ts | 1 + 16 files changed, 1005 insertions(+), 131 deletions(-) diff --git a/docs/api/agent.md b/docs/api/agent.md index b78b8578..03db69b8 100644 --- a/docs/api/agent.md +++ b/docs/api/agent.md @@ -591,7 +591,7 @@ substantive prompt rewrites, etc.) via this callback. ##### allowCreateForKinds? -> `optional` **allowCreateForKinds?**: readonly (`"code"` \| `"mcp"` \| `"memory"` \| `"agent-profile"` \| `"rollout-policy"` \| `"knowledge.wiki"` \| `"knowledge.claim"` \| `"knowledge.raw"` \| `"knowledge.stale"` \| `"system-prompt"` \| `"skill"` \| `"tool-doc"` \| `"new-tool"` \| `"hook"` \| `"subagent"` \| `"workflow"` \| `"rag"` \| `"scaffolding"` \| `"output-schema"` \| `"websearch.outdated"` \| `"prior-run-summary"` \| `"cluster"`)[] +> `optional` **allowCreateForKinds?**: readonly (`"mcp"` \| `"code"` \| `"memory"` \| `"agent-profile"` \| `"rollout-policy"` \| `"knowledge.wiki"` \| `"knowledge.claim"` \| `"knowledge.raw"` \| `"knowledge.stale"` \| `"system-prompt"` \| `"skill"` \| `"tool-doc"` \| `"new-tool"` \| `"hook"` \| `"subagent"` \| `"workflow"` \| `"rag"` \| `"scaffolding"` \| `"output-schema"` \| `"websearch.outdated"` \| `"prior-run-summary"` \| `"cluster"`)[] When the resolved target doesn't exist, allow the substrate to CREATE the file (for `knowledge.wiki`, `new-tool` subjects). Default diff --git a/docs/api/index.md b/docs/api/index.md index 710bc324..85498428 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1050,16 +1050,11 @@ default move, or the loop silently runs a topology nobody chose. **`Stable`** -The caller's `onAdmission` durability hook rejected, so a retained run's -admission record is not durable while provider work may already be live. -Distinct from a provider failure: the provider call succeeded, and the -environment is intentionally kept so `recoverRetainedRun` (or a provider -metadata lookup) can rebuild or disprove the run. Carries `capture_integrity` -because the durable record a later recovery requires was not written. +The caller could not persist one detached-run recovery record. #### Extends -- `AgentEvalError` +- `RetainedAdmissionError`\<[`RetainedRunAdmission`](runtime.md#retainedrunadmission)\> #### Constructors @@ -1083,9 +1078,9 @@ because the durable record a later recovery requires was not written. [`RetainedRunAdmissionError`](#retainedrunadmissionerror) -###### Overrides +###### Inherited from -`AgentEvalError.constructor` +`RetainedAdmissionError.constructor` #### Properties @@ -1093,12 +1088,151 @@ because the durable record a later recovery requires was not written. > `readonly` **phase**: `"environment"` \| `"dispatched"` +###### Inherited from + +`RetainedAdmissionError.phase` + ##### admission > `readonly` **admission**: [`RetainedRunAdmission`](runtime.md#retainedrunadmission) The exact record the hook failed to persist, for direct recovery. +###### Inherited from + +`RetainedAdmissionError.admission` + +*** + +### RetainedInteractiveAdmissionError + +**`Stable`** + +The caller could not persist one exact interactive-process recovery record. + +#### Extends + +- `RetainedAdmissionError`\<[`RetainedInteractiveAdmission`](runtime.md#retainedinteractiveadmission)\> + +#### Constructors + +##### Constructor + +> **new RetainedInteractiveAdmissionError**(`admission`, `options?`): [`RetainedInteractiveAdmissionError`](#retainedinteractiveadmissionerror) + +###### Parameters + +###### admission + +[`RetainedInteractiveAdmission`](runtime.md#retainedinteractiveadmission) + +###### options? + +###### cause? + +`unknown` + +###### Returns + +[`RetainedInteractiveAdmissionError`](#retainedinteractiveadmissionerror) + +###### Inherited from + +`RetainedAdmissionError.constructor` + +#### Properties + +##### phase + +> `readonly` **phase**: `"interactive_intent"` \| `"interactive_environment"` \| `"interactive_started"` + +###### Inherited from + +`RetainedAdmissionError.phase` + +##### admission + +> `readonly` **admission**: [`RetainedInteractiveAdmission`](runtime.md#retainedinteractiveadmission) + +The exact record the hook failed to persist, for direct recovery. + +###### Inherited from + +`RetainedAdmissionError.admission` + +*** + +### RetainedInteractiveBindingError + +**`Stable`** + +A provider returned a valid interactive reference that does not bind to the +exact start request, or returned data that could not be parsed as one. + +The requested start and any valid provider reference are detached snapshots. +Malformed provider data is never copied into the error, so the error remains +safe to persist while the environment remains available for orphan cleanup. + +#### Extends + +- `AgentEvalError` + +#### Constructors + +##### Constructor + +> **new RetainedInteractiveBindingError**(`requested`, `returned`, `options?`): [`RetainedInteractiveBindingError`](#retainedinteractivebindingerror) + +###### Parameters + +###### requested + +###### returned + +###### ref? + +\{ \} + +###### status? + +\{ \} \| \{ \} \| \{ \} + +###### options? + +###### cause? + +`unknown` + +###### Returns + +[`RetainedInteractiveBindingError`](#retainedinteractivebindingerror) + +###### Overrides + +`AgentEvalError.constructor` + +#### Properties + +##### requested + +> `readonly` **requested**: `object` + +The exact native-process start request sent to the provider. + +##### returned + +> `readonly` **returned**: `object` + +The valid provider data, when the provider returned a parseable value. + +###### ref? + +> `readonly` `optional` **ref?**: `object` + +###### status? + +> `readonly` `optional` **status?**: \{ \} \| \{ \} \| \{ \} + *** ### RetainedRunDispatchBindingError @@ -1504,7 +1638,7 @@ Immutable signed identity stored for one execution attempt. ##### retryPolicy -> `readonly` **retryPolicy**: `"pre-model-infrastructure-only"` \| `"none"` +> `readonly` **retryPolicy**: `"none"` \| `"pre-model-infrastructure-only"` ##### bundleDigest @@ -1963,7 +2097,7 @@ Provider-neutral model request resolved before any grant is reserved. ###### reasoningEffort -> **reasoningEffort**: `"medium"` \| `"high"` \| `"low"` \| `"none"` \| `"minimal"` \| `"xhigh"` \| `"ultracode"` \| `undefined` +> **reasoningEffort**: `"medium"` \| `"none"` \| `"minimal"` \| `"low"` \| `"high"` \| `"xhigh"` \| `"ultracode"` \| `undefined` ##### reserve @@ -2170,7 +2304,7 @@ Catalog/snapshot resolution stays separate from credential issuance. ###### reasoningEffort -`"medium"` \| `"high"` \| `"low"` \| `"none"` \| `"minimal"` \| `"xhigh"` \| `"ultracode"` \| `undefined` +`"medium"` \| `"none"` \| `"minimal"` \| `"low"` \| `"high"` \| `"xhigh"` \| `"ultracode"` \| `undefined` ###### Returns @@ -2476,7 +2610,7 @@ any archive encoding, or no-op when the exact workspace is already present. ###### reasoningEffort -`"medium"` \| `"high"` \| `"low"` \| `"none"` \| `"minimal"` \| `"xhigh"` \| `"ultracode"` \| `undefined` +`"medium"` \| `"none"` \| `"minimal"` \| `"low"` \| `"high"` \| `"xhigh"` \| `"ultracode"` \| `undefined` ###### Returns @@ -4719,7 +4853,7 @@ Exact profile identity admitted before the shot. ##### reasoningEffort -> `readonly` **reasoningEffort**: `"medium"` \| `"high"` \| `"low"` \| `"none"` \| `"minimal"` \| `"xhigh"` \| `"ultracode"` \| `null` +> `readonly` **reasoningEffort**: `"medium"` \| `"none"` \| `"minimal"` \| `"low"` \| `"high"` \| `"xhigh"` \| `"ultracode"` \| `null` ##### promptSha256 diff --git a/docs/api/intelligence.md b/docs/api/intelligence.md index 29f46248..a67c520f 100644 --- a/docs/api/intelligence.md +++ b/docs/api/intelligence.md @@ -4761,7 +4761,7 @@ readonly [`AgentImprovementActivationTargetIdentity`](#agentimprovementactivatio ### isAgentImprovementProfileSurface() -> **isAgentImprovementProfileSurface**(`surface`): surface is "mcp" \| "subagents" \| "hooks" \| "prompt" \| "tools" \| "skills" +> **isAgentImprovementProfileSurface**(`surface`): surface is "prompt" \| "tools" \| "mcp" \| "subagents" \| "hooks" \| "skills" Return whether a measured surface can be delivered through an agent profile. @@ -4773,13 +4773,13 @@ Return whether a measured surface can be delivered through an agent profile. #### Returns -surface is "mcp" \| "subagents" \| "hooks" \| "prompt" \| "tools" \| "skills" +surface is "prompt" \| "tools" \| "mcp" \| "subagents" \| "hooks" \| "skills" *** ### isAgentProfileMeasuredSurface() -> **isAgentProfileMeasuredSurface**(`surface`): surface is "mcp" \| "subagents" \| "hooks" \| "prompt" \| "tools" \| "skills" \| "agent-profile" +> **isAgentProfileMeasuredSurface**(`surface`): surface is "prompt" \| "tools" \| "mcp" \| "subagents" \| "hooks" \| "skills" \| "agent-profile" Return whether a surface is eligible for shared profile measurement. @@ -4791,7 +4791,7 @@ Return whether a surface is eligible for shared profile measurement. #### Returns -surface is "mcp" \| "subagents" \| "hooks" \| "prompt" \| "tools" \| "skills" \| "agent-profile" +surface is "prompt" \| "tools" \| "mcp" \| "subagents" \| "hooks" \| "skills" \| "agent-profile" *** @@ -4812,7 +4812,7 @@ same profile inside a candidate bundle. ##### surface -`"mcp"` \| `"subagents"` \| `"hooks"` \| `"prompt"` \| `"tools"` \| `"skills"` +`"prompt"` \| `"tools"` \| `"mcp"` \| `"subagents"` \| `"hooks"` \| `"skills"` #### Returns @@ -4834,7 +4834,7 @@ Return the `Sha256Digest` of one profile surface using Runtime's canonical candi ##### surface -`"mcp"` \| `"subagents"` \| `"hooks"` \| `"prompt"` \| `"tools"` \| `"skills"` +`"prompt"` \| `"tools"` \| `"mcp"` \| `"subagents"` \| `"hooks"` \| `"skills"` #### Returns @@ -4856,7 +4856,7 @@ so exact replacement requires a reset record followed by a set record. ###### surface -`"mcp"` \| `"subagents"` \| `"hooks"` \| `"prompt"` \| `"tools"` \| `"skills"` +`"prompt"` \| `"tools"` \| `"mcp"` \| `"subagents"` \| `"hooks"` \| `"skills"` ###### desiredInput diff --git a/docs/api/mcp.md b/docs/api/mcp.md index 14c42b4d..6a69daa3 100644 --- a/docs/api/mcp.md +++ b/docs/api/mcp.md @@ -6907,7 +6907,7 @@ created, against the same table that emits the argv. ##### reasoningEffort -`"medium"` \| `"high"` \| `"low"` \| `"none"` \| `"minimal"` \| `"xhigh"` \| `"ultracode"` +`"medium"` \| `"none"` \| `"minimal"` \| `"low"` \| `"high"` \| `"xhigh"` \| `"ultracode"` #### Returns diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index 8664d378..bc9bc341 100644 --- a/docs/api/primitive-catalog.md +++ b/docs/api/primitive-catalog.md @@ -7,7 +7,7 @@ # Primitive catalog — the never-stale anti-reinvention inventory -> **GENERATED** from `@tangle-network/agent-runtime@0.135.3` and `@tangle-network/agent-eval@0.145.15` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. +> **GENERATED** from `@tangle-network/agent-runtime@0.135.3` and `@tangle-network/agent-eval@0.145.17` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. ## 1. agent-runtime — own public surface @@ -15,7 +15,7 @@ Every subpath this package declares in `package.json` `exports`. Reach for these ### Root — task lifecycle, conversation, RSI verbs, observability -Import from `@tangle-network/agent-runtime` — 432 exports. +Import from `@tangle-network/agent-runtime` — 434 exports. | Symbol | Kind | Summary | |---|---|---| @@ -157,7 +157,9 @@ Import from `@tangle-network/agent-runtime` — 432 exports. | `NotFoundError` | class | A named resource (run, span, rubric, scenario, dataset row, route) does not exist. | | `OfficialOptimizerUnavailableError` | class | Missing optional Python dependencies for an official optimizer. | | `PlannerError` | class | The dynamic-loop planner returned an unusable topology move — the LLM emitted | -| `RetainedRunAdmissionError` | class | The caller's `onAdmission` durability hook rejected, so a retained run's | +| `RetainedInteractiveAdmissionError` | class | The caller could not persist one exact interactive-process recovery record. | +| `RetainedInteractiveBindingError` | class | A provider returned a valid interactive reference that does not bind to the | +| `RetainedRunAdmissionError` | class | The caller could not persist one detached-run recovery record. | | `RetainedRunDispatchBindingError` | class | A retained dispatch answered with coordinates that do not bind to the | | `RuntimeRunStateError` | class | A runtime-run lifecycle method was called in an order the state machine does | | `SqlConversationJournal` | class | SQL-backed ConversationJournal. Two tables — runs (one row per runId, holds | @@ -526,7 +528,7 @@ Import from `@tangle-network/agent-runtime/intelligence` — 166 exports. ### Execution kernel — recursive atom, supervision, executors, round-synchronous loop -Import from `@tangle-network/agent-runtime/kernel` — 772 exports. +Import from `@tangle-network/agent-runtime/kernel` — 786 exports. | Symbol | Kind | Summary | |---|---|---| @@ -676,7 +678,9 @@ Import from `@tangle-network/agent-runtime/kernel` — 772 exports. | `readWorkerProgress` | function | Fold the scope-derived facts and the executor's optional enrichment into one read. Pure: the | | `readWorkerSteerRequests` | function | Read every valid steer request in a worker's inbox. Corrupt or partial lines are skipped. | | `readWorkerTraceContext` | function | Read the inherited trace context off an `ExecutorContext`, or `undefined` when the run records no | +| `reconnectRetainedInteractiveRun` | function | Rebuild controls for one exact provider-owned coding-agent process. | | `reconnectRetainedRun` | function | Rebuild a retained-run client without retaining any object from the starter. | +| `recoverRetainedInteractiveRun` | function | Retry one exact start after its provider response may have been lost. | | `recoverRetainedRun` | function | Rebuild the exact run named by pre-dispatch admission coordinates, or | | `registerShape` | function | Register a composed shape on the default `builtinShapes` registry — the one-call extension | | `registryScopeAnalyst` | function | A `ScopeAnalyst` backed by an `AnalystRegistry` — the panel-of-analysts seam. The registry merges | @@ -719,6 +723,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 772 exports. | `settledToIteration` | function | The step-8 merge-boundary adapter (M4): rehydrate a `Settled.done` into the kernel's | | `settledWorkerOut` | function | What a settled worker exposes as its output artifact (the blob the brain's | | `spendFromUsageEvents` | function | Fold a normalized `UsageEvent` array into a `Spend`. Tokens and usd are separate | +| `startRetainedInteractiveRun` | function | Start one retry-safe native coding-agent TUI without dispatching a headless turn. | | `startRetainedRun` | function | Dispatch one detached, replayable run and return only after exact durable | | `startRetainedRunInEnvironment` | function | Dispatch a fresh retained session inside an existing provider environment. | | `stopSentinel` | function | A unique, attributable stop sentinel for a node (ralph-loop style). Deterministic from the | @@ -942,7 +947,9 @@ Import from `@tangle-network/agent-runtime/kernel` — 772 exports. | `ProviderExecutorOptions` | interface | Options for running a provider as a supervise-mode executor. | | `ProviderModelAttemptEvidence` | interface | One provider/harness inference attempt. An empty observation list means the attempt started but | | `ProviderSeam` | interface | Generic environment provider executor config. External packages implement | +| `ReconnectRetainedInteractiveRunOptions` | interface | Reconstruct one exact provider-owned native coding-agent process. | | `ReconnectRetainedRunOptions` | interface | Inputs sufficient to rebuild a control client in a new process. | +| `RecoverRetainedInteractiveRunOptions` | interface | Recover a start after a pre-create crash or a lost provider response. | | `RecoverRetainedRunOptions` | interface | Pre-dispatch admission coordinates for one recovery attempt. | | `RegisteredPrompt` | interface | One registry entry: the handle plus the text it pins. | | `RegistryAnalyzeProjection` | interface | Project a `ScopeAnalyzeInput` into the `AnalystRegistry.run` arguments. The registry runs over a | @@ -953,6 +960,11 @@ Import from `@tangle-network/agent-runtime/kernel` — 772 exports. | `ResultBlobStore` | interface | Content-addressed result blobs (the `outRef` → artifact map) backing the replay | | `ResumedKeyState` | interface | What the journal proves about one keyed assignment at resume time. | | `ResumedWork` | interface | The committed work a resumed run inherits from its journal. `settled` is the replayed | +| `RetainedInteractiveEnvironmentAdmission` | interface | Exact interactive start request durable after environment creation. | +| `RetainedInteractiveIntentAdmission` | interface | Sanitized intent durable before an interactive environment create begins. | +| `RetainedInteractiveRunHandle` | interface | Exact interactive process controls plus measured environment capabilities. | +| `RetainedInteractiveStartedAdmission` | interface | Provider-issued interactive process reference durable before start returns. | +| `RetainedInteractiveStartMaterial` | interface | Material used to create and start one native coding-agent TUI. | | `RetainedRunCancellation` | interface | Durable acknowledgement state for one retained control operation. | | `RetainedRunCancelOptions` | interface | Options for an idempotent retained cancellation. | | `RetainedRunDispatchedAdmission` | interface | The verified exact reference, durable before the start promise resolves. | @@ -999,6 +1011,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 772 exports. | `SpawnJournal` | interface | The spawn-tree event source (mirrors `ConversationJournal`'s begin/append/load shape). | | `Spend` | interface | Conserved spend, reconciled from the normalized `UsageEvent` stream. Tokens and usd | | `SpendGap` | interface | One journaled node whose usage accounting is incomplete — the named gap behind a `false` | +| `StartRetainedInteractiveRunOptions` | interface | Start one retry-safe native coding-agent TUI in a new environment. | | `StartRetainedRunInEnvironmentOptions` | interface | A fresh retained session inside a provider environment that already exists. | | `StartRetainedRunOptions` | interface | A retained start is retry-safe only when environment and turn keys are explicit. | | `SteerableRootHandle` | interface | A Runtime-minted root handle that can deliver raw steering or answers to a live manager inbox. | @@ -1105,7 +1118,10 @@ Import from `@tangle-network/agent-runtime/kernel` — 772 exports. | `ResolveDriveHarness` | type | Resolve an external harness for one exact Runtime-owned manager identity. | | `ResolveSupervisorTools` | type | Product policy for the tools one exact supervisor node may call. Resolved once per node. | | `Restart` | type | OTP child-spec restart class. | -| `RetainedRunAdmission` | type | One admission record the runtime persists through the caller before proceeding. | +| `RetainedInteractiveAdmission` | type | Durable records for one exact native coding-agent process. | +| `RetainedInteractiveAdmissionHook` | type | Persist each exact interactive record before the runtime proceeds. | +| `RetainedInteractiveEnvironmentInput` | type | Environment and exact AgentProfile used to start one native coding-agent process. | +| `RetainedRunAdmission` | type | One detached-run admission record the runtime persists before dispatch proceeds. | | `RetainedRunAdmissionHook` | type | Awaited durability hook for retained admission records. | | `RetainedRunEffect` | type | Effect recorded for one retained control operation. | | `RootMaterialization` | type | Trusted root composition evidence. Generic `Agent.act` roots omit this and remain unknown. | diff --git a/docs/api/runtime.md b/docs/api/runtime.md index eabaa1f6..690094f1 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -5902,6 +5902,222 @@ Per-turn deadline (ms). *** +### RetainedInteractiveStartMaterial + +**`Stable`** + +Material used to create and start one native coding-agent TUI. + +#### Extended by + +- [`StartRetainedInteractiveRunOptions`](#startretainedinteractiverunoptions) + +#### Properties + +##### environment + +> `readonly` **environment**: [`RetainedInteractiveEnvironmentInput`](#retainedinteractiveenvironmentinput) + +##### interactiveIdempotencyKey + +> `readonly` **interactiveIdempotencyKey**: `string` + +##### initialPrompt? + +> `readonly` `optional` **initialPrompt?**: `string` + +##### cwd? + +> `readonly` `optional` **cwd?**: `string` + +##### cols? + +> `readonly` `optional` **cols?**: `number` + +##### rows? + +> `readonly` `optional` **rows?**: `number` + +*** + +### StartRetainedInteractiveRunOptions + +**`Stable`** + +Start one retry-safe native coding-agent TUI in a new environment. + +#### Extends + +- [`RetainedInteractiveStartMaterial`](#retainedinteractivestartmaterial) + +#### Properties + +##### environment + +> `readonly` **environment**: [`RetainedInteractiveEnvironmentInput`](#retainedinteractiveenvironmentinput) + +###### Inherited from + +[`RetainedInteractiveStartMaterial`](#retainedinteractivestartmaterial).[`environment`](#environment) + +##### interactiveIdempotencyKey + +> `readonly` **interactiveIdempotencyKey**: `string` + +###### Inherited from + +[`RetainedInteractiveStartMaterial`](#retainedinteractivestartmaterial).[`interactiveIdempotencyKey`](#interactiveidempotencykey) + +##### initialPrompt? + +> `readonly` `optional` **initialPrompt?**: `string` + +###### Inherited from + +[`RetainedInteractiveStartMaterial`](#retainedinteractivestartmaterial).[`initialPrompt`](#initialprompt) + +##### cwd? + +> `readonly` `optional` **cwd?**: `string` + +###### Inherited from + +[`RetainedInteractiveStartMaterial`](#retainedinteractivestartmaterial).[`cwd`](#cwd) + +##### cols? + +> `readonly` `optional` **cols?**: `number` + +###### Inherited from + +[`RetainedInteractiveStartMaterial`](#retainedinteractivestartmaterial).[`cols`](#cols) + +##### rows? + +> `readonly` `optional` **rows?**: `number` + +###### Inherited from + +[`RetainedInteractiveStartMaterial`](#retainedinteractivestartmaterial).[`rows`](#rows) + +##### provider + +> `readonly` **provider**: `AgentEnvironmentProvider` + +##### intent? + +> `readonly` `optional` **intent?**: [`RetainedInteractiveIntentAdmission`](#retainedinteractiveintentadmission) + +A previously persisted intent used to replay the exact create operation. + +##### onAdmission + +> `readonly` **onAdmission**: [`RetainedInteractiveAdmissionHook`](#retainedinteractiveadmissionhook) + +##### signal? + +> `readonly` `optional` **signal?**: `AbortSignal` + +*** + +### ReconnectRetainedInteractiveRunOptions + +**`Stable`** + +Reconstruct one exact provider-owned native coding-agent process. + +#### Properties + +##### provider + +> `readonly` **provider**: `AgentEnvironmentProvider` + +##### ref + +> `readonly` **ref**: `object` + +##### signal? + +> `readonly` `optional` **signal?**: `AbortSignal` + +*** + +### RecoverRetainedInteractiveRunOptions + +**`Stable`** + +Recover a start after a pre-create crash or a lost provider response. + +#### Properties + +##### provider + +> `readonly` **provider**: `AgentEnvironmentProvider` + +##### admission + +> `readonly` **admission**: [`RetainedInteractiveIntentAdmission`](#retainedinteractiveintentadmission) \| [`RetainedInteractiveEnvironmentAdmission`](#retainedinteractiveenvironmentadmission) + +##### replay? + +> `readonly` `optional` **replay?**: [`RetainedInteractiveStartMaterial`](#retainedinteractivestartmaterial) + +Required when recovering from an intent before an environment existed. + +##### onAdmission + +> `readonly` **onAdmission**: [`RetainedInteractiveAdmissionHook`](#retainedinteractiveadmissionhook) + +##### signal? + +> `readonly` `optional` **signal?**: `AbortSignal` + +*** + +### RetainedInteractiveRunHandle + +**`Stable`** + +Exact interactive process controls plus measured environment capabilities. + +#### Extends + +- `AgentInteractiveSession` + +#### Properties + +##### capabilities + +> `readonly` **capabilities**: `AgentEnvironmentCapabilities` + +#### Methods + +##### sendPrompt() + +> **sendPrompt**(`command`, `options?`): `Promise`\<`AgentInteractiveSessionPromptAcknowledgement`\> + +###### Parameters + +###### command + +`AgentInteractiveSessionPromptCommand` + +###### options? + +###### signal? + +`AbortSignal` + +###### Returns + +`Promise`\<`AgentInteractiveSessionPromptAcknowledgement`\> + +###### Overrides + +`AgentInteractiveSession.sendPrompt` + +*** + ### RetainedRunReplayPoint **`Stable`** @@ -6234,6 +6450,115 @@ The verified exact reference, durable before the start promise resolves. *** +### RetainedInteractiveIntentAdmission + +**`Stable`** + +Sanitized intent durable before an interactive environment create begins. + +The digest covers the exact start and create material without retaining that +material. It never carries environment variables, secrets, or provider options. + +#### Properties + +##### phase + +> `readonly` **phase**: `"interactive_intent"` + +##### provider + +> `readonly` **provider**: `string` + +##### idempotencyKey + +> `readonly` **idempotencyKey**: `string` + +##### interactiveIdempotencyKey + +> `readonly` **interactiveIdempotencyKey**: `string` + +##### sessionId + +> `readonly` **sessionId**: `string` + +##### executionId + +> `readonly` **executionId**: `string` + +##### runId + +> `readonly` **runId**: `string` + +##### requestedProfileDigest + +> `readonly` **requestedProfileDigest**: `` `sha256:${string}` `` + +##### requestDigest + +> `readonly` **requestDigest**: `` `sha256:${string}` `` + +*** + +### RetainedInteractiveEnvironmentAdmission + +**`Stable`** + +Exact interactive start request durable after environment creation. + +#### Properties + +##### phase + +> `readonly` **phase**: `"interactive_environment"` + +##### provider + +> `readonly` **provider**: `string` + +##### environmentId + +> `readonly` **environmentId**: `string` + +##### idempotencyKey + +> `readonly` **idempotencyKey**: `string` + +##### interactiveIdempotencyKey + +> `readonly` **interactiveIdempotencyKey**: `string` + +##### request + +> `readonly` **request**: `object` + +*** + +### RetainedInteractiveStartedAdmission + +**`Stable`** + +Provider-issued interactive process reference durable before start returns. + +#### Properties + +##### phase + +> `readonly` **phase**: `"interactive_started"` + +##### idempotencyKey + +> `readonly` **idempotencyKey**: `string` + +##### interactiveIdempotencyKey + +> `readonly` **interactiveIdempotencyKey**: `string` + +##### ref + +> `readonly` **ref**: `object` + +*** + ### StartRetainedRunOptions **`Stable`** @@ -14672,7 +14997,7 @@ breaker, or a recursive parent. ###### Inherited from -[`SupervisorNodeContext`](#supervisornodecontext).[`runId`](#runid-16) +[`SupervisorNodeContext`](#supervisornodecontext).[`runId`](#runid-17) ##### runNamespace @@ -16454,7 +16779,7 @@ Phantom: binds the handle to the supervised run's output type. Type-only — nev ###### Inherited from -[`RootHandle`](#roothandle-1).[`signal`](#signal-21) +[`RootHandle`](#roothandle-1).[`signal`](#signal-24) ##### abort() @@ -18698,7 +19023,7 @@ The spawn label (`shot:0`, `analyst:1`, a nested agent's label) — the row name ##### status -> **status**: `"done"` \| `"down"` \| `"running"` +> **status**: `"running"` \| `"done"` \| `"down"` ##### usd @@ -19587,6 +19912,46 @@ judge/verdict/score scheme is rejected. Fail loud — a tainted finding aborts. *** +### RetainedInteractiveEnvironmentInput + +> **RetainedInteractiveEnvironmentInput** = `Omit`\<`CreateAgentEnvironmentInput`, `"idempotencyKey"` \| `"profile"` \| `"signal"`\> & `object` + +**`Stable`** + +Environment and exact AgentProfile used to start one native coding-agent process. + +#### Type Declaration + +##### idempotencyKey + +> `readonly` **idempotencyKey**: `string` + +##### profile + +> `readonly` **profile**: `AgentProfile` + +*** + +### RetainedInteractiveAdmissionHook + +> **RetainedInteractiveAdmissionHook** = (`admission`) => `Promise`\<`void`\> + +**`Stable`** + +Persist each exact interactive record before the runtime proceeds. + +#### Parameters + +##### admission + +[`RetainedInteractiveAdmission`](#retainedinteractiveadmission) + +#### Returns + +`Promise`\<`void`\> + +*** + ### RetainedRunEffect > **RetainedRunEffect** = `"cancel_requested"` \| `"cancelled"` \| `"not_live"` \| `"unknown"` @@ -19617,13 +19982,23 @@ Result of one verified same-session continuation. *** +### RetainedInteractiveAdmission + +> **RetainedInteractiveAdmission** = [`RetainedInteractiveIntentAdmission`](#retainedinteractiveintentadmission) \| [`RetainedInteractiveEnvironmentAdmission`](#retainedinteractiveenvironmentadmission) \| [`RetainedInteractiveStartedAdmission`](#retainedinteractivestartedadmission) + +**`Stable`** + +Durable records for one exact native coding-agent process. + +*** + ### RetainedRunAdmission > **RetainedRunAdmission** = [`RetainedRunEnvironmentAdmission`](#retainedrunenvironmentadmission) \| [`RetainedRunDispatchedAdmission`](#retainedrundispatchedadmission) **`Stable`** -One admission record the runtime persists through the caller before proceeding. +One detached-run admission record the runtime persists before dispatch proceeds. *** @@ -19635,10 +20010,10 @@ One admission record the runtime persists through the caller before proceeding. Awaited durability hook for retained admission records. -The runtime blocks after environment creation and again after dispatch until -the hook resolves, so no retained run becomes caller-visible before its -recovery record is durable. A rejection fails the start without destroying -the environment; the persisted record or provider state is the recovery path. +The runtime blocks after the pre-create intent, environment creation, and +provider work until the hook resolves. No retained run becomes caller-visible +before its exact recovery record is durable. A rejection keeps provider state +for recovery when provider work has already started. #### Parameters @@ -23141,6 +23516,68 @@ that `resolveBenchClient` builds on — reuse this instead of hand-rolling the *** +### startRetainedInteractiveRun() + +> **startRetainedInteractiveRun**(`options`): `Promise`\<[`RetainedInteractiveRunHandle`](#retainedinteractiverunhandle)\> + +**`Stable`** + +Start one retry-safe native coding-agent TUI without dispatching a headless turn. +The intent admission is durable before provider.create; the environment and +process admissions follow only after their exact provider coordinates exist. + +#### Parameters + +##### options + +[`StartRetainedInteractiveRunOptions`](#startretainedinteractiverunoptions) + +#### Returns + +`Promise`\<[`RetainedInteractiveRunHandle`](#retainedinteractiverunhandle)\> + +*** + +### recoverRetainedInteractiveRun() + +> **recoverRetainedInteractiveRun**(`options`): `Promise`\<[`RetainedInteractiveRunHandle`](#retainedinteractiverunhandle) \| `null`\> + +**`Stable`** + +Retry one exact start after its provider response may have been lost. + +#### Parameters + +##### options + +[`RecoverRetainedInteractiveRunOptions`](#recoverretainedinteractiverunoptions) + +#### Returns + +`Promise`\<[`RetainedInteractiveRunHandle`](#retainedinteractiverunhandle) \| `null`\> + +*** + +### reconnectRetainedInteractiveRun() + +> **reconnectRetainedInteractiveRun**(`options`): `Promise`\<[`RetainedInteractiveRunHandle`](#retainedinteractiverunhandle) \| `null`\> + +**`Stable`** + +Rebuild controls for one exact provider-owned coding-agent process. + +#### Parameters + +##### options + +[`ReconnectRetainedInteractiveRunOptions`](#reconnectretainedinteractiverunoptions) + +#### Returns + +`Promise`\<[`RetainedInteractiveRunHandle`](#retainedinteractiverunhandle) \| `null`\> + +*** + ### startRetainedRun() > **startRetainedRun**(`options`): `Promise`\<[`RetainedRunHandle`](#retainedrunhandle)\> @@ -26911,7 +27348,7 @@ and a watched path that was also mounted compares against its mount (never repor The harvest takes no `AbortSignal`: it is pure fan-out over the read seam and waits on nothing itself, so every cancellable moment belongs to the reader. Pass a signal to the reader instead -([BoxSurfaceReaderOptions.signal](#signal-23), or close over one in a custom [SurfaceReader](#surfacereader)) — +([BoxSurfaceReaderOptions.signal](#signal-26), or close over one in a custom [SurfaceReader](#surfacereader)) — that cuts the backoff waits, and the harvest still returns the diffs it did establish rather than discarding settle-time evidence on a late cancellation. diff --git a/docs/api/testing.md b/docs/api/testing.md index 4de50d37..245ced4c 100644 --- a/docs/api/testing.md +++ b/docs/api/testing.md @@ -430,7 +430,7 @@ The run journal the edge ledger and every spawn/settle ride. Default: in-memory. ###### Inherited from -[`RunGraphOptions`](runtime.md#rungraphoptions).[`runId`](runtime.md#runid-11) +[`RunGraphOptions`](runtime.md#rungraphoptions).[`runId`](runtime.md#runid-12) ##### perWorker? @@ -485,7 +485,7 @@ Product authority over every steer/answer instruction (the filter seam). `runGra ###### Inherited from -[`RunGraphOptions`](runtime.md#rungraphoptions).[`signal`](runtime.md#signal-15) +[`RunGraphOptions`](runtime.md#rungraphoptions).[`signal`](runtime.md#signal-18) ##### now? @@ -586,7 +586,7 @@ root scope and every live child, including acquisition and backend execution. ###### Inherited from -[`SuperviseOptions`](runtime.md#superviseoptions).[`signal`](runtime.md#signal-17) +[`SuperviseOptions`](runtime.md#superviseoptions).[`signal`](runtime.md#signal-20) ##### execution? @@ -1223,7 +1223,7 @@ Give the supervisor brain a chapter-lifecycle on its OWN context window (router ###### Inherited from -[`SuperviseOptions`](runtime.md#superviseoptions).[`runId`](runtime.md#runid-15) +[`SuperviseOptions`](runtime.md#superviseoptions).[`runId`](runtime.md#runid-16) ##### now? diff --git a/docs/api/tui.md b/docs/api/tui.md index bbb9f4a5..9e3fedc7 100644 --- a/docs/api/tui.md +++ b/docs/api/tui.md @@ -464,7 +464,7 @@ How the app was invoked. Defaults read `process.argv` / `process.cwd()`. ##### status -> `readonly` **status**: `"done"` \| `"down"` \| `"running"` \| `"cancelled"` +> `readonly` **status**: `"running"` \| `"done"` \| `"down"` \| `"cancelled"` **`Experimental`** diff --git a/docs/canonical-api.md b/docs/canonical-api.md index 0b9c1faa..7f6393ab 100644 --- a/docs/canonical-api.md +++ b/docs/canonical-api.md @@ -6,9 +6,9 @@ Run pnpm docs:freshness after editing this file. --> > **Version 0.135.3.** > [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path. -> `agent-eval` must satisfy `>=0.145.15 <0.146.0`. +> `agent-eval` must satisfy `>=0.145.17 <0.146.0`. > `sandbox` must satisfy `>=0.27.0 <0.28.0`. -> Portable profile, turn-input, and tool-part types come from `@tangle-network/agent-interface` `>=0.54.0 <0.55.0`. +> Portable profile, turn-input, and tool-part types come from `@tangle-network/agent-interface` `>=0.56.0 <0.57.0`. > > **`./kernel` is the execution kernel**: `package.json` maps it to `src/runtime/index.ts`. Everything below labelled `/kernel` lives there — the recursive atom (`Scope`/`Supervisor`), the executor registry, budget conservation, the finalizer seam, analyst wiring, and the round-synchronous loop. > diff --git a/package.json b/package.json index aba8a25f..b0f02604 100644 --- a/package.json +++ b/package.json @@ -170,8 +170,8 @@ "license": "MIT", "packageManager": "pnpm@11.17.0", "peerDependencies": { - "@tangle-network/agent-eval": ">=0.145.15 <0.146.0", - "@tangle-network/agent-interface": ">=0.54.0 <0.55.0", + "@tangle-network/agent-eval": ">=0.145.17 <0.146.0", + "@tangle-network/agent-interface": ">=0.56.0 <0.57.0", "@tangle-network/sandbox": ">=0.27.0 <0.28.0" }, "peerDependenciesMeta": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d332a74..0b556499 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,17 +13,17 @@ catalogs: specifier: 1.30.0 version: 1.30.0 '@tangle-network/agent-core': - specifier: 0.9.1 - version: 0.9.1 + specifier: 0.9.2 + version: 0.9.2 '@tangle-network/agent-eval': - specifier: 0.145.15 - version: 0.145.15 + specifier: 0.145.17 + version: 0.145.17 '@tangle-network/agent-interface': - specifier: 0.54.0 - version: 0.54.0 + specifier: 0.56.0 + version: 0.56.0 '@tangle-network/agent-knowledge': - specifier: 8.0.1 - version: 8.0.1 + specifier: 8.0.2 + version: 8.0.2 '@tangle-network/agent-profile-materialize': specifier: 0.15.2 version: 0.15.2 @@ -55,13 +55,13 @@ importers: dependencies: '@tangle-network/agent-core': specifier: 'catalog:' - version: 0.9.1(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + version: 0.9.2(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) '@tangle-network/agent-knowledge': specifier: 'catalog:' - version: 8.0.1(@tangle-network/agent-eval@0.145.15(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.54.0) + version: 8.0.2(@tangle-network/agent-eval@0.145.17(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.56.0) '@tangle-network/agent-profile-materialize': specifier: 'catalog:' - version: 0.15.2(@tangle-network/agent-interface@0.54.0) + version: 0.15.2(@tangle-network/agent-interface@0.56.0) '@tangle-network/agent-trace-contract': specifier: 'catalog:' version: 1.0.2 @@ -80,10 +80,10 @@ importers: version: 1.30.0(supports-color@10.2.2)(zod@4.4.3) '@tangle-network/agent-eval': specifier: 'catalog:' - version: 0.145.15(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + version: 0.145.17(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) '@tangle-network/agent-interface': specifier: 'catalog:' - version: 0.54.0 + version: 0.56.0 '@tangle-network/sandbox': specifier: 'catalog:' version: 0.27.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(viem@2.54.6(typescript@6.0.3)(zod@4.4.3)) @@ -128,13 +128,13 @@ importers: dependencies: '@tangle-network/agent-eval': specifier: 'catalog:' - version: 0.145.15(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + version: 0.145.17(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) '@tangle-network/agent-interface': specifier: 'catalog:' - version: 0.54.0 + version: 0.56.0 '@tangle-network/agent-knowledge': specifier: 'catalog:' - version: 8.0.1(@tangle-network/agent-eval@0.145.15(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.54.0) + version: 8.0.2(@tangle-network/agent-eval@0.145.17(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.56.0) '@tangle-network/agent-runtime': specifier: workspace:* version: link:.. @@ -1132,32 +1132,35 @@ packages: '@modelcontextprotocol/sdk': optional: true - '@tangle-network/agent-core@0.9.1': - resolution: {integrity: sha512-Ke6Hh3PiiJjRdWEGkZSnAsSMcJgr6pYJXaV1qNqdh12t7vteTjBod6ehaFRrp7cIWkB3cAwKh0ADHByV2KONWA==} + '@tangle-network/agent-core@0.9.2': + resolution: {integrity: sha512-ReN84FwmMnMEWpu6zk9I1ovuE5DBhP30Rx+nqsu1QEo6qzM4rs2T0WA5t4EF5BA1uS3Kybs1jtW/DRUzEdgbcw==} peerDependencies: '@modelcontextprotocol/sdk': ^1.30.0 peerDependenciesMeta: '@modelcontextprotocol/sdk': optional: true - '@tangle-network/agent-eval@0.145.15': - resolution: {integrity: sha512-VTvbAqhVKpXWsNwASZNcMCmYWTdsXicR3i8yeHa/ViIv1Uo3496VqWXs2FRzkMxrOB6wOI0s5DEnxCZ3qaoRzA==} + '@tangle-network/agent-eval@0.145.17': + resolution: {integrity: sha512-ivXKnFqhJbS6PEDPkbxXWLhf6fs10oFj6ynnV9XkWr8dP/BwhGlgmLwbmGgQO5aW0kdmV/IF5/Zr2KsLWax/hA==} engines: {node: '>=20'} hasBin: true '@tangle-network/agent-interface@0.53.0': resolution: {integrity: sha512-XWH+4t9vPkVog9C9P+094USuoO//TJwaI+P6ntAm32wHqZ/kh5a9r6kSkHYMKsXgQjkY3IA5NO21w3rZ7M6U+g==} - '@tangle-network/agent-interface@0.54.0': - resolution: {integrity: sha512-KBWcP2KQzroyjbhsG65OJecyt7AXIV0MWQuqkjUDiSac8Gtach4+oeYlBba6i9euA/8PhlGv9VBgVNiC2D/YuQ==} + '@tangle-network/agent-interface@0.55.0': + resolution: {integrity: sha512-N92rOPhErm28FoPB9HqA9oFZ+MgTGnmes0NSuir3JqJ6gC42aj3sfhsPdzb/wyYF9PQuxHx+HH5+Ui8+ZBKnKw==} - '@tangle-network/agent-knowledge@8.0.1': - resolution: {integrity: sha512-H1zMyyNyVeTCijaPGMMbZX7QY7gjE1YyHEgtNtEJ9PgvTYNBuQSh938cIKNQBHM6MPB0Lq3xECgf9ELTVveTEQ==} + '@tangle-network/agent-interface@0.56.0': + resolution: {integrity: sha512-MFaUB/PHUMfOSpu+9o7LMEWwqlu61Nf5zE9oKEQ++bNM7g4ZbQKFPmdzw8iqTGpG/x6/phF75d+XxTXVi8J0rA==} + + '@tangle-network/agent-knowledge@8.0.2': + resolution: {integrity: sha512-KrUsUwhd8mcoR0cW+G2aoUXgcDm8fyAZywcUVBGecVMWcqd3BsAJcmHX9my8G+BrAQXqMN135xl/TkEU8TqzoQ==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: - '@tangle-network/agent-eval': '>=0.145.14 <0.146.0' - '@tangle-network/agent-interface': '>=0.53.0 <0.54.0' + '@tangle-network/agent-eval': '>=0.145.16 <0.146.0' + '@tangle-network/agent-interface': '>=0.54.0 <0.55.0' '@tangle-network/agent-profile-materialize@0.15.2': resolution: {integrity: sha512-j9ld23ADJRbAIJEkQ0xk49+RYt47WzARquN5S3W5Pyb3rbD++gxf1AKJQ3O613wmkwdcc0V10a5jHruX0SZ1Vg==} @@ -3188,19 +3191,19 @@ snapshots: optionalDependencies: '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@4.4.3) - '@tangle-network/agent-core@0.9.1(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))': + '@tangle-network/agent-core@0.9.2(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))': dependencies: - '@tangle-network/agent-interface': 0.54.0 + '@tangle-network/agent-interface': 0.55.0 zod: 4.4.3 optionalDependencies: '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@4.4.3) - '@tangle-network/agent-eval@0.145.15(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))': + '@tangle-network/agent-eval@0.145.17(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))': dependencies: '@asteasolutions/zod-to-openapi': 9.1.0(zod@4.4.3) '@hono/node-server': 2.0.12(hono@4.12.32) - '@tangle-network/agent-core': 0.9.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) - '@tangle-network/agent-interface': 0.53.0 + '@tangle-network/agent-core': 0.9.2(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + '@tangle-network/agent-interface': 0.55.0 '@tangle-network/agent-trace-contract': 1.0.2 hono: 4.12.32 linear-sum-assignment: 1.0.9 @@ -3215,22 +3218,28 @@ snapshots: spdx-expression-parse: 5.0.0 zod: 4.4.3 - '@tangle-network/agent-interface@0.54.0': + '@tangle-network/agent-interface@0.55.0': + dependencies: + '@noble/hashes': 1.8.0 + spdx-expression-parse: 5.0.0 + zod: 4.4.3 + + '@tangle-network/agent-interface@0.56.0': dependencies: '@noble/hashes': 1.8.0 spdx-expression-parse: 5.0.0 zod: 4.4.3 - '@tangle-network/agent-knowledge@8.0.1(@tangle-network/agent-eval@0.145.15(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.54.0)': + '@tangle-network/agent-knowledge@8.0.2(@tangle-network/agent-eval@0.145.17(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.56.0)': dependencies: - '@tangle-network/agent-eval': 0.145.15(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) - '@tangle-network/agent-interface': 0.54.0 + '@tangle-network/agent-eval': 0.145.17(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + '@tangle-network/agent-interface': 0.56.0 proper-lockfile: 4.1.2 zod: 4.4.3 - '@tangle-network/agent-profile-materialize@0.15.2(@tangle-network/agent-interface@0.54.0)': + '@tangle-network/agent-profile-materialize@0.15.2(@tangle-network/agent-interface@0.56.0)': dependencies: - '@tangle-network/agent-interface': 0.54.0 + '@tangle-network/agent-interface': 0.56.0 '@tangle-network/agent-trace-contract@1.0.2': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4e0dbd52..6498ecfe 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,11 +18,11 @@ allowBuilds: catalog: '@arethetypeswrong/cli': 0.18.5 '@modelcontextprotocol/sdk': 1.30.0 - '@tangle-network/agent-core': 0.9.1 + '@tangle-network/agent-core': 0.9.2 '@types/node': 26.1.1 - '@tangle-network/agent-eval': 0.145.15 - '@tangle-network/agent-interface': 0.54.0 - '@tangle-network/agent-knowledge': 8.0.1 + '@tangle-network/agent-eval': 0.145.17 + '@tangle-network/agent-interface': 0.56.0 + '@tangle-network/agent-knowledge': 8.0.2 '@tangle-network/agent-profile-materialize': 0.15.2 '@tangle-network/agent-trace-contract': ^1.0.2 '@tangle-network/sandbox': 0.27.0 diff --git a/src/runtime/retained-interactive-handle.ts b/src/runtime/retained-interactive-handle.ts index 716a0e73..6916bcd8 100644 --- a/src/runtime/retained-interactive-handle.ts +++ b/src/runtime/retained-interactive-handle.ts @@ -1,13 +1,28 @@ import type { + AgentInteractiveSessionAttach, + AgentInteractiveSessionControlClaimAcknowledgement, + AgentInteractiveSessionControlClaimRequest, + AgentInteractiveSessionPromptAcknowledgement, + AgentInteractiveSessionPromptCommand, AgentInteractiveSessionRef, AgentInteractiveSessionStart, AgentInteractiveSessionStatus, - AgentTerminalSession, + AgentInteractiveSessionStopAcknowledgement, + AgentInteractiveSessionStopCommand, + AgentInteractiveTerminalSession, } from '@tangle-network/agent-interface' import { + AgentInteractiveSessionControlClaimAcknowledgementSchema, + AgentInteractiveSessionControlClaimSchema, + AgentInteractiveSessionPromptAcknowledgementSchema, AgentInteractiveSessionRefSchema, AgentInteractiveSessionStatusSchema, + AgentInteractiveSessionStopAcknowledgementSchema, + agentInteractiveSessionControlClaimAcknowledgementMatchesRequest, + agentInteractiveSessionControlClaimMatchesRef, + agentInteractiveSessionPromptAcknowledgementMatchesCommand, agentInteractiveSessionStatusMatchesRef, + agentInteractiveSessionStopAcknowledgementMatchesCommand, canonicalCandidateDigest, TerminalReplayWindowSchema, TerminalSessionRefSchema, @@ -18,7 +33,7 @@ import type { } from '@tangle-network/agent-interface/environment-provider' import { RetainedInteractiveBindingError } from '../errors' import type { RetainedInteractiveRunHandle } from './retained-interactive-types' -import { awaitAbortable } from './retained-run-binding' +import { awaitAbortable, RetainedRunProviderContractError } from './retained-run-binding' import { detachedSnapshot } from './supervise/snapshot' export function createRetainedInteractiveRunHandle( @@ -38,6 +53,18 @@ export function createRetainedInteractiveRunHandle( return Object.freeze({ ref, capabilities, + claimControl: async ( + request: AgentInteractiveSessionControlClaimRequest, + options?: { signal?: AbortSignal }, + ): Promise => + exactClaim( + ref, + request, + await awaitAbortable( + Promise.resolve().then(() => source.claimControl(request, options)), + options?.signal, + ), + ), status: async (options?: { signal?: AbortSignal }) => exactStatus( ref, @@ -47,30 +74,38 @@ export function createRetainedInteractiveRunHandle( ), requestedStart, ), - attach: async ( - request?: { cols?: number; rows?: number }, - options?: { signal?: AbortSignal }, - ) => + attach: async (request: AgentInteractiveSessionAttach, options?: { signal?: AbortSignal }) => exactTerminal( ref, + request, await awaitAbortable( Promise.resolve().then(() => source.attach(request, options)), options?.signal, ), ), - sendPrompt: async (prompt: string, options?: { signal?: AbortSignal }) => - await awaitAbortable( - Promise.resolve().then(() => source.sendPrompt!(prompt, options)), - options?.signal, + sendPrompt: async ( + command: AgentInteractiveSessionPromptCommand, + options?: { signal?: AbortSignal }, + ): Promise => + exactPrompt( + ref, + await awaitAbortable( + Promise.resolve().then(() => source.sendPrompt!(command, options)), + options?.signal, + ), + command, ), - stop: async (options?: { signal?: AbortSignal }) => - exactStatus( + stop: async ( + command: AgentInteractiveSessionStopCommand, + options?: { signal?: AbortSignal }, + ): Promise => + exactStop( ref, await awaitAbortable( - Promise.resolve().then(() => source.stop(options)), + Promise.resolve().then(() => source.stop(command, options)), options?.signal, ), - requestedStart, + command, ), }) } @@ -78,8 +113,93 @@ export function createRetainedInteractiveRunHandle( export function freezeInteractiveRef( value: AgentInteractiveSessionRef, ): AgentInteractiveSessionRef { - const ref = AgentInteractiveSessionRefSchema.parse(value) - return Object.freeze({ ...ref, run: Object.freeze({ ...ref.run }) }) + return detachedSnapshot(AgentInteractiveSessionRefSchema.parse(value), 'interactive session ref') +} + +function exactClaim( + ref: AgentInteractiveSessionRef, + request: AgentInteractiveSessionControlClaimRequest, + value: unknown, +): AgentInteractiveSessionControlClaimAcknowledgement { + const acknowledgement = parseProviderAcknowledgement( + AgentInteractiveSessionControlClaimAcknowledgementSchema, + value, + 'control claim', + ) + if (!agentInteractiveSessionControlClaimAcknowledgementMatchesRequest(request, acknowledgement)) { + throw new RetainedRunProviderContractError( + 'provider returned a control claim acknowledgement for another request', + ) + } + if ( + acknowledgement.control && + !agentInteractiveSessionControlClaimMatchesRef(ref, acknowledgement.control) + ) { + throw new RetainedRunProviderContractError( + 'provider returned a control claim for another interactive process', + ) + } + return acknowledgement +} + +function exactPrompt( + ref: AgentInteractiveSessionRef, + value: unknown, + command: AgentInteractiveSessionPromptCommand, +): AgentInteractiveSessionPromptAcknowledgement { + const acknowledgement = parseProviderAcknowledgement( + AgentInteractiveSessionPromptAcknowledgementSchema, + value, + 'prompt', + ) + if (!agentInteractiveSessionPromptAcknowledgementMatchesCommand(command, acknowledgement)) { + throw new RetainedRunProviderContractError( + 'provider returned a prompt acknowledgement for another request', + ) + } + if (!agentInteractiveSessionControlClaimMatchesRef(ref, acknowledgement.control)) { + throw new RetainedRunProviderContractError( + 'provider returned a prompt acknowledgement for another interactive process', + ) + } + return acknowledgement +} + +function exactStop( + ref: AgentInteractiveSessionRef, + value: unknown, + command: AgentInteractiveSessionStopCommand, +): AgentInteractiveSessionStopAcknowledgement { + const acknowledgement = parseProviderAcknowledgement( + AgentInteractiveSessionStopAcknowledgementSchema, + value, + 'stop', + ) + if (!agentInteractiveSessionStopAcknowledgementMatchesCommand(command, acknowledgement)) { + throw new RetainedRunProviderContractError( + 'provider returned a stop acknowledgement for another request', + ) + } + if (!agentInteractiveSessionControlClaimMatchesRef(ref, acknowledgement.control)) { + throw new RetainedRunProviderContractError( + 'provider returned a stop acknowledgement for another interactive process', + ) + } + return acknowledgement +} + +function parseProviderAcknowledgement( + schema: { parse(value: unknown): T }, + value: unknown, + operation: string, +): T { + try { + return schema.parse(value) + } catch { + throw new RetainedRunProviderContractError( + `provider returned an invalid interactive ${operation} acknowledgement`, + ) + } } function exactStatus( @@ -116,13 +236,21 @@ function exactStatus( function exactTerminal( ref: AgentInteractiveSessionRef, - terminal: AgentTerminalSession, -): AgentTerminalSession { + request: AgentInteractiveSessionAttach, + terminal: AgentInteractiveTerminalSession, +): AgentInteractiveTerminalSession { const terminalRef = TerminalSessionRefSchema.parse(terminal.ref) TerminalReplayWindowSchema.parse(terminal.cursors) if (terminalRef.parentExecutionId !== ref.run.executionId) { throw new Error('provider attached a terminal from another interactive run') } + AgentInteractiveSessionControlClaimSchema.parse(terminal.control) + if (!agentInteractiveSessionControlClaimMatchesRef(ref, terminal.control)) { + throw new Error('provider attached a terminal with a claim for another interactive process') + } + if (canonicalCandidateDigest(terminal.control) !== canonicalCandidateDigest(request.control)) { + throw new Error('provider attached a terminal with a different interactive control claim') + } return terminal } diff --git a/src/runtime/retained-interactive-types.ts b/src/runtime/retained-interactive-types.ts index b7f19cf5..c4a3b363 100644 --- a/src/runtime/retained-interactive-types.ts +++ b/src/runtime/retained-interactive-types.ts @@ -1,5 +1,7 @@ import type { AgentInteractiveSession, + AgentInteractiveSessionPromptAcknowledgement, + AgentInteractiveSessionPromptCommand, AgentInteractiveSessionRef, AgentProfile, } from '@tangle-network/agent-interface' @@ -67,5 +69,8 @@ export interface RecoverRetainedInteractiveRunOptions { /** Exact interactive process controls plus measured environment capabilities. @stable */ export interface RetainedInteractiveRunHandle extends AgentInteractiveSession { readonly capabilities: AgentEnvironmentCapabilities - sendPrompt(prompt: string, options?: { signal?: AbortSignal }): Promise + sendPrompt( + command: AgentInteractiveSessionPromptCommand, + options?: { signal?: AbortSignal }, + ): Promise } diff --git a/src/runtime/retained-interactive.test.ts b/src/runtime/retained-interactive.test.ts index 83e2010f..3b09eb9d 100644 --- a/src/runtime/retained-interactive.test.ts +++ b/src/runtime/retained-interactive.test.ts @@ -1,10 +1,18 @@ import { type AgentInteractiveSession, + type AgentInteractiveSessionControlClaim, + type AgentInteractiveSessionControlClaimRequest, + type AgentInteractiveSessionPromptCommand, type AgentInteractiveSessionRef, AgentInteractiveSessionRefSchema, type AgentInteractiveSessionStart, + type AgentInteractiveSessionStopCommand, + type AgentInteractiveTerminalSession, type AgentProfile, - type AgentTerminalSession, + agentInteractiveSessionControlClaimRequestDigest, + agentInteractiveSessionPromptRequestDigest, + agentInteractiveSessionStopRequestDigest, + canonicalCandidateDigest, } from '@tangle-network/agent-interface' import type { AgentEnvironment, @@ -72,9 +80,22 @@ describe('retained interactive runs', () => { }) expect(handle.ref.run.sessionId).toBe('retained-session:workspace-1:native-turn-1') expect((await handle.status()).state).toBe('running') - await handle.sendPrompt('Run tests.') + const claim = await handle.claimControl(controlClaimRequest(handle.ref)) + expect(claim.status).toBe('accepted') + if (!claim.control) throw new Error('expected a provider control claim') + const promptAcknowledgement = await handle.sendPrompt( + promptCommand(handle.ref, claim.control, 'Run tests.'), + ) + expect(promptAcknowledgement).toMatchObject({ + status: 'accepted', + operationId: `${handle.ref.run.runId}:prompt`, + }) expect(fixture.prompts).toEqual(['Run tests.']) - expect((await handle.attach()).ref.parentExecutionId).toBe(handle.ref.run.executionId) + const terminal = await handle.attach({ control: claim.control }) + expect(terminal.ref.parentExecutionId).toBe(handle.ref.run.executionId) + expect(terminal.control).toEqual(claim.control) + const stopAcknowledgement = await handle.stop(stopCommand(handle.ref, claim.control)) + expect(stopAcknowledgement).toMatchObject({ status: 'accepted', effect: 'stopped' }) }) it('replays a persisted intent after a crash before environment create', async () => { @@ -465,7 +486,7 @@ describe('retained interactive runs', () => { const wrongTerminal = interactiveProvider({ returnWrongTerminal: true }) const handle = await start(wrongTerminal.provider) - await expect(handle.attach()).rejects.toThrow( + await expect(handle.attach({ control: controlFor(handle.ref) })).rejects.toThrow( 'attached a terminal from another interactive run', ) }) @@ -503,20 +524,27 @@ describe('retained interactive runs', () => { }, ) - it.each(['status', 'attach', 'sendPrompt', 'stop'] as const)( + it.each(['claimControl', 'status', 'attach', 'sendPrompt', 'stop'] as const)( 'cancels a hanging interactive %s call', async (hangAt) => { const fixture = interactiveProvider({ hangAt }) const handle = await start(fixture.provider) const controller = new AbortController() + const control = controlFor(handle.ref) const pending = - hangAt === 'status' - ? handle.status({ signal: controller.signal }) - : hangAt === 'attach' - ? handle.attach(undefined, { signal: controller.signal }) - : hangAt === 'sendPrompt' - ? handle.sendPrompt('continue', { signal: controller.signal }) - : handle.stop({ signal: controller.signal }) + hangAt === 'claimControl' + ? handle.claimControl(controlClaimRequest(handle.ref), { + signal: controller.signal, + }) + : hangAt === 'status' + ? handle.status({ signal: controller.signal }) + : hangAt === 'attach' + ? handle.attach({ control }, { signal: controller.signal }) + : hangAt === 'sendPrompt' + ? handle.sendPrompt(promptCommand(handle.ref, control, 'continue'), { + signal: controller.signal, + }) + : handle.stop(stopCommand(handle.ref, control), { signal: controller.signal }) await waitForHangingCall(fixture, 1) controller.abort(`cancel ${hangAt}`) @@ -618,6 +646,7 @@ type HangPoint = | 'create' | 'get' | 'start' + | 'claimControl' | 'status' | 'attach' | 'sendPrompt' @@ -648,7 +677,7 @@ function interactiveProvider( let hangAt = options.hangAt let ref: AgentInteractiveSessionRef | undefined let lost = false - const terminal = (): AgentTerminalSession => ({ + const terminal = (): Omit => ({ ref: { terminalSessionId: 'terminal-1', parentExecutionId: options.returnWrongTerminal ? 'another-execution' : ref!.run.executionId, @@ -673,6 +702,20 @@ function interactiveProvider( }) const session = (): AgentInteractiveSession => ({ ref: ref!, + claimControl: async (request) => { + if (hangAt === 'claimControl') { + fixture.hangingCalls += 1 + return neverPending() + } + const control = controlFor(ref!, request.holderId, request.expectedGeneration + 1) + return { + operationId: request.operationId, + requestDigest: request.requestDigest, + ref: ref!, + status: 'accepted' as const, + control, + } + }, status: async () => { if (hangAt === 'status') { fixture.hangingCalls += 1 @@ -680,30 +723,39 @@ function interactiveProvider( } return { state: 'running' as const, ref: fixture.statusRef ?? ref! } }, - attach: async () => { + attach: async (request) => { if (hangAt === 'attach') { fixture.hangingCalls += 1 return neverPending() } - return terminal() + return { ...terminal(), control: request.control } }, - sendPrompt: async (prompt: string) => { + sendPrompt: async (command) => { if (hangAt === 'sendPrompt') { fixture.hangingCalls += 1 return neverPending() } - fixture.prompts.push(prompt) + fixture.prompts.push(command.prompt) + return { + operationId: command.operationId, + requestDigest: command.requestDigest, + ref: ref!, + control: command.control, + status: 'accepted' as const, + } }, - stop: async () => { + stop: async (command) => { if (hangAt === 'stop') { fixture.hangingCalls += 1 return neverPending() } return { - state: 'exited' as const, - ref: fixture.statusRef ?? ref!, - endedAt: '2026-08-16T01:00:00.000Z', - reason: 'stopped' as const, + operationId: command.operationId, + requestDigest: command.requestDigest, + ref: ref!, + control: command.control, + status: 'accepted' as const, + effect: 'stopped' as const, } }, }) @@ -727,12 +779,47 @@ function interactiveProvider( const run = options.returnWrongRun ? { ...request.run, runId: `${request.run.runId}-other` } : request.run + const preparationReceipt = { + kind: 'agent-execution-preparation' as const, + schemaVersion: 1 as const, + preparationId: 'preparation-1', + requestDigest: request.run.requestDigest, + authoredProfileDigest: request.requestedProfileDigest, + effectiveProfileDigest: request.requestedProfileDigest, + backend: 'test-backend', + harness: request.profile.harness, + harnessVersion: 'test-harness-1', + resolvedModel: { + requested: request.profile.model?.default ?? 'test-model', + resolved: request.profile.model?.default ?? 'test-model', + }, + workspace: { + leaseId: 'workspace-lease-1', + provider: 'test-provider', + identityDigest: digest('2'), + isolation: 'per-run' as const, + sourceSnapshotDigest: digest('3'), + sourceSnapshotPolicy: { + kind: 'provider-declared' as const, + name: 'test-snapshot', + version: 1, + digest: digest('4'), + }, + preparedWorkspaceDigest: digest('5'), + profileActivationDigest: digest('6'), + }, + axisResults: [], + executionPlanDigest: digest('7'), + materializer: { name: 'test-materializer', version: '1' }, + expiresAtMs: 4102444800000, + } ref = AgentInteractiveSessionRefSchema.parse({ run, - requestedProfileDigest: request.requestedProfileDigest, - admittedProfileDigest: request.requestedProfileDigest, + preparationReceipt: { + ...preparationReceipt, + digest: canonicalCandidateDigest(preparationReceipt), + }, incarnationId: 'incarnation-1', - harness: request.profile.harness, startedAt: '2026-08-16T00:00:00.000Z', }) } @@ -807,6 +894,62 @@ function neverPending(): Promise { return new Promise(() => {}) } +function digest(seed: string): `sha256:${string}` { + return `sha256:${seed.repeat(64).slice(0, 64)}` +} + +function controlFor( + ref: AgentInteractiveSessionRef, + holderId = 'braid-ui', + generation = 1, +): AgentInteractiveSessionControlClaim { + return { + refDigest: canonicalCandidateDigest(ref), + generation, + leaseId: 'interactive-lease-1', + holderId, + expiresAt: '2026-08-17T00:00:00.000Z', + } +} + +function controlClaimRequest( + ref: AgentInteractiveSessionRef, +): AgentInteractiveSessionControlClaimRequest { + const material = { + operationId: `${ref.run.runId}:claim`, + ref, + holderId: 'braid-ui', + expectedGeneration: 0, + } + return { ...material, requestDigest: agentInteractiveSessionControlClaimRequestDigest(material) } +} + +function promptCommand( + ref: AgentInteractiveSessionRef, + control: AgentInteractiveSessionControlClaim, + prompt: string, +): AgentInteractiveSessionPromptCommand { + const material = { + operationId: `${ref.run.runId}:prompt`, + ref, + control, + prompt, + } + return { ...material, requestDigest: agentInteractiveSessionPromptRequestDigest(material) } +} + +function stopCommand( + ref: AgentInteractiveSessionRef, + control: AgentInteractiveSessionControlClaim, +): AgentInteractiveSessionStopCommand { + const material = { + operationId: `${ref.run.runId}:stop`, + ref, + control, + } + return { ...material, requestDigest: agentInteractiveSessionStopRequestDigest(material) } +} + async function waitForHangingCall(fixture: ProviderFixture, expected: number): Promise { for (let attempt = 0; attempt < 20 && fixture.hangingCalls < expected; attempt += 1) { await new Promise((resolve) => setTimeout(resolve, 0)) @@ -837,6 +980,7 @@ function interactiveCapabilities(complete: boolean): AgentEnvironmentCapabilitie confidential: false, interactiveAgent: { start: true, + control: true, status: true, attach: true, reattach: complete, diff --git a/src/runtime/retained-interactive.ts b/src/runtime/retained-interactive.ts index 388cd0bb..5cdaf1ff 100644 --- a/src/runtime/retained-interactive.ts +++ b/src/runtime/retained-interactive.ts @@ -480,6 +480,7 @@ function assertInteractiveCapabilities( const interactive = capabilities.interactiveAgent if ( !interactive?.start || + !interactive.control || !interactive.status || !interactive.attach || !interactive.reattach || From 516423c86b6ba9a22579461e2d20a861984ecc79 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 02:29:39 -0600 Subject: [PATCH 07/16] chore(release): 0.137.0 --- CHANGELOG.md | 9 +++++++++ docs/api/primitive-catalog.md | 2 +- docs/canonical-api.md | 2 +- package.json | 2 +- src/testing/fixtures/agent-improvement-proposal.json | 10 +++++----- .../fixtures/agent-profile-improvement-proposal.json | 6 +++--- 6 files changed, 20 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adc50e62..3f3401f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.137.0 + +### Durable retained interactive sessions + +- Persist interactive intent before provider environment creation. +- Replay exact intent material and environment idempotency without duplicate starts after a crash. +- Bind retained claim, prompt, attach, status, and stop operations to Interface 0.56 acknowledgements. +- Cancel provider calls that ignore `AbortSignal` through the runtime's abortable boundary. + ## 0.136.0 ### Peer mail: workers can reach a live sibling, bounded and audited diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index fd709c25..7957cdf5 100644 --- a/docs/api/primitive-catalog.md +++ b/docs/api/primitive-catalog.md @@ -7,7 +7,7 @@ # Primitive catalog — the never-stale anti-reinvention inventory -> **GENERATED** from `@tangle-network/agent-runtime@0.136.0` and `@tangle-network/agent-eval@0.145.17` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. +> **GENERATED** from `@tangle-network/agent-runtime@0.137.0` and `@tangle-network/agent-eval@0.145.17` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. ## 1. agent-runtime — own public surface diff --git a/docs/canonical-api.md b/docs/canonical-api.md index cf1d21d7..7da8ddd7 100644 --- a/docs/canonical-api.md +++ b/docs/canonical-api.md @@ -4,7 +4,7 @@ Generated signatures and the complete export list live in docs/api/. Run pnpm docs:freshness after editing this file. --> -> **Version 0.136.0.** +> **Version 0.137.0.** > [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path. > `agent-eval` must satisfy `>=0.145.17 <0.146.0`. > `sandbox` must satisfy `>=0.27.0 <0.28.0`. diff --git a/package.json b/package.json index 22eb88b2..03f49e27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-runtime", - "version": "0.136.0", + "version": "0.137.0", "description": "Shared task-lifecycle skeleton for agents: a recursive loop kernel for chat turns, one-shot tasks, and multi-attempt loops, with trace capture and eval-gated self-improvement. Domain behavior lives in adapters; scoring and ship-gates in @tangle-network/agent-eval.", "homepage": "https://github.com/tangle-network/agent-runtime#readme", "repository": { diff --git a/src/testing/fixtures/agent-improvement-proposal.json b/src/testing/fixtures/agent-improvement-proposal.json index b275a1f2..8974eb70 100644 --- a/src/testing/fixtures/agent-improvement-proposal.json +++ b/src/testing/fixtures/agent-improvement-proposal.json @@ -1,6 +1,6 @@ { "changedSurfaces": ["prompt"], - "digest": "sha256:e9554c9e0017c330bee7cfbf2f7b40487a19d29e846eb8e9e6284a77b486831f", + "digest": "sha256:a2c6a1a25118f7149ff56f1c7484ed1f649df9af07c09c5890a01e40d718daf5", "evaluation": { "decision": { "contributingChecks": [ @@ -4882,7 +4882,7 @@ ], "metadata": { "fixture": "agent-improvement-proposal", - "runtimeVersion": "0.136.0" + "runtimeVersion": "0.137.0" }, "objectives": [ { @@ -4993,8 +4993,8 @@ "baselineContentHash": "sha256:5c21ee53e513fc604cb09754e21c392b24a424da0ef37dbf8f1ee4a8a0b08f09", "candidateContentHash": "sha256:60fcbb1c728194bd51d7d19cb732d1c3f1881dce7e0a6266b41c8b98cfd65693", "kind": "agent-eval-loop", - "recordDigest": "sha256:77e6b6c92fa08e5cf0109d0cca61b1c0c611bf028a331363725a43b167ebf90f", - "runId": "agent-runtime-0.136.0-proposal-fixture", + "recordDigest": "sha256:b9bc6ab59f170c0b281057ff402b2bbe74c78313543c4e0aa71cae61ba066c07", + "runId": "agent-runtime-0.137.0-proposal-fixture", "schema": "agent-candidate-experiment" } }, @@ -5021,5 +5021,5 @@ ], "kind": "agent-improvement-proposal", "proposedAt": "2026-07-10T01:00:00.000Z", - "runId": "agent-runtime-0.136.0-proposal-fixture" + "runId": "agent-runtime-0.137.0-proposal-fixture" } diff --git a/src/testing/fixtures/agent-profile-improvement-proposal.json b/src/testing/fixtures/agent-profile-improvement-proposal.json index cc840b15..8ae41ba1 100644 --- a/src/testing/fixtures/agent-profile-improvement-proposal.json +++ b/src/testing/fixtures/agent-profile-improvement-proposal.json @@ -1,6 +1,6 @@ { "changedSurfaces": ["prompt", "skills"], - "digest": "sha256:5d05442ba3ccbe33bbb937e53adb96b0daf69f5f8b8c0544a9c5e9d97b16eb05", + "digest": "sha256:b77f4da43acd31a4d084c1bbbbb924cfa8d647d670d73b32ac08188d7c2ce282", "evaluation": { "decision": { "contributingChecks": [ @@ -1715,7 +1715,7 @@ ], "metadata": { "fixture": "agent-profile-improvement-proposal", - "runtimeVersion": "0.136.0" + "runtimeVersion": "0.137.0" }, "objectives": [ { @@ -1826,7 +1826,7 @@ "baselineContentHash": "sha256:21c495a37c418c10bde64fbaa188beddeed31f1f051ea60a6a6582a9ee0db704", "candidateContentHash": "sha256:103f77bc8481601eef1ad5fe6ba84a40dffabc3a44f421f8c8559121edab84e9", "kind": "agent-eval-loop", - "recordDigest": "sha256:85cc50e312e941d7c61bc337e0fb6957daeee6c2b740d99f0dabb9c77013783c", + "recordDigest": "sha256:3984f31ca644bdf0a773531a8a11f51262992928b0cf96061ce048c910844e70", "runId": "profile-improvement-1", "schema": "agent-profile-improvement-experiment" } From 2ac3bda524e5d1a1137dfd0d6e0e12bd83511369 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 02:32:18 -0600 Subject: [PATCH 08/16] chore(release): bump agent-bench to 0.8.12 --- bench/CHANGELOG.md | 4 ++++ bench/package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bench/CHANGELOG.md b/bench/CHANGELOG.md index c6e3164b..a8f5c581 100644 --- a/bench/CHANGELOG.md +++ b/bench/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.8.12 + +- Consume Runtime 0.137.0, Eval 0.145.17, Interface 0.56.0, Knowledge 8.0.2, and Sandbox 0.27.0 as one compatible set. + ## 0.8.11 - Consume Runtime 0.135.3, Eval 0.145.15, Interface 0.53.0, Knowledge 8.0.1, and Sandbox 0.26.2 as one compatible set. diff --git a/bench/package.json b/bench/package.json index 632ecebb..bfbb8d81 100644 --- a/bench/package.json +++ b/bench/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-bench", - "version": "0.8.11", + "version": "0.8.12", "type": "module", "description": "Benchmark adapters and execution for agent-runtime across coding, tool-use, RAG, memory, browser, and terminal tasks.", "repository": { From 04f228891df6b57f6f378ed9157a6b5e6029a3fa Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 02:37:29 -0600 Subject: [PATCH 09/16] fix(release): pin the interface 0.56 cohort --- .github/workflows/ci.yml | 6 +++--- .github/workflows/publish.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af388763..776bf1e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,21 +81,21 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-sdk - ref: a12e37c3ff18642aad455db0c4f6d6c75000ba30 # @tangle-network/agent-interface@0.53.0 + ref: 8de51fe11e9eb568cc96302791feb65683a420c16 # @tangle-network/agent-interface@0.56.0 path: .cohort/agent-sdk persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-eval - ref: d237da433a8b4fac20953a71f953265715e8abb2 # @tangle-network/agent-eval@0.145.15 + ref: 6c0966c5bf44169656343d81184b8e8ec818da8a # @tangle-network/agent-eval@0.145.17 path: .cohort/agent-eval persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-knowledge - ref: fd0eaa7b525c96304656905314aefba95066a265 # @tangle-network/agent-knowledge@8.0.1 + ref: 173d2306149932d61b0bc0db216050567df88719 # @tangle-network/agent-knowledge@8.0.2 path: .cohort/agent-knowledge persist-credentials: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2cfc110f..072fc742 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -135,7 +135,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-sdk - ref: a12e37c3ff18642aad455db0c4f6d6c75000ba30 # @tangle-network/agent-interface@0.53.0 + ref: 8de51fe11e9eb568cc96302791feb65683a420c16 # @tangle-network/agent-interface@0.56.0 path: .cohort/agent-sdk persist-credentials: false @@ -143,7 +143,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-eval - ref: d237da433a8b4fac20953a71f953265715e8abb2 # @tangle-network/agent-eval@0.145.15 + ref: 6c0966c5bf44169656343d81184b8e8ec818da8a # @tangle-network/agent-eval@0.145.17 path: .cohort/agent-eval persist-credentials: false @@ -151,7 +151,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-knowledge - ref: fd0eaa7b525c96304656905314aefba95066a265 # @tangle-network/agent-knowledge@8.0.1 + ref: 173d2306149932d61b0bc0db216050567df88719 # @tangle-network/agent-knowledge@8.0.2 path: .cohort/agent-knowledge persist-credentials: false From eafea0744a4d70cf05ee04fd4c2c8bace0c1943f Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 02:37:29 -0600 Subject: [PATCH 10/16] fix(release): pin the interface 0.56 cohort --- .github/workflows/ci.yml | 6 +++--- .github/workflows/publish.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af388763..41aa18e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,21 +81,21 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-sdk - ref: a12e37c3ff18642aad455db0c4f6d6c75000ba30 # @tangle-network/agent-interface@0.53.0 + ref: 8de51fe11e9eb568cc96302791feb65683a420c1 # @tangle-network/agent-interface@0.56.0 path: .cohort/agent-sdk persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-eval - ref: d237da433a8b4fac20953a71f953265715e8abb2 # @tangle-network/agent-eval@0.145.15 + ref: 6c0966c5bf44169656343d81184b8e8ec818da8a # @tangle-network/agent-eval@0.145.17 path: .cohort/agent-eval persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-knowledge - ref: fd0eaa7b525c96304656905314aefba95066a265 # @tangle-network/agent-knowledge@8.0.1 + ref: 173d2306149932d61b0bc0db216050567df88719 # @tangle-network/agent-knowledge@8.0.2 path: .cohort/agent-knowledge persist-credentials: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2cfc110f..162c9f3c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -135,7 +135,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-sdk - ref: a12e37c3ff18642aad455db0c4f6d6c75000ba30 # @tangle-network/agent-interface@0.53.0 + ref: 8de51fe11e9eb568cc96302791feb65683a420c1 # @tangle-network/agent-interface@0.56.0 path: .cohort/agent-sdk persist-credentials: false @@ -143,7 +143,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-eval - ref: d237da433a8b4fac20953a71f953265715e8abb2 # @tangle-network/agent-eval@0.145.15 + ref: 6c0966c5bf44169656343d81184b8e8ec818da8a # @tangle-network/agent-eval@0.145.17 path: .cohort/agent-eval persist-credentials: false @@ -151,7 +151,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-knowledge - ref: fd0eaa7b525c96304656905314aefba95066a265 # @tangle-network/agent-knowledge@8.0.1 + ref: 173d2306149932d61b0bc0db216050567df88719 # @tangle-network/agent-knowledge@8.0.2 path: .cohort/agent-knowledge persist-credentials: false From 410155f1e1f4a92ce3c33ceadc422529eaef8c3e Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 04:55:37 -0600 Subject: [PATCH 11/16] fix(runtime): harden retained interactive recovery --- .github/workflows/ci.yml | 4 +- .github/workflows/publish.yml | 4 +- bench/package.json | 1 + bench/src/agent-graphs-improve.mts | 2 +- bench/src/atom-mcp-e2e.mts | 2 +- bench/src/commit0-gate.mts | 2 +- bench/src/humaneval-repair-gate.mts | 2 +- bench/src/mcp-mount-probe.mts | 2 +- bench/src/quant-arena/quant-loop.mts | 2 +- bench/src/swe-arena/arms.ts | 2 +- docs/api/primitive-catalog.md | 6 +- package.json | 5 +- pnpm-lock.yaml | 61 ++--- pnpm-workspace.yaml | 6 +- src/runtime/environment-provider.test.ts | 11 +- src/runtime/environment-provider.ts | 5 +- src/runtime/index.ts | 2 + .../retained-interactive-control.test.ts | 244 ++++++++++++++++++ src/runtime/retained-interactive-control.ts | 94 +++++++ src/runtime/retained-interactive-lifecycle.ts | 130 ++++++++++ src/runtime/retained-interactive.test.ts | 109 +++++++- src/runtime/retained-interactive.ts | 24 +- src/runtime/retained-run.ts | 4 + src/runtime/stream-agent-turn.test.ts | 20 ++ src/runtime/stream-agent-turn.ts | 8 +- src/runtime/types.ts | 12 +- 26 files changed, 684 insertions(+), 80 deletions(-) create mode 100644 src/runtime/retained-interactive-control.test.ts create mode 100644 src/runtime/retained-interactive-control.ts create mode 100644 src/runtime/retained-interactive-lifecycle.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41aa18e3..6c59e62b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,14 +88,14 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-eval - ref: 6c0966c5bf44169656343d81184b8e8ec818da8a # @tangle-network/agent-eval@0.145.17 + ref: 70e88485ad8a1b46a736c66b6d635a882637e1c6 # @tangle-network/agent-eval@0.145.19 path: .cohort/agent-eval persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-knowledge - ref: 173d2306149932d61b0bc0db216050567df88719 # @tangle-network/agent-knowledge@8.0.2 + ref: 8ee086c416941a063c78990b973c3a9b76d34667 # @tangle-network/agent-knowledge@8.0.4 path: .cohort/agent-knowledge persist-credentials: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 162c9f3c..7dc2cd30 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -143,7 +143,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-eval - ref: 6c0966c5bf44169656343d81184b8e8ec818da8a # @tangle-network/agent-eval@0.145.17 + ref: 70e88485ad8a1b46a736c66b6d635a882637e1c6 # @tangle-network/agent-eval@0.145.19 path: .cohort/agent-eval persist-credentials: false @@ -151,7 +151,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-knowledge - ref: 173d2306149932d61b0bc0db216050567df88719 # @tangle-network/agent-knowledge@8.0.2 + ref: 8ee086c416941a063c78990b973c3a9b76d34667 # @tangle-network/agent-knowledge@8.0.4 path: .cohort/agent-knowledge persist-credentials: false diff --git a/bench/package.json b/bench/package.json index bfbb8d81..8bc36caa 100644 --- a/bench/package.json +++ b/bench/package.json @@ -34,6 +34,7 @@ "gate-report": "tsx src/corpus-report.mts corpus/finsearch.jsonl", "terminal-compare": "tsx src/terminal-compare.ts", "test": "node scripts/run-package-tests.mjs && node --test scripts/run-package-tests.test.mjs scripts/wait-for-published-dependencies.test.mjs", + "typecheck": "tsc --noEmit -p tsconfig.json", "typecheck:public": "tsc -p tsconfig.public.json", "verify:package": "pnpm run verify:package:static && node scripts/verify-packed-consumer.mjs", "verify:package:local-runtime": "pnpm run verify:package:static && node scripts/verify-packed-consumer.mjs --local-runtime", diff --git a/bench/src/agent-graphs-improve.mts b/bench/src/agent-graphs-improve.mts index f6f96295..a830898f 100644 --- a/bench/src/agent-graphs-improve.mts +++ b/bench/src/agent-graphs-improve.mts @@ -226,7 +226,7 @@ export async function callAuthor( const turn = await collectAgentTurn( streamAgentTurn( { kind: 'executor', factory, profile, agentRunName: profile.name ?? 'agent-graphs-author' }, - prompt, + { prompt }, timeoutMs === undefined ? {} : { timeoutMs }, ), ) diff --git a/bench/src/atom-mcp-e2e.mts b/bench/src/atom-mcp-e2e.mts index d14b9a38..686f7e82 100644 --- a/bench/src/atom-mcp-e2e.mts +++ b/bench/src/atom-mcp-e2e.mts @@ -103,7 +103,7 @@ async function bridgeChat(opts: { const turn = await collectAgentTurn( streamAgentTurn( { kind: 'executor', factory, profile }, - opts.messages.map((message) => message.content).join('\n\n'), + { prompt: opts.messages.map((message) => message.content).join('\n\n') }, ), ) if (turn.status !== 'completed') { diff --git a/bench/src/commit0-gate.mts b/bench/src/commit0-gate.mts index 8be533d1..3f46efbc 100644 --- a/bench/src/commit0-gate.mts +++ b/bench/src/commit0-gate.mts @@ -330,7 +330,7 @@ async function runShotLocal(task: BenchTask, attempt: number, cfg: ShotCfg, stee const turn = await collectAgentTurn( streamAgentTurn( { kind: 'executor', factory, profile: workerProfile(cfg, `commit0-local-${attempt}`) }, - prompt, + { prompt }, cfg.timeoutMs > 0 ? { timeoutMs: cfg.timeoutMs } : {}, ), ) diff --git a/bench/src/humaneval-repair-gate.mts b/bench/src/humaneval-repair-gate.mts index 43de640c..97a9b8d1 100644 --- a/bench/src/humaneval-repair-gate.mts +++ b/bench/src/humaneval-repair-gate.mts @@ -89,7 +89,7 @@ async function repairAttempt(cfg: BenchRouterTarget, task: HumanEvalTask, k: num }, }) const r = await collectAgentTurn( - streamAgentTurn({ kind: 'executor', factory, profile }, basePrompt(task)), + streamAgentTurn({ kind: 'executor', factory, profile }, { prompt: basePrompt(task) }), ) if (r.status !== 'completed') { throw new Error(r.error?.message ?? `repair turn ended with status ${r.status}`) diff --git a/bench/src/mcp-mount-probe.mts b/bench/src/mcp-mount-probe.mts index 81f7a148..cdeff007 100644 --- a/bench/src/mcp-mount-probe.mts +++ b/bench/src/mcp-mount-probe.mts @@ -70,7 +70,7 @@ async function bridgeChat(messages: Array<{ role: string; content: string }>, mc const turn = await collectAgentTurn( streamAgentTurn( { kind: 'executor', factory, profile }, - messages.map((message) => message.content).join('\n\n'), + { prompt: messages.map((message) => message.content).join('\n\n') }, ), ) if (turn.status !== 'completed') { diff --git a/bench/src/quant-arena/quant-loop.mts b/bench/src/quant-arena/quant-loop.mts index 7758d467..1c3effc0 100644 --- a/bench/src/quant-arena/quant-loop.mts +++ b/bench/src/quant-arena/quant-loop.mts @@ -249,7 +249,7 @@ async function profileShot(opts: { const turn = await collectAgentTurn( streamAgentTurn( { kind: 'executor', factory, profile: opts.profile }, - opts.prompt, + { prompt: opts.prompt }, { timeoutMs: opts.timeoutMs }, ), ) diff --git a/bench/src/swe-arena/arms.ts b/bench/src/swe-arena/arms.ts index a81bc4a4..92a502a5 100644 --- a/bench/src/swe-arena/arms.ts +++ b/bench/src/swe-arena/arms.ts @@ -525,7 +525,7 @@ export async function runSoloArm(spec: SoloArmSpec, ctx: ArmRunContext): Promise const turn = await collectAgentTurn( streamAgentTurn( { kind: 'executor', factory, profile: spec.profile }, - prompt, + { prompt }, { timeoutMs, ...(ctx.signal ? { signal: ctx.signal } : {}) }, ), ) diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index 7957cdf5..b5dd44dc 100644 --- a/docs/api/primitive-catalog.md +++ b/docs/api/primitive-catalog.md @@ -7,7 +7,7 @@ # Primitive catalog — the never-stale anti-reinvention inventory -> **GENERATED** from `@tangle-network/agent-runtime@0.137.0` and `@tangle-network/agent-eval@0.145.17` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. +> **GENERATED** from `@tangle-network/agent-runtime@0.137.0` and `@tangle-network/agent-eval@0.145.19` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. ## 1. agent-runtime — own public surface @@ -528,7 +528,7 @@ Import from `@tangle-network/agent-runtime/intelligence` — 166 exports. ### Execution kernel — recursive atom, supervision, executors, round-synchronous loop -Import from `@tangle-network/agent-runtime/kernel` — 806 exports. +Import from `@tangle-network/agent-runtime/kernel` — 808 exports. | Symbol | Kind | Summary | |---|---|---| @@ -557,6 +557,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 806 exports. | `captureWorkerTraceEvidence` | function | Collect and persist one executor's structured tool trace without changing its task outcome. | | `chatTransportExecutor` | function | Build one exact profile-driven chat executor through `createExecutor`. | | `chatWorkerSeam` | function | Session-owning worker factory for graph continuity. | +| `claimRetainedInteractiveControl` | function | Acquire provider-issued write authority without reading authority from status. | | `claimsAuthority` | function | True when `text` carries a phrase reserved for the run's authority. Case-insensitive, because | | `classifyDriverFailure` | function | Classify one driver failure. Runtime's own typed refusals are decisions and stay terminal; | | `closingWorkerNote` | function | The worker's closing commentary off a local harness run: the TAIL of its | @@ -850,6 +851,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 806 exports. | `CheckRunner` | interface | Executes the frozen checks against one candidate. Implementations MUST fail loud | | `CheckSource` | interface | Produces the task's visible checks. MUST derive them from agent-visible information | | `CheckSourceCtx` | interface | What a CheckSource composes with. `consult` is the strategy family's raw analyst | +| `ClaimRetainedInteractiveControlOptions` | interface | Input for acquiring write authority over one exact interactive process. | | `CliSeam` | interface | UNMETERED CLI subprocess seam. `bin` + `args` describe the process to spawn. | | `CliWorktreeSeam` | interface | cli-worktree seam. A supervisor-authored `AgentProfile` driving a local coding-harness CLI | | `CollectedAgentTurn` | interface | A drained turn: the terminal summary plus every event the stream yielded. | diff --git a/package.json b/package.json index 03f49e27..7a9fd08c 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,8 @@ "test:watch": "vitest", "lint": "biome check src tests examples", "lint:fix": "biome check --write src tests examples", - "typecheck": "tsc --noEmit && pnpm run typecheck:examples", + "typecheck": "tsc --noEmit && pnpm run typecheck:examples && pnpm run typecheck:bench", + "typecheck:bench": "tsc --noEmit -p bench/tsconfig.json", "typecheck:examples": "tsc --noEmit -p tsconfig.examples.json", "generate:testing-fixture": "tsx scripts/generate-agent-improvement-proposal-fixtures.ts", "check:testing-fixture": "tsx scripts/generate-agent-improvement-proposal-fixtures.ts --check", @@ -170,7 +171,7 @@ "license": "MIT", "packageManager": "pnpm@11.17.0", "peerDependencies": { - "@tangle-network/agent-eval": ">=0.145.17 <0.146.0", + "@tangle-network/agent-eval": ">=0.145.19 <0.146.0", "@tangle-network/agent-interface": ">=0.56.0 <0.57.0", "@tangle-network/sandbox": ">=0.27.0 <0.28.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b556499..41409005 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,17 +13,17 @@ catalogs: specifier: 1.30.0 version: 1.30.0 '@tangle-network/agent-core': - specifier: 0.9.2 - version: 0.9.2 + specifier: 0.9.3 + version: 0.9.3 '@tangle-network/agent-eval': - specifier: 0.145.17 - version: 0.145.17 + specifier: 0.145.19 + version: 0.145.19 '@tangle-network/agent-interface': specifier: 0.56.0 version: 0.56.0 '@tangle-network/agent-knowledge': - specifier: 8.0.2 - version: 8.0.2 + specifier: 8.0.4 + version: 8.0.4 '@tangle-network/agent-profile-materialize': specifier: 0.15.2 version: 0.15.2 @@ -55,10 +55,10 @@ importers: dependencies: '@tangle-network/agent-core': specifier: 'catalog:' - version: 0.9.2(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + version: 0.9.3(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) '@tangle-network/agent-knowledge': specifier: 'catalog:' - version: 8.0.2(@tangle-network/agent-eval@0.145.17(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.56.0) + version: 8.0.4(@tangle-network/agent-eval@0.145.19(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.56.0) '@tangle-network/agent-profile-materialize': specifier: 'catalog:' version: 0.15.2(@tangle-network/agent-interface@0.56.0) @@ -80,7 +80,7 @@ importers: version: 1.30.0(supports-color@10.2.2)(zod@4.4.3) '@tangle-network/agent-eval': specifier: 'catalog:' - version: 0.145.17(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + version: 0.145.19(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) '@tangle-network/agent-interface': specifier: 'catalog:' version: 0.56.0 @@ -128,13 +128,13 @@ importers: dependencies: '@tangle-network/agent-eval': specifier: 'catalog:' - version: 0.145.17(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + version: 0.145.19(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) '@tangle-network/agent-interface': specifier: 'catalog:' version: 0.56.0 '@tangle-network/agent-knowledge': specifier: 'catalog:' - version: 8.0.2(@tangle-network/agent-eval@0.145.17(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.56.0) + version: 8.0.4(@tangle-network/agent-eval@0.145.19(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.56.0) '@tangle-network/agent-runtime': specifier: workspace:* version: link:.. @@ -1132,35 +1132,32 @@ packages: '@modelcontextprotocol/sdk': optional: true - '@tangle-network/agent-core@0.9.2': - resolution: {integrity: sha512-ReN84FwmMnMEWpu6zk9I1ovuE5DBhP30Rx+nqsu1QEo6qzM4rs2T0WA5t4EF5BA1uS3Kybs1jtW/DRUzEdgbcw==} + '@tangle-network/agent-core@0.9.3': + resolution: {integrity: sha512-mxB6Ydw5uJR80FTGBMs3f0z9nO9JfcU0ywoH+846VAql/pIDvinpBwmHE6bpy2KfIF+/SeKiRYbcR5Oe49kn7Q==} peerDependencies: '@modelcontextprotocol/sdk': ^1.30.0 peerDependenciesMeta: '@modelcontextprotocol/sdk': optional: true - '@tangle-network/agent-eval@0.145.17': - resolution: {integrity: sha512-ivXKnFqhJbS6PEDPkbxXWLhf6fs10oFj6ynnV9XkWr8dP/BwhGlgmLwbmGgQO5aW0kdmV/IF5/Zr2KsLWax/hA==} + '@tangle-network/agent-eval@0.145.19': + resolution: {integrity: sha512-S6U2/BfQutEkSK+hvI4BoxZ2sAHEQn9N5lQqif/v5mzGA6ieQF89NRSfcwJG2eVU9AGmRo9E61FpVDuuGEouCQ==} engines: {node: '>=20'} hasBin: true '@tangle-network/agent-interface@0.53.0': resolution: {integrity: sha512-XWH+4t9vPkVog9C9P+094USuoO//TJwaI+P6ntAm32wHqZ/kh5a9r6kSkHYMKsXgQjkY3IA5NO21w3rZ7M6U+g==} - '@tangle-network/agent-interface@0.55.0': - resolution: {integrity: sha512-N92rOPhErm28FoPB9HqA9oFZ+MgTGnmes0NSuir3JqJ6gC42aj3sfhsPdzb/wyYF9PQuxHx+HH5+Ui8+ZBKnKw==} - '@tangle-network/agent-interface@0.56.0': resolution: {integrity: sha512-MFaUB/PHUMfOSpu+9o7LMEWwqlu61Nf5zE9oKEQ++bNM7g4ZbQKFPmdzw8iqTGpG/x6/phF75d+XxTXVi8J0rA==} - '@tangle-network/agent-knowledge@8.0.2': - resolution: {integrity: sha512-KrUsUwhd8mcoR0cW+G2aoUXgcDm8fyAZywcUVBGecVMWcqd3BsAJcmHX9my8G+BrAQXqMN135xl/TkEU8TqzoQ==} + '@tangle-network/agent-knowledge@8.0.4': + resolution: {integrity: sha512-SxshzVY75yWm4TB0QY/0NkSjuY+mKIjU3SPP3Qn2n3ZHI2JksG0fG2wLM9qXA4+n7XuRgv1v7TP3/mYUGUF0mQ==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: - '@tangle-network/agent-eval': '>=0.145.16 <0.146.0' - '@tangle-network/agent-interface': '>=0.54.0 <0.55.0' + '@tangle-network/agent-eval': '>=0.145.18 <0.146.0' + '@tangle-network/agent-interface': '>=0.56.0 <0.57.0' '@tangle-network/agent-profile-materialize@0.15.2': resolution: {integrity: sha512-j9ld23ADJRbAIJEkQ0xk49+RYt47WzARquN5S3W5Pyb3rbD++gxf1AKJQ3O613wmkwdcc0V10a5jHruX0SZ1Vg==} @@ -3191,19 +3188,19 @@ snapshots: optionalDependencies: '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@4.4.3) - '@tangle-network/agent-core@0.9.2(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))': + '@tangle-network/agent-core@0.9.3(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))': dependencies: - '@tangle-network/agent-interface': 0.55.0 + '@tangle-network/agent-interface': 0.56.0 zod: 4.4.3 optionalDependencies: '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@4.4.3) - '@tangle-network/agent-eval@0.145.17(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))': + '@tangle-network/agent-eval@0.145.19(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))': dependencies: '@asteasolutions/zod-to-openapi': 9.1.0(zod@4.4.3) '@hono/node-server': 2.0.12(hono@4.12.32) - '@tangle-network/agent-core': 0.9.2(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) - '@tangle-network/agent-interface': 0.55.0 + '@tangle-network/agent-core': 0.9.3(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + '@tangle-network/agent-interface': 0.56.0 '@tangle-network/agent-trace-contract': 1.0.2 hono: 4.12.32 linear-sum-assignment: 1.0.9 @@ -3218,21 +3215,15 @@ snapshots: spdx-expression-parse: 5.0.0 zod: 4.4.3 - '@tangle-network/agent-interface@0.55.0': - dependencies: - '@noble/hashes': 1.8.0 - spdx-expression-parse: 5.0.0 - zod: 4.4.3 - '@tangle-network/agent-interface@0.56.0': dependencies: '@noble/hashes': 1.8.0 spdx-expression-parse: 5.0.0 zod: 4.4.3 - '@tangle-network/agent-knowledge@8.0.2(@tangle-network/agent-eval@0.145.17(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.56.0)': + '@tangle-network/agent-knowledge@8.0.4(@tangle-network/agent-eval@0.145.19(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.56.0)': dependencies: - '@tangle-network/agent-eval': 0.145.17(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + '@tangle-network/agent-eval': 0.145.19(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) '@tangle-network/agent-interface': 0.56.0 proper-lockfile: 4.1.2 zod: 4.4.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6498ecfe..b5e6b85f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,11 +18,11 @@ allowBuilds: catalog: '@arethetypeswrong/cli': 0.18.5 '@modelcontextprotocol/sdk': 1.30.0 - '@tangle-network/agent-core': 0.9.2 + '@tangle-network/agent-core': 0.9.3 '@types/node': 26.1.1 - '@tangle-network/agent-eval': 0.145.17 + '@tangle-network/agent-eval': 0.145.19 '@tangle-network/agent-interface': 0.56.0 - '@tangle-network/agent-knowledge': 8.0.2 + '@tangle-network/agent-knowledge': 8.0.4 '@tangle-network/agent-profile-materialize': 0.15.2 '@tangle-network/agent-trace-contract': ^1.0.2 '@tangle-network/sandbox': 0.27.0 diff --git a/src/runtime/environment-provider.test.ts b/src/runtime/environment-provider.test.ts index 5585df33..583657f7 100644 --- a/src/runtime/environment-provider.test.ts +++ b/src/runtime/environment-provider.test.ts @@ -7,6 +7,7 @@ import { } from '@tangle-network/agent-interface' import type { BackendType, + CreateRequestOptions, CreateSandboxOptions, SandboxEvent, SandboxInstance, @@ -164,6 +165,7 @@ describe('environment provider adapters', () => { it('adapts a SandboxClient to a neutral provider with create/stream/workspace methods', async () => { let createOptions: CreateSandboxOptions | undefined + let createRequestOptions: CreateRequestOptions | undefined let streamedPrompt: unknown const box = { id: 'sbx-1', @@ -193,8 +195,12 @@ describe('environment provider adapters', () => { async delete(): Promise {}, } as unknown as SandboxInstance const client: SandboxClient = { - async create(options?: CreateSandboxOptions): Promise { + async create( + options?: CreateSandboxOptions, + requestOptions?: CreateRequestOptions, + ): Promise { createOptions = options + createRequestOptions = requestOptions return box }, describePlacement() { @@ -203,6 +209,7 @@ describe('environment provider adapters', () => { } const provider = sandboxClientAsProvider(client) + const controller = new AbortController() const environment = await provider.create({ profile: { name: 'worker' }, backend: 'codex', @@ -214,6 +221,7 @@ describe('environment provider adapters', () => { env: { A: '1' }, secrets: ['SECRET_NAME'], idempotencyKey: 'create-2', + signal: controller.signal, }) const events = await collect(environment.stream({ prompt: 'go' })) @@ -225,6 +233,7 @@ describe('environment provider adapters', () => { secrets: ['SECRET_NAME'], idempotencyKey: 'create-2', }) + expect(createRequestOptions?.signal).toBe(controller.signal) expect(streamedPrompt).toBe('go') expect(events[0]).toMatchObject({ type: 'result', diff --git a/src/runtime/environment-provider.ts b/src/runtime/environment-provider.ts index d75eb551..48343d4a 100644 --- a/src/runtime/environment-provider.ts +++ b/src/runtime/environment-provider.ts @@ -285,7 +285,10 @@ export function sandboxClientAsProvider( options.defaultBackend ?? 'opencode', options.resolveProfile, )) - const box = await client.create(createOptions) + const box = await client.create( + createOptions, + input.signal === undefined ? undefined : { signal: input.signal }, + ) return sandboxInstanceAsEnvironment(box, providerName, client) }, ...(hasGet(client) diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 8d03bcc4..ca7c9e25 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -345,6 +345,8 @@ export { resolveSandboxClient, } from './resolve-sandbox-client' export { + type ClaimRetainedInteractiveControlOptions, + claimRetainedInteractiveControl, type NativeContextContinuationExecution, type NativeContextContinuationInput, type ReconnectRetainedInteractiveRunOptions, diff --git a/src/runtime/retained-interactive-control.test.ts b/src/runtime/retained-interactive-control.test.ts new file mode 100644 index 00000000..f32bf0d1 --- /dev/null +++ b/src/runtime/retained-interactive-control.test.ts @@ -0,0 +1,244 @@ +import type { + AgentInteractiveSessionControlClaim, + AgentInteractiveSessionControlClaimAcknowledgement, + AgentInteractiveSessionControlClaimRequest, + AgentInteractiveSessionRef, +} from '@tangle-network/agent-interface' +import { + AgentInteractiveSessionControlClaimAcknowledgementSchema, + agentExecutionPreparationReceiptSchema, + agentInteractiveSessionControlClaimMatchesRef, + canonicalCandidateDigest, +} from '@tangle-network/agent-interface' +import { describe, expect, it } from 'vitest' +import { claimRetainedInteractiveControl } from './retained-interactive-control' +import type { RetainedInteractiveRunHandle } from './retained-interactive-types' + +const preparationReceipt = { + kind: 'agent-execution-preparation' as const, + schemaVersion: 1 as const, + preparationId: 'preparation-1', + requestDigest: `sha256:${'1'.repeat(64)}`, + authoredProfileDigest: `sha256:${'2'.repeat(64)}`, + effectiveProfileDigest: `sha256:${'2'.repeat(64)}`, + backend: 'test-backend', + harness: 'pi', + harnessVersion: 'test-harness-1', + resolvedModel: { + requested: 'test/model', + resolved: 'test/model', + }, + workspace: { + leaseId: 'workspace-lease-1', + provider: 'test-provider', + identityDigest: `sha256:${'3'.repeat(64)}`, + isolation: 'per-run' as const, + sourceSnapshotDigest: `sha256:${'4'.repeat(64)}`, + sourceSnapshotPolicy: { + kind: 'provider-declared' as const, + name: 'test-snapshot', + version: 1, + digest: `sha256:${'5'.repeat(64)}`, + }, + preparedWorkspaceDigest: `sha256:${'6'.repeat(64)}`, + profileActivationDigest: `sha256:${'7'.repeat(64)}`, + }, + axisResults: [], + executionPlanDigest: `sha256:${'8'.repeat(64)}`, + materializer: { name: 'test-materializer', version: '1' }, + expiresAtMs: 4102444800000, +} + +const ref: AgentInteractiveSessionRef = { + run: { + provider: 'test-provider', + environmentId: 'environment-1', + sessionId: 'session-1', + executionId: 'execution-1', + runId: 'run-1', + requestDigest: `sha256:${'1'.repeat(64)}`, + }, + preparationReceipt: agentExecutionPreparationReceiptSchema.parse({ + ...preparationReceipt, + digest: canonicalCandidateDigest(preparationReceipt), + }), + incarnationId: 'incarnation-1', + startedAt: '2026-08-16T00:00:00.000Z', +} + +describe('claimRetainedInteractiveControl', () => { + it('discovers the launch generation and acquires the next exact claim', async () => { + const requests: AgentInteractiveSessionControlClaimRequest[] = [] + const handle = interactiveHandle(async (request) => { + requests.push(request) + if (request.expectedGeneration === 0) { + return acknowledgement(request, 'conflict', { + conflictReason: 'generation_mismatch', + currentGeneration: 1, + }) + } + return acknowledgement(request, 'accepted', { + control: control(request, request.expectedGeneration + 1), + }) + }) + + const claimed = await claimRetainedInteractiveControl({ handle, holderId: 'braid-ui' }) + + expect(requests.map((request) => request.expectedGeneration)).toEqual([0, 1]) + expect(requests[0]?.operationId).not.toBe(requests[1]?.operationId) + expect(claimed).toMatchObject({ generation: 2, holderId: 'braid-ui' }) + expect(agentInteractiveSessionControlClaimMatchesRef(ref, claimed)).toBe(true) + }) + + it('reuses one operation identity when an ambiguous acquisition is retried', async () => { + const operationIds: string[] = [] + const handle = interactiveHandle(async (request) => { + operationIds.push(request.operationId) + return acknowledgement(request, 'unknown', { + message: 'transport interrupted', + retryable: true, + }) + }) + + await expect(claimRetainedInteractiveControl({ handle, holderId: 'braid-ui' })).rejects.toThrow( + 'outcome is unknown', + ) + await expect(claimRetainedInteractiveControl({ handle, holderId: 'braid-ui' })).rejects.toThrow( + 'outcome is unknown', + ) + + expect(operationIds).toEqual([operationIds[0], operationIds[0]]) + }) + + it('follows multiple concurrent generation advances with fresh request identities', async () => { + const requests: AgentInteractiveSessionControlClaimRequest[] = [] + const handle = interactiveHandle(async (request) => { + requests.push(request) + if (request.expectedGeneration === 0) { + return acknowledgement(request, 'conflict', { + conflictReason: 'generation_mismatch', + currentGeneration: 2, + }) + } + if (request.expectedGeneration === 2) { + return acknowledgement(request, 'conflict', { + conflictReason: 'generation_mismatch', + currentGeneration: 5, + }) + } + return acknowledgement(request, 'accepted', { + control: control(request, request.expectedGeneration + 1), + }) + }) + + const claimed = await claimRetainedInteractiveControl({ handle, holderId: 'braid-ui' }) + + expect(requests.map((request) => request.expectedGeneration)).toEqual([0, 2, 5]) + expect(new Set(requests.map((request) => request.operationId)).size).toBe(3) + expect(new Set(requests.map((request) => request.requestDigest)).size).toBe(3) + expect(claimed.generation).toBe(6) + }) + + it('starts from a caller-known generation', async () => { + const requests: AgentInteractiveSessionControlClaimRequest[] = [] + const handle = interactiveHandle(async (request) => { + requests.push(request) + return acknowledgement(request, 'accepted', { + control: control(request, request.expectedGeneration + 1), + }) + }) + + const claimed = await claimRetainedInteractiveControl({ + handle, + holderId: 'braid-ui', + expectedGeneration: 9, + }) + + expect(requests.map((request) => request.expectedGeneration)).toEqual([9]) + expect(claimed.generation).toBe(10) + }) + + it('stops before a retry when the caller aborts after a conflict', async () => { + const controller = new AbortController() + const requests: AgentInteractiveSessionControlClaimRequest[] = [] + const handle = interactiveHandle(async (request) => { + requests.push(request) + controller.abort('stop control acquisition') + return acknowledgement(request, 'conflict', { + conflictReason: 'generation_mismatch', + currentGeneration: 1, + }) + }) + + await expect( + claimRetainedInteractiveControl({ + handle, + holderId: 'braid-ui', + signal: controller.signal, + }), + ).rejects.toMatchObject({ name: 'AbortError', message: 'stop control acquisition' }) + expect(requests).toHaveLength(1) + }) + + it('rejects a provider generation that does not advance', async () => { + const handle = interactiveHandle(async (request) => + acknowledgement(request, 'conflict', { + conflictReason: 'generation_mismatch', + currentGeneration: request.expectedGeneration, + }), + ) + + await expect(claimRetainedInteractiveControl({ handle, holderId: 'braid-ui' })).rejects.toThrow( + 'non-advancing', + ) + }) +}) + +function interactiveHandle( + claim: ( + request: AgentInteractiveSessionControlClaimRequest, + ) => Promise, +): RetainedInteractiveRunHandle { + return { + ref, + capabilities: {} as RetainedInteractiveRunHandle['capabilities'], + claimControl: claim, + status: async () => ({ state: 'running', ref }), + attach: async () => { + throw new Error('not used') + }, + sendPrompt: async () => { + throw new Error('not used') + }, + stop: async () => { + throw new Error('not used') + }, + } +} + +function control( + request: AgentInteractiveSessionControlClaimRequest, + generation: number, +): AgentInteractiveSessionControlClaim { + return { + refDigest: canonicalCandidateDigest(request.ref), + generation, + leaseId: `lease-${generation}`, + holderId: request.holderId, + expiresAt: '2099-01-01T00:00:00.000Z', + } +} + +function acknowledgement( + request: AgentInteractiveSessionControlClaimRequest, + status: AgentInteractiveSessionControlClaimAcknowledgement['status'], + fields: Partial, +): AgentInteractiveSessionControlClaimAcknowledgement { + return AgentInteractiveSessionControlClaimAcknowledgementSchema.parse({ + operationId: request.operationId, + requestDigest: request.requestDigest, + ref: request.ref, + status, + ...fields, + }) +} diff --git a/src/runtime/retained-interactive-control.ts b/src/runtime/retained-interactive-control.ts new file mode 100644 index 00000000..dad0e341 --- /dev/null +++ b/src/runtime/retained-interactive-control.ts @@ -0,0 +1,94 @@ +import type { AgentInteractiveSessionControlClaim } from '@tangle-network/agent-interface' +import { + agentInteractiveSessionControlClaimRequestDigest, + canonicalCandidateDigest, +} from '@tangle-network/agent-interface' +import type { RetainedInteractiveRunHandle } from './retained-interactive-types' +import { abortError } from './retained-run-binding' + +const MAX_GENERATION_CONFLICTS = 8 + +/** Input for acquiring write authority over one exact interactive process. @stable */ +export interface ClaimRetainedInteractiveControlOptions { + readonly handle: RetainedInteractiveRunHandle + readonly holderId: string + /** Last known provider generation. Zero discovers the current generation safely. */ + readonly expectedGeneration?: number + readonly signal?: AbortSignal +} + +/** + * Acquire provider-issued write authority without reading authority from status. + * + * A new coordinator starts at generation zero. If another claim already exists, + * the provider returns its public generation and this helper retries one new + * compare-and-swap operation. Every generation has a deterministic operation + * identifier, so retrying after an ambiguous response cannot create two claims. + * @stable + */ +export async function claimRetainedInteractiveControl( + options: ClaimRetainedInteractiveControlOptions, +): Promise { + const expected = options.expectedGeneration ?? 0 + if (!Number.isSafeInteger(expected) || expected < 0) { + throw new Error('interactive control expectedGeneration must be a non-negative integer') + } + + let generation = expected + for (let conflict = 0; conflict <= MAX_GENERATION_CONFLICTS; conflict += 1) { + if (options.signal?.aborted) throw abortError(options.signal.reason) + const material = { + operationId: controlClaimOperationId(options.handle, options.holderId, generation), + ref: options.handle.ref, + holderId: options.holderId, + expectedGeneration: generation, + } + const acknowledgement = await options.handle.claimControl( + { + ...material, + requestDigest: agentInteractiveSessionControlClaimRequestDigest(material), + }, + options.signal === undefined ? undefined : { signal: options.signal }, + ) + if (acknowledgement.status === 'accepted' || acknowledgement.status === 'replayed') { + const control = acknowledgement.control + if (control === undefined) { + throw new Error('provider accepted interactive control without returning its claim') + } + if (Date.parse(control.expiresAt) <= Date.now()) { + throw new Error('provider returned an expired interactive control claim') + } + return control + } + if ( + acknowledgement.status !== 'conflict' || + acknowledgement.conflictReason !== 'generation_mismatch' + ) { + throw new Error( + acknowledgement.status === 'unknown' + ? 'interactive control claim outcome is unknown; retry the same acquisition' + : 'interactive control claim operation conflicts with different request material', + ) + } + const current = acknowledgement.currentGeneration + if (current === undefined || current <= generation) { + throw new Error('provider returned a non-advancing interactive control generation') + } + generation = current + } + throw new Error('interactive control changed too often to acquire safely') +} + +function controlClaimOperationId( + handle: RetainedInteractiveRunHandle, + holderId: string, + expectedGeneration: number, +): string { + const digest = canonicalCandidateDigest({ + kind: 'retained-interactive-control-claim.v1', + ref: handle.ref, + holderId, + expectedGeneration, + }) + return `interactive-claim-${digest.slice('sha256:'.length, 'sha256:'.length + 40)}` +} diff --git a/src/runtime/retained-interactive-lifecycle.ts b/src/runtime/retained-interactive-lifecycle.ts new file mode 100644 index 00000000..36ac09b3 --- /dev/null +++ b/src/runtime/retained-interactive-lifecycle.ts @@ -0,0 +1,130 @@ +import type { AgentEnvironment } from '@tangle-network/agent-interface/environment-provider' +import { abortError } from './retained-run-binding' + +const INTERACTIVE_CLEANUP_TIMEOUT_MS = 30_000 + +/** Create one environment while retaining ownership if cancellation wins the race. */ +export async function createInteractiveEnvironment( + create: () => Promise, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) throw abortError(signal.reason) + const creation = Promise.resolve().then(create) + if (signal === undefined) return creation + + return new Promise((resolve, reject) => { + let owner: 'pending' | 'caller' | 'runtime' = 'pending' + const removeAbortListener = () => signal.removeEventListener('abort', onAbort) + const onAbort = () => { + if (owner !== 'pending') return + owner = 'runtime' + removeAbortListener() + disposeInteractiveEnvironment(creation, signal.reason) + reject(abortError(signal.reason)) + } + + signal.addEventListener('abort', onAbort, { once: true }) + creation.then( + (environment) => { + if (owner !== 'pending') return + owner = 'caller' + removeAbortListener() + resolve(environment) + }, + (error) => { + if (owner !== 'pending') return + owner = 'runtime' + removeAbortListener() + reject(error) + }, + ) + }) +} + +/** Start one process while destroying its unreturned environment on cancellation. */ +export async function startInteractiveProcess( + environment: AgentEnvironment, + start: () => Promise, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) { + disposeInteractiveEnvironment(Promise.resolve(environment), signal.reason) + throw abortError(signal.reason) + } + const pending = Promise.resolve().then(start) + if (signal === undefined) return pending + + return new Promise((resolve, reject) => { + let owner: 'pending' | 'caller' | 'runtime' = 'pending' + const removeAbortListener = () => signal.removeEventListener('abort', onAbort) + const onAbort = () => { + if (owner !== 'pending') return + owner = 'runtime' + removeAbortListener() + disposeInteractiveEnvironment(Promise.resolve(environment), signal.reason) + reject(abortError(signal.reason)) + } + + signal.addEventListener('abort', onAbort, { once: true }) + pending.then( + (result) => { + if (owner !== 'pending') return + owner = 'caller' + removeAbortListener() + resolve(result) + }, + (error) => { + if (owner !== 'pending') return + owner = 'runtime' + removeAbortListener() + reject(error) + }, + ) + }) +} + +/** Destroy an environment with fresh, bounded cleanup authority. */ +export async function destroyInteractiveEnvironment(environment: AgentEnvironment): Promise { + if (!environment.destroy) { + throw new Error(`provider environment "${environment.id}" does not expose destroy()`) + } + + const controller = new AbortController() + const timeout = new Error('interactive environment cleanup timed out') + let timer: ReturnType | undefined + const destruction = Promise.resolve().then(() => + environment.destroy?.({ signal: controller.signal }), + ) + void destruction.catch(() => undefined) + try { + await Promise.race([ + destruction, + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + controller.abort(timeout) + reject(timeout) + }, INTERACTIVE_CLEANUP_TIMEOUT_MS) + }), + ]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + +function disposeInteractiveEnvironment( + environment: Promise, + abortReason: unknown, +): void { + void environment + .then(destroyInteractiveEnvironment, () => undefined) + .catch((cleanupError: unknown) => { + const reason = abortReason instanceof Error ? abortReason : new Error(String(abortReason)) + const failure = new AggregateError( + [reason, cleanupError], + 'cancelled interactive environment cleanup failed', + ) + process.emitWarning(failure, { + code: 'AGENT_RUNTIME_INTERACTIVE_CLEANUP_FAILED', + }) + }) +} diff --git a/src/runtime/retained-interactive.test.ts b/src/runtime/retained-interactive.test.ts index 3b09eb9d..22bbc92d 100644 --- a/src/runtime/retained-interactive.test.ts +++ b/src/runtime/retained-interactive.test.ts @@ -26,6 +26,7 @@ import { recoverRetainedInteractiveRun, startRetainedInteractiveRun, } from './retained-interactive' +import { claimRetainedInteractiveControl } from './retained-interactive-control' import type { RetainedInteractiveAdmission } from './retained-run-types' const profile: AgentProfile = { @@ -80,21 +81,19 @@ describe('retained interactive runs', () => { }) expect(handle.ref.run.sessionId).toBe('retained-session:workspace-1:native-turn-1') expect((await handle.status()).state).toBe('running') - const claim = await handle.claimControl(controlClaimRequest(handle.ref)) - expect(claim.status).toBe('accepted') - if (!claim.control) throw new Error('expected a provider control claim') + const control = await claimRetainedInteractiveControl({ handle, holderId: 'braid-ui' }) const promptAcknowledgement = await handle.sendPrompt( - promptCommand(handle.ref, claim.control, 'Run tests.'), + promptCommand(handle.ref, control, 'Run tests.'), ) expect(promptAcknowledgement).toMatchObject({ status: 'accepted', operationId: `${handle.ref.run.runId}:prompt`, }) expect(fixture.prompts).toEqual(['Run tests.']) - const terminal = await handle.attach({ control: claim.control }) + const terminal = await handle.attach({ control }) expect(terminal.ref.parentExecutionId).toBe(handle.ref.run.executionId) - expect(terminal.control).toEqual(claim.control) - const stopAcknowledgement = await handle.stop(stopCommand(handle.ref, claim.control)) + expect(terminal.control).toEqual(control) + const stopAcknowledgement = await handle.stop(stopCommand(handle.ref, control)) expect(stopAcknowledgement).toMatchObject({ status: 'accepted', effect: 'stopped' }) }) @@ -524,6 +523,75 @@ describe('retained interactive runs', () => { }, ) + it('destroys an environment that resolves after create cancellation', async () => { + const fixture = interactiveProvider() + const originalCreate = fixture.provider.create.bind(fixture.provider) + let releaseCreate: (() => void) | undefined + let createStarted = false + const createGate = new Promise((resolve) => { + releaseCreate = resolve + }) + fixture.provider.create = async (input) => { + createStarted = true + await createGate + return originalCreate(input) + } + + const controller = new AbortController() + const pending = start(fixture.provider, controller.signal) + await waitFor(() => createStarted) + controller.abort('cancel late create') + + await expect(pending).rejects.toMatchObject({ + name: 'AbortError', + message: 'cancel late create', + }) + releaseCreate?.() + await waitFor(() => fixture.destroyCalls === 1) + + expect(fixture.environmentCreations).toBe(1) + expect(fixture.destroyCalls).toBe(1) + }) + + it('destroys the environment with fresh authority when start cancellation wins', async () => { + const fixture = interactiveProvider() + const originalCreate = fixture.provider.create.bind(fixture.provider) + let releaseStart: (() => void) | undefined + let startEntered = false + const startGate = new Promise((resolve) => { + releaseStart = resolve + }) + fixture.provider.create = async (input) => { + const environment = await originalCreate(input) + const originalStart = environment.startInteractive?.bind(environment) + if (!originalStart) throw new Error('fixture does not expose interactive start') + environment.startInteractive = async (request, options) => { + startEntered = true + await startGate + return originalStart(request, options) + } + return environment + } + + const controller = new AbortController() + const pending = start(fixture.provider, controller.signal) + await waitFor(() => startEntered) + controller.abort('cancel late start') + + await expect(pending).rejects.toMatchObject({ + name: 'AbortError', + message: 'cancel late start', + }) + await waitFor(() => fixture.destroyCalls === 1) + expect(fixture.destroySignal).toBeDefined() + expect(fixture.destroySignal).not.toBe(controller.signal) + expect(fixture.destroySignal?.aborted).toBe(false) + + releaseStart?.() + await waitFor(() => fixture.processStarts === 1) + expect(fixture.destroyCalls).toBe(1) + }) + it.each(['claimControl', 'status', 'attach', 'sendPrompt', 'stop'] as const)( 'cancels a hanging interactive %s call', async (hangAt) => { @@ -636,6 +704,7 @@ interface ProviderFixture { readonly startCalls: number readonly processStarts: number readonly destroyCalls: number + readonly destroySignal?: AbortSignal readonly hangingCalls: number hangAt?: HangPoint statusRef?: AgentInteractiveSessionRef @@ -670,12 +739,14 @@ function interactiveProvider( startCalls: 0, processStarts: 0, destroyCalls: 0, + destroySignal: undefined as AbortSignal | undefined, hangingCalls: 0, statusRef: undefined as AgentInteractiveSessionRef | undefined, } const environmentKeys = new Set() let hangAt = options.hangAt let ref: AgentInteractiveSessionRef | undefined + let controlGeneration = 1 let lost = false const terminal = (): Omit => ({ ref: { @@ -707,7 +778,18 @@ function interactiveProvider( fixture.hangingCalls += 1 return neverPending() } - const control = controlFor(ref!, request.holderId, request.expectedGeneration + 1) + if (request.expectedGeneration !== controlGeneration) { + return { + operationId: request.operationId, + requestDigest: request.requestDigest, + ref: ref!, + status: 'conflict' as const, + conflictReason: 'generation_mismatch' as const, + currentGeneration: controlGeneration, + } + } + controlGeneration += 1 + const control = controlFor(ref!, request.holderId, controlGeneration) return { operationId: request.operationId, requestDigest: request.requestDigest, @@ -831,8 +913,9 @@ function interactiveProvider( return ref }, interactive: () => session(), - destroy: async () => { + destroy: async (destroyOptions) => { fixture.destroyCalls += 1 + fixture.destroySignal = destroyOptions?.signal }, } const provider: AgentEnvironmentProvider = { @@ -873,6 +956,7 @@ function interactiveProvider( startCalls: { get: () => fixture.startCalls }, processStarts: { get: () => fixture.processStarts }, destroyCalls: { get: () => fixture.destroyCalls }, + destroySignal: { get: () => fixture.destroySignal }, hangingCalls: { get: () => fixture.hangingCalls }, hangAt: { get: () => hangAt, @@ -957,6 +1041,13 @@ async function waitForHangingCall(fixture: ProviderFixture, expected: number): P expect(fixture.hangingCalls).toBeGreaterThanOrEqual(expected) } +async function waitFor(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 40 && !predicate(); attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + expect(predicate()).toBe(true) +} + function interactiveCapabilities(complete: boolean): AgentEnvironmentCapabilities { return { profile: { diff --git a/src/runtime/retained-interactive.ts b/src/runtime/retained-interactive.ts index 5cdaf1ff..7da495b6 100644 --- a/src/runtime/retained-interactive.ts +++ b/src/runtime/retained-interactive.ts @@ -22,6 +22,11 @@ import { createRetainedInteractiveRunHandle, freezeInteractiveRef, } from './retained-interactive-handle' +import { + createInteractiveEnvironment, + destroyInteractiveEnvironment, + startInteractiveProcess, +} from './retained-interactive-lifecycle' import type { ReconnectRetainedInteractiveRunOptions, RecoverRetainedInteractiveRunOptions, @@ -80,8 +85,8 @@ export async function startRetainedInteractiveRun( ) assertInteractiveCapabilities(options.provider.name, providerCapabilities) - const environment = await awaitAbortable( - Promise.resolve().then(() => + const environment = await createInteractiveEnvironment( + () => options.provider.create({ ...options.environment, profile, @@ -97,7 +102,6 @@ export async function startRetainedInteractiveRun( executionId: identity.executionId, }, }), - ), options.signal, ) let capabilities: AgentEnvironmentCapabilities @@ -111,7 +115,7 @@ export async function startRetainedInteractiveRun( ) assertInteractiveMethods(options.provider.name, environment) } catch (error) { - await destroyUnusedEnvironment(environment, error, options.signal) + await destroyUnusedEnvironment(environment, error) throw error } @@ -133,12 +137,12 @@ export async function startRetainedInteractiveRun( const ref = exactStartedRef( request, - await awaitAbortable( - Promise.resolve().then(() => + await startInteractiveProcess( + environment, + () => environment.startInteractive!(request, { signal: options.signal, }), - ), options.signal, ), ) @@ -502,13 +506,9 @@ function assertInteractiveMethods(providerName: string, environment: AgentEnviro async function destroyUnusedEnvironment( environment: AgentEnvironment, cause: unknown, - signal?: AbortSignal, ): Promise { try { - await awaitAbortable( - Promise.resolve().then(() => environment.destroy?.({ signal })), - signal, - ) + await destroyInteractiveEnvironment(environment) } catch (cleanupError) { throw new AggregateError( [cause, cleanupError], diff --git a/src/runtime/retained-run.ts b/src/runtime/retained-run.ts index 28fce61b..1f4da871 100644 --- a/src/runtime/retained-run.ts +++ b/src/runtime/retained-run.ts @@ -10,6 +10,10 @@ export { recoverRetainedInteractiveRun, startRetainedInteractiveRun, } from './retained-interactive' +export { + type ClaimRetainedInteractiveControlOptions, + claimRetainedInteractiveControl, +} from './retained-interactive-control' export type { ReconnectRetainedInteractiveRunOptions, RecoverRetainedInteractiveRunOptions, diff --git a/src/runtime/stream-agent-turn.test.ts b/src/runtime/stream-agent-turn.test.ts index df357684..cc9db7c1 100644 --- a/src/runtime/stream-agent-turn.test.ts +++ b/src/runtime/stream-agent-turn.test.ts @@ -870,6 +870,26 @@ describe('streamAgentTurn: chat backend', () => { expect(final.metadata).not.toHaveProperty('costUsd') }) + it('uses the final provider message as the normalized task intent', async () => { + const seen: RuntimeStreamEvent[] = [] + for await (const event of streamObservedAgentTurn( + { kind: 'chat', backend: stubChatBackend() }, + { + providerOptions: { + messages: [ + { role: 'system', content: 'Keep the change small.' }, + { role: 'user', content: 'Fix the failing release check.' }, + ], + }, + }, + )) { + seen.push(event) + } + + const delta = seen.find((event) => event.type === 'text_delta') + expect(delta?.task?.intent).toBe('Fix the failing release check.') + }) + it('abort mid-stream terminates with status aborted after partial deltas', async () => { const controller = new AbortController() const stream = streamObservedAgentTurn( diff --git a/src/runtime/stream-agent-turn.ts b/src/runtime/stream-agent-turn.ts index 0905dd90..d4abb380 100644 --- a/src/runtime/stream-agent-turn.ts +++ b/src/runtime/stream-agent-turn.ts @@ -99,7 +99,11 @@ import type { ProfileMaterializationReceipt, UsageEvent, } from './supervise/types' -import { promptFromAgentTurnInput, promptOptionsFromAgentTurnInput } from './turn-input' +import { + promptFromAgentTurnInput, + promptOptionsFromAgentTurnInput, + providerMessageText, +} from './turn-input' /** * The execution substrate one turn runs on — a closed discriminated union over @@ -184,7 +188,7 @@ export interface StreamAgentTurnOptions { function turnIntent(input: AgentTurnInput): string { if (input.prompt !== undefined) return input.prompt if (input.parts !== undefined) return renderInputPartsAsText(input.parts) - return 'structured agent turn' + return providerMessageText(input.providerOptions) ?? 'structured agent turn' } function turnBackendInput(task: AgentTaskSpec, input: AgentTurnInput): AgentBackendInput { diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 0cd44311..8d994449 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -14,7 +14,12 @@ import type { DefaultVerdict } from '@tangle-network/agent-eval' import type { AgentProfile } from '@tangle-network/agent-interface' -import type { CreateSandboxOptions, SandboxEvent, SandboxInstance } from '@tangle-network/sandbox' +import type { + CreateRequestOptions, + CreateSandboxOptions, + SandboxEvent, + SandboxInstance, +} from '@tangle-network/sandbox' import type { RuntimeHooks } from '../runtime-hooks' import type { RuntimeRunHandle } from '../runtime-run' @@ -336,7 +341,10 @@ export interface LoopResult { * @stable */ export interface SandboxClient { - create(options?: CreateSandboxOptions): Promise + create( + options?: CreateSandboxOptions, + requestOptions?: CreateRequestOptions, + ): Promise describePlacement?(box: SandboxInstance): LoopSandboxPlacement /** * Optional legacy CRIU capability probe. When present and it resolves From fe9db44bb83799b3c3320844b6a202582ceff1b0 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 06:28:23 -0600 Subject: [PATCH 12/16] fix(runtime): admit retained runs before create --- docs/agent-managed-compute/roadmap.md | 9 +- docs/api/index.md | 20 +- docs/api/primitive-catalog.md | 9 +- docs/api/runtime.md | 296 ++++++++++++++++++++++++-- docs/api/testing.md | 12 +- docs/canonical-api.md | 4 +- src/errors.ts | 2 +- src/runtime/index.ts | 3 + src/runtime/retained-interactive.ts | 35 +-- src/runtime/retained-run-intent.ts | 51 +++++ src/runtime/retained-run-start.ts | 136 ++++++++++-- src/runtime/retained-run-types.ts | 49 ++++- src/runtime/retained-run.test.ts | 178 +++++++++++++++- src/runtime/retained-run.ts | 3 + src/runtime/turn-input.test.ts | 27 +++ src/runtime/turn-input.ts | 13 +- tests/helpers/retained-run-child.ts | 45 +++- 17 files changed, 781 insertions(+), 111 deletions(-) create mode 100644 src/runtime/retained-run-intent.ts create mode 100644 src/runtime/turn-input.test.ts diff --git a/docs/agent-managed-compute/roadmap.md b/docs/agent-managed-compute/roadmap.md index 89e2f9f2..28276774 100644 --- a/docs/agent-managed-compute/roadmap.md +++ b/docs/agent-managed-compute/roadmap.md @@ -57,10 +57,11 @@ The work is ordered to prove the two-agent atom before adding scale. - Extend the existing run record with revisions, ownership generation, commands, provider references, and coordination events. - Add durable adapters with conditional writes. - Persist dispatch intent before provider creation. - Partially done: `startRetainedRun` persists admission after creation and dispatch. - `startRetainedRunInEnvironment` applies the same boundary to a fresh session in an existing environment. - `recoverRetainedRun` rebuilds a run from the pre-dispatch record. - The pre-creation intent record remains open. + `startRetainedRun` persists a digest-only intent before creation, then exact + environment and dispatch admissions. + `startRetainedRunInEnvironment` uses the same retained admissions for a fresh session in an existing environment. + `recoverRetainedRun` rebuilds a run from either a pre-create intent or a + pre-dispatch environment record. - Add a durable provider-command outbox with coordinator generation and command sequence. - Rebuild budget reservations and interaction state on restart. - Adapt `SpawnJournal`, `ConversationJournal`, and delegation status onto the shared internal record. diff --git a/docs/api/index.md b/docs/api/index.md index b02c6afb..49f6c4e3 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1086,7 +1086,7 @@ The caller could not persist one detached-run recovery record. ##### phase -> `readonly` **phase**: `"environment"` \| `"dispatched"` +> `readonly` **phase**: `"intent"` \| `"environment"` \| `"dispatched"` ###### Inherited from @@ -8699,7 +8699,7 @@ One tree-wide view of simultaneous spawned work. Every nested scope reads the sa ##### spawn() -> **spawn**\<`C`\>(`agent`, `task`, `opts`): \{ `ok`: `true`; `handle`: [`Handle`](runtime.md#handle-2)\<`C`\>; `prior?`: [`SpawnPrior`](runtime.md#spawnprior)\<`C`\>; \} \| \{ `ok`: `false`; `reason`: [`SpawnRejection`](runtime.md#spawnrejection); \} +> **spawn**\<`C`\>(`agent`, `task`, `opts`): \{ `ok`: `true`; `handle`: [`Handle`](runtime.md#handle-3)\<`C`\>; `prior?`: [`SpawnPrior`](runtime.md#spawnprior)\<`C`\>; \} \| \{ `ok`: `false`; `reason`: [`SpawnRejection`](runtime.md#spawnrejection); \} Spawn a child. For a fresh key or an unkeyed spawn, tree-wide worker admission happens before a lazy factory is called, so a full worker allocation creates no worker, executor, or reservation. @@ -8733,7 +8733,7 @@ work: it returns the committed result on `prior` (see `SpawnOpts.key`). ###### Returns -\{ `ok`: `true`; `handle`: [`Handle`](runtime.md#handle-2)\<`C`\>; `prior?`: [`SpawnPrior`](runtime.md#spawnprior)\<`C`\>; \} \| \{ `ok`: `false`; `reason`: [`SpawnRejection`](runtime.md#spawnrejection); \} +\{ `ok`: `true`; `handle`: [`Handle`](runtime.md#handle-3)\<`C`\>; `prior?`: [`SpawnPrior`](runtime.md#spawnprior)\<`C`\>; \} \| \{ `ok`: `false`; `reason`: [`SpawnRejection`](runtime.md#spawnrejection); \} ##### next() @@ -8786,7 +8786,7 @@ is a direct call; the sandbox/Agent-Bus transports surface the SAME verb as an M ##### wait() -> **wait**(`spec`, `opts`): \{ `ok`: `true`; `handle`: [`Handle`](runtime.md#handle-2)\<[`WaitOutcome`](runtime.md#waitoutcome)\>; \} \| \{ `ok`: `false`; `reason`: [`WaitRejection`](runtime.md#waitrejection); \} +> **wait**(`spec`, `opts`): \{ `ok`: `true`; `handle`: [`Handle`](runtime.md#handle-3)\<[`WaitOutcome`](runtime.md#waitoutcome)\>; \} \| \{ `ok`: `false`; `reason`: [`WaitRejection`](runtime.md#waitrejection); \} Arm a WAIT-STATE node: a first-class tree node that waits on wall-clock time (`timer`) or on a named external predicate (`poll`) and settles through THIS scope's `next()` cursor like any @@ -8818,7 +8818,7 @@ and nothing about it survives a restart. See `supervise/wait.ts`. ###### Returns -\{ `ok`: `true`; `handle`: [`Handle`](runtime.md#handle-2)\<[`WaitOutcome`](runtime.md#waitoutcome)\>; \} \| \{ `ok`: `false`; `reason`: [`WaitRejection`](runtime.md#waitrejection); \} +\{ `ok`: `true`; `handle`: [`Handle`](runtime.md#handle-3)\<[`WaitOutcome`](runtime.md#waitoutcome)\>; \} \| \{ `ok`: `false`; `reason`: [`WaitRejection`](runtime.md#waitrejection); \} ##### progress() @@ -11822,7 +11822,7 @@ Content-addressed pointer to a persisted `WorkerToolTraceArtifact`. ### Settled -> **Settled**\<`Out`\> = \{ `kind`: `"done"`; `handle`: [`Handle`](runtime.md#handle-2)\<`Out`\>; `out`: `Out`; `outRef`: `string`; `verdict?`: `DefaultVerdict`; `spent`: [`Spend`](#spend); `providerModel?`: [`ProviderModelExecutionEvidence`](#providermodelexecutionevidence); `trace`: [`WorkerTraceEvidence`](#workertraceevidence); `settledAt?`: `number`; `seq`: `number`; \} \| \{ `kind`: `"down"`; `handle`: [`Handle`](runtime.md#handle-2)\<`Out`\>; `reason`: `string`; `infra`: `boolean`; `restartCount`: `number`; `trace`: [`WorkerTraceEvidence`](#workertraceevidence); `providerModel?`: [`ProviderModelExecutionEvidence`](#providermodelexecutionevidence); `settledAt?`: `number`; `seq`: `number`; \} +> **Settled**\<`Out`\> = \{ `kind`: `"done"`; `handle`: [`Handle`](runtime.md#handle-3)\<`Out`\>; `out`: `Out`; `outRef`: `string`; `verdict?`: `DefaultVerdict`; `spent`: [`Spend`](#spend); `providerModel?`: [`ProviderModelExecutionEvidence`](#providermodelexecutionevidence); `trace`: [`WorkerTraceEvidence`](#workertraceevidence); `settledAt?`: `number`; `seq`: `number`; \} \| \{ `kind`: `"down"`; `handle`: [`Handle`](runtime.md#handle-3)\<`Out`\>; `reason`: `string`; `infra`: `boolean`; `restartCount`: `number`; `trace`: [`WorkerTraceEvidence`](#workertraceevidence); `providerModel?`: [`ProviderModelExecutionEvidence`](#providermodelexecutionevidence); `settledAt?`: `number`; `seq`: `number`; \} A settled child, delivered by `scope.next()`. `seq` is the monotonic cursor order `next()` yielded this settlement (B2) — NOT wall-clock — and replay delivers strictly @@ -11838,7 +11838,7 @@ in `seq` order. `outRef` rehydrates `out` from the `ResultBlobStore` on replay. ##### Type Literal -\{ `kind`: `"done"`; `handle`: [`Handle`](runtime.md#handle-2)\<`Out`\>; `out`: `Out`; `outRef`: `string`; `verdict?`: `DefaultVerdict`; `spent`: [`Spend`](#spend); `providerModel?`: [`ProviderModelExecutionEvidence`](#providermodelexecutionevidence); `trace`: [`WorkerTraceEvidence`](#workertraceevidence); `settledAt?`: `number`; `seq`: `number`; \} +\{ `kind`: `"done"`; `handle`: [`Handle`](runtime.md#handle-3)\<`Out`\>; `out`: `Out`; `outRef`: `string`; `verdict?`: `DefaultVerdict`; `spent`: [`Spend`](#spend); `providerModel?`: [`ProviderModelExecutionEvidence`](#providermodelexecutionevidence); `trace`: [`WorkerTraceEvidence`](#workertraceevidence); `settledAt?`: `number`; `seq`: `number`; \} ###### kind @@ -11846,7 +11846,7 @@ in `seq` order. `outRef` rehydrates `out` from the `ResultBlobStore` on replay. ###### handle -> **handle**: [`Handle`](runtime.md#handle-2)\<`Out`\> +> **handle**: [`Handle`](runtime.md#handle-3)\<`Out`\> ###### out @@ -11890,7 +11890,7 @@ Epoch ms parsed from the durable settlement record when available. ##### Type Literal -\{ `kind`: `"down"`; `handle`: [`Handle`](runtime.md#handle-2)\<`Out`\>; `reason`: `string`; `infra`: `boolean`; `restartCount`: `number`; `trace`: [`WorkerTraceEvidence`](#workertraceevidence); `providerModel?`: [`ProviderModelExecutionEvidence`](#providermodelexecutionevidence); `settledAt?`: `number`; `seq`: `number`; \} +\{ `kind`: `"down"`; `handle`: [`Handle`](runtime.md#handle-3)\<`Out`\>; `reason`: `string`; `infra`: `boolean`; `restartCount`: `number`; `trace`: [`WorkerTraceEvidence`](#workertraceevidence); `providerModel?`: [`ProviderModelExecutionEvidence`](#providermodelexecutionevidence); `settledAt?`: `number`; `seq`: `number`; \} ###### kind @@ -11898,7 +11898,7 @@ Epoch ms parsed from the durable settlement record when available. ###### handle -> **handle**: [`Handle`](runtime.md#handle-2)\<`Out`\> +> **handle**: [`Handle`](runtime.md#handle-3)\<`Out`\> ###### reason diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index b5dd44dc..a1ab426d 100644 --- a/docs/api/primitive-catalog.md +++ b/docs/api/primitive-catalog.md @@ -528,7 +528,7 @@ Import from `@tangle-network/agent-runtime/intelligence` — 166 exports. ### Execution kernel — recursive atom, supervision, executors, round-synchronous loop -Import from `@tangle-network/agent-runtime/kernel` — 808 exports. +Import from `@tangle-network/agent-runtime/kernel` — 811 exports. | Symbol | Kind | Summary | |---|---|---| @@ -686,7 +686,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 808 exports. | `reconnectRetainedInteractiveRun` | function | Rebuild controls for one exact provider-owned coding-agent process. | | `reconnectRetainedRun` | function | Rebuild a retained-run client without retaining any object from the starter. | | `recoverRetainedInteractiveRun` | function | Retry one exact start after its provider response may have been lost. | -| `recoverRetainedRun` | function | Rebuild the exact run named by pre-dispatch admission coordinates, or | +| `recoverRetainedRun` | function | Rebuild the exact run named by a persisted pre-create intent or pre-dispatch | | `registerShape` | function | Register a composed shape on the default `builtinShapes` registry — the one-call extension | | `registryScopeAnalyst` | function | A `ScopeAnalyst` backed by an `AnalystRegistry` — the panel-of-analysts seam. The registry merges | | `renderAnytimeTable` | function | One row per (strategy, satisficing target): the shareable time-to-satisfactory table. | @@ -965,6 +965,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 808 exports. | `ReconnectRetainedInteractiveRunOptions` | interface | Reconstruct one exact provider-owned native coding-agent process. | | `ReconnectRetainedRunOptions` | interface | Inputs sufficient to rebuild a control client in a new process. | | `RecoverRetainedInteractiveRunOptions` | interface | Recover a start after a pre-create crash or a lost provider response. | +| `RecoverRetainedRunIntentOptions` | interface | Recover a headless start after its pre-create intent was persisted. | | `RecoverRetainedRunOptions` | interface | Pre-dispatch admission coordinates for one recovery attempt. | | `RegisteredPrompt` | interface | One registry entry: the handle plus the text it pins. | | `RegistryAnalyzeProjection` | interface | Project a `ScopeAnalyzeInput` into the `AnalystRegistry.run` arguments. The registry runs over a | @@ -986,8 +987,10 @@ Import from `@tangle-network/agent-runtime/kernel` — 808 exports. | `RetainedRunEnvironmentAdmission` | interface | Recovery coordinates durable after environment creation and before dispatch. | | `RetainedRunEventOptions` | interface | Options for replaying canonical events strictly after a saved point. | | `RetainedRunHandle` | interface | Reconstructable control of one provider-retained run. | +| `RetainedRunIntentAdmission` | interface | Sanitized headless intent durable before environment creation. | | `RetainedRunReplayPoint` | interface | Cursor plus runtime sequence needed to continue one ordered replay. | | `RetainedRunSnapshot` | interface | Stable status snapshot for a retained run. | +| `RetainedRunStartMaterial` | interface | Environment, turn, and optional identity needed to replay one retained start. | | `RootHandle` | interface | Live root handle — a chat/pi-viz client uses it to inspect and control one root run. | | `RouterSeam` | interface | Router/inline transport seam. The profile owns model, prompt, and generation behavior. | | `RouterToolsSeam` | interface | Router seam WITH tool use — the tool-using router backend. Same direct | @@ -1138,7 +1141,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 808 exports. | `RetainedInteractiveAdmission` | type | Durable records for one exact native coding-agent process. | | `RetainedInteractiveAdmissionHook` | type | Persist each exact interactive record before the runtime proceeds. | | `RetainedInteractiveEnvironmentInput` | type | Environment and exact AgentProfile used to start one native coding-agent process. | -| `RetainedRunAdmission` | type | One detached-run admission record the runtime persists before dispatch proceeds. | +| `RetainedRunAdmission` | type | One detached-run admission record the runtime persists before creation or dispatch proceeds. | | `RetainedRunAdmissionHook` | type | Awaited durability hook for retained admission records. | | `RetainedRunEffect` | type | Effect recorded for one retained control operation. | | `RootMaterialization` | type | Trusted root composition evidence. Generic `Agent.act` roots omit this and remain unknown. | diff --git a/docs/api/runtime.md b/docs/api/runtime.md index b5186f2b..f8890598 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -920,7 +920,7 @@ Manager-scoped assignment identity, including deterministic ids for unkeyed sibl ###### Inherited from -[`NodeSnapshot`](#nodesnapshot).[`identity`](#identity-8) +[`NodeSnapshot`](#nodesnapshot).[`identity`](#identity-9) ##### materialization? @@ -5146,7 +5146,7 @@ A lineage the gate may widen toward — the settled child that looked promising ###### handle -> **handle**: [`Handle`](#handle-2)\<[`Outcome`](#outcome-2)\<`D`\>\> +> **handle**: [`Handle`](#handle-3)\<[`Outcome`](#outcome-2)\<`D`\>\> ###### out @@ -5916,6 +5916,34 @@ Per-turn deadline (ms). *** +### ClaimRetainedInteractiveControlOptions + +**`Stable`** + +Input for acquiring write authority over one exact interactive process. + +#### Properties + +##### handle + +> `readonly` **handle**: [`RetainedInteractiveRunHandle`](#retainedinteractiverunhandle) + +##### holderId + +> `readonly` **holderId**: `string` + +##### expectedGeneration? + +> `readonly` `optional` **expectedGeneration?**: `number` + +Last known provider generation. Zero discovers the current generation safely. + +##### signal? + +> `readonly` `optional` **signal?**: `AbortSignal` + +*** + ### RetainedInteractiveStartMaterial **`Stable`** @@ -6396,6 +6424,56 @@ Capabilities measured from the exact environment that owns this run. *** +### RetainedRunIntentAdmission + +**`Stable`** + +Sanitized headless intent durable before environment creation. + +The request digest binds the exact create and turn material without retaining +secrets or provider options. The original start material is required to +replay this record after a process crash. + +#### Properties + +##### phase + +> `readonly` **phase**: `"intent"` + +##### provider + +> `readonly` **provider**: `string` + +##### idempotencyKey + +> `readonly` **idempotencyKey**: `string` + +##### turnId + +> `readonly` **turnId**: `string` + +##### sessionId + +> `readonly` **sessionId**: `string` + +##### executionId + +> `readonly` **executionId**: `string` + +##### runId + +> `readonly` **runId**: `string` + +##### requestedProfileDigest + +> `readonly` **requestedProfileDigest**: `` `sha256:${string}` `` + +##### requestDigest + +> `readonly` **requestDigest**: `` `sha256:${string}` `` + +*** + ### RetainedRunEnvironmentAdmission **`Stable`** @@ -6573,17 +6651,67 @@ Provider-issued interactive process reference durable before start returns. *** +### RetainedRunStartMaterial + +**`Stable`** + +Environment, turn, and optional identity needed to replay one retained start. + +#### Extended by + +- [`StartRetainedRunOptions`](#startretainedrunoptions) + +#### Properties + +##### environment + +> `readonly` **environment**: `CreateAgentEnvironmentInput` & `object` + +###### Type Declaration + +###### idempotencyKey + +> **idempotencyKey**: `string` + +##### turn + +> `readonly` **turn**: `AgentTurnInput` & `object` + +###### Type Declaration + +###### turnId + +> **turnId**: `string` + +##### identity? + +> `readonly` `optional` **identity?**: `object` + +Explicit dispatch coordinates. When omitted, the runtime mints +deterministic coordinates from `(environment.idempotencyKey, turn.turnId)` +so every process derives the same values. + +###### sessionId + +> `readonly` **sessionId**: `string` + +###### executionId + +> `readonly` **executionId**: `string` + +*** + ### StartRetainedRunOptions **`Stable`** A retained start is retry-safe only when environment and turn keys are explicit. -#### Properties +#### Extends -##### provider +- [`RetainedRunStartMaterial`](#retainedrunstartmaterial) -> `readonly` **provider**: `AgentEnvironmentProvider` +#### Properties ##### environment @@ -6595,6 +6723,10 @@ A retained start is retry-safe only when environment and turn keys are explicit. > **idempotencyKey**: `string` +###### Inherited from + +[`RetainedRunStartMaterial`](#retainedrunstartmaterial).[`environment`](#environment-2) + ##### turn > `readonly` **turn**: `AgentTurnInput` & `object` @@ -6605,6 +6737,10 @@ A retained start is retry-safe only when environment and turn keys are explicit. > **turnId**: `string` +###### Inherited from + +[`RetainedRunStartMaterial`](#retainedrunstartmaterial).[`turn`](#turn) + ##### identity? > `readonly` `optional` **identity?**: `object` @@ -6621,6 +6757,20 @@ so every process derives the same values. > `readonly` **executionId**: `string` +###### Inherited from + +[`RetainedRunStartMaterial`](#retainedrunstartmaterial).[`identity`](#identity-1) + +##### provider + +> `readonly` **provider**: `AgentEnvironmentProvider` + +##### intent? + +> `readonly` `optional` **intent?**: [`RetainedRunIntentAdmission`](#retainedrunintentadmission) + +A previously persisted intent used to replay the exact create operation. + ##### onAdmission > `readonly` **onAdmission**: [`RetainedRunAdmissionHook`](#retainedrunadmissionhook) @@ -6728,6 +6878,42 @@ Inputs sufficient to rebuild a control client in a new process. *** +### RecoverRetainedRunIntentOptions + +**`Stable`** + +Recover a headless start after its pre-create intent was persisted. + +#### Properties + +##### provider + +> `readonly` **provider**: `AgentEnvironmentProvider` + +##### admission + +> `readonly` **admission**: [`RetainedRunIntentAdmission`](#retainedrunintentadmission) + +##### replay + +> `readonly` **replay**: [`RetainedRunStartMaterial`](#retainedrunstartmaterial) + +The exact original environment, turn, and optional identity material. + +##### onAdmission + +> `readonly` **onAdmission**: [`RetainedRunAdmissionHook`](#retainedrunadmissionhook) + +##### now? + +> `readonly` `optional` **now?**: () => `number` + +###### Returns + +`number` + +*** + ### RecoverRetainedRunOptions **`Stable`** @@ -15462,7 +15648,7 @@ breaker, or a recursive parent. ###### Inherited from -[`SupervisorNodeContext`](#supervisornodecontext).[`runId`](#runid-17) +[`SupervisorNodeContext`](#supervisornodecontext).[`runId`](#runid-18) ##### runNamespace @@ -15508,7 +15694,7 @@ Stable identity of this manager's coordination stream. ###### Inherited from -[`SupervisorNodeContext`](#supervisornodecontext).[`identity`](#identity-3) +[`SupervisorNodeContext`](#supervisornodecontext).[`identity`](#identity-4) ##### assignmentId? @@ -17244,7 +17430,7 @@ Phantom: binds the handle to the supervised run's output type. Type-only — nev ###### Inherited from -[`RootHandle`](#roothandle-1).[`signal`](#signal-24) +[`RootHandle`](#roothandle-1).[`signal`](#signal-25) ##### abort() @@ -18819,7 +19005,7 @@ the kernel falls back to `{ placement: 'sibling', sandboxId: box.id }`. ##### create() -> **create**(`options?`): `Promise`\<`SandboxInstance`\> +> **create**(`options?`, `requestOptions?`): `Promise`\<`SandboxInstance`\> ###### Parameters @@ -18827,6 +19013,10 @@ the kernel falls back to `{ placement: 'sibling', sandboxId: box.id }`. `CreateSandboxOptions` +###### requestOptions? + +`CreateRequestOptions` + ###### Returns `Promise`\<`SandboxInstance`\> @@ -20459,11 +20649,11 @@ Durable records for one exact native coding-agent process. ### RetainedRunAdmission -> **RetainedRunAdmission** = [`RetainedRunEnvironmentAdmission`](#retainedrunenvironmentadmission) \| [`RetainedRunDispatchedAdmission`](#retainedrundispatchedadmission) +> **RetainedRunAdmission** = [`RetainedRunIntentAdmission`](#retainedrunintentadmission) \| [`RetainedRunEnvironmentAdmission`](#retainedrunenvironmentadmission) \| [`RetainedRunDispatchedAdmission`](#retainedrundispatchedadmission) **`Stable`** -One detached-run admission record the runtime persists before dispatch proceeds. +One detached-run admission record the runtime persists before creation or dispatch proceeds. *** @@ -24054,6 +24244,31 @@ that `resolveBenchClient` builds on — reuse this instead of hand-rolling the *** +### claimRetainedInteractiveControl() + +> **claimRetainedInteractiveControl**(`options`): `Promise`\<\{ \}\> + +**`Stable`** + +Acquire provider-issued write authority without reading authority from status. + +A new coordinator starts at generation zero. If another claim already exists, +the provider returns its public generation and this helper retries one new +compare-and-swap operation. Every generation has a deterministic operation +identifier, so retrying after an ambiguous response cannot create two claims. + +#### Parameters + +##### options + +[`ClaimRetainedInteractiveControlOptions`](#claimretainedinteractivecontroloptions) + +#### Returns + +`Promise`\<\{ \}\> + +*** + ### startRetainedInteractiveRun() > **startRetainedInteractiveRun**(`options`): `Promise`\<[`RetainedInteractiveRunHandle`](#retainedinteractiverunhandle)\> @@ -24125,11 +24340,10 @@ Rebuild controls for one exact provider-owned coding-agent process. Dispatch one detached, replayable run and return only after exact durable coordinates are confirmed by the provider and persisted by the caller. -The required `onAdmission` hook is awaited twice: with the recovery -coordinates after environment creation, and with the verified exact control -reference after dispatch. The returned promise resolves only after the -dispatched admission is durable, so no caller can observe a successful start -whose exact reference a crash would lose. +The required `onAdmission` hook first records a digest-only intent before +creation, then records recovery coordinates and the verified exact control +reference. The returned promise resolves only after the dispatched admission +is durable, so a crash cannot lose a successful start's exact reference. #### Parameters @@ -24169,12 +24383,17 @@ must use `RetainedRunHandle.continueNative` for a verified same-chat turn. ### recoverRetainedRun() +#### Call Signature + > **recoverRetainedRun**(`options`): `Promise`\<[`RecoverRetainedRunResult`](#recoverretainedrunresult)\> **`Stable`** -Rebuild the exact run named by pre-dispatch admission coordinates, or -report why the provider cannot prove it. +Rebuild the exact run named by a persisted pre-create intent or pre-dispatch +admission coordinates, or report why the provider cannot prove it. + +An intent recovery replays the exact original start material through +`startRetainedRun`; a changed replay is rejected before provider creation. `not_found`: the provider no longer holds the environment, so nothing remains to destroy. `recovered`: the provider self-identified the session @@ -24187,13 +24406,46 @@ outcome is never destroy-safe: keep the environment, retry environment with provider-native tools. A session that self-identifies with different coordinates throws: something live is not the recorded run. -#### Parameters +##### Parameters -##### options +###### options + +[`RecoverRetainedRunIntentOptions`](#recoverretainedrunintentoptions) + +##### Returns + +`Promise`\<[`RecoverRetainedRunResult`](#recoverretainedrunresult)\> + +#### Call Signature + +> **recoverRetainedRun**(`options`): `Promise`\<[`RecoverRetainedRunResult`](#recoverretainedrunresult)\> + +**`Stable`** + +Rebuild the exact run named by a persisted pre-create intent or pre-dispatch +admission coordinates, or report why the provider cannot prove it. + +An intent recovery replays the exact original start material through +`startRetainedRun`; a changed replay is rejected before provider creation. + +`not_found`: the provider no longer holds the environment, so nothing +remains to destroy. `recovered`: the provider self-identified the session +with a strict exact reference matching the recorded coordinates. +`unverifiable`: the environment exists but the provider cannot +self-identify the session — no session accessor, an accessor that throws, +a lazy accessor with no stored reference, or a loose reference. That +outcome is never destroy-safe: keep the environment, retry +`reconnectRetainedRun` with a dispatched admission record, or inspect the +environment with provider-native tools. A session that self-identifies +with different coordinates throws: something live is not the recorded run. + +##### Parameters + +###### options [`RecoverRetainedRunOptions`](#recoverretainedrunoptions) -#### Returns +##### Returns `Promise`\<[`RecoverRetainedRunResult`](#recoverretainedrunresult)\> @@ -27984,7 +28236,7 @@ and a watched path that was also mounted compares against its mount (never repor The harvest takes no `AbortSignal`: it is pure fan-out over the read seam and waits on nothing itself, so every cancellable moment belongs to the reader. Pass a signal to the reader instead -([BoxSurfaceReaderOptions.signal](#signal-26), or close over one in a custom [SurfaceReader](#surfacereader)) — +([BoxSurfaceReaderOptions.signal](#signal-27), or close over one in a custom [SurfaceReader](#surfacereader)) — that cuts the backoff waits, and the harvest still returns the diffs it did establish rather than discarding settle-time evidence on a late cancellation. diff --git a/docs/api/testing.md b/docs/api/testing.md index 47d79d10..a52943c1 100644 --- a/docs/api/testing.md +++ b/docs/api/testing.md @@ -430,7 +430,7 @@ The run journal the edge ledger and every spawn/settle ride. Default: in-memory. ###### Inherited from -[`RunGraphOptions`](runtime.md#rungraphoptions).[`runId`](runtime.md#runid-12) +[`RunGraphOptions`](runtime.md#rungraphoptions).[`runId`](runtime.md#runid-13) ##### perWorker? @@ -485,7 +485,7 @@ Product authority over every steer/answer instruction (the filter seam). `runGra ###### Inherited from -[`RunGraphOptions`](runtime.md#rungraphoptions).[`signal`](runtime.md#signal-18) +[`RunGraphOptions`](runtime.md#rungraphoptions).[`signal`](runtime.md#signal-19) ##### now? @@ -497,7 +497,7 @@ Product authority over every steer/answer instruction (the filter seam). `runGra ###### Inherited from -[`RunGraphOptions`](runtime.md#rungraphoptions).[`now`](runtime.md#now-8) +[`RunGraphOptions`](runtime.md#rungraphoptions).[`now`](runtime.md#now-9) ##### otel? @@ -586,7 +586,7 @@ root scope and every live child, including acquisition and backend execution. ###### Inherited from -[`SuperviseOptions`](runtime.md#superviseoptions).[`signal`](runtime.md#signal-20) +[`SuperviseOptions`](runtime.md#superviseoptions).[`signal`](runtime.md#signal-21) ##### execution? @@ -1223,7 +1223,7 @@ Give the supervisor brain a chapter-lifecycle on its OWN context window (router ###### Inherited from -[`SuperviseOptions`](runtime.md#superviseoptions).[`runId`](runtime.md#runid-16) +[`SuperviseOptions`](runtime.md#superviseoptions).[`runId`](runtime.md#runid-17) ##### now? @@ -1235,7 +1235,7 @@ Give the supervisor brain a chapter-lifecycle on its OWN context window (router ###### Inherited from -[`SuperviseOptions`](runtime.md#superviseoptions).[`now`](runtime.md#now-15) +[`SuperviseOptions`](runtime.md#superviseoptions).[`now`](runtime.md#now-16) ##### allowedModels? diff --git a/docs/canonical-api.md b/docs/canonical-api.md index 7da8ddd7..4522684a 100644 --- a/docs/canonical-api.md +++ b/docs/canonical-api.md @@ -6,7 +6,7 @@ Run pnpm docs:freshness after editing this file. --> > **Version 0.137.0.** > [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path. -> `agent-eval` must satisfy `>=0.145.17 <0.146.0`. +> `agent-eval` must satisfy `>=0.145.19 <0.146.0`. > `sandbox` must satisfy `>=0.27.0 <0.28.0`. > Portable profile, turn-input, and tool-part types come from `@tangle-network/agent-interface` `>=0.56.0 <0.57.0`. > @@ -144,7 +144,7 @@ A general "loop" primitive is the single most common modelling error in this rep | Run **agent-eval fixture folders** through Runtime `runAgentRounds` | agent-eval fixture loading/planning, then `loopCampaignDispatch(...)`: `/kernel`; it starts the Runtime cell inside Eval's paid-call lifecycle | a one-off `runCampaign` dispatch, or attaching a completed `LoopResult` after paid work already ran | | Run a **recursive `supervise()` tree** through an agent-eval profile matrix | `superviseDispatch({ toTask, toSuperviseOptions, ... })`: `/kernel`; it admits the tree through Eval before Runtime spends, then records its receipt only when Runtime proves one model. Mixed or unknown trees fail instead of being relabelled. | a Lab receipt mapper, a second scheduler, or attaching a completed `SupervisedResult` after paid work already ran | | Run + **resume** ONE persistent box across turns | `openSandboxRun(client, opts, deliverable)`: `/kernel` | a per-domain `new Sandbox`+`box.fs.read`+delete copy | -| Start a retry-safe detached run in a new environment, or a fresh harness chat in one existing environment | `startRetainedRun(...)` or `startRetainedRunInEnvironment(...)`: `/kernel`; both persist exact coordinates before and after dispatch; the existing-environment path also verifies its retained key through provider metadata; only `continueNative(...)` may claim same-chat continuity | calling `provider.create/get/dispatch` directly, reusing an environment as proof of chat continuity, or appending to an unverified native session | +| Start a retry-safe detached run in a new environment, or a fresh harness chat in one existing environment | `startRetainedRun(...)` or `startRetainedRunInEnvironment(...)`: `/kernel`; a new environment persists a digest-only intent before `provider.create`, then exact environment and dispatch coordinates; the existing-environment path verifies its retained key through provider metadata; only `continueNative(...)` may claim same-chat continuity | calling `provider.create/get/dispatch` directly, reusing an environment as proof of chat continuity, or appending to an unverified native session | | Start, recover, or reconnect one native coding-agent TUI | `startRetainedInteractiveRun(...)`, `recoverRetainedInteractiveRun(...)`, or `reconnectRetainedInteractiveRun(...)`: `/kernel`; Runtime persists a sanitized interactive intent before environment creation, exact environment coordinates before process start, and the server-issued process incarnation before returning | calling `environment.startInteractive` directly, replacing the process with a generic shell, or treating a detached headless turn as attachable | | Run **ONE agent turn** on any substrate: box (`streamPrompt`), cli-bridge/router `Executor`, or in-process chat backend: as ONE normalized `RuntimeStreamEvent` stream with a guaranteed terminal result+usage event; pass the shared `AgentTurnInput` so text, image, file, and provider parts stay intact; canonical Sandbox events win over legacy projections, while unknown provider payloads remain observer-only | `streamAgentTurn(backend, agentTurnInput, { signal, timeoutMs, preserveToolParts?, onRawEvent? })` + `collectAgentTurn(stream)`: `/kernel` | a second string/messages turn contract, a per-provider stream→event mapper zoo, a hand-faked box around a non-box executor, or raw fetch leaking through the turn abstraction | | Adapt a Sandbox box to the neutral environment/session contract, or expose a neutral provider to existing Sandbox callers | `sandboxClientAsProvider(client)` / `providerAsSandboxClient(provider)`: `/kernel`; dispatch/reconnect carries the exact `executionId` and run-control reference, every session operation scopes to that execution, and detached interaction requests require declared kind support plus replay and response idempotency | reconstructing control coordinates from metadata, forwarding an unscoped cancel, dispatching unsupported durable interactions, or emitting arbitrary provider payloads into the public stream | diff --git a/src/errors.ts b/src/errors.ts index 60d46d31..d7031f4e 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -171,7 +171,7 @@ abstract class RetainedAdmissionError< constructor(admission: TAdmission, options?: { cause?: unknown }) { const recovery = - admission.phase === 'interactive_intent' + admission.phase === 'intent' || admission.phase === 'interactive_intent' ? 'no provider work has started' : 'the environment is kept for recovery' super( diff --git a/src/runtime/index.ts b/src/runtime/index.ts index ca7c9e25..f39e10b7 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -352,6 +352,7 @@ export { type ReconnectRetainedInteractiveRunOptions, type ReconnectRetainedRunOptions, type RecoverRetainedInteractiveRunOptions, + type RecoverRetainedRunIntentOptions, type RecoverRetainedRunOptions, type RecoverRetainedRunResult, type RetainedInteractiveAdmission, @@ -371,8 +372,10 @@ export { type RetainedRunEnvironmentAdmission, type RetainedRunEventOptions, type RetainedRunHandle, + type RetainedRunIntentAdmission, type RetainedRunReplayPoint, type RetainedRunSnapshot, + type RetainedRunStartMaterial, reconnectRetainedInteractiveRun, reconnectRetainedRun, recoverRetainedInteractiveRun, diff --git a/src/runtime/retained-interactive.ts b/src/runtime/retained-interactive.ts index 7da495b6..0f21e47d 100644 --- a/src/runtime/retained-interactive.ts +++ b/src/runtime/retained-interactive.ts @@ -35,6 +35,7 @@ import type { StartRetainedInteractiveRunOptions, } from './retained-interactive-types' import { assertStableText, awaitAbortable } from './retained-run-binding' +import { retainedCreateMaterial } from './retained-run-intent' import { admitDurably, mintRetainedIdentity } from './retained-run-start' import type { RetainedInteractiveEnvironmentAdmission, @@ -281,6 +282,7 @@ function interactiveIntent( profile: StartRetainedInteractiveRunOptions['environment']['profile'], identity: { readonly sessionId: string; readonly executionId: string }, ): RetainedInteractiveIntentAdmission { + const requestedProfileDigest = canonicalAgentProfileDigest(profile) const requestDigest = canonicalCandidateDigest({ kind: 'retained-interactive-intent.v1', provider: options.provider.name, @@ -288,8 +290,8 @@ function interactiveIntent( interactiveIdempotencyKey: options.interactiveIdempotencyKey, sessionId: identity.sessionId, executionId: identity.executionId, - requestedProfileDigest: canonicalAgentProfileDigest(profile), - create: sanitizedCreateMaterial(options.environment), + requestedProfileDigest, + create: retainedCreateMaterial(options.environment), start: sanitizedStartMaterial(options), }) return { @@ -300,38 +302,11 @@ function interactiveIntent( sessionId: identity.sessionId, executionId: identity.executionId, runId: `interactive-intent-run:${requestDigest.slice('sha256:'.length)}`, - requestedProfileDigest: canonicalAgentProfileDigest(profile), + requestedProfileDigest, requestDigest, } } -function sanitizedCreateMaterial( - environment: StartRetainedInteractiveRunOptions['environment'], -): Record { - return { - ...(environment.backend === undefined ? {} : { backend: environment.backend }), - ...(environment.workspace === undefined - ? {} - : { workspaceDigest: canonicalCandidateDigest(environment.workspace) }), - ...(environment.resources === undefined - ? {} - : { resourcesDigest: canonicalCandidateDigest(environment.resources) }), - ...(environment.name === undefined ? {} : { name: environment.name }), - ...(environment.env === undefined - ? {} - : { envDigest: canonicalCandidateDigest(environment.env) }), - ...(environment.secrets === undefined - ? {} - : { secretsDigest: canonicalCandidateDigest(environment.secrets) }), - ...(environment.metadata === undefined - ? {} - : { metadataDigest: canonicalCandidateDigest(environment.metadata) }), - ...(environment.providerOptions === undefined - ? {} - : { providerOptionsDigest: canonicalCandidateDigest(environment.providerOptions) }), - } -} - function sanitizedStartMaterial( options: RetainedInteractiveStartMaterial, ): Record { diff --git a/src/runtime/retained-run-intent.ts b/src/runtime/retained-run-intent.ts new file mode 100644 index 00000000..7dfafcfb --- /dev/null +++ b/src/runtime/retained-run-intent.ts @@ -0,0 +1,51 @@ +import { canonicalCandidateDigest } from '@tangle-network/agent-interface' +import type { + AgentTurnInput, + CreateAgentEnvironmentInput, +} from '@tangle-network/agent-interface/environment-provider' + +/** + * Project environment creation input into digest-only material. + * + * Secret values and provider options never enter an admission record. Their + * digests still bind a replay to the exact request without retaining them. + */ +export function retainedCreateMaterial( + environment: CreateAgentEnvironmentInput, +): Record { + return { + ...(environment.backend === undefined ? {} : { backend: environment.backend }), + ...(environment.workspace === undefined + ? {} + : { workspaceDigest: canonicalCandidateDigest(environment.workspace) }), + ...(environment.resources === undefined + ? {} + : { resourcesDigest: canonicalCandidateDigest(environment.resources) }), + ...(environment.name === undefined ? {} : { name: environment.name }), + ...(environment.env === undefined + ? {} + : { envDigest: canonicalCandidateDigest(environment.env) }), + ...(environment.secrets === undefined + ? {} + : { secretsDigest: canonicalCandidateDigest(environment.secrets) }), + ...(environment.metadata === undefined + ? {} + : { metadataDigest: canonicalCandidateDigest(environment.metadata) }), + ...(environment.providerOptions === undefined + ? {} + : { providerOptionsDigest: canonicalCandidateDigest(environment.providerOptions) }), + } +} + +/** Project one headless turn into the material that `freshTurnInput` forwards. */ +export function retainedTurnMaterial(input: AgentTurnInput): Record { + return { + ...(input.prompt === undefined ? {} : { prompt: input.prompt }), + ...(input.parts === undefined ? {} : { parts: input.parts }), + ...(input.model === undefined ? {} : { model: input.model }), + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + ...(input.context === undefined ? {} : { context: input.context }), + ...(input.interactions === undefined ? {} : { interactions: input.interactions }), + ...(input.providerOptions === undefined ? {} : { providerOptions: input.providerOptions }), + } +} diff --git a/src/runtime/retained-run-start.ts b/src/runtime/retained-run-start.ts index 7a2c9916..5b715ebf 100644 --- a/src/runtime/retained-run-start.ts +++ b/src/runtime/retained-run-start.ts @@ -1,6 +1,8 @@ +import type { Sha256Digest } from '@tangle-network/agent-interface' import { AgentEnvironmentCapabilitiesSchema, AgentExactRunControlRefSchema, + canonicalCandidateDigest, } from '@tangle-network/agent-interface' import type { AgentEnvironment, @@ -21,14 +23,17 @@ import { freezeControlRef, } from './retained-run-binding' import { createRetainedRunHandle } from './retained-run-handle' +import { retainedCreateMaterial, retainedTurnMaterial } from './retained-run-intent' import type { ReconnectRetainedRunOptions, + RecoverRetainedRunIntentOptions, RecoverRetainedRunOptions, RecoverRetainedRunResult, RetainedInteractiveAdmission, RetainedRunAdmission, RetainedRunAdmissionHook, RetainedRunHandle, + RetainedRunIntentAdmission, StartRetainedRunInEnvironmentOptions, StartRetainedRunOptions, } from './retained-run-types' @@ -61,11 +66,10 @@ export function mintRetainedIdentity( * Dispatch one detached, replayable run and return only after exact durable * coordinates are confirmed by the provider and persisted by the caller. * - * The required `onAdmission` hook is awaited twice: with the recovery - * coordinates after environment creation, and with the verified exact control - * reference after dispatch. The returned promise resolves only after the - * dispatched admission is durable, so no caller can observe a successful start - * whose exact reference a crash would lose. + * The required `onAdmission` hook first records a digest-only intent before + * creation, then records recovery coordinates and the verified exact control + * reference. The returned promise resolves only after the dispatched admission + * is durable, so a crash cannot lose a successful start's exact reference. * * @stable */ @@ -84,16 +88,21 @@ export async function startRetainedRun( const identity = options.identity ?? mintRetainedIdentity(options.environment.idempotencyKey, options.turn.turnId) + if (!options.provider.get) { + throw new Error(`provider "${options.provider.name}" cannot reconstruct an environment by id`) + } + const intent = retainedRunIntent(options, identity) + if (options.intent === undefined) { + await admitDurably(options.onAdmission, intent) + } else { + assertExactRetainedRunIntent(options.intent, intent) + } const providerCapabilities = await assertRetainedCapabilities(options.provider) assertRequestedInteractionCapabilities( options.provider.name, options.turn.interactions, providerCapabilities, ) - if (!options.provider.get) { - throw new Error(`provider "${options.provider.name}" cannot reconstruct an environment by id`) - } - // Runtime-owned keys in metadata give operators provider-side visibility // into which retained coordinates created this environment. Caller metadata // is preserved; the runtime keys overwrite same-named caller keys. @@ -102,6 +111,8 @@ export async function startRetainedRun( metadata: { ...options.environment.metadata, retainedIdempotencyKey: options.environment.idempotencyKey, + retainedIntentDigest: intent.requestDigest, + retainedRunId: intent.runId, sessionId: identity.sessionId, executionId: identity.executionId, }, @@ -370,8 +381,11 @@ function isInteractiveAdmission( } /** - * Rebuild the exact run named by pre-dispatch admission coordinates, or - * report why the provider cannot prove it. + * Rebuild the exact run named by a persisted pre-create intent or pre-dispatch + * admission coordinates, or report why the provider cannot prove it. + * + * An intent recovery replays the exact original start material through + * `startRetainedRun`; a changed replay is rejected before provider creation. * * `not_found`: the provider no longer holds the environment, so nothing * remains to destroy. `recovered`: the provider self-identified the session @@ -386,9 +400,28 @@ function isInteractiveAdmission( * * @stable */ -export async function recoverRetainedRun( +export function recoverRetainedRun( + options: RecoverRetainedRunIntentOptions, +): Promise +export function recoverRetainedRun( options: RecoverRetainedRunOptions, +): Promise +export async function recoverRetainedRun( + options: RecoverRetainedRunIntentOptions | RecoverRetainedRunOptions, ): Promise { + if ('admission' in options) { + if (options.admission.provider !== options.provider.name) { + throw new Error('retained run intent belongs to another provider') + } + const handle = await startRetainedRun({ + provider: options.provider, + ...options.replay, + intent: options.admission, + onAdmission: options.onAdmission, + now: options.now, + }) + return { outcome: 'recovered', handle } + } assertStableText(options.environmentId, 'retained environment id') assertStableText(options.sessionId, 'retained session id') assertStableText(options.executionId, 'retained execution id') @@ -437,6 +470,85 @@ export async function recoverRetainedRun( } } +function retainedRunIntent( + options: StartRetainedRunOptions, + identity: { readonly sessionId: string; readonly executionId: string }, +): RetainedRunIntentAdmission { + const requestedProfileDigest = canonicalCandidateDigest(options.environment.profile) + const requestDigest = canonicalCandidateDigest({ + kind: 'retained-run-intent.v1', + provider: options.provider.name, + idempotencyKey: options.environment.idempotencyKey, + turnId: options.turn.turnId, + sessionId: identity.sessionId, + executionId: identity.executionId, + requestedProfileDigest, + create: retainedCreateMaterial(options.environment), + turn: retainedTurnMaterial(options.turn), + }) + return { + phase: 'intent', + provider: options.provider.name, + idempotencyKey: options.environment.idempotencyKey, + turnId: options.turn.turnId, + sessionId: identity.sessionId, + executionId: identity.executionId, + runId: `retained-intent-run:${requestDigest.slice('sha256:'.length)}`, + requestedProfileDigest, + requestDigest, + } +} + +function assertExactRetainedRunIntent( + received: RetainedRunIntentAdmission, + expected: RetainedRunIntentAdmission, +): void { + const stableReceived = parseRetainedRunIntent(received) + if (canonicalCandidateDigest(stableReceived) !== canonicalCandidateDigest(expected)) { + throw new Error('retained run intent conflicts with replay material') + } +} + +function parseRetainedRunIntent(value: unknown): RetainedRunIntentAdmission { + const stable = detachedSnapshot(value, 'retained run intent') + if (stable === null || typeof stable !== 'object' || Array.isArray(stable)) { + throw new Error('retained run intent is malformed') + } + const record = stable as Record + const allowed = new Set([ + 'phase', + 'provider', + 'idempotencyKey', + 'turnId', + 'sessionId', + 'executionId', + 'runId', + 'requestedProfileDigest', + 'requestDigest', + ]) + if (Object.keys(record).some((key) => !allowed.has(key))) { + throw new Error('retained run intent contains unsupported material') + } + if (record.phase !== 'intent') { + throw new Error('retained run intent has an invalid phase') + } + for (const [key, value] of Object.entries(record)) { + if (key === 'phase') continue + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`retained run intent field "${key}" is invalid`) + } + } + assertDigest(record.requestedProfileDigest, 'retained run intent profile digest') + assertDigest(record.requestDigest, 'retained run intent request digest') + return stable as RetainedRunIntentAdmission +} + +function assertDigest(value: unknown, label: string): asserts value is Sha256Digest { + if (typeof value !== 'string' || !/^sha256:[a-f0-9]{64}$/u.test(value)) { + throw new Error(`${label} is invalid`) + } +} + /** Rebuild a retained-run client without retaining any object from the starter. @stable */ export async function reconnectRetainedRun( options: ReconnectRetainedRunOptions, diff --git a/src/runtime/retained-run-types.ts b/src/runtime/retained-run-types.ts index 6e54f0f9..1afeb7d3 100644 --- a/src/runtime/retained-run-types.ts +++ b/src/runtime/retained-run-types.ts @@ -93,6 +93,26 @@ export interface RetainedRunHandle { cancel(options: RetainedRunCancelOptions): Promise } +/** + * Sanitized headless intent durable before environment creation. + * + * The request digest binds the exact create and turn material without retaining + * secrets or provider options. The original start material is required to + * replay this record after a process crash. + * @stable + */ +export interface RetainedRunIntentAdmission { + readonly phase: 'intent' + readonly provider: string + readonly idempotencyKey: string + readonly turnId: string + readonly sessionId: string + readonly executionId: string + readonly runId: string + readonly requestedProfileDigest: Sha256Digest + readonly requestDigest: Sha256Digest +} + /** Recovery coordinates durable after environment creation and before dispatch. @stable */ export interface RetainedRunEnvironmentAdmission { readonly phase: 'environment' @@ -157,8 +177,11 @@ export type RetainedInteractiveAdmission = | RetainedInteractiveEnvironmentAdmission | RetainedInteractiveStartedAdmission -/** One detached-run admission record the runtime persists before dispatch proceeds. @stable */ -export type RetainedRunAdmission = RetainedRunEnvironmentAdmission | RetainedRunDispatchedAdmission +/** One detached-run admission record the runtime persists before creation or dispatch proceeds. @stable */ +export type RetainedRunAdmission = + | RetainedRunIntentAdmission + | RetainedRunEnvironmentAdmission + | RetainedRunDispatchedAdmission /** * Awaited durability hook for retained admission records. @@ -172,9 +195,8 @@ export type RetainedRunAdmission = RetainedRunEnvironmentAdmission | RetainedRun */ export type RetainedRunAdmissionHook = (admission: RetainedRunAdmission) => Promise -/** A retained start is retry-safe only when environment and turn keys are explicit. @stable */ -export interface StartRetainedRunOptions { - readonly provider: AgentEnvironmentProvider +/** Environment, turn, and optional identity needed to replay one retained start. @stable */ +export interface RetainedRunStartMaterial { readonly environment: CreateAgentEnvironmentInput & { idempotencyKey: string } readonly turn: AgentTurnInput & { turnId: string } /** @@ -186,6 +208,13 @@ export interface StartRetainedRunOptions { readonly sessionId: string readonly executionId: string } +} + +/** A retained start is retry-safe only when environment and turn keys are explicit. @stable */ +export interface StartRetainedRunOptions extends RetainedRunStartMaterial { + readonly provider: AgentEnvironmentProvider + /** A previously persisted intent used to replay the exact create operation. */ + readonly intent?: RetainedRunIntentAdmission readonly onAdmission: RetainedRunAdmissionHook readonly now?: () => number } @@ -219,6 +248,16 @@ export interface ReconnectRetainedRunOptions { readonly now?: () => number } +/** Recover a headless start after its pre-create intent was persisted. @stable */ +export interface RecoverRetainedRunIntentOptions { + readonly provider: AgentEnvironmentProvider + readonly admission: RetainedRunIntentAdmission + /** The exact original environment, turn, and optional identity material. */ + readonly replay: RetainedRunStartMaterial + readonly onAdmission: RetainedRunAdmissionHook + readonly now?: () => number +} + /** * Pre-dispatch admission coordinates for one recovery attempt. * diff --git a/src/runtime/retained-run.test.ts b/src/runtime/retained-run.test.ts index 8f54803b..9e71c4c6 100644 --- a/src/runtime/retained-run.test.ts +++ b/src/runtime/retained-run.test.ts @@ -52,6 +52,7 @@ function recordedAdmissions(): { function assertDetachedAdmissionPhase(admission: RetainedRunAdmission): void { switch (admission.phase) { + case 'intent': case 'environment': case 'dispatched': return @@ -75,6 +76,8 @@ async function runChild( phase: | 'start' | 'reconnect' + | 'start-kill-intent' + | 'recover-intent' | 'start-kill-dispatched' | 'recover-dispatched' | 'start-kill-environment' @@ -135,8 +138,12 @@ describe('retained runtime run control', () => { executionId?: string controlRef?: { runId: string } }> - expect(admissions.map((admission) => admission.phase)).toEqual(['environment', 'dispatched']) - expect(admissions[1]?.controlRef).toEqual(started.controlRef) + expect(admissions.map((admission) => admission.phase)).toEqual([ + 'intent', + 'environment', + 'dispatched', + ]) + expect(admissions[2]?.controlRef).toEqual(started.controlRef) // The child minted identity in its own process; re-mint here and compare. expect(admissions[0]).toMatchObject(mintRetainedIdentity('restart-proof', 'restart-proof')) @@ -217,6 +224,137 @@ describe('retained runtime run control', () => { ).toEqual(['restart-native-operation']) }) + it('persists headless intent before create and replays the exact material after a crash', async () => { + const identity = mintRetainedIdentity('headless-intent-environment', 'headless-intent-turn') + const controlRef = { + runId: 'headless-intent-run', + provider: 'test-provider', + environmentId: 'environment-1', + ...identity, + requestDigest: retainedRequestDigest, + } + const session: AgentSession = { + id: identity.sessionId, + controlRef, + status: async () => 'running', + async *events() { + yield* [] + }, + result: async () => ({ text: 'recovered', success: true, sessionId: identity.sessionId }), + prompt: async () => ({ text: 'continued', success: true }), + cancel: async () => {}, + } + const provider = providerWithEnvironment({ + async dispatch() { + return { id: session.id, provider: 'test-provider', controlRef } + }, + session: () => session, + }) + const environment = { + profile: { name: 'worker' }, + idempotencyKey: 'headless-intent-environment', + secrets: { TANGLE_TOKEN: 'headless-secret-value' }, + providerOptions: { credential: 'headless-provider-secret' }, + } + const turn = { prompt: 'replay this exact turn', turnId: 'headless-intent-turn' } + let creates = 0 + let created: CreateAgentEnvironmentInput | undefined + const originalCreate = provider.create + provider.create = async (input) => { + creates += 1 + created = input + return originalCreate(input) + } + const firstAdmissions: RetainedRunAdmission[] = [] + const failed = await startRetainedRun({ + provider, + environment, + turn, + onAdmission: async (admission) => { + firstAdmissions.push(admission) + if (admission.phase === 'intent') throw new Error('coordinator crashed') + }, + }).catch((error: unknown) => error) + + expect(failed).toBeInstanceOf(RetainedRunAdmissionError) + expect((failed as RetainedRunAdmissionError).phase).toBe('intent') + expect(creates).toBe(0) + const intent = firstAdmissions[0] + if (intent?.phase !== 'intent') throw new Error('expected the headless intent admission') + expect(JSON.stringify(intent)).not.toContain('headless-secret-value') + expect(JSON.stringify(intent)).not.toContain('headless-provider-secret') + + await expect( + startRetainedRun({ + provider, + environment, + turn: { ...turn, prompt: 'changed replay material' }, + intent, + onAdmission: async () => {}, + }), + ).rejects.toThrow('retained run intent conflicts with replay material') + expect(creates).toBe(0) + + const recoveryAdmissions = recordedAdmissions() + const recovered = await recoverRetainedRun({ + provider, + admission: intent, + replay: { environment, turn }, + onAdmission: recoveryAdmissions.onAdmission, + }) + expect(recovered.outcome).toBe('recovered') + expect(creates).toBe(1) + expect(created?.metadata).toMatchObject({ + retainedIntentDigest: intent.requestDigest, + retainedRunId: intent.runId, + }) + expect(created?.secrets).toEqual({ TANGLE_TOKEN: 'headless-secret-value' }) + expect(created?.providerOptions).toEqual({ credential: 'headless-provider-secret' }) + expect(recoveryAdmissions.admissions.map((admission) => admission.phase)).toEqual([ + 'environment', + 'dispatched', + ]) + }) + + it('recovers a headless intent after a coordinator SIGKILL before provider.create', async () => { + const stateFile = join(directory, 'intent-crash-provider.json') + const referenceFile = join(directory, 'intent-crash-reference.json') + + const killed = await runChild(stateFile, referenceFile, 'start-kill-intent') + expect(killed.code).toBeNull() + expect(killed.signal).toBe('SIGKILL') + expect(existsSync(stateFile)).toBe(false) + const intent = JSON.parse(await readFile(referenceFile, 'utf8')) as { + phase: string + sessionId: string + executionId: string + requestDigest: string + } + expect(intent).toMatchObject({ + phase: 'intent', + sessionId: mintRetainedIdentity('kill-intent', 'kill-intent').sessionId, + }) + + const recovered = await runChild(stateFile, referenceFile, 'recover-intent') + expect(recovered.code, recovered.stderr).toBe(0) + expect(recovered.signal).toBeNull() + const output = JSON.parse(await readFile(`${referenceFile}.output`, 'utf8')) as { + controlRef: { environmentId: string; sessionId: string; executionId: string } + } + expect(output.controlRef).toMatchObject({ + environmentId: 'environment-kill-intent', + sessionId: intent.sessionId, + executionId: intent.executionId, + }) + const recoveryAdmissions = JSON.parse( + await readFile(`${referenceFile}.recovered-admissions`, 'utf8'), + ) as Array<{ phase: string }> + expect(recoveryAdmissions.map((admission) => admission.phase)).toEqual([ + 'environment', + 'dispatched', + ]) + }) + it('keeps an environment after dispatch becomes uncertain, but cleans an unused one', async () => { let destroys = 0 const uncertain = providerWithEnvironment({ @@ -239,6 +377,7 @@ describe('retained runtime run control', () => { expect(destroys).toBe(0) // The environment admission was durable before the uncertain dispatch. expect(uncertainRecorder.admissions.map((admission) => admission.phase)).toEqual([ + 'intent', 'environment', ]) @@ -259,8 +398,8 @@ describe('retained runtime run control', () => { }), ).rejects.toThrow('does not expose detached session control') expect(destroys).toBe(1) - // A destroyed unusable environment never leaves an admission record. - expect(unusableRecorder.admissions).toEqual([]) + // The durable intent remains, but the unusable environment is destroyed. + expect(unusableRecorder.admissions.map((admission) => admission.phase)).toEqual(['intent']) }) it('starts a fresh retained session inside an existing environment', async () => { @@ -576,13 +715,28 @@ describe('retained runtime run control', () => { executionId: controlRef.executionId, }) // Caller metadata is merged, never clobbered, with the recovery coordinates. - expect(created?.metadata).toEqual({ + const intent = recorder.admissions[0] + if (intent?.phase !== 'intent') throw new Error('expected the headless intent admission') + expect(created?.metadata).toMatchObject({ tenant: 'acme', retainedIdempotencyKey: controlRef.environmentId, + retainedIntentDigest: intent.requestDigest, + retainedRunId: intent.runId, sessionId: controlRef.sessionId, executionId: controlRef.executionId, }) - expect(recorder.admissions).toEqual([ + expect(recorder.admissions).toMatchObject([ + { + phase: 'intent', + provider: controlRef.provider, + idempotencyKey: controlRef.environmentId, + turnId: 'owned-turn', + sessionId: controlRef.sessionId, + executionId: controlRef.executionId, + runId: expect.any(String), + requestedProfileDigest: expect.any(String), + requestDigest: expect.any(String), + }, { phase: 'environment', provider: controlRef.provider, @@ -1909,6 +2063,7 @@ describe('retained runtime run control', () => { log.push('resolved') expect(log).toEqual([ + 'admission:intent', 'create', 'admission:environment', 'dispatch', @@ -2226,8 +2381,8 @@ describe('retained runtime run control', () => { expect(dispatched).toMatchObject(minted) expect(created?.metadata).toMatchObject(minted) - expect(recorder.admissions[0]).toMatchObject({ phase: 'environment', ...minted }) - expect(recorder.admissions[1]).toMatchObject({ + expect(recorder.admissions[1]).toMatchObject({ phase: 'environment', ...minted }) + expect(recorder.admissions[2]).toMatchObject({ phase: 'dispatched', controlRef: { sessionId: minted.sessionId, executionId: minted.executionId }, }) @@ -2274,8 +2429,11 @@ describe('retained runtime run control', () => { expect(binding.returned.controlRef).toMatchObject({ sessionId: 'rogue-session' }) // The environment stays alive, and its admission is already durable. expect(destroys).toBe(0) - expect(recorder.admissions.map((admission) => admission.phase)).toEqual(['environment']) - expect(recorder.admissions[0]).toMatchObject({ + expect(recorder.admissions.map((admission) => admission.phase)).toEqual([ + 'intent', + 'environment', + ]) + expect(recorder.admissions[1]).toMatchObject({ sessionId: 'honest-session', executionId: 'honest-execution', }) diff --git a/src/runtime/retained-run.ts b/src/runtime/retained-run.ts index 1f4da871..a46c3e18 100644 --- a/src/runtime/retained-run.ts +++ b/src/runtime/retained-run.ts @@ -33,6 +33,7 @@ export type { NativeContextContinuationExecution, NativeContextContinuationInput, ReconnectRetainedRunOptions, + RecoverRetainedRunIntentOptions, RecoverRetainedRunOptions, RecoverRetainedRunResult, RetainedInteractiveAdmission, @@ -48,8 +49,10 @@ export type { RetainedRunEnvironmentAdmission, RetainedRunEventOptions, RetainedRunHandle, + RetainedRunIntentAdmission, RetainedRunReplayPoint, RetainedRunSnapshot, + RetainedRunStartMaterial, StartRetainedRunInEnvironmentOptions, StartRetainedRunOptions, } from './retained-run-types' diff --git a/src/runtime/turn-input.test.ts b/src/runtime/turn-input.test.ts new file mode 100644 index 00000000..9c2d6ea3 --- /dev/null +++ b/src/runtime/turn-input.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { providerMessageText } from './turn-input' + +describe('providerMessageText', () => { + it('keeps the last-user-message preference for messages-only turns', () => { + expect( + providerMessageText({ + messages: [ + { role: 'user', content: 'first request' }, + { role: 'assistant', content: 'assistant response' }, + ], + }), + ).toBe('first request') + }) + + it('skips non-text and non-user messages while searching backwards', () => { + expect( + providerMessageText({ + messages: [ + { role: 'user', content: 'usable request' }, + { role: 'user', content: [{ type: 'text', text: 'structured request' }] }, + { role: 'tool', content: 'tool output' }, + ], + }), + ).toBe('usable request') + }) +}) diff --git a/src/runtime/turn-input.ts b/src/runtime/turn-input.ts index aef733aa..5683b348 100644 --- a/src/runtime/turn-input.ts +++ b/src/runtime/turn-input.ts @@ -84,11 +84,14 @@ export function providerMessageText( ): string | undefined { const messages = providerOptions?.messages if (!Array.isArray(messages)) return undefined - const last = messages.at(-1) - if (!last || typeof last !== 'object' || Array.isArray(last)) return undefined - if (!('content' in last)) return undefined - const content = last.content - return typeof content === 'string' ? content : undefined + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (!message || typeof message !== 'object' || Array.isArray(message)) continue + if (!('role' in message) || message.role !== 'user') continue + if (!('content' in message)) continue + if (typeof message.content === 'string') return message.content + } + return undefined } function promptPartFromInputPart(part: InputPart): PromptInputPart { diff --git a/tests/helpers/retained-run-child.ts b/tests/helpers/retained-run-child.ts index a70a8edb..8a563559 100644 --- a/tests/helpers/retained-run-child.ts +++ b/tests/helpers/retained-run-child.ts @@ -13,12 +13,17 @@ import { recoverRetainedRun, startRetainedRun, } from '../../src/runtime/retained-run' -import type { RetainedRunAdmission } from '../../src/runtime/retained-run-types' +import type { + RetainedRunAdmission, + RetainedRunIntentAdmission, +} from '../../src/runtime/retained-run-types' import { durableRetainedProvider } from './durable-retained-provider' const phases = [ 'start', 'reconnect', + 'start-kill-intent', + 'recover-intent', 'start-kill-dispatched', 'recover-dispatched', 'start-kill-environment', @@ -146,6 +151,42 @@ if (phase === 'start') { })}\n`, 'utf8', ) +} else if (phase === 'start-kill-intent') { + await startRetainedRun({ + provider: durableRetainedProvider(stateFile), + environment: { profile: { name: 'worker' }, idempotencyKey: 'kill-intent' }, + turn: { prompt: 'start', turnId: 'kill-intent' }, + onAdmission: async (admission) => { + if (admission.phase === 'intent') { + persistDurably(referenceFile, admission) + process.kill(process.pid, 'SIGKILL') + } + }, + }) + throw new Error('the intent admission must kill this process before provider.create') +} else if (phase === 'recover-intent') { + const admission = JSON.parse(await readFile(referenceFile, 'utf8')) as RetainedRunIntentAdmission + const admissions: RetainedRunAdmission[] = [] + const result = await recoverRetainedRun({ + provider: durableRetainedProvider(stateFile), + admission, + replay: { + environment: { profile: { name: 'worker' }, idempotencyKey: 'kill-intent' }, + turn: { prompt: 'start', turnId: 'kill-intent' }, + }, + onAdmission: async (recoveredAdmission) => { + admissions.push(recoveredAdmission) + persistDurably(`${referenceFile}.recovered-admissions`, admissions) + }, + }) + if (result.outcome !== 'recovered') { + throw new Error(`expected the intent recovery to start a run, got ${result.outcome}`) + } + await writeFile( + `${referenceFile}.output`, + `${JSON.stringify({ controlRef: result.handle.controlRef })}\n`, + 'utf8', + ) } else if (phase === 'start-kill-dispatched') { // Die the way a real process dies: inside the dispatched hook, after the // record is durable, before the hook returns. The parent asserts SIGKILL @@ -207,6 +248,7 @@ if (phase === 'start') { executionId: 'execution-kill-environment', }, onAdmission: async (admission) => { + if (admission.phase === 'intent') return if (admission.phase === 'environment') { persistDurably(referenceFile, admission) process.kill(process.pid, 'SIGKILL') @@ -223,6 +265,7 @@ if (phase === 'start') { environment: { profile: { name: 'worker' }, idempotencyKey: 'kill-live' }, turn: { prompt: 'start', turnId: 'kill-live' }, onAdmission: async (admission) => { + if (admission.phase === 'intent') return if (admission.phase === 'environment') { persistDurably(referenceFile, admission) return From b493c81bef0106aaecc2b0b9be59c76840d565ba Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 07:26:05 -0600 Subject: [PATCH 13/16] fix(runtime): keep retained secret bindings public --- src/runtime/retained-run-intent.test.ts | 42 +++++++++++++++++++++++++ src/runtime/retained-run-intent.ts | 15 ++++++--- src/runtime/retained-run-types.ts | 11 ++++--- src/runtime/retained-run.test.ts | 26 +++++++++++++-- src/runtime/stream-agent-turn.test.ts | 26 +++++++++++++++ src/runtime/turn-input.ts | 2 +- 6 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 src/runtime/retained-run-intent.test.ts diff --git a/src/runtime/retained-run-intent.test.ts b/src/runtime/retained-run-intent.test.ts new file mode 100644 index 00000000..82446531 --- /dev/null +++ b/src/runtime/retained-run-intent.test.ts @@ -0,0 +1,42 @@ +import { canonicalCandidateDigest } from '@tangle-network/agent-interface' +import type { CreateAgentEnvironmentInput } from '@tangle-network/agent-interface/environment-provider' +import { describe, expect, it } from 'vitest' +import { retainedCreateMaterial } from './retained-run-intent' + +function digestForSecrets(secrets: NonNullable): string { + return canonicalCandidateDigest( + retainedCreateMaterial({ + profile: { name: 'worker' }, + secrets, + }), + ) +} + +describe('retained create admission material', () => { + it('binds secret names without exposing or hashing secret values', () => { + const firstSecret = 'guessable-a' + const secondSecret = 'guessable-b' + const first = retainedCreateMaterial({ + profile: { name: 'worker' }, + secrets: { API_TOKEN: firstSecret }, + }) + const second = retainedCreateMaterial({ + profile: { name: 'worker' }, + secrets: { API_TOKEN: secondSecret }, + }) + + expect(first).toMatchObject({ secretNames: ['API_TOKEN'] }) + expect(second).toEqual(first) + expect(digestForSecrets({ API_TOKEN: firstSecret })).toBe( + digestForSecrets({ API_TOKEN: secondSecret }), + ) + expect(JSON.stringify(first)).not.toContain(firstSecret) + expect(JSON.stringify(first)).not.toContain(secondSecret) + }) + + it('changes the public admission digest when secret names change', () => { + expect(digestForSecrets({ API_TOKEN: 'guessable-a' })).not.toBe( + digestForSecrets({ OTHER_TOKEN: 'guessable-a' }), + ) + }) +}) diff --git a/src/runtime/retained-run-intent.ts b/src/runtime/retained-run-intent.ts index 7dfafcfb..b9823b3f 100644 --- a/src/runtime/retained-run-intent.ts +++ b/src/runtime/retained-run-intent.ts @@ -5,10 +5,11 @@ import type { } from '@tangle-network/agent-interface/environment-provider' /** - * Project environment creation input into digest-only material. + * Project environment creation input into public digest material. * - * Secret values and provider options never enter an admission record. Their - * digests still bind a replay to the exact request without retaining them. + * Values from the `secrets` channel never enter the material or its digest. + * Secret names remain public binding data, so changing which credentials are + * requested still conflicts before provider effects begin. */ export function retainedCreateMaterial( environment: CreateAgentEnvironmentInput, @@ -27,7 +28,7 @@ export function retainedCreateMaterial( : { envDigest: canonicalCandidateDigest(environment.env) }), ...(environment.secrets === undefined ? {} - : { secretsDigest: canonicalCandidateDigest(environment.secrets) }), + : { secretNames: retainedSecretNames(environment.secrets) }), ...(environment.metadata === undefined ? {} : { metadataDigest: canonicalCandidateDigest(environment.metadata) }), @@ -37,6 +38,12 @@ export function retainedCreateMaterial( } } +function retainedSecretNames( + secrets: NonNullable, +): string[] { + return Array.isArray(secrets) ? [...secrets].sort() : Object.keys(secrets).sort() +} + /** Project one headless turn into the material that `freshTurnInput` forwards. */ export function retainedTurnMaterial(input: AgentTurnInput): Record { return { diff --git a/src/runtime/retained-run-types.ts b/src/runtime/retained-run-types.ts index 1afeb7d3..f19141c7 100644 --- a/src/runtime/retained-run-types.ts +++ b/src/runtime/retained-run-types.ts @@ -96,9 +96,9 @@ export interface RetainedRunHandle { /** * Sanitized headless intent durable before environment creation. * - * The request digest binds the exact create and turn material without retaining - * secrets or provider options. The original start material is required to - * replay this record after a process crash. + * The request digest binds the public create and turn material without + * retaining secret values. The original start material is required to replay + * this record after a process crash. * @stable */ export interface RetainedRunIntentAdmission { @@ -137,8 +137,9 @@ export interface RetainedRunDispatchedAdmission { /** * Sanitized intent durable before an interactive environment create begins. * - * The digest covers the exact start and create material without retaining that - * material. It never carries environment variables, secrets, or provider options. + * The digest covers the public start and create material without retaining that + * material. It never carries environment variables, secret values, or provider + * options. The replay input supplies private values after this check. * @stable */ export interface RetainedInteractiveIntentAdmission { diff --git a/src/runtime/retained-run.test.ts b/src/runtime/retained-run.test.ts index 9e71c4c6..cf84da75 100644 --- a/src/runtime/retained-run.test.ts +++ b/src/runtime/retained-run.test.ts @@ -224,7 +224,7 @@ describe('retained runtime run control', () => { ).toEqual(['restart-native-operation']) }) - it('persists headless intent before create and replays the exact material after a crash', async () => { + it('persists public headless intent before create and replays private values after a crash', async () => { const identity = mintRetainedIdentity('headless-intent-environment', 'headless-intent-turn') const controlRef = { runId: 'headless-intent-run', @@ -295,11 +295,31 @@ describe('retained runtime run control', () => { ).rejects.toThrow('retained run intent conflicts with replay material') expect(creates).toBe(0) + await expect( + startRetainedRun({ + provider, + environment: { + ...environment, + secrets: { OTHER_TOKEN: 'headless-secret-value' }, + }, + turn, + intent, + onAdmission: async () => {}, + }), + ).rejects.toThrow('retained run intent conflicts with replay material') + expect(creates).toBe(0) + const recoveryAdmissions = recordedAdmissions() const recovered = await recoverRetainedRun({ provider, admission: intent, - replay: { environment, turn }, + replay: { + environment: { + ...environment, + secrets: { TANGLE_TOKEN: 'changed-low-entropy' }, + }, + turn, + }, onAdmission: recoveryAdmissions.onAdmission, }) expect(recovered.outcome).toBe('recovered') @@ -308,7 +328,7 @@ describe('retained runtime run control', () => { retainedIntentDigest: intent.requestDigest, retainedRunId: intent.runId, }) - expect(created?.secrets).toEqual({ TANGLE_TOKEN: 'headless-secret-value' }) + expect(created?.secrets).toEqual({ TANGLE_TOKEN: 'changed-low-entropy' }) expect(created?.providerOptions).toEqual({ credential: 'headless-provider-secret' }) expect(recoveryAdmissions.admissions.map((admission) => admission.phase)).toEqual([ 'environment', diff --git a/src/runtime/stream-agent-turn.test.ts b/src/runtime/stream-agent-turn.test.ts index cc9db7c1..3f464a0f 100644 --- a/src/runtime/stream-agent-turn.test.ts +++ b/src/runtime/stream-agent-turn.test.ts @@ -104,6 +104,32 @@ describe('streamAgentTurn: box backend', () => { expect(types).toContain('backend_error') expect(types.at(-1)).toBe('final') }) + + it('uses the latest user message when a box turn has provider messages only', async () => { + const prompts: string[] = [] + const box = await inProcessSandboxClient({ + onPrompt: (prompt): SandboxEvent[] => { + prompts.push(prompt) + return [{ type: 'done', data: { finalText: 'answer' } }] + }, + }).create() + + await collectAgentTurn( + streamObservedAgentTurn( + { kind: 'box', box }, + { + providerOptions: { + messages: [ + { role: 'user', content: 'latest request' }, + { role: 'assistant', content: 'previous response' }, + ], + }, + }, + ), + ) + + expect(prompts).toEqual(['latest request']) + }) }) describe('streamAgentTurn: current Sandbox prompt options', () => { diff --git a/src/runtime/turn-input.ts b/src/runtime/turn-input.ts index 5683b348..a36238b1 100644 --- a/src/runtime/turn-input.ts +++ b/src/runtime/turn-input.ts @@ -44,7 +44,7 @@ export function freshTurnInput( /** Project canonical turn parts onto the Sandbox prompt vocabulary once. */ export function promptFromAgentTurnInput(input: AgentTurnInput): string | PromptInputPart[] { if (input.parts !== undefined) return input.parts.map(promptPartFromInputPart) - return input.prompt ?? '' + return input.prompt ?? providerMessageText(input.providerOptions) ?? '' } /** Project canonical turn controls onto the Sandbox prompt options once. */ From 044801a7adb71604b497e6c6117908ba2c08354e Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 07:32:22 -0600 Subject: [PATCH 14/16] fix(runtime): redact opaque retained material --- src/runtime/retained-run-intent.test.ts | 37 +++++++++++++++++ src/runtime/retained-run-intent.ts | 54 +++++++++++++++++++++---- 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/runtime/retained-run-intent.test.ts b/src/runtime/retained-run-intent.test.ts index 82446531..0c1a3021 100644 --- a/src/runtime/retained-run-intent.test.ts +++ b/src/runtime/retained-run-intent.test.ts @@ -34,6 +34,43 @@ describe('retained create admission material', () => { expect(JSON.stringify(first)).not.toContain(secondSecret) }) + it('keeps opaque environment values out of the public material', () => { + const first = retainedCreateMaterial({ + profile: { name: 'worker' }, + env: { PUBLIC_OR_SECRET: 'guessable-a' }, + secrets: { API_TOKEN: 'guessable-a' }, + metadata: { note: 'guessable-a' }, + providerOptions: { credential: 'guessable-a' }, + workspace: { + repoUrl: 'https://example.com/repo.git', + providerOptions: { credential: 'guessable-a' }, + }, + resources: { cpu: 2, providerOptions: { credential: 'guessable-a' } }, + }) + const second = retainedCreateMaterial({ + profile: { name: 'worker' }, + env: { PUBLIC_OR_SECRET: 'guessable-b' }, + secrets: { API_TOKEN: 'guessable-b' }, + metadata: { note: 'guessable-b' }, + providerOptions: { credential: 'guessable-b' }, + workspace: { + repoUrl: 'https://example.com/repo.git', + providerOptions: { credential: 'guessable-b' }, + }, + resources: { cpu: 2, providerOptions: { credential: 'guessable-b' } }, + }) + + expect(second).toEqual(first) + expect(canonicalCandidateDigest(second)).toBe(canonicalCandidateDigest(first)) + expect(JSON.stringify(first)).not.toContain('guessable-a') + expect(JSON.stringify(first)).not.toContain('guessable-b') + expect(first).toMatchObject({ + environmentVariableNames: ['PUBLIC_OR_SECRET'], + metadataKeys: ['note'], + providerOptionNames: ['credential'], + }) + }) + it('changes the public admission digest when secret names change', () => { expect(digestForSecrets({ API_TOKEN: 'guessable-a' })).not.toBe( digestForSecrets({ OTHER_TOKEN: 'guessable-a' }), diff --git a/src/runtime/retained-run-intent.ts b/src/runtime/retained-run-intent.ts index b9823b3f..ba2bc114 100644 --- a/src/runtime/retained-run-intent.ts +++ b/src/runtime/retained-run-intent.ts @@ -7,9 +7,10 @@ import type { /** * Project environment creation input into public digest material. * - * Values from the `secrets` channel never enter the material or its digest. - * Secret names remain public binding data, so changing which credentials are - * requested still conflicts before provider effects begin. + * Opaque values never enter the material or its digest. Public scalar fields + * remain bound directly, while opaque records retain only their key names. + * Secret names remain public binding data, so changing requested credentials + * still conflicts before provider effects begin. */ export function retainedCreateMaterial( environment: CreateAgentEnvironmentInput, @@ -18,26 +19,63 @@ export function retainedCreateMaterial( ...(environment.backend === undefined ? {} : { backend: environment.backend }), ...(environment.workspace === undefined ? {} - : { workspaceDigest: canonicalCandidateDigest(environment.workspace) }), + : { + workspaceDigest: canonicalCandidateDigest(publicWorkspaceMaterial(environment.workspace)), + }), ...(environment.resources === undefined ? {} - : { resourcesDigest: canonicalCandidateDigest(environment.resources) }), + : { + resourcesDigest: canonicalCandidateDigest(publicResourceMaterial(environment.resources)), + }), ...(environment.name === undefined ? {} : { name: environment.name }), ...(environment.env === undefined ? {} - : { envDigest: canonicalCandidateDigest(environment.env) }), + : { environmentVariableNames: retainedObjectNames(environment.env) }), ...(environment.secrets === undefined ? {} : { secretNames: retainedSecretNames(environment.secrets) }), ...(environment.metadata === undefined ? {} - : { metadataDigest: canonicalCandidateDigest(environment.metadata) }), + : { metadataKeys: retainedObjectNames(environment.metadata) }), ...(environment.providerOptions === undefined ? {} - : { providerOptionsDigest: canonicalCandidateDigest(environment.providerOptions) }), + : { providerOptionNames: retainedObjectNames(environment.providerOptions) }), } } +function publicWorkspaceMaterial( + workspace: NonNullable, +): Record { + return { + ...(workspace.environment === undefined ? {} : { environment: workspace.environment }), + ...(workspace.image === undefined ? {} : { image: workspace.image }), + ...(workspace.repoUrl === undefined ? {} : { repoUrl: workspace.repoUrl }), + ...(workspace.gitRef === undefined ? {} : { gitRef: workspace.gitRef }), + ...(workspace.cwd === undefined ? {} : { cwd: workspace.cwd }), + ...(workspace.providerOptions === undefined + ? {} + : { providerOptionNames: retainedObjectNames(workspace.providerOptions) }), + } +} + +function publicResourceMaterial( + resources: NonNullable, +): Record { + return { + ...(resources.cpu === undefined ? {} : { cpu: resources.cpu }), + ...(resources.memoryMb === undefined ? {} : { memoryMb: resources.memoryMb }), + ...(resources.diskMb === undefined ? {} : { diskMb: resources.diskMb }), + ...(resources.gpu === undefined ? {} : { gpu: resources.gpu }), + ...(resources.providerOptions === undefined + ? {} + : { providerOptionNames: retainedObjectNames(resources.providerOptions) }), + } +} + +function retainedObjectNames(value: Record): string[] { + return Object.keys(value).sort() +} + function retainedSecretNames( secrets: NonNullable, ): string[] { From f5855e8f39919a0340595b46b467e73e84b7a56d Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 07:38:53 -0600 Subject: [PATCH 15/16] chore(deps): migrate agent package cohort --- docs/api/primitive-catalog.md | 2 +- docs/api/runtime.md | 11 ++--- docs/canonical-api.md | 6 +-- package.json | 6 +-- pnpm-lock.yaml | 76 +++++++++++++++++------------------ pnpm-workspace.yaml | 10 ++--- 6 files changed, 56 insertions(+), 55 deletions(-) diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index a1ab426d..29d6ecbc 100644 --- a/docs/api/primitive-catalog.md +++ b/docs/api/primitive-catalog.md @@ -7,7 +7,7 @@ # Primitive catalog — the never-stale anti-reinvention inventory -> **GENERATED** from `@tangle-network/agent-runtime@0.137.0` and `@tangle-network/agent-eval@0.145.19` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. +> **GENERATED** from `@tangle-network/agent-runtime@0.137.0` and `@tangle-network/agent-eval@0.145.21` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. ## 1. agent-runtime — own public surface diff --git a/docs/api/runtime.md b/docs/api/runtime.md index f8890598..056c6a6a 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -6430,9 +6430,9 @@ Capabilities measured from the exact environment that owns this run. Sanitized headless intent durable before environment creation. -The request digest binds the exact create and turn material without retaining -secrets or provider options. The original start material is required to -replay this record after a process crash. +The request digest binds the public create and turn material without +retaining secret values. The original start material is required to replay +this record after a process crash. #### Properties @@ -6548,8 +6548,9 @@ The verified exact reference, durable before the start promise resolves. Sanitized intent durable before an interactive environment create begins. -The digest covers the exact start and create material without retaining that -material. It never carries environment variables, secrets, or provider options. +The digest covers the public start and create material without retaining that +material. It never carries environment variables, secret values, or provider +options. The replay input supplies private values after this check. #### Properties diff --git a/docs/canonical-api.md b/docs/canonical-api.md index 4522684a..0a2b7092 100644 --- a/docs/canonical-api.md +++ b/docs/canonical-api.md @@ -6,9 +6,9 @@ Run pnpm docs:freshness after editing this file. --> > **Version 0.137.0.** > [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path. -> `agent-eval` must satisfy `>=0.145.19 <0.146.0`. -> `sandbox` must satisfy `>=0.27.0 <0.28.0`. -> Portable profile, turn-input, and tool-part types come from `@tangle-network/agent-interface` `>=0.56.0 <0.57.0`. +> `agent-eval` must satisfy `>=0.145.21 <0.146.0`. +> `sandbox` must satisfy `>=0.27.0 <0.29.0`; the durable interactive contract lands in the unpublished 0.28.0 ADC release. +> Portable profile, turn-input, and tool-part types come from `@tangle-network/agent-interface` `>=1.0.0 <2.0.0`. > > **`./kernel` is the execution kernel**: `package.json` maps it to `src/runtime/index.ts`. Everything below labelled `/kernel` lives there — the recursive atom (`Scope`/`Supervisor`), the executor registry, budget conservation, the finalizer seam, analyst wiring, and the round-synchronous loop. > diff --git a/package.json b/package.json index 7a9fd08c..a74052f2 100644 --- a/package.json +++ b/package.json @@ -171,9 +171,9 @@ "license": "MIT", "packageManager": "pnpm@11.17.0", "peerDependencies": { - "@tangle-network/agent-eval": ">=0.145.19 <0.146.0", - "@tangle-network/agent-interface": ">=0.56.0 <0.57.0", - "@tangle-network/sandbox": ">=0.27.0 <0.28.0" + "@tangle-network/agent-eval": ">=0.145.21 <0.146.0", + "@tangle-network/agent-interface": ">=1.0.0 <2.0.0", + "@tangle-network/sandbox": ">=0.27.0 <0.29.0" }, "peerDependenciesMeta": { "@tangle-network/sandbox": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41409005..839ff5e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,22 +13,22 @@ catalogs: specifier: 1.30.0 version: 1.30.0 '@tangle-network/agent-core': - specifier: 0.9.3 - version: 0.9.3 + specifier: 0.9.4 + version: 0.9.4 '@tangle-network/agent-eval': - specifier: 0.145.19 - version: 0.145.19 + specifier: 0.145.21 + version: 0.145.21 '@tangle-network/agent-interface': - specifier: 0.56.0 - version: 0.56.0 + specifier: 1.0.0 + version: 1.0.0 '@tangle-network/agent-knowledge': - specifier: 8.0.4 - version: 8.0.4 + specifier: 8.0.5 + version: 8.0.5 '@tangle-network/agent-profile-materialize': specifier: 0.15.2 version: 0.15.2 '@tangle-network/agent-trace-contract': - specifier: ^1.0.2 + specifier: 1.0.2 version: 1.0.2 '@tangle-network/sandbox': specifier: 0.27.0 @@ -55,13 +55,13 @@ importers: dependencies: '@tangle-network/agent-core': specifier: 'catalog:' - version: 0.9.3(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + version: 0.9.4(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) '@tangle-network/agent-knowledge': specifier: 'catalog:' - version: 8.0.4(@tangle-network/agent-eval@0.145.19(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.56.0) + version: 8.0.5(@tangle-network/agent-eval@0.145.21(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@1.0.0) '@tangle-network/agent-profile-materialize': specifier: 'catalog:' - version: 0.15.2(@tangle-network/agent-interface@0.56.0) + version: 0.15.2(@tangle-network/agent-interface@1.0.0) '@tangle-network/agent-trace-contract': specifier: 'catalog:' version: 1.0.2 @@ -80,10 +80,10 @@ importers: version: 1.30.0(supports-color@10.2.2)(zod@4.4.3) '@tangle-network/agent-eval': specifier: 'catalog:' - version: 0.145.19(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + version: 0.145.21(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) '@tangle-network/agent-interface': specifier: 'catalog:' - version: 0.56.0 + version: 1.0.0 '@tangle-network/sandbox': specifier: 'catalog:' version: 0.27.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(viem@2.54.6(typescript@6.0.3)(zod@4.4.3)) @@ -128,13 +128,13 @@ importers: dependencies: '@tangle-network/agent-eval': specifier: 'catalog:' - version: 0.145.19(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + version: 0.145.21(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) '@tangle-network/agent-interface': specifier: 'catalog:' - version: 0.56.0 + version: 1.0.0 '@tangle-network/agent-knowledge': specifier: 'catalog:' - version: 8.0.4(@tangle-network/agent-eval@0.145.19(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.56.0) + version: 8.0.5(@tangle-network/agent-eval@0.145.21(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@1.0.0) '@tangle-network/agent-runtime': specifier: workspace:* version: link:.. @@ -1132,32 +1132,32 @@ packages: '@modelcontextprotocol/sdk': optional: true - '@tangle-network/agent-core@0.9.3': - resolution: {integrity: sha512-mxB6Ydw5uJR80FTGBMs3f0z9nO9JfcU0ywoH+846VAql/pIDvinpBwmHE6bpy2KfIF+/SeKiRYbcR5Oe49kn7Q==} + '@tangle-network/agent-core@0.9.4': + resolution: {integrity: sha512-2iSPCHPKI/9A4t1XXPTbpaJTzf3BkwNzXOJfb3M7AMjpIGR1SKNiRHKpDTTEUVbfx4tRny5qDDMhCnFmKgPsHA==} peerDependencies: '@modelcontextprotocol/sdk': ^1.30.0 peerDependenciesMeta: '@modelcontextprotocol/sdk': optional: true - '@tangle-network/agent-eval@0.145.19': - resolution: {integrity: sha512-S6U2/BfQutEkSK+hvI4BoxZ2sAHEQn9N5lQqif/v5mzGA6ieQF89NRSfcwJG2eVU9AGmRo9E61FpVDuuGEouCQ==} + '@tangle-network/agent-eval@0.145.21': + resolution: {integrity: sha512-BeNIO8/+AJwQ8KYeJ1juqZT9gqQkaWwfLhX7KLQUo7LkRF++tBfulvARGesOpVHorx0fEkheJiyGMM5Tsx8IGQ==} engines: {node: '>=20'} hasBin: true '@tangle-network/agent-interface@0.53.0': resolution: {integrity: sha512-XWH+4t9vPkVog9C9P+094USuoO//TJwaI+P6ntAm32wHqZ/kh5a9r6kSkHYMKsXgQjkY3IA5NO21w3rZ7M6U+g==} - '@tangle-network/agent-interface@0.56.0': - resolution: {integrity: sha512-MFaUB/PHUMfOSpu+9o7LMEWwqlu61Nf5zE9oKEQ++bNM7g4ZbQKFPmdzw8iqTGpG/x6/phF75d+XxTXVi8J0rA==} + '@tangle-network/agent-interface@1.0.0': + resolution: {integrity: sha512-rhXoscOE1TwkLvHg3tfqdumz++kcDRf7c5GRSTBnJkKLVypwvXB5yQJCB4aTUTrOCJxJMqowm2VPOmlTrUszOQ==} - '@tangle-network/agent-knowledge@8.0.4': - resolution: {integrity: sha512-SxshzVY75yWm4TB0QY/0NkSjuY+mKIjU3SPP3Qn2n3ZHI2JksG0fG2wLM9qXA4+n7XuRgv1v7TP3/mYUGUF0mQ==} + '@tangle-network/agent-knowledge@8.0.5': + resolution: {integrity: sha512-QMY16NSdy7aLxZZDejrb17hlUb6ejh4rg5Keg1TfA+FDMeIbyCaTSx6I/CFmK27TQq5aZ0RoIgaa1lZVc5rDRg==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: - '@tangle-network/agent-eval': '>=0.145.18 <0.146.0' - '@tangle-network/agent-interface': '>=0.56.0 <0.57.0' + '@tangle-network/agent-eval': '>=0.145.21 <0.146.0' + '@tangle-network/agent-interface': ^1.0.0 '@tangle-network/agent-profile-materialize@0.15.2': resolution: {integrity: sha512-j9ld23ADJRbAIJEkQ0xk49+RYt47WzARquN5S3W5Pyb3rbD++gxf1AKJQ3O613wmkwdcc0V10a5jHruX0SZ1Vg==} @@ -3188,19 +3188,19 @@ snapshots: optionalDependencies: '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@4.4.3) - '@tangle-network/agent-core@0.9.3(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))': + '@tangle-network/agent-core@0.9.4(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))': dependencies: - '@tangle-network/agent-interface': 0.56.0 + '@tangle-network/agent-interface': 1.0.0 zod: 4.4.3 optionalDependencies: '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@4.4.3) - '@tangle-network/agent-eval@0.145.19(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))': + '@tangle-network/agent-eval@0.145.21(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))': dependencies: '@asteasolutions/zod-to-openapi': 9.1.0(zod@4.4.3) '@hono/node-server': 2.0.12(hono@4.12.32) - '@tangle-network/agent-core': 0.9.3(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) - '@tangle-network/agent-interface': 0.56.0 + '@tangle-network/agent-core': 0.9.4(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + '@tangle-network/agent-interface': 1.0.0 '@tangle-network/agent-trace-contract': 1.0.2 hono: 4.12.32 linear-sum-assignment: 1.0.9 @@ -3215,22 +3215,22 @@ snapshots: spdx-expression-parse: 5.0.0 zod: 4.4.3 - '@tangle-network/agent-interface@0.56.0': + '@tangle-network/agent-interface@1.0.0': dependencies: '@noble/hashes': 1.8.0 spdx-expression-parse: 5.0.0 zod: 4.4.3 - '@tangle-network/agent-knowledge@8.0.4(@tangle-network/agent-eval@0.145.19(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@0.56.0)': + '@tangle-network/agent-knowledge@8.0.5(@tangle-network/agent-eval@0.145.21(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@1.0.0)': dependencies: - '@tangle-network/agent-eval': 0.145.19(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) - '@tangle-network/agent-interface': 0.56.0 + '@tangle-network/agent-eval': 0.145.21(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + '@tangle-network/agent-interface': 1.0.0 proper-lockfile: 4.1.2 zod: 4.4.3 - '@tangle-network/agent-profile-materialize@0.15.2(@tangle-network/agent-interface@0.56.0)': + '@tangle-network/agent-profile-materialize@0.15.2(@tangle-network/agent-interface@1.0.0)': dependencies: - '@tangle-network/agent-interface': 0.56.0 + '@tangle-network/agent-interface': 1.0.0 '@tangle-network/agent-trace-contract@1.0.2': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b5e6b85f..15a4117c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,13 +18,13 @@ allowBuilds: catalog: '@arethetypeswrong/cli': 0.18.5 '@modelcontextprotocol/sdk': 1.30.0 - '@tangle-network/agent-core': 0.9.3 + '@tangle-network/agent-core': 0.9.4 '@types/node': 26.1.1 - '@tangle-network/agent-eval': 0.145.19 - '@tangle-network/agent-interface': 0.56.0 - '@tangle-network/agent-knowledge': 8.0.4 + '@tangle-network/agent-eval': 0.145.21 + '@tangle-network/agent-interface': 1.0.0 + '@tangle-network/agent-knowledge': 8.0.5 '@tangle-network/agent-profile-materialize': 0.15.2 - '@tangle-network/agent-trace-contract': ^1.0.2 + '@tangle-network/agent-trace-contract': 1.0.2 '@tangle-network/sandbox': 0.27.0 publint: 0.3.22 tsdown: 0.22.14 From 91ab0eb3641ca3091d6a57e986f92263197ea51f Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 19:22:18 -0600 Subject: [PATCH 16/16] test(runtime): express interactive claim expiry relative to now A wall-clock expiry turns the suite red once that instant passes. The claim must be in the future for the acquisition to succeed, so derive it. --- src/runtime/retained-interactive.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runtime/retained-interactive.test.ts b/src/runtime/retained-interactive.test.ts index 22bbc92d..269d1725 100644 --- a/src/runtime/retained-interactive.test.ts +++ b/src/runtime/retained-interactive.test.ts @@ -760,7 +760,7 @@ function interactiveProvider( rows: 40, createdAt: '2026-08-16T00:00:00.000Z', lastActivityAt: '2026-08-16T00:00:00.000Z', - expiresAt: '2026-08-17T00:00:00.000Z', + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), isRunning: true, attachCount: 1, }, @@ -992,7 +992,7 @@ function controlFor( generation, leaseId: 'interactive-lease-1', holderId, - expiresAt: '2026-08-17T00:00:00.000Z', + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), } }