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/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/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/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 } : {}), 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/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/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 7a4c6121..0859f13c 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,15 +1078,19 @@ because the durable record a later recovery requires was not written. [`RetainedRunAdmissionError`](#retainedrunadmissionerror) -###### Overrides +###### Inherited from -`AgentEvalError.constructor` +`RetainedAdmissionError.constructor` #### Properties ##### phase -> `readonly` **phase**: `"environment"` \| `"dispatched"` +> `readonly` **phase**: `"intent"` \| `"environment"` \| `"dispatched"` + +###### Inherited from + +`RetainedAdmissionError.phase` ##### admission @@ -1099,6 +1098,141 @@ because the durable record a later recovery requires was not written. 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 @@ -8664,7 +8798,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. @@ -8698,7 +8832,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() @@ -8751,7 +8885,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 @@ -8783,7 +8917,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() @@ -10088,6 +10222,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`\> @@ -11799,7 +11945,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 @@ -11815,7 +11961,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 @@ -11823,7 +11969,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 @@ -11867,7 +12013,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 @@ -11875,7 +12021,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 @@ -12269,14 +12415,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/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 ee5a596e..5fe9ea90 100644 --- a/docs/api/mcp.md +++ b/docs/api/mcp.md @@ -6929,7 +6929,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 d8776ba9..284a435d 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` — 437 exports. +Import from `@tangle-network/agent-runtime` — 440 exports. | Symbol | Kind | Summary | |---|---|---| @@ -160,7 +160,9 @@ Import from `@tangle-network/agent-runtime` — 437 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 | @@ -268,6 +270,7 @@ Import from `@tangle-network/agent-runtime` — 437 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. | @@ -533,7 +536,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` — 791 exports. +Import from `@tangle-network/agent-runtime/kernel` — 810 exports. | Symbol | Kind | Summary | |---|---|---| @@ -562,6 +565,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 791 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 | @@ -687,8 +691,10 @@ Import from `@tangle-network/agent-runtime/kernel` — 791 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. | -| `recoverRetainedRun` | function | Rebuild the exact run named by pre-dispatch admission coordinates, or | +| `recoverRetainedInteractiveRun` | function | Retry one exact start after its provider response may have been lost. | +| `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. | @@ -729,6 +735,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 791 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 | @@ -851,6 +858,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 791 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. | @@ -961,7 +969,10 @@ Import from `@tangle-network/agent-runtime/kernel` — 791 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. | +| `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 | @@ -972,14 +983,21 @@ Import from `@tangle-network/agent-runtime/kernel` — 791 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. | | `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 | @@ -1018,6 +1036,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 791 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. | @@ -1073,7 +1092,6 @@ Import from `@tangle-network/agent-runtime/kernel` — 791 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. | @@ -1127,7 +1145,10 @@ Import from `@tangle-network/agent-runtime/kernel` — 791 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 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. | @@ -1176,7 +1197,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 791 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`, `PeerMailbox`, `PeerMailboxOptions`, `PeerMailSendInput`, `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`, `InboxMessage`, `LoopTraceEvent`, `MakeWorkerAgent`, `PeerMailOutcome`, `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`, `PeerMailbox`, `PeerMailboxOptions`, `PeerMailSendInput`, `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`, `InboxMessage`, `LoopTraceEvent`, `MakeWorkerAgent`, `PeerMailOutcome`, `RepairStop`, `SandboxControlClient`, `WorkspaceCommit`. ### Environment provider adapters — generic sandbox/compute bridge diff --git a/docs/api/runtime.md b/docs/api/runtime.md index 2fc5d928..76f1ea10 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,250 @@ 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`** + +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`** @@ -6062,6 +6306,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() @@ -6164,81 +6414,291 @@ Reconstructable control of one provider-retained run. ###### Parameters -###### options +###### options + +[`RetainedRunCancelOptions`](#retainedruncanceloptions) + +###### Returns + +`Promise`\<[`RetainedRunCancellation`](#retainedruncancellation)\> + +*** + +### RetainedRunIntentAdmission + +**`Stable`** + +Sanitized headless intent durable before environment creation. + +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 + +##### 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`** + +Recovery coordinates durable after environment creation and before dispatch. + +#### Properties + +##### phase + +> `readonly` **phase**: `"environment"` + +##### provider + +> `readonly` **provider**: `string` + +##### environmentId + +> `readonly` **environmentId**: `string` + +##### idempotencyKey + +> `readonly` **idempotencyKey**: `string` + +##### turnId + +> `readonly` **turnId**: `string` + +##### sessionId + +> `readonly` **sessionId**: `string` + +Caller-supplied or runtime-minted; always the identity the dispatch will request. + +##### executionId + +> `readonly` **executionId**: `string` + +Caller-supplied or runtime-minted; always the identity the dispatch will request. + +*** + +### RetainedRunDispatchedAdmission + +**`Stable`** + +The verified exact reference, durable before the start promise resolves. + +#### Properties + +##### phase + +> `readonly` **phase**: `"dispatched"` + +##### controlRef + +> `readonly` **controlRef**: `AgentExactRunControlRef` + +##### idempotencyKey + +> `readonly` **idempotencyKey**: `string` + +##### turnId + +> `readonly` **turnId**: `string` + +*** + +### RetainedInteractiveIntentAdmission + +**`Stable`** + +Sanitized intent durable before an interactive environment create begins. + +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 + +##### 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 -[`RetainedRunCancelOptions`](#retainedruncanceloptions) +> `readonly` **interactiveIdempotencyKey**: `string` -###### Returns +##### request -`Promise`\<[`RetainedRunCancellation`](#retainedruncancellation)\> +> `readonly` **request**: `object` *** -### RetainedRunEnvironmentAdmission +### RetainedInteractiveStartedAdmission **`Stable`** -Recovery coordinates durable after environment creation and before dispatch. +Provider-issued interactive process reference durable before start returns. #### Properties ##### phase -> `readonly` **phase**: `"environment"` +> `readonly` **phase**: `"interactive_started"` -##### provider +##### idempotencyKey -> `readonly` **provider**: `string` +> `readonly` **idempotencyKey**: `string` -##### environmentId +##### interactiveIdempotencyKey -> `readonly` **environmentId**: `string` +> `readonly` **interactiveIdempotencyKey**: `string` -##### idempotencyKey +##### ref -> `readonly` **idempotencyKey**: `string` +> `readonly` **ref**: `object` -##### turnId +*** -> `readonly` **turnId**: `string` +### RetainedRunStartMaterial -##### sessionId +**`Stable`** -> `readonly` **sessionId**: `string` +Environment, turn, and optional identity needed to replay one retained start. -Caller-supplied or runtime-minted; always the identity the dispatch will request. +#### Extended by -##### executionId +- [`StartRetainedRunOptions`](#startretainedrunoptions) -> `readonly` **executionId**: `string` +#### Properties -Caller-supplied or runtime-minted; always the identity the dispatch will request. +##### environment -*** +> `readonly` **environment**: `CreateAgentEnvironmentInput` & `object` -### RetainedRunDispatchedAdmission +###### Type Declaration -**`Stable`** +###### idempotencyKey -The verified exact reference, durable before the start promise resolves. +> **idempotencyKey**: `string` -#### Properties +##### turn -##### phase +> `readonly` **turn**: `AgentTurnInput` & `object` -> `readonly` **phase**: `"dispatched"` +###### Type Declaration -##### controlRef +###### turnId -> `readonly` **controlRef**: `AgentExactRunControlRef` +> **turnId**: `string` -##### idempotencyKey +##### identity? -> `readonly` **idempotencyKey**: `string` +> `readonly` `optional` **identity?**: `object` -##### turnId +Explicit dispatch coordinates. When omitted, the runtime mints +deterministic coordinates from `(environment.idempotencyKey, turn.turnId)` +so every process derives the same values. -> `readonly` **turnId**: `string` +###### sessionId + +> `readonly` **sessionId**: `string` + +###### executionId + +> `readonly` **executionId**: `string` *** @@ -6248,11 +6708,11 @@ The verified exact reference, durable before the start promise resolves. A retained start is retry-safe only when environment and turn keys are explicit. -#### Properties +#### Extends -##### provider +- [`RetainedRunStartMaterial`](#retainedrunstartmaterial) -> `readonly` **provider**: `AgentEnvironmentProvider` +#### Properties ##### environment @@ -6264,6 +6724,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` @@ -6274,6 +6738,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` @@ -6290,6 +6758,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) @@ -6397,6 +6879,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`** @@ -15131,7 +15649,7 @@ breaker, or a recursive parent. ###### Inherited from -[`SupervisorNodeContext`](#supervisornodecontext).[`runId`](#runid-16) +[`SupervisorNodeContext`](#supervisornodecontext).[`runId`](#runid-18) ##### runNamespace @@ -15177,7 +15695,7 @@ Stable identity of this manager's coordination stream. ###### Inherited from -[`SupervisorNodeContext`](#supervisornodecontext).[`identity`](#identity-3) +[`SupervisorNodeContext`](#supervisornodecontext).[`identity`](#identity-4) ##### assignmentId? @@ -16913,7 +17431,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-25) ##### abort() @@ -18488,7 +19006,7 @@ the kernel falls back to `{ placement: 'sibling', sandboxId: box.id }`. ##### create() -> **create**(`options?`): `Promise`\<`SandboxInstance`\> +> **create**(`options?`, `requestOptions?`): `Promise`\<`SandboxInstance`\> ###### Parameters @@ -18496,6 +19014,10 @@ the kernel falls back to `{ placement: 'sibling', sandboxId: box.id }`. `CreateSandboxOptions` +###### requestOptions? + +`CreateRequestOptions` + ###### Returns `Promise`\<`SandboxInstance`\> @@ -19157,7 +19679,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 @@ -20046,6 +20568,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"` @@ -20076,13 +20638,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) +> **RetainedRunAdmission** = [`RetainedRunIntentAdmission`](#retainedrunintentadmission) \| [`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 creation or dispatch proceeds. *** @@ -20094,10 +20666,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 @@ -20230,14 +20802,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`\> @@ -23681,6 +24245,93 @@ 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)\> + +**`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)\> @@ -23690,11 +24341,10 @@ that `resolveBenchClient` builds on — reuse this instead of hand-rolling the 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 @@ -23734,12 +24384,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 @@ -23752,13 +24407,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)\> @@ -24569,7 +25257,7 @@ timeout alike. The generator never throws; failures surface in-band as ##### input -[`AgentTurnInput`](#agentturninput) +`AgentTurnInput` ##### opts? @@ -27525,7 +28213,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-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 a8183dc6..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-11) +[`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-15) +[`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-17) +[`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-15) +[`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/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/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/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/errors.ts b/src/errors.ts index 2cbd740a..d7031f4e 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, @@ -147,23 +155,28 @@ 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. + * 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 */ -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 }) { + const recovery = + admission.phase === 'intent' || 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 @@ -171,6 +184,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/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..26c3df40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,6 +102,8 @@ export { JudgeError, NotFoundError, PlannerError, + RetainedInteractiveAdmissionError, + RetainedInteractiveBindingError, RetainedRunAdmissionError, RetainedRunDispatchBindingError, RuntimeRunStateError, @@ -298,6 +300,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..583657f7 100644 --- a/src/runtime/environment-provider.test.ts +++ b/src/runtime/environment-provider.test.ts @@ -1,7 +1,13 @@ 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, + CreateRequestOptions, CreateSandboxOptions, SandboxEvent, SandboxInstance, @@ -159,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', @@ -188,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() { @@ -198,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', @@ -209,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' })) @@ -220,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', @@ -522,6 +536,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 +1670,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..48343d4a 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' @@ -275,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) @@ -570,13 +583,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 +683,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 +732,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 +749,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 +758,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 +813,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 +921,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 +944,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 +1022,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 +1125,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 promptPartFromInputPart(part: InputPart): PromptInputPart { - if (part.type === 'text' || part.type === 'image') return part - if (part.content !== undefined || part.path !== undefined) { +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 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 +1435,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 +1484,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 +1713,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/index.ts b/src/runtime/index.ts index c5f17b4f..db0265c0 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -345,11 +345,24 @@ export { resolveSandboxClient, } from './resolve-sandbox-client' export { + type ClaimRetainedInteractiveControlOptions, + claimRetainedInteractiveControl, type NativeContextContinuationExecution, type NativeContextContinuationInput, + type ReconnectRetainedInteractiveRunOptions, type ReconnectRetainedRunOptions, + type RecoverRetainedInteractiveRunOptions, + type RecoverRetainedRunIntentOptions, type RecoverRetainedRunOptions, type RecoverRetainedRunResult, + type RetainedInteractiveAdmission, + type RetainedInteractiveAdmissionHook, + type RetainedInteractiveEnvironmentAdmission, + type RetainedInteractiveEnvironmentInput, + type RetainedInteractiveIntentAdmission, + type RetainedInteractiveRunHandle, + type RetainedInteractiveStartedAdmission, + type RetainedInteractiveStartMaterial, type RetainedRunAdmission, type RetainedRunAdmissionHook, type RetainedRunCancellation, @@ -359,12 +372,18 @@ export { type RetainedRunEnvironmentAdmission, type RetainedRunEventOptions, type RetainedRunHandle, + type RetainedRunIntentAdmission, type RetainedRunReplayPoint, type RetainedRunSnapshot, + type RetainedRunStartMaterial, + reconnectRetainedInteractiveRun, reconnectRetainedRun, + recoverRetainedInteractiveRun, recoverRetainedRun, + type StartRetainedInteractiveRunOptions, type StartRetainedRunInEnvironmentOptions, type StartRetainedRunOptions, + startRetainedInteractiveRun, startRetainedRun, startRetainedRunInEnvironment, } from './retained-run' diff --git a/src/runtime/interaction-capabilities.ts b/src/runtime/interaction-capabilities.ts new file mode 100644 index 00000000..e1d32f7c --- /dev/null +++ b/src/runtime/interaction-capabilities.ts @@ -0,0 +1,38 @@ +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 ?? {}) + .filter(([, enabled]) => enabled === true) + .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-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-handle.ts b/src/runtime/retained-interactive-handle.ts new file mode 100644 index 00000000..6916bcd8 --- /dev/null +++ b/src/runtime/retained-interactive-handle.ts @@ -0,0 +1,262 @@ +import type { + AgentInteractiveSessionAttach, + AgentInteractiveSessionControlClaimAcknowledgement, + AgentInteractiveSessionControlClaimRequest, + AgentInteractiveSessionPromptAcknowledgement, + AgentInteractiveSessionPromptCommand, + AgentInteractiveSessionRef, + AgentInteractiveSessionStart, + AgentInteractiveSessionStatus, + AgentInteractiveSessionStopAcknowledgement, + AgentInteractiveSessionStopCommand, + AgentInteractiveTerminalSession, +} from '@tangle-network/agent-interface' +import { + AgentInteractiveSessionControlClaimAcknowledgementSchema, + AgentInteractiveSessionControlClaimSchema, + AgentInteractiveSessionPromptAcknowledgementSchema, + AgentInteractiveSessionRefSchema, + AgentInteractiveSessionStatusSchema, + AgentInteractiveSessionStopAcknowledgementSchema, + agentInteractiveSessionControlClaimAcknowledgementMatchesRequest, + agentInteractiveSessionControlClaimMatchesRef, + agentInteractiveSessionPromptAcknowledgementMatchesCommand, + agentInteractiveSessionStatusMatchesRef, + agentInteractiveSessionStopAcknowledgementMatchesCommand, + 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, RetainedRunProviderContractError } 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, + 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, + await awaitAbortable( + Promise.resolve().then(() => source.status(options)), + options?.signal, + ), + requestedStart, + ), + attach: async (request: AgentInteractiveSessionAttach, options?: { signal?: AbortSignal }) => + exactTerminal( + ref, + request, + await awaitAbortable( + Promise.resolve().then(() => source.attach(request, 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 ( + command: AgentInteractiveSessionStopCommand, + options?: { signal?: AbortSignal }, + ): Promise => + exactStop( + ref, + await awaitAbortable( + Promise.resolve().then(() => source.stop(command, options)), + options?.signal, + ), + command, + ), + }) +} + +export function freezeInteractiveRef( + value: AgentInteractiveSessionRef, +): AgentInteractiveSessionRef { + 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( + 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, + 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 +} + +function sameInteractiveRef( + left: AgentInteractiveSessionRef, + right: AgentInteractiveSessionRef, +): boolean { + return canonicalCandidateDigest(left) === canonicalCandidateDigest(right) +} 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-types.ts b/src/runtime/retained-interactive-types.ts new file mode 100644 index 00000000..c4a3b363 --- /dev/null +++ b/src/runtime/retained-interactive-types.ts @@ -0,0 +1,76 @@ +import type { + AgentInteractiveSession, + AgentInteractiveSessionPromptAcknowledgement, + AgentInteractiveSessionPromptCommand, + AgentInteractiveSessionRef, + AgentProfile, +} from '@tangle-network/agent-interface' +import type { + AgentEnvironmentCapabilities, + AgentEnvironmentProvider, + CreateAgentEnvironmentInput, +} from '@tangle-network/agent-interface/environment-provider' +import type { + RetainedInteractiveAdmission, + RetainedInteractiveEnvironmentAdmission, + RetainedInteractiveIntentAdmission, +} 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 +} + +/** 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 +} + +/** 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 after a pre-create crash or a lost provider response. @stable */ +export interface RecoverRetainedInteractiveRunOptions { + readonly provider: AgentEnvironmentProvider + readonly admission: RetainedInteractiveIntentAdmission | RetainedInteractiveEnvironmentAdmission + /** Required when recovering from an intent before an environment existed. */ + readonly replay?: RetainedInteractiveStartMaterial + readonly onAdmission: RetainedInteractiveAdmissionHook + readonly signal?: AbortSignal +} + +/** Exact interactive process controls plus measured environment capabilities. @stable */ +export interface RetainedInteractiveRunHandle extends AgentInteractiveSession { + readonly capabilities: AgentEnvironmentCapabilities + sendPrompt( + command: AgentInteractiveSessionPromptCommand, + 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..269d1725 --- /dev/null +++ b/src/runtime/retained-interactive.test.ts @@ -0,0 +1,1084 @@ +import { + type AgentInteractiveSession, + type AgentInteractiveSessionControlClaim, + type AgentInteractiveSessionControlClaimRequest, + type AgentInteractiveSessionPromptCommand, + type AgentInteractiveSessionRef, + AgentInteractiveSessionRefSchema, + type AgentInteractiveSessionStart, + type AgentInteractiveSessionStopCommand, + type AgentInteractiveTerminalSession, + type AgentProfile, + agentInteractiveSessionControlClaimRequestDigest, + agentInteractiveSessionPromptRequestDigest, + agentInteractiveSessionStopRequestDigest, + canonicalCandidateDigest, +} 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 { claimRetainedInteractiveControl } from './retained-interactive-control' +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_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 }, + }) + expect(handle.ref.run.sessionId).toBe('retained-session:workspace-1:native-turn-1') + expect((await handle.status()).state).toBe('running') + const control = await claimRetainedInteractiveControl({ handle, holderId: 'braid-ui' }) + const promptAcknowledgement = await handle.sendPrompt( + 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 }) + expect(terminal.ref.parentExecutionId).toBe(handle.ref.run.executionId) + expect(terminal.control).toEqual(control) + const stopAcknowledgement = await handle.stop(stopCommand(handle.ref, control)) + expect(stopAcknowledgement).toMatchObject({ status: 'accepted', effect: 'stopped' }) + }) + + 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[] = [] + 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 (admission) => { + if (admission.phase === 'interactive_environment') { + 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({ control: controlFor(handle.ref) })).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('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) => { + const fixture = interactiveProvider({ hangAt }) + const handle = await start(fixture.provider) + const controller = new AbortController() + const control = controlFor(handle.ref) + const pending = + 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}`) + + 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 environmentCreations: number + readonly dispatchCalls: number + readonly startCalls: number + readonly processStarts: number + readonly destroyCalls: number + readonly destroySignal?: AbortSignal + readonly hangingCalls: number + hangAt?: HangPoint + statusRef?: AgentInteractiveSessionRef +} + +type HangPoint = + | 'capabilities' + | 'create' + | 'get' + | 'start' + | 'claimControl' + | '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, + environmentCreations: 0, + dispatchCalls: 0, + 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: { + 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: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + 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!, + claimControl: async (request) => { + if (hangAt === 'claimControl') { + fixture.hangingCalls += 1 + return neverPending() + } + 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, + ref: ref!, + status: 'accepted' as const, + control, + } + }, + status: async () => { + if (hangAt === 'status') { + fixture.hangingCalls += 1 + return neverPending() + } + return { state: 'running' as const, ref: fixture.statusRef ?? ref! } + }, + attach: async (request) => { + if (hangAt === 'attach') { + fixture.hangingCalls += 1 + return neverPending() + } + return { ...terminal(), control: request.control } + }, + sendPrompt: async (command) => { + if (hangAt === 'sendPrompt') { + fixture.hangingCalls += 1 + return neverPending() + } + fixture.prompts.push(command.prompt) + return { + operationId: command.operationId, + requestDigest: command.requestDigest, + ref: ref!, + control: command.control, + status: 'accepted' as const, + } + }, + stop: async (command) => { + if (hangAt === 'stop') { + fixture.hangingCalls += 1 + return neverPending() + } + return { + operationId: command.operationId, + requestDigest: command.requestDigest, + ref: ref!, + control: command.control, + status: 'accepted' as const, + effect: '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 + 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, + preparationReceipt: { + ...preparationReceipt, + digest: canonicalCandidateDigest(preparationReceipt), + }, + incarnationId: 'incarnation-1', + 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 (destroyOptions) => { + fixture.destroyCalls += 1 + fixture.destroySignal = destroyOptions?.signal + }, + } + const provider: AgentEnvironmentProvider = { + name: 'test-provider', + capabilities: async () => { + if (hangAt === 'capabilities') { + fixture.hangingCalls += 1 + return neverPending() + } + return interactiveCapabilities(options.completeCapabilities !== false) + }, + 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) => { + 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 }, + environmentCreations: { get: () => fixture.environmentCreations }, + dispatchCalls: { get: () => fixture.dispatchCalls }, + startCalls: { get: () => fixture.startCalls }, + processStarts: { get: () => fixture.processStarts }, + destroyCalls: { get: () => fixture.destroyCalls }, + destroySignal: { get: () => fixture.destroySignal }, + 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(() => {}) +} + +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: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + } +} + +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)) + } + 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: { + 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, + control: 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..0f21e47d --- /dev/null +++ b/src/runtime/retained-interactive.ts @@ -0,0 +1,493 @@ +import type { + AgentInteractiveSessionRef, + AgentInteractiveSessionStart, + Sha256Digest, +} from '@tangle-network/agent-interface' +import { + AgentEnvironmentCapabilitiesSchema, + AgentInteractiveSessionRefSchema, + agentInteractiveSessionRefMatchesStart, + agentInteractiveSessionRunRef, + agentProfileSchema, + canonicalAgentProfileDigest, + canonicalCandidateDigest, + 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 { + createInteractiveEnvironment, + destroyInteractiveEnvironment, + startInteractiveProcess, +} from './retained-interactive-lifecycle' +import type { + ReconnectRetainedInteractiveRunOptions, + RecoverRetainedInteractiveRunOptions, + RetainedInteractiveRunHandle, + RetainedInteractiveStartMaterial, + 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, + RetainedInteractiveIntentAdmission, +} from './retained-run-types' +import { detachedSnapshot } from './supervise/snapshot' + +/** + * 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 { + 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 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()), + options.signal, + ), + ) + assertInteractiveCapabilities(options.provider.name, providerCapabilities) + + const environment = await createInteractiveEnvironment( + () => + options.provider.create({ + ...options.environment, + profile, + signal: options.signal, + metadata: { + ...options.environment.metadata, + retainedIdempotencyKey: options.environment.idempotencyKey, + interactiveIdempotencyKey: options.interactiveIdempotencyKey, + requestedProfileDigest, + interactiveIntentDigest: intent.requestDigest, + interactiveRunId: intent.runId, + 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) + 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 startInteractiveProcess( + environment, + () => + 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') + } + 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, + 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: RetainedInteractiveEnvironmentAdmission, +): 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 }) +} + +function interactiveIntent( + options: StartRetainedInteractiveRunOptions, + 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, + idempotencyKey: options.environment.idempotencyKey, + interactiveIdempotencyKey: options.interactiveIdempotencyKey, + sessionId: identity.sessionId, + executionId: identity.executionId, + requestedProfileDigest, + create: retainedCreateMaterial(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, + requestDigest, + } +} + +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, + 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.control || + !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, +): Promise { + try { + await destroyInteractiveEnvironment(environment) + } catch (cleanupError) { + throw new AggregateError( + [cause, cleanupError], + 'interactive environment was invalid and could not be destroyed', + ) + } +} diff --git a/src/runtime/retained-run-binding.ts b/src/runtime/retained-run-binding.ts index d79a5709..6ec29547 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) } @@ -306,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-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-intent.test.ts b/src/runtime/retained-run-intent.test.ts new file mode 100644 index 00000000..0c1a3021 --- /dev/null +++ b/src/runtime/retained-run-intent.test.ts @@ -0,0 +1,79 @@ +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('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 new file mode 100644 index 00000000..ba2bc114 --- /dev/null +++ b/src/runtime/retained-run-intent.ts @@ -0,0 +1,96 @@ +import { canonicalCandidateDigest } from '@tangle-network/agent-interface' +import type { + AgentTurnInput, + CreateAgentEnvironmentInput, +} from '@tangle-network/agent-interface/environment-provider' + +/** + * Project environment creation input into public digest material. + * + * 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, +): Record { + return { + ...(environment.backend === undefined ? {} : { backend: environment.backend }), + ...(environment.workspace === undefined + ? {} + : { + workspaceDigest: canonicalCandidateDigest(publicWorkspaceMaterial(environment.workspace)), + }), + ...(environment.resources === undefined + ? {} + : { + resourcesDigest: canonicalCandidateDigest(publicResourceMaterial(environment.resources)), + }), + ...(environment.name === undefined ? {} : { name: environment.name }), + ...(environment.env === undefined + ? {} + : { environmentVariableNames: retainedObjectNames(environment.env) }), + ...(environment.secrets === undefined + ? {} + : { secretNames: retainedSecretNames(environment.secrets) }), + ...(environment.metadata === undefined + ? {} + : { metadataKeys: retainedObjectNames(environment.metadata) }), + ...(environment.providerOptions === undefined + ? {} + : { 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[] { + 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 { + ...(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 7092acfe..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, @@ -8,7 +10,12 @@ 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, exactControlRef, @@ -16,16 +23,21 @@ 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' +import { detachedSnapshot } from './supervise/snapshot' import { freshTurnInput } from './turn-input' /** @@ -54,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 */ @@ -77,11 +88,21 @@ export async function startRetainedRun( const identity = options.identity ?? mintRetainedIdentity(options.environment.idempotencyKey, options.turn.turnId) - const capabilities = await assertRetainedCapabilities(options.provider) 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, + ) // 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. @@ -90,10 +111,35 @@ export async function startRetainedRun( metadata: { ...options.environment.metadata, retainedIdempotencyKey: options.environment.idempotencyKey, + retainedIntentDigest: intent.requestDigest, + retainedRunId: intent.runId, sessionId: identity.sessionId, 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 +192,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 +205,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 +274,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', @@ -295,20 +356,36 @@ 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_intent' || + 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. + * 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 @@ -323,18 +400,42 @@ async function admitDurably( * * @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') - 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 { @@ -369,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, @@ -379,12 +559,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 +584,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. + */ +export 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 +620,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..f19141c7 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, @@ -13,6 +15,7 @@ import type { } from '@tangle-network/agent-interface' import type { AgentEnvironment, + AgentEnvironmentCapabilities, AgentEnvironmentProvider, AgentTurnInput, AgentTurnResult, @@ -73,6 +76,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 @@ -88,6 +93,26 @@ export interface RetainedRunHandle { cancel(options: RetainedRunCancelOptions): Promise } +/** + * Sanitized headless intent durable before environment creation. + * + * 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 { + 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' @@ -109,24 +134,70 @@ export interface RetainedRunDispatchedAdmission { readonly turnId: string } -/** One admission record the runtime persists through the caller before proceeding. @stable */ -export type RetainedRunAdmission = RetainedRunEnvironmentAdmission | RetainedRunDispatchedAdmission +/** + * Sanitized intent durable before an interactive environment create begins. + * + * 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 { + 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 + 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 = + | RetainedInteractiveIntentAdmission + | RetainedInteractiveEnvironmentAdmission + | RetainedInteractiveStartedAdmission + +/** 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. * - * 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. * * @stable */ 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 } /** @@ -138,6 +209,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 } @@ -171,6 +249,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 04f9851e..cf84da75 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' @@ -42,11 +44,25 @@ function recordedAdmissions(): { return { admissions, onAdmission: async (admission) => { + assertDetachedAdmissionPhase(admission) admissions.push(admission) }, } } +function assertDetachedAdmissionPhase(admission: RetainedRunAdmission): void { + switch (admission.phase) { + case 'intent': + 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 @@ -60,6 +76,8 @@ async function runChild( phase: | 'start' | 'reconnect' + | 'start-kill-intent' + | 'recover-intent' | 'start-kill-dispatched' | 'recover-dispatched' | 'start-kill-environment' @@ -120,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')) @@ -202,6 +224,157 @@ describe('retained runtime run control', () => { ).toEqual(['restart-native-operation']) }) + 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', + 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) + + 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: { + ...environment, + secrets: { TANGLE_TOKEN: 'changed-low-entropy' }, + }, + 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: 'changed-low-entropy' }) + 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({ @@ -224,6 +397,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', ]) @@ -244,8 +418,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 () => { @@ -561,13 +735,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, @@ -961,6 +1150,121 @@ describe('retained runtime run control', () => { expect(creates).toBe(0) }) + 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 }, + }, + onAdmission: recordedAdmissions().onAdmission, + }), + ).rejects.toThrow('does not support requested interactions: question') + expect({ creates, dispatches }).toEqual({ creates: 0, dispatches: 0 }) + }) + + 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* [] + }, + 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 }], + ['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 +1842,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', @@ -1705,6 +2083,7 @@ describe('retained runtime run control', () => { log.push('resolved') expect(log).toEqual([ + 'admission:intent', 'create', 'admission:environment', 'dispatch', @@ -2022,8 +2401,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 }, }) @@ -2070,8 +2449,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', }) @@ -2151,3 +2533,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/retained-run.ts b/src/runtime/retained-run.ts index 1fff575f..a46c3e18 100644 --- a/src/runtime/retained-run.ts +++ b/src/runtime/retained-run.ts @@ -5,6 +5,24 @@ * startup, replay, binding checks, and handle operations. */ +export { + reconnectRetainedInteractiveRun, + recoverRetainedInteractiveRun, + startRetainedInteractiveRun, +} from './retained-interactive' +export { + type ClaimRetainedInteractiveControlOptions, + claimRetainedInteractiveControl, +} from './retained-interactive-control' +export type { + ReconnectRetainedInteractiveRunOptions, + RecoverRetainedInteractiveRunOptions, + RetainedInteractiveAdmissionHook, + RetainedInteractiveEnvironmentInput, + RetainedInteractiveRunHandle, + RetainedInteractiveStartMaterial, + StartRetainedInteractiveRunOptions, +} from './retained-interactive-types' export { reconnectRetainedRun, recoverRetainedRun, @@ -15,8 +33,13 @@ export type { NativeContextContinuationExecution, NativeContextContinuationInput, ReconnectRetainedRunOptions, + RecoverRetainedRunIntentOptions, RecoverRetainedRunOptions, RecoverRetainedRunResult, + RetainedInteractiveAdmission, + RetainedInteractiveEnvironmentAdmission, + RetainedInteractiveIntentAdmission, + RetainedInteractiveStartedAdmission, RetainedRunAdmission, RetainedRunAdmissionHook, RetainedRunCancellation, @@ -26,8 +49,10 @@ export type { RetainedRunEnvironmentAdmission, RetainedRunEventOptions, RetainedRunHandle, + RetainedRunIntentAdmission, RetainedRunReplayPoint, RetainedRunSnapshot, + RetainedRunStartMaterial, StartRetainedRunInEnvironmentOptions, StartRetainedRunOptions, } from './retained-run-types' 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..3f464a0f 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' @@ -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,13 +95,41 @@ 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) 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', () => { @@ -127,7 +160,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,10 +191,164 @@ 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('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({ + 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) }) }) @@ -213,7 +400,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 +427,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 +454,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 +478,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 +509,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 +556,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 +602,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 +649,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 +701,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 +716,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 +771,7 @@ describe('streamAgentTurn: executor backend', () => { }, }), }, - 'ping', + { prompt: 'ping' }, ), ) @@ -578,7 +789,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) @@ -587,6 +798,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', () => { @@ -613,7 +870,7 @@ describe('streamAgentTurn: chat backend', () => { const seen: RuntimeStreamEvent[] = [] for await (const event of streamObservedAgentTurn( { kind: 'chat', backend: stubChatBackend() }, - 'hi', + { prompt: 'hi' }, )) { seen.push(event) } @@ -639,11 +896,31 @@ 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( { kind: 'chat', backend: stubChatBackend({ hangUntilAbort: true }) }, - 'hang', + { prompt: 'hang' }, { signal: controller.signal }, ) setTimeout(() => controller.abort(new Error('user stopped')), 20) @@ -667,7 +944,7 @@ describe('streamAgentTurn: chat backend', () => { kind: 'chat', backend: stubChatBackend({ hangUntilAbort: true }), }, - 'slow', + { prompt: 'slow' }, { timeoutMs: 25, }, @@ -676,6 +953,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 af6113f2..d4abb380 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,13 @@ import type { RuntimeSession, RuntimeStreamEvent, } from '../types' -import { createSandboxToolPartState, mapSandboxEvent, mapSandboxToolEvent } from './sandbox-events' +import { awaitAbortable } from './retained-run-binding' +import { + canonicalStreamEventFromSandboxEvent, + createSandboxToolPartState, + mapSandboxEvent, + mapSandboxToolEvent, +} from './sandbox-events' import { executableAgentProfileSnapshot } from './supervise/executable-spec' import { authoredProfileDigest, @@ -88,6 +99,11 @@ import type { ProfileMaterializationReceipt, UsageEvent, } from './supervise/types' +import { + promptFromAgentTurnInput, + promptOptionsFromAgentTurnInput, + providerMessageText, +} from './turn-input' /** * The execution substrate one turn runs on — a closed discriminated union over @@ -132,10 +148,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 +186,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 - } - return 'structured agent turn' + if (input.prompt !== undefined) return input.prompt + if (input.parts !== undefined) return renderInputPartsAsText(input.parts) + return providerMessageText(input.providerOptions) ?? '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) }), } } @@ -578,11 +592,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 @@ -604,12 +622,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, @@ -617,7 +635,7 @@ async function* streamAgentTurnInternal( ) : driveBoxTurn( backend.box, - turnIntent(input), + input, deadline.signal, backend.agentRunName ?? 'agent', acc, @@ -627,10 +645,13 @@ 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) } + // 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)) @@ -708,7 +729,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() } } @@ -837,29 +863,50 @@ 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) + 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) + 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. } } @@ -875,7 +922,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 @@ -891,16 +938,16 @@ 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)) { - 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) @@ -965,10 +1012,58 @@ 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 } +/** + * 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/supervise/runtime.ts b/src/runtime/supervise/runtime.ts index 546de253..5b189ee0 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 { @@ -4764,6 +4766,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.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 1f54448b..a36238b1 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,7 @@ export function freshTurnInput( ...(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 }), ...(input.signal === undefined ? {} : { signal: input.signal }), turnId: runtime.turnId, @@ -34,3 +40,74 @@ 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 ?? providerMessageText(input.providerOptions) ?? '' +} + +/** 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 + 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 { + 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/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 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 { 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