From 7b40963b8b64da14a48339390098f370cd751365 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Fri, 26 Jun 2026 13:23:03 -0600 Subject: [PATCH 1/3] feat(runtime): add environment provider adapters --- docs/api/primitive-catalog.md | 35 +- docs/api/runtime.md | 574 +++++++- docs/canonical-api.md | 2 +- docs/research/README.md | 1 + .../environment-provider-adapter-spec.md | 646 +++++++++ package.json | 4 +- pnpm-lock.yaml | 11 +- src/runtime/environment-provider.test.ts | 361 ++++++ src/runtime/environment-provider.ts | 1150 +++++++++++++++++ src/runtime/index.ts | 35 + src/runtime/supervise/runtime.ts | 25 + 11 files changed, 2831 insertions(+), 13 deletions(-) create mode 100644 docs/research/environment-provider-adapter-spec.md create mode 100644 src/runtime/environment-provider.test.ts create mode 100644 src/runtime/environment-provider.ts diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index b14b8627..3a8d841e 100644 --- a/docs/api/primitive-catalog.md +++ b/docs/api/primitive-catalog.md @@ -337,7 +337,7 @@ Import from `@tangle-network/agent-runtime/intelligence` — 60 exports. ### Recursive atom + loop kernel (alias of ./runtime) -Import from `@tangle-network/agent-runtime/loops` — 346 exports. +Import from `@tangle-network/agent-runtime/loops` — 379 exports. | Symbol | Kind | Summary | |---|---|---| @@ -356,6 +356,7 @@ Import from `@tangle-network/agent-runtime/loops` — 346 exports. | `completionAuthorizes` | function | _(no summary — add a TSDoc line at the declaration)_ | | `computeFindingId` | function | Compute the stable finding_id from the identity-defining fields. | | `contentAddress` | function | Mint the content-addressed `outRef` for a result artifact: `sha256:` over a | +| `createAgentEnvironmentProviderRegistry` | function | Create a registry that resolves provider names to concrete provider instances. | | `createBudgetPool` | function | Create a conserved reservation pool from a root `Budget`. `now()` is injected so the | | `createEventBus` | function | _(no summary — add a TSDoc line at the declaration)_ | | `createExecutor` | function | The single built-in executor factory. Picks a leaf backend by data (`config.backend`), | @@ -410,12 +411,15 @@ Import from `@tangle-network/agent-runtime/loops` — 346 exports. | `probeSandboxCapabilities` | function | Probe (and memoize per client) what the loop may rely on. A client without a | | `profileRichnessFinding` | function | Turn a {@link ProfileRichness} verdict into a bus-routable `AnalystFinding` (area `profile-quality`). | | `promotionGate` | function | _(no summary — add a TSDoc line at the declaration)_ | +| `providerAsExecutor` | function | Adapt an environment provider into an `ExecutorFactory` for `createExecutor`. | +| `providerAsSandboxClient` | function | Adapt a neutral environment provider to the `SandboxClient` interface used by existing loop paths. | | `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. | | `renderCorpusToInstructions` | function | The learning-flywheel READ side. Queries the corpus through `filter`, renders the matching facts | | `renderReport` | function | Operator-facing report, split by who should act. The agent block is the | | `reportLoopUsage` | function | Forward a `LoopResult`'s aggregated cost + token usage into a campaign cost | +| `resolveAgentEnvironmentProvider` | function | Resolve a provider instance or registry name, failing loudly when a name is unknown. | | `routerBrain` | function | The router as a supervisor BRAIN: the canonical `ToolLoopChat` seam backed by the router's | | `routerChatWithTools` | function | A router completion WITH tool-calling — the operator driver's LLM seam. Passes OpenAI-shape | | `routerChatWithUsage` | function | _(no summary — add a TSDoc line at the declaration)_ | @@ -426,6 +430,7 @@ Import from `@tangle-network/agent-runtime/loops` — 346 exports. | `runLoop` | function | _(no summary — add a TSDoc line at the declaration)_ | | `runPersonified` | function | Compose the persona + chosen shape onto a fresh keystone `Supervisor`. Resolves the shape | | `runStrategyEvolution` | function | _(no summary — add a TSDoc line at the declaration)_ | +| `sandboxClientAsProvider` | function | Adapt a `SandboxClient` into the shared `AgentEnvironmentProvider` contract. | | `sandboxSessionTraceSource` | function | The SANDBOX / fleet trace source: read a box session's message parts and decode the harness's tool | | `selectChampion` | function | Search-side champion selection over a tournament report. | | `selectValidWinner` | function | The single content-free valid-only winner selector. Among the gated-VALID children only | @@ -462,6 +467,13 @@ Import from `@tangle-network/agent-runtime/loops` — 346 exports. | `SandboxInstance` | class | A sandbox instance with methods for interaction. | | `SandboxRunAbortError` | class | _(no summary — add a TSDoc line at the declaration)_ | | `Agent` | interface | One self-similar atom. A leaf is an `Agent` that never calls `scope.spawn`; a driver | +| `AgentEnvironment` | interface | _(no summary — add a TSDoc line at the declaration)_ | +| `AgentEnvironmentCapabilities` | interface | _(no summary — add a TSDoc line at the declaration)_ | +| `AgentEnvironmentEvent` | interface | _(no summary — add a TSDoc line at the declaration)_ | +| `AgentEnvironmentProvider` | interface | _(no summary — add a TSDoc line at the declaration)_ | +| `AgentEnvironmentProviderRegistry` | interface | In-memory registry for named `AgentEnvironmentProvider` instances. | +| `AgentEnvironmentQuery` | interface | _(no summary — add a TSDoc line at the declaration)_ | +| `AgentEnvironmentSummary` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `AgenticOptions` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `AgenticRunResult` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `AgenticSurface` | interface | A stateful, checkable environment an agent operates over with tools. Open behind one interface. | @@ -469,7 +481,11 @@ Import from `@tangle-network/agent-runtime/loops` — 346 exports. | `AgenticTool` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `AgentProfile` | interface | Public provider-neutral agent profile contract. | | `AgentRunSpec` | interface | Sandbox-SDK-shaped agent specification. | +| `AgentSession` | interface | _(no summary — add a TSDoc line at the declaration)_ | +| `AgentSessionRef` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `AgentSpec` | interface | `AgentProfile` does NOT carry a `harness`/backend field — `harness` lives on the | +| `AgentTurnInput` | interface | _(no summary — add a TSDoc line at the declaration)_ | +| `AgentTurnResult` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `AnalystFinding` | interface | Unified envelope every analyst emits. Schema-versioned so renderers | | `AnytimeReport` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `AnytimeStrategySummary` | interface | _(no summary — add a TSDoc line at the declaration)_ | @@ -494,6 +510,8 @@ Import from `@tangle-network/agent-runtime/loops` — 346 exports. | `BusStats` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `ChampionPick` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `CheckpointCapableBox` | interface | Loop-side widening of the box's optional checkpoint method. The | +| `CheckpointRef` | interface | _(no summary — add a TSDoc line at the declaration)_ | +| `CheckpointRequest` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `CompletionAnalyst` | interface | Reads a node's trace → a completion verdict. Same input shape as the `analyze` hook, so | | `CompletionEvidence` | interface | Trace-derived evidence for a completion claim — an artifact (output) or a verifier metric, | | `CompletionPolicy` | interface | When a verdict authorizes the driver to END. Deterministic → trust (ground truth); | @@ -502,6 +520,7 @@ Import from `@tangle-network/agent-runtime/loops` — 346 exports. | `Corpus` | interface | The durable cross-run corpus — the learning-flywheel store. DISTINCT from `SpawnJournal` | | `CorpusFilter` | interface | A corpus query filter — every field is an AND-narrowing; an omitted field does not constrain. | | `CorpusRecord` | interface | One accreted fact in the cross-run corpus — the learning-flywheel's durable unit. DISTINCT from | +| `CreateAgentEnvironmentInput` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `CreateSandboxOptions` | interface | Configuration for creating a new sandbox. | | `CreateScopeAnalystOptions` | interface | The analyst run an `Agent` performs over the children settled so far. | | `CriuCapableClient` | interface | Narrowed view of the optional CRIU probe. The loop-side `SandboxClient` | @@ -523,12 +542,15 @@ Import from `@tangle-network/agent-runtime/loops` — 346 exports. | `EvolutionGeneration` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `EvolutionReport` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `ExecCtx` | interface | _(no summary — add a TSDoc line at the declaration)_ | +| `ExecRequest` | interface | _(no summary — add a TSDoc line at the declaration)_ | +| `ExecResult` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `Executor` | interface | The leaf runtime — ONE open interface, not a closed union. `execute` returns a | | `ExecutorContext` | interface | Construction context handed to a `ExecutorFactory` — the seams a built-in needs | | `ExecutorResult` | interface | Terminal artifact of a one-shot `Executor.execute`. | | `FanoutOptions` | interface | `fanout(items, { synthesize? })` — N children spawned in one round (one per item, bounded by | | `FanoutSynthesis` | interface | How a fanout's synthesis child is built + read. `synthesisTask` projects the drained child | | `ForkCapableBox` | interface | Loop-side widening of the box's optional fork method. | +| `ForkRequest` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `GitWorkspaceOptions` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `HarvestCorpusOptions` | interface | harvestCorpus — production traces → corpus, the G2 bridge (the playbook's step 6). | | `HarvestFailure` | interface | _(no summary — add a TSDoc line at the declaration)_ | @@ -576,14 +598,19 @@ Import from `@tangle-network/agent-runtime/loops` — 346 exports. | `PersonaContext` | interface | The persona context blob — who the loop is acting as. Open by intent: a persona names its | | `PersonaExecutors` | interface | How a persona supplies executor resolution. Either a pre-built registry (factories already | | `PipelineStage` | interface | `pipeline(stages)` — sequential composition: each stage's `Outcome.deliverable` feeds the next | +| `PlacementInfo` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `ProfileRichness` | interface | Per-field verdict on one authored profile — the raw material the bench renders + scores. | | `ProfileRichnessThresholds` | interface | Thresholds below which a system prompt is treated as a thin stub. Tunable per call. | | `PromotionGateOptions` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `PromotionVerdict` | interface | _(no summary — add a TSDoc line at the declaration)_ | +| `ProviderAsSandboxClientOptions` | interface | Options for exposing an `AgentEnvironmentProvider` through the legacy sandbox client port. | +| `ProviderExecutorOptions` | interface | Options for running a provider as a supervise-mode executor. | +| `ProviderSeam` | interface | Generic environment provider executor config. External packages implement | | `PublishOptions` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `RegistryAnalyzeProjection` | interface | Project a `ScopeAnalyzeInput` into the `AnalystRegistry.run` arguments. The registry runs over a | | `RenderCorpusToInstructionsOptions` | interface | Project accreted corpus facts into an `AgentProfile`'s instruction seams — the learning-flywheel | | `ReservationTicket` | interface | Opaque, single-use reservation handle returned by `reserve` and consumed by | +| `ResourceRequest` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `ResultBlobStore` | interface | Content-addressed result blobs (the `outRef` → artifact map) backing the replay | | `RouterChatResult` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `RouterChatToolsResult` | interface | _(no summary — add a TSDoc line at the declaration)_ | @@ -595,6 +622,7 @@ Import from `@tangle-network/agent-runtime/loops` — 346 exports. | `RunProvenance` | interface | Domain-free run provenance: a manifest of what was mounted into the run's | | `SandboxCapabilities` | interface | What the loop kernel is allowed to know about a sandbox backend: a single | | `SandboxClient` | interface | Minimal sandbox client surface the kernel calls. Satisfied structurally by | +| `SandboxClientProviderOptions` | interface | Options for wrapping the current Tangle sandbox client as an environment provider. | | `SandboxEvent` | interface | SSE event from sandbox streaming. | | `SandboxLineage` | interface | Owns box + session handles for one loop run and offers the three | | `SandboxLineageHandle` | interface | A live box plus the session that threads its iterations together. Handed back | @@ -644,10 +672,15 @@ Import from `@tangle-network/agent-runtime/loops` — 346 exports. | `WidenLineage` | interface | A lineage the gate may widen toward — the settled child that looked promising + the findings | | `WidenSpec` | interface | `widen({ gate })` (G5) — the STREAMING spawn-on-completion driver. Unlike the static-fanout | | `Workspace` | interface | _(no summary — add a TSDoc line at the declaration)_ | +| `WorkspaceRequest` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `WorkspaceRun` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `WorktreeCliExecutorOptions` | interface | _(no summary — add a TSDoc line at the declaration)_ | | `WorktreeCommandResult` | interface | Outcome of one verification command run in the worktree (test or typecheck). | | `WorktreeFanoutOptions` | interface | _(no summary — add a TSDoc line at the declaration)_ | +| `AgentEnvironmentProviderRef` | type | Provider object or registry name accepted by runtime provider adapters. | +| `AgentEnvironmentStatus` | type | _(no summary — add a TSDoc line at the declaration)_ | +| `AgentProfileRef` | type | Portable profile reference: inline profile or provider catalog id. | +| `AgentSessionStatus` | type | _(no summary — add a TSDoc line at the declaration)_ | | `ApplyContinuation` | type | Fold a steering string into the caller's Task shape, producing the Task for | | `AssertTraceDerivedFindings` | type | The firewall assertion contract, re-stated for the reactive seam (PORT of | | `BudgetReadout` | type | Post-reservation pool readout — the shape `Scope.budget` exposes. `tokensLeft`, | diff --git a/docs/api/runtime.md b/docs/api/runtime.md index 9ec36053..15ff0231 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -74,7 +74,7 @@ Defined in: [durable/spawn-journal.ts:77](https://github.com/tangle-network/agen ###### Implementation of -[`ResultBlobStore`](#resultblobstore).[`get`](#get-1) +[`ResultBlobStore`](#resultblobstore).[`get`](#get-2) *** @@ -884,6 +884,326 @@ Minimum confidence a PROBABILISTIC verdict must clear to end. Default 0.8. *** +### AgentEnvironmentProviderRegistry + +Defined in: runtime/environment-provider.ts:83 + +**`Experimental`** + +In-memory registry for named `AgentEnvironmentProvider` instances. + +#### Methods + +##### register() + +> **register**(`provider`, `options?`): `void` + +Defined in: runtime/environment-provider.ts:84 + +**`Experimental`** + +###### Parameters + +###### provider + +`AgentEnvironmentProvider` + +###### options? + +###### replace? + +`boolean` + +###### Returns + +`void` + +##### has() + +> **has**(`name`): `boolean` + +Defined in: runtime/environment-provider.ts:85 + +**`Experimental`** + +###### Parameters + +###### name + +`string` + +###### Returns + +`boolean` + +##### get() + +> **get**(`name`): `AgentEnvironmentProvider` \| `undefined` + +Defined in: runtime/environment-provider.ts:86 + +**`Experimental`** + +###### Parameters + +###### name + +`string` + +###### Returns + +`AgentEnvironmentProvider` \| `undefined` + +##### require() + +> **require**(`name`): `AgentEnvironmentProvider` + +Defined in: runtime/environment-provider.ts:87 + +**`Experimental`** + +###### Parameters + +###### name + +`string` + +###### Returns + +`AgentEnvironmentProvider` + +##### names() + +> **names**(): `string`[] + +Defined in: runtime/environment-provider.ts:88 + +**`Experimental`** + +###### Returns + +`string`[] + +##### providers() + +> **providers**(): `AgentEnvironmentProvider`[] + +Defined in: runtime/environment-provider.ts:89 + +**`Experimental`** + +###### Returns + +`AgentEnvironmentProvider`[] + +##### capabilities() + +> **capabilities**(`name`): `Promise`\<`AgentEnvironmentCapabilities`\> + +Defined in: runtime/environment-provider.ts:90 + +**`Experimental`** + +###### Parameters + +###### name + +`string` + +###### Returns + +`Promise`\<`AgentEnvironmentCapabilities`\> + +*** + +### ProviderAsSandboxClientOptions + +Defined in: runtime/environment-provider.ts:161 + +**`Experimental`** + +Options for exposing an `AgentEnvironmentProvider` through the legacy sandbox client port. + +#### Properties + +##### defaults? + +> `optional` **defaults?**: `Partial`\<`CreateAgentEnvironmentInput`\> + +Defined in: runtime/environment-provider.ts:162 + +**`Experimental`** + +##### requireTerminalEvent? + +> `optional` **requireTerminalEvent?**: `boolean` + +Defined in: runtime/environment-provider.ts:163 + +**`Experimental`** + +##### mapCreateOptions? + +> `optional` **mapCreateOptions?**: (`options`) => `Partial`\<`CreateAgentEnvironmentInput`\> + +Defined in: runtime/environment-provider.ts:164 + +**`Experimental`** + +###### Parameters + +###### options + +`CreateSandboxOptions` \| `undefined` + +###### Returns + +`Partial`\<`CreateAgentEnvironmentInput`\> + +*** + +### SandboxClientProviderOptions + +Defined in: runtime/environment-provider.ts:197 + +**`Experimental`** + +Options for wrapping the current Tangle sandbox client as an environment provider. + +#### Properties + +##### name? + +> `optional` **name?**: `string` + +Defined in: runtime/environment-provider.ts:198 + +**`Experimental`** + +##### defaultBackend? + +> `optional` **defaultBackend?**: `BackendType` + +Defined in: runtime/environment-provider.ts:199 + +**`Experimental`** + +##### capabilities? + +> `optional` **capabilities?**: `AgentEnvironmentCapabilities` \| (() => `AgentEnvironmentCapabilities` \| `Promise`\<`AgentEnvironmentCapabilities`\>) + +Defined in: runtime/environment-provider.ts:200 + +**`Experimental`** + +##### validateProfile? + +> `optional` **validateProfile?**: (`profile`) => `AgentProfileValidationResult` \| `Promise`\<`AgentProfileValidationResult`\> + +Defined in: runtime/environment-provider.ts:203 + +**`Experimental`** + +###### Parameters + +###### profile + +`AgentProfileRef` + +###### Returns + +`AgentProfileValidationResult` \| `Promise`\<`AgentProfileValidationResult`\> + +##### mapCreateInput? + +> `optional` **mapCreateInput?**: (`input`) => `CreateSandboxOptions` + +Defined in: runtime/environment-provider.ts:206 + +**`Experimental`** + +###### Parameters + +###### input + +`CreateAgentEnvironmentInput` + +###### Returns + +`CreateSandboxOptions` + +*** + +### ProviderExecutorOptions + +Defined in: runtime/environment-provider.ts:261 + +**`Experimental`** + +Options for running a provider as a supervise-mode executor. + +#### Extended by + +- [`ProviderSeam`](#providerseam) + +#### Properties + +##### defaults? + +> `optional` **defaults?**: `Partial`\<`CreateAgentEnvironmentInput`\> + +Defined in: runtime/environment-provider.ts:262 + +**`Experimental`** + +##### runtime? + +> `optional` **runtime?**: `Runtime` + +Defined in: runtime/environment-provider.ts:263 + +**`Experimental`** + +##### destroyOnSettle? + +> `optional` **destroyOnSettle?**: `boolean` + +Defined in: runtime/environment-provider.ts:264 + +**`Experimental`** + +##### requireTerminalEvent? + +> `optional` **requireTerminalEvent?**: `boolean` + +Defined in: runtime/environment-provider.ts:265 + +**`Experimental`** + +##### taskToTurn? + +> `optional` **taskToTurn?**: (`task`, `specProfile`) => `AgentTurnInput` + +Defined in: runtime/environment-provider.ts:266 + +**`Experimental`** + +###### Parameters + +###### task + +`unknown` + +###### specProfile + +`AgentProfile` + +###### Returns + +`AgentTurnInput` + +*** + ### HarvestCorpusOptions Defined in: [runtime/harvest-corpus.ts:28](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/harvest-corpus.ts#L28) @@ -8070,6 +8390,108 @@ Defined in: [runtime/supervise/run-context.ts:49](https://github.com/tangle-netw *** +### ProviderSeam + +Defined in: [runtime/supervise/runtime.ts:154](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/supervise/runtime.ts#L154) + +Generic environment provider executor config. External packages implement + `AgentEnvironmentProvider`; this built-in wrapper lets `createExecutor` + consume them as backend data while preserving the existing usage channel. + +#### Extends + +- [`ProviderExecutorOptions`](#providerexecutoroptions) + +#### Properties + +##### defaults? + +> `optional` **defaults?**: `Partial`\<`CreateAgentEnvironmentInput`\> + +Defined in: runtime/environment-provider.ts:262 + +**`Experimental`** + +###### Inherited from + +[`ProviderExecutorOptions`](#providerexecutoroptions).[`defaults`](#defaults-1) + +##### runtime? + +> `optional` **runtime?**: `Runtime` + +Defined in: runtime/environment-provider.ts:263 + +**`Experimental`** + +###### Inherited from + +[`ProviderExecutorOptions`](#providerexecutoroptions).[`runtime`](#runtime) + +##### destroyOnSettle? + +> `optional` **destroyOnSettle?**: `boolean` + +Defined in: runtime/environment-provider.ts:264 + +**`Experimental`** + +###### Inherited from + +[`ProviderExecutorOptions`](#providerexecutoroptions).[`destroyOnSettle`](#destroyonsettle) + +##### requireTerminalEvent? + +> `optional` **requireTerminalEvent?**: `boolean` + +Defined in: runtime/environment-provider.ts:265 + +**`Experimental`** + +###### Inherited from + +[`ProviderExecutorOptions`](#providerexecutoroptions).[`requireTerminalEvent`](#requireterminalevent-1) + +##### taskToTurn? + +> `optional` **taskToTurn?**: (`task`, `specProfile`) => `AgentTurnInput` + +Defined in: runtime/environment-provider.ts:266 + +**`Experimental`** + +###### Parameters + +###### task + +`unknown` + +###### specProfile + +`AgentProfile` + +###### Returns + +`AgentTurnInput` + +###### Inherited from + +[`ProviderExecutorOptions`](#providerexecutoroptions).[`taskToTurn`](#tasktoturn) + +##### provider + +> **provider**: `string` \| `AgentEnvironmentProvider` + +Defined in: [runtime/supervise/runtime.ts:155](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/supervise/runtime.ts#L155) + +##### registry? + +> `optional` **registry?**: [`AgentEnvironmentProviderRegistry`](#agentenvironmentproviderregistry) + +Defined in: [runtime/supervise/runtime.ts:156](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/supervise/runtime.ts#L156) + +*** + ### SuperviseOptions Defined in: [runtime/supervise/supervise.ts:46](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/supervise/supervise.ts#L46) @@ -9604,7 +10026,7 @@ that command). Default `[]` — gate on no-op / secret / forbidden / diff-size o ###### Inherited from -[`PatchDeliverableOptions`](#patchdeliverableoptions).[`require`](#require) +[`PatchDeliverableOptions`](#patchdeliverableoptions).[`require`](#require-1) ##### repoRoot @@ -11870,6 +12292,18 @@ Every message on the one typed pipe. UP (child→parent): question / settled / f *** +### AgentEnvironmentProviderRef + +> **AgentEnvironmentProviderRef** = `AgentEnvironmentProvider` \| `string` + +Defined in: runtime/environment-provider.ts:79 + +**`Experimental`** + +Provider object or registry name accepted by runtime provider adapters. + +*** + ### InProcessOnPrompt > **InProcessOnPrompt** = (`prompt`, `ctx`) => `SandboxEvent`[] \| `AsyncIterable`\<`SandboxEvent`\> \| `Promise`\<`SandboxEvent`[]\> @@ -12551,9 +12985,9 @@ Post-reservation pool readout — the shape `Scope.budget` exposes. `tokensLeft` ### ExecutorConfig -> **ExecutorConfig** = `object` & `RouterSeam` \| `object` & `RouterToolsSeam` \| `object` & `BridgeSeam` \| `object` & `CliSeam` \| `object` & `CliWorktreeSeam` \| `object` & `SandboxSeam` +> **ExecutorConfig** = `object` & `RouterSeam` \| `object` & `RouterToolsSeam` \| `object` & `BridgeSeam` \| `object` & `CliSeam` \| `object` & `CliWorktreeSeam` \| `object` & [`ProviderSeam`](#providerseam) \| `object` & `SandboxSeam` -Defined in: [runtime/supervise/runtime.ts:1138](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/supervise/runtime.ts#L1138) +Defined in: [runtime/supervise/runtime.ts:1154](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/supervise/runtime.ts#L1154) Config for [createExecutor](#createexecutor): the backend is DATA — the cost dial a profile, an experiment config, or a replay journal can name — not an import choice. Each @@ -12973,7 +13407,7 @@ The conserved pool a `delegate()` call applies when the caller does not pass its > `const` **cliWorktreeExecutor**: `ExecutorFactory`\<`unknown`\> -Defined in: [runtime/supervise/runtime.ts:1113](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/supervise/runtime.ts#L1113) +Defined in: [runtime/supervise/runtime.ts:1129](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/supervise/runtime.ts#L1129) The leaf `createWorktreeCliExecutor` as a backend-as-data factory: a supervisor-authored `AgentProfile` driving claude / codex / opencode on its own worktree. `budgetExempt` like @@ -13191,6 +13625,132 @@ passes. Ground truth — the driver ends directly, no validation. The check read *** +### createAgentEnvironmentProviderRegistry() + +> **createAgentEnvironmentProviderRegistry**(`providers?`): [`AgentEnvironmentProviderRegistry`](#agentenvironmentproviderregistry) + +Defined in: runtime/environment-provider.ts:95 + +**`Experimental`** + +Create a registry that resolves provider names to concrete provider instances. + +#### Parameters + +##### providers? + +`Iterable`\<`AgentEnvironmentProvider`\> = `[]` + +#### Returns + +[`AgentEnvironmentProviderRegistry`](#agentenvironmentproviderregistry) + +*** + +### resolveAgentEnvironmentProvider() + +> **resolveAgentEnvironmentProvider**(`provider`, `registry?`): `AgentEnvironmentProvider` + +Defined in: runtime/environment-provider.ts:146 + +**`Experimental`** + +Resolve a provider instance or registry name, failing loudly when a name is unknown. + +#### Parameters + +##### provider + +[`AgentEnvironmentProviderRef`](#agentenvironmentproviderref) + +##### registry? + +[`AgentEnvironmentProviderRegistry`](#agentenvironmentproviderregistry) + +#### Returns + +`AgentEnvironmentProvider` + +*** + +### providerAsSandboxClient() + +> **providerAsSandboxClient**(`provider`, `options?`): [`SandboxClient`](#sandboxclient-1) + +Defined in: runtime/environment-provider.ts:171 + +**`Experimental`** + +Adapt a neutral environment provider to the `SandboxClient` interface used by existing loop paths. + +#### Parameters + +##### provider + +`AgentEnvironmentProvider` + +##### options? + +[`ProviderAsSandboxClientOptions`](#providerassandboxclientoptions) = `{}` + +#### Returns + +[`SandboxClient`](#sandboxclient-1) + +*** + +### sandboxClientAsProvider() + +> **sandboxClientAsProvider**(`client`, `options?`): `AgentEnvironmentProvider` + +Defined in: runtime/environment-provider.ts:211 + +**`Experimental`** + +Adapt a `SandboxClient` into the shared `AgentEnvironmentProvider` contract. + +#### Parameters + +##### client + +[`SandboxClient`](#sandboxclient-1) + +##### options? + +[`SandboxClientProviderOptions`](#sandboxclientprovideroptions) = `{}` + +#### Returns + +`AgentEnvironmentProvider` + +*** + +### providerAsExecutor() + +> **providerAsExecutor**(`provider`, `options?`): `ExecutorFactory`\<`unknown`\> + +Defined in: runtime/environment-provider.ts:271 + +**`Experimental`** + +Adapt an environment provider into an `ExecutorFactory` for `createExecutor`. + +#### Parameters + +##### provider + +`AgentEnvironmentProvider` + +##### options? + +[`ProviderExecutorOptions`](#providerexecutoroptions) = `{}` + +#### Returns + +`ExecutorFactory`\<`unknown`\> + +*** + ### harvestCorpus() > **harvestCorpus**(`opts`): `Promise`\<[`HarvestReport`](#harvestreport)\> @@ -15447,7 +16007,7 @@ state between runs), so two runs never cross-contaminate their journals/blobs. > **createExecutor**(`config`): `ExecutorFactory`\<`unknown`\> -Defined in: [runtime/supervise/runtime.ts:1154](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/supervise/runtime.ts#L1154) +Defined in: [runtime/supervise/runtime.ts:1171](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/supervise/runtime.ts#L1171) The single built-in executor factory. Picks a leaf backend by data (`config.backend`), injects the matching seam, and delegates to that backend's built-in implementation. @@ -15472,7 +16032,7 @@ per-vendor adapter or a closed `inline|sandbox|cli` switch — those bypass the > **createExecutorRegistry**(): `ExecutorRegistry` -Defined in: [runtime/supervise/runtime.ts:1192](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/supervise/runtime.ts#L1192) +Defined in: [runtime/supervise/runtime.ts:1217](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/supervise/runtime.ts#L1217) The open resolver/registry. Pre-registers the three built-ins under their runtime tags (`'router'`, `'sandbox'`, `'cli'`) and accepts `register(name, diff --git a/docs/canonical-api.md b/docs/canonical-api.md index 7b791f78..0e18cfa2 100644 --- a/docs/canonical-api.md +++ b/docs/canonical-api.md @@ -2,7 +2,7 @@ -> **Version 0.78.0.** The export inventory + per-symbol signatures live in the generated `docs/api/` reference: **`docs/api/primitive-catalog.md`** is the never-stale, grouped list of every primitive to reuse (own surface + the agent-eval judge / authenticity / verification / statistics / campaign / token-usage surfaces), with each one's import path and one-line summary read live from source; the per-module pages hold the full signatures. The pinned substrate is agent-eval `>=0.97.0 <1.0.0`; the sandbox substrate that materializes profiles into harness shapes is `@tangle-network/sandbox` (peer `>=0.8.0 <1.0.0`). The neutral contract types (`AgentProfile`, `AgentProfileMcpServer`, `HarnessType`, `ReasoningEffort`, `Part`/`ToolPart`/`ToolState`) are owned by **`@tangle-network/agent-interface`** (peer `>=0.10.0 <1.0.0`) — the single source of truth. Substrate primitives are re-exported through `@tangle-network/agent-eval/contract` (or `/campaign`), not local to this package — the catalog's §2 shows exactly which subpath each lives under. +> **Version 0.78.0.** The export inventory + per-symbol signatures live in the generated `docs/api/` reference: **`docs/api/primitive-catalog.md`** is the never-stale, grouped list of every primitive to reuse (own surface + the agent-eval judge / authenticity / verification / statistics / campaign / token-usage surfaces), with each one's import path and one-line summary read live from source; the per-module pages hold the full signatures. The pinned substrate is agent-eval `>=0.97.0 <1.0.0`; the sandbox substrate that materializes profiles into harness shapes is `@tangle-network/sandbox` (peer `>=0.8.0 <1.0.0`). The neutral contract types (`AgentProfile`, `AgentProfileMcpServer`, `HarnessType`, `ReasoningEffort`, `Part`/`ToolPart`/`ToolState`, plus environment-provider types) are owned by **`@tangle-network/agent-interface`** (peer `>=0.14.0 <1.0.0`) — the single source of truth. Substrate primitives are re-exported through `@tangle-network/agent-eval/contract` (or `/campaign`), not local to this package — the catalog's §2 shows exactly which subpath each lives under. > > **`./loops` is the runtime barrel** — `package.json` maps it to `src/runtime/index.ts`. Everything below labelled `/loops` is the recursive-atom + loop-kernel surface. > diff --git a/docs/research/README.md b/docs/research/README.md index 664a5c5a..cbe5a8a7 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -25,6 +25,7 @@ evidence ledger. | [long-horizon-agent-map.md](./long-horizon-agent-map.md) | The long-horizon steered-agent product — map + decisions. | | [atom-compression-plan.md](./atom-compression-plan.md) | The self-designing atom's cut-list + build-list (feeds the deep-clean). | | [loop-facade-postmortem.md](./loop-facade-postmortem.md) | **Active guardrail.** Failure record for the deleted `defineLoop` facade + the prevention rule. | +| [environment-provider-adapter-spec.md](./environment-provider-adapter-spec.md) | Generic environment provider adapter spec: what to lift from sandbox SDK, cli-bridge, runtime routing, and profile execution so third-party compute/sandbox providers can plug in. | | [deletion-ledger.md](./deletion-ledger.md) | The deletion record for the `chore/atom-deep-clean` passes. | ## Moved to the run archive ([tangle-network/agent-lab](https://github.com/tangle-network/agent-lab), private) diff --git a/docs/research/environment-provider-adapter-spec.md b/docs/research/environment-provider-adapter-spec.md new file mode 100644 index 00000000..8482e1ac --- /dev/null +++ b/docs/research/environment-provider-adapter-spec.md @@ -0,0 +1,646 @@ +# Generic environment provider adapter spec + +**Status:** draft, evidence checked 2026-06-26 against this repo at `main`. + +**Implementation status in this repo:** the runtime provider layer landed in +`src/runtime/environment-provider.ts`. It defines the runtime-facing provider +contract, exports it from `/runtime`, and includes compatibility adapters: + +- `providerAsSandboxClient(provider)` so existing `runLoop`/`openSandboxRun` + paths can run against any provider. +- `sandboxClientAsProvider(client)` so today's Tangle sandbox SDK client is the + first provider implementation. +- `providerAsExecutor(provider)` so supervise-mode leaves can run a provider + directly and still report usage through the existing `UsageEvent` channel. +- `createAgentEnvironmentProviderRegistry()` plus + `createExecutor({ backend: 'provider', provider: 'name', registry })` so + runtime config can resolve providers by name. + +**Shared package status:** the public contract ships in +`@tangle-network/agent-interface@0.14.0` as +`@tangle-network/agent-interface/environment-provider`, and this runtime imports +those shared types directly. The SDK monorepo also contains external provider +packages for Tangle, CLI bridge, ComputeSDK, E2B, Daytona, and shared provider +conformance tests. Provider package publication is still blocked on npm +first-publish permissions/trusted publishing setup, but the code is present in +`/home/drew/code/agent-sdk`. + +## Verdict + +Yes: we should lift a provider-neutral environment contract above the current +Tangle sandbox SDK shape. The current runtime is close, but third-party +providers would have to pretend to be `SandboxInstance`, a SDK class with +private state, or squeeze through the task-only `Executor` path and lose +sessions/files/exec/fork behavior. + +The durable split should be: + +1. `@tangle-network/agent-interface` owns shared profile, event, session, and + environment lifecycle types. +2. `@tangle-network/sandbox` implements those types for Tangle sandboxes and + keeps its richer SDK methods. +3. `agent-runtime` consumes the neutral provider contract and supplies adapters + to its existing `SandboxClient` and `Executor` ports during migration. +4. CLI bridge execution is exposed through an external HTTP provider package, + instead of forcing runtime to edit the bridge repo directly. + +This lets E2B, Daytona, a compute SDK, or a local process provider implement +one contract without importing the Tangle sandbox SDK. + +## Evidence checked + +- Runtime execution code: + - `src/runtime/types.ts` + - `src/runtime/run-loop.ts` + - `src/runtime/sandbox-backend.ts` + - `src/runtime/sandbox-acquire.ts` + - `src/runtime/sandbox-lineage.ts` + - `src/runtime/sandbox-events.ts` + - `src/runtime/sandbox-capabilities.ts` + - `src/runtime/inline-sandbox-client.ts` + - `src/runtime/in-process-sandbox-client.ts` + - `src/runtime/supervise/runtime.ts` + - `src/runtime/supervise/types.ts` + - `src/mcp/in-process-executor.ts` + - `src/runtime/supervise/worktree-cli-executor.ts` + - `src/agent/sandbox-act.ts` +- Installed package types: + - `@tangle-network/sandbox@0.8.2` + - `@tangle-network/agent-interface@0.14.0` +- SDK packages in `/home/drew/code/agent-sdk`: + - `@tangle-network/agent-interface/environment-provider` + - `@tangle-network/agent-provider-testkit` + - `@tangle-network/agent-provider-tangle` + - `@tangle-network/agent-provider-cli-bridge` + - `@tangle-network/agent-provider-computesdk` + - `@tangle-network/agent-provider-e2b` + - `@tangle-network/agent-provider-daytona` +- Bridge execution code in `/home/drew/code/cli-bridge`: + - `src/routes/chat-completions.ts` + - `src/backends/types.ts` + - `src/backends/profile-support.ts` + - `src/backends/sandbox.ts` + - `src/sessions/store.ts` + +Important correction to older research: `AgentProfile` already lives in +`@tangle-network/agent-interface@0.14.0` and is re-exported by +`@tangle-network/sandbox@0.8.2`. The remaining missing shared layer is not +profile shape; it is environment lifecycle, sessions, workspace operations, +and capability reporting. + +## AgentProfile decision + +Do not change `AgentProfile` for provider selection. It remains the portable +description of who the agent is and what it can use: prompt, model hints, +permissions, tools, MCP, subagents, resources, hooks, modes, confidential +settings, metadata, and extensions. + +Provider/runtime choice belongs beside the profile in run config: + +```ts +{ + profile, + target: { provider: 'daytona', backend: 'codex' } +} +``` + +Provider-specific profile knobs should use the existing extension escape hatch: + +```ts +{ + extensions: { + daytona: { snapshotId: 'snap_123' }, + e2b: { template: 'codex' } + } +} +``` + +Each provider must expose `validateProfile(profile)` and capabilities so callers +can see which profile fields are honored before a run starts. + +## Current execution map + +| Path | Where the agent runs | Current contract | Adapter implication | +|---|---|---|---| +| `runLoop` sandbox path | Managed sandbox | `SandboxClient.create` -> `SandboxInstance.streamPrompt` | Needs neutral create/stream/session/workspace contract. | +| `SandboxLineage` | Managed sandbox with continuation/forking | `box.streamPrompt`, `box.session`, `box.checkpoint`, `box.fork` | Capabilities must say whether sessions, replay, checkpoint, and fork exist. | +| `createSandboxAct` | Managed sandbox | `createSandboxForSpec` then `box.streamPrompt` | Must keep a direct "run this profile in an environment" entry point. | +| `sandboxExecutor` | Managed sandbox via `runLoop` | `Executor.execute` wraps a single sandbox run | Provider should expose an `Executor` view for supervise mode. | +| `routerInlineExecutor` | Direct model call, no workspace | `Executor.execute` | Not an environment provider; keep as a task executor. | +| `routerToolsInlineExecutor` | Host tool loop, no isolated workspace | `Executor.execute` plus `deliver` | Could stay executor-only, or implement a minimal provider with no workspace. | +| `bridgeExecutor` | cli-bridge HTTP session | OpenAI-compatible stream plus stable `session_id` | Should become an environment/session provider for host CLI execution. | +| worktree CLI executor | Local worktree process | `Executor.execute` | Should become a local provider with exec/workspace and limited session support. | +| `SandboxBackend` in cli-bridge | sandbox-api `/batch/run` | Batch task SSE -> OpenAI deltas | Should become a provider adapter over sandbox-api, not a bridge-only special case. | +| in-process sandbox clients | Test/local callback | SDK-shaped fake box | Should move to neutral fake environment; the SDK cast disappears. | + +## What is wrong with the current shape + +### Runtime has two good ideas, but they are split + +`SandboxClient` is box-shaped and supports environment lifecycle, workspace, +sessions, and forking, but it is typed to the Tangle sandbox SDK: + +- `CreateSandboxOptions` +- `SandboxInstance` +- `SandboxEvent` + +`Executor` is open and provider-neutral enough for router/CLI/sandbox tasks, +but it does not describe environment lifecycle, files, exec, sessions, replay, +or fork. A provider can run a task through it, but cannot expose the richer +environment behavior the runtime already uses. + +### `SandboxInstance` is not structurally implementable + +The SDK exports `SandboxInstance` as a class with private fields. Local adapters +currently work around this in `in-process-sandbox-client.ts` by casting objects +into `SandboxInstance`. That is acceptable as a local compatibility bridge, but +it is the wrong target for third-party providers. + +### Profile execution is provider-owned, but the runtime still builds SDK options + +`src/runtime/sandbox-backend.ts` builds `CreateSandboxOptions` by injecting +`backend.profile = profile`. That is correct for Tangle sandbox, but it bakes +one provider's creation shape into the runtime. E2B, Daytona, and plain compute +providers will have different creation inputs and different profile +materialization steps. + +### Current capabilities are too narrow + +`src/runtime/sandbox-capabilities.ts` only asks whether fork can work through +`criuStatus`. A generic adapter needs feature reporting for: + +- profile fields it honors +- live streaming +- reconnect/replay +- stable turn ids +- sessions +- file read/write +- command execution +- repository helpers +- checkpoint/fork +- placement metadata +- usage reporting +- confidential execution support + +### The router registry collapses too much into "sandbox" + +`createExecutorRegistry` currently maps non-null backend kinds to the sandbox +executor. That was fine with one managed sandbox provider. A provider-capable +runtime needs to resolve a requested runtime/backend to a named provider and +capability set, not collapse every code-agent backend into one sandbox path. + +## Proposed shared contract + +These types should live in `@tangle-network/agent-interface` because they are +provider-neutral and should be public. Runtime-specific adapters can live in +this repo. + +```ts +import type { + AgentProfile, + AgentProfileCapabilities, + InputPart, + StreamEvent, + TokenUsage, +} from '@tangle-network/agent-interface' + +export interface AgentEnvironmentProvider { + readonly name: string + capabilities(): AgentEnvironmentCapabilities | Promise + validateProfile?(profile: AgentProfile): AgentProfileValidationResult | Promise + create(input: CreateAgentEnvironmentInput): Promise + get?(id: string): Promise + list?(query?: AgentEnvironmentQuery): Promise +} + +export interface CreateAgentEnvironmentInput { + profile: AgentProfile + workspace?: WorkspaceRequest + resources?: ResourceRequest + env?: Record + secrets?: string[] | Record + metadata?: Record + name?: string + idempotencyKey?: string + signal?: AbortSignal + providerOptions?: Record +} + +export interface AgentEnvironment { + readonly id: string + readonly provider: string + readonly name?: string + status(): Promise + stream(input: AgentTurnInput): AsyncIterable + dispatch?(input: AgentTurnInput): Promise + session?(id: string): AgentSession + read?(path: string, options?: { sessionId?: string }): Promise + write?(path: string, content: string, options?: { sessionId?: string }): Promise + exec?(command: string, options?: ExecRequest): Promise + checkpoint?(options?: CheckpointRequest): Promise + fork?(checkpoint: CheckpointRef, options?: ForkRequest): Promise + placement?(): Promise + refresh?(): Promise + destroy?(): Promise +} + +export interface AgentTurnInput { + prompt?: string + parts?: InputPart[] + sessionId?: string + model?: string + timeoutMs?: number + executionId?: string + lastEventId?: string + turnId?: string + detach?: boolean + context?: Record + signal?: AbortSignal +} + +export interface AgentSession { + readonly id: string + status(): Promise + events(options?: { since?: string; signal?: AbortSignal }): AsyncIterable + result(): Promise + prompt(input: AgentTurnInput): Promise + cancel(): Promise +} + +export interface AgentEnvironmentEvent { + type: string + data: Record + id?: string + normalized?: StreamEvent + usage?: TokenUsage + providerEvent?: unknown +} + +export interface AgentEnvironmentCapabilities { + profile: AgentProfileCapabilities + streaming: { + live: boolean + replay: boolean + detach: boolean + turnIdempotency: boolean + } + sessions: { + continue: boolean + list: boolean + messages: boolean + } + workspace: { + read: boolean + write: boolean + exec: boolean + git: boolean + upload: boolean + download: boolean + } + branching: { + checkpoint: boolean + fork: boolean + } + placement: boolean + usage: boolean + confidential: boolean +} +``` + +The exact names can change, but the shape should keep one rule: the provider +owns materialization and environment behavior; the runtime owns planning, +parallelism, parsing, validation, and cost accounting. + +## Adapter views required by runtime + +A provider should be adaptable into both existing runtime ports while migration +is in progress: + +```ts +export function providerAsSandboxClient( + provider: AgentEnvironmentProvider, +): SandboxClient + +export function providerAsExecutor( + provider: AgentEnvironmentProvider, + defaults: CreateAgentEnvironmentInput, +): Executor +``` + +`providerAsSandboxClient` is compatibility only. New code should consume the +neutral provider contract directly. + +## Provider responsibilities + +Every provider must own these translations: + +1. **Profile materialization** + - Map `AgentProfile.prompt`, `model`, `tools`, `mcp`, `permissions`, + `resources`, `hooks`, `modes`, `confidential`, and `extensions` into the + provider's native setup. + - Return validation warnings/errors before a run when a field cannot be + honored. + - Use `profile.extensions.` for non-portable options. + +2. **Environment lifecycle** + - Create, find/reconnect, refresh, and destroy an environment. + - Support an idempotency key when the provider can, so retries do not create + duplicate machines. + +3. **Session lifecycle** + - Accept caller-supplied `sessionId` when possible. + - Return a provider session id. + - Support status/result/cancel where possible. + - Report unsupported session features in capabilities, not by silently + ignoring the field. + +4. **Event normalization** + - Preserve raw provider events. + - Emit `message.part.updated`, usage, status, and terminal events where the + provider can. + - Guarantee that a completed stream has a terminal success/failure signal. + +5. **Workspace behavior** + - Expose read/write/exec when backed by a real workspace. + - Use capability flags for providers that only run model calls and cannot + touch files. + +6. **Cost and usage** + - Surface token usage when the provider reports it. + - Preserve provider-native usage in raw event data for future parsing. + +## What each existing component should lift + +### Sandbox SDK + +Lift these public, neutral types to `agent-interface` or re-export them from +there: + +- environment provider +- environment instance +- session +- turn input/result +- environment event +- environment capabilities +- workspace/resource request +- placement summary + +Keep these SDK-specific: + +- Tangle client configuration +- sandbox-api HTTP routes +- Firecracker/CRIU specifics +- collaboration, preview links, TEE report transport, and other Tangle-only + capabilities +- rich convenience methods on the SDK class + +Then implement `createTangleProvider(client)` that maps: + +- `CreateAgentEnvironmentInput` -> `CreateSandboxOptions` +- `AgentTurnInput` -> `PromptOptions` +- `AgentEnvironment` -> `SandboxInstance` +- `AgentSession` -> `SandboxSession` +- capabilities -> `backend.capabilities`, `criuStatus`, and SDK feature probes + +### CLI bridge + +The bridge already has most of the generic concepts: + +- stable caller `session_id` +- backend-owned internal session id +- OpenAI-compatible streaming deltas +- request/session persistence +- `agent_profile` forwarding +- MCP materializers for Claude, Codex, Kimi, and OpenCode +- `execution.kind = 'host' | 'sandbox'` + +Lift it by: + +1. Importing `AgentProfile` from `@tangle-network/agent-interface`, not the + sandbox SDK. +2. Adding a provider adapter over the existing `/v1/chat/completions` stream: + `createCliBridgeProvider({ baseUrl, token })`. +3. Mapping `session_id` to `AgentSession`. +4. Mapping `agent_profile`, `mcp`, `cwd`, `env`, and `execution` into + `CreateAgentEnvironmentInput`. +5. Treating its sandbox-api backend as just another provider implementation, + not a separate special path. + +The bridge should remain able to serve OpenAI-compatible clients. The provider +adapter is an additional typed entry point, not a replacement for the HTTP API. + +### Runtime router + +Keep the direct router paths as executors. They do not need to become fake +workspaces. + +Change provider resolution so the runtime can distinguish: + +- direct model executor +- host CLI provider +- Tangle sandbox provider +- third-party compute provider +- third-party sandbox provider + +The legacy `AgentSpec` backend field can keep working, but the new config +should name a provider plus an agent backend. Example: + +```ts +type ExecutionTarget = + | { kind: 'router'; model: string } + | { kind: 'provider'; provider: string; backend?: string; environment?: string } +``` + +The runtime should fail fast when a requested behavior needs a capability the +provider does not expose. Examples: a run requiring fork cannot use a provider +with `branching.fork = false`; a run requiring file edits cannot use a +router-only executor. + +### Agent profiles + +Do not create a new profile type. `AgentProfile` is already the shared behavior +manifest. + +Provider choice should live outside the profile in run config. The current +`profile.metadata.backendType` fallback in `sandbox-backend.ts` should become a +compatibility path or move under a provider namespaced extension such as: + +```ts +{ + extensions: { + tangleSandbox: { backendType: 'opencode' } + } +} +``` + +The provider must report which profile fields it honors through +`AgentProfileCapabilities` and `validateProfile`. + +## Third-party provider model + +### Package layout + +Providers should be external packages. Runtime core should depend on the shared +contract, not every vendor SDK: + +```txt +@tangle-network/agent-interface +@tangle-network/agent-provider-testkit +@tangle-network/agent-provider-tangle +@tangle-network/agent-provider-cli-bridge +@tangle-network/agent-provider-computesdk +@tangle-network/agent-provider-e2b +@tangle-network/agent-provider-daytona +``` + +`agent-provider-testkit` should own the conformance checks below so community +providers can prove they behave correctly without copying tests from runtime. + +### ComputeSDK + +ComputeSDK should be the broad adapter. Its value is provider coverage: one +adapter can reach many sandbox/compute backends and give users a fast on-ramp. + +Expected mapping: + +- ComputeSDK sandbox/session id -> `AgentEnvironment.id`. +- ComputeSDK filesystem APIs -> `read`, `write`, upload/download capability. +- ComputeSDK command execution -> `exec`. +- ComputeSDK provider selection -> `providerOptions`. +- Agent backend startup -> a CLI materializer layered on top of ComputeSDK when + the selected compute provider does not ship a native agent runtime. + +Use this as the default "bring many providers" path. Keep direct adapters for +provider-specific strengths. + +### E2B or Daytona + +Direct E2B and Daytona adapters should not import `@tangle-network/sandbox`. +They should: + +1. Create a machine/workspace through the provider SDK. +2. Clone or mount the requested workspace. +3. Install or start the requested agent backend if the provider does not ship + one. +4. Materialize `AgentProfile` into files, CLI flags, MCP config, env, and + permissions. +5. Stream stdout/SSE/native events into `AgentEnvironmentEvent`. +6. Implement read/write/exec through the provider SDK. +7. Report unsupported features, especially checkpoint/fork and event replay. + +E2B direct adapter priorities: + +- templates and prebuilt images +- command/process streaming +- filesystem operations +- internet access controls +- Codex/CLI agent startup patterns + +Daytona direct adapter priorities: + +- workspace lifecycle +- filesystem, git, process, and PTY operations +- snapshots/fork where available +- long-lived development environments + +### Compute SDK + +A compute SDK may provide only lifecycle, files, and exec. That is still useful. +It can compose with a CLI materializer: + +- compute SDK creates the machine +- CLI materializer installs/configures the agent backend +- provider adapter streams the process output +- workspace methods delegate to compute SDK files/exec + +This makes "bring your own compute" possible without requiring every compute +provider to implement native agent semantics. + +## Migration plan + +1. **Define neutral provider types** + - Shared types ship in + `@tangle-network/agent-interface/environment-provider`. + - Runtime imports and re-exports those shared types from + `src/runtime/environment-provider.ts`. + - Keep the first shared version additive and public. + +2. **Implement the Tangle sandbox provider adapter** + - Runtime adapter exists as `sandboxClientAsProvider`. + - Keep current `SandboxClient` runtime path through `providerAsSandboxClient`. + - Add conformance tests that run against the adapter, not the SDK class. + +3. **Switch runtime internals to the neutral provider where practical** + - Start with `sandbox-acquire`, `sandbox-lineage`, and `sandbox-capabilities`. + - Keep `runLoop` behavior unchanged. + - Keep old SDK-shaped entry points as compatibility wrappers. + +4. **Add the CLI bridge provider adapter** + - Use stable `session_id`. + - Preserve usage and final chunks. + - Validate profile/MCP support by backend. + +5. **Update router/provider selection** + - Provider registry keyed by provider name exists in runtime. + - `createExecutor({ backend: 'provider', provider: 'name', registry })` + resolves a named provider without changing `AgentProfile`. + +6. **Publish example adapters** + - `createComputeSdkProvider` + - `createE2BProvider` + - `createDaytonaProvider` + +7. **Retire casts and duplicate profile imports** + - Replace in-process fake `SandboxInstance` casts with neutral fake + environments. + - Move cli-bridge imports from sandbox SDK to agent-interface. + +## Conformance checks + +Every provider adapter should pass the same test suite: + +| Check | Required for minimum provider | Notes | +|---|---:|---| +| create and destroy environment | yes | Idempotency key required if provider supports it. | +| stream one prompt to terminal event | yes | Must not silently end without status. | +| propagate abort | yes | Stop spending work when caller aborts. | +| preserve raw events | yes | Needed for provider-specific debugging. | +| emit normalized text delta | yes | Can be synthesized from stdout. | +| emit usage when provider reports it | yes | Optional only if provider truly lacks usage. | +| validate unsupported profile fields | yes | Warnings or errors, not silent drops. | +| continue a session | optional | Must be accurately reported. | +| replay events after event id | optional | Required for durable long runs. | +| read/write files | optional | Required for code-editing workflows. | +| exec command | optional | Required for test-running workflows. | +| checkpoint/fork | optional | Required for lineage fanout. | + +## Non-goals + +- Do not rebuild sandbox SDK stream durability in runtime. The SDK already has + stable execution ids, last-event replay, turn idempotency, and detach. +- Do not move planning, output parsing, validation, or cost policy into + providers. +- Do not make direct router calls pretend to have files or shell execution. +- Do not invent another agent profile format. +- Do not require third-party providers to depend on the Tangle sandbox SDK. + +## Decisions + +1. `AgentEnvironmentProvider` ships from `agent-interface` directly. It is the + public adapter contract. + +2. Profile validation remains optional at the type level but expected for + provider packages that drop or reinterpret profile fields. + +3. The CLI bridge adapter ships as an external package in the SDK monorepo. + That avoids editing the dirty bridge repo and lets any runtime use it. + +4. Provider-specific options use generic `providerOptions` at creation time + plus `profile.extensions.` for profile-bound behavior. + +## Bottom line + +The core change is not a large rewrite. It is a type and adapter boundary: +move environment/session/workspace vocabulary into the shared public interface, +wrap Tangle sandbox as the first provider, then let runtime, bridge, E2B, +Daytona, and compute providers all meet the same contract. diff --git a/package.json b/package.json index 68e82022..952b0203 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "devDependencies": { "@biomejs/biome": "^2.4.15", "@tangle-network/agent-eval": ">=0.100.0 <1.0.0", - "@tangle-network/agent-interface": ">=0.10.0 <1.0.0", + "@tangle-network/agent-interface": ">=0.14.0 <1.0.0", "@tangle-network/sandbox": ">=0.8.0 <1.0.0", "@types/node": "^25.9.3", "playwright": "^1.61.0", @@ -119,7 +119,7 @@ "packageManager": "pnpm@10.28.0", "peerDependencies": { "@tangle-network/agent-eval": ">=0.97.0 <1.0.0", - "@tangle-network/agent-interface": ">=0.10.0 <1.0.0", + "@tangle-network/agent-interface": ">=0.14.0 <1.0.0", "@tangle-network/sandbox": ">=0.8.0 <1.0.0", "playwright": "^1.40.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18937303..da847b6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: '>=0.100.0 <1.0.0' version: 0.100.0(typescript@5.9.3) '@tangle-network/agent-interface': - specifier: '>=0.10.0 <1.0.0' - version: 0.10.0 + specifier: '>=0.14.0 <1.0.0' + version: 0.14.0 '@tangle-network/sandbox': specifier: '>=0.8.0 <1.0.0' version: 0.8.2(viem@2.52.2(typescript@5.9.3)(zod@4.4.3)) @@ -646,6 +646,9 @@ packages: '@tangle-network/agent-interface@0.10.0': resolution: {integrity: sha512-oiREgihkeX/xcGEtFfi9AkAfU2VzuF7SSla2s0iliXPUXyHCIIx6jwzHiYdwb1ZGCfvC+T+0SWOIa6fN5u195g==} + '@tangle-network/agent-interface@0.14.0': + resolution: {integrity: sha512-9CyGhIpl90E7v4MTm3b1ti3Bp7BfPigk2Nafgi21Lg0U+QxlNB656F2JmVpUuSbOo9aGZPtg5nXu5EBTlV5a1g==} + '@tangle-network/agent-interface@0.8.0': resolution: {integrity: sha512-okz9LGKwPNKODNyT9Y7+T+sQsJ4g6oTy/hpWpxR6r2BI7pS6WqIdgCOQcx98+WtlPoibkY3ewRRAb8YJMrPHog==} @@ -1642,6 +1645,10 @@ snapshots: dependencies: zod: 4.4.3 + '@tangle-network/agent-interface@0.14.0': + dependencies: + zod: 4.4.3 + '@tangle-network/agent-interface@0.8.0': dependencies: zod: 4.4.3 diff --git a/src/runtime/environment-provider.test.ts b/src/runtime/environment-provider.test.ts new file mode 100644 index 00000000..6385e364 --- /dev/null +++ b/src/runtime/environment-provider.test.ts @@ -0,0 +1,361 @@ +import type { AgentProfile } from '@tangle-network/agent-interface' +import type { + BackendType, + CreateSandboxOptions, + SandboxEvent, + SandboxInstance, +} from '@tangle-network/sandbox' +import { describe, expect, it } from 'vitest' +import { + type AgentEnvironment, + type AgentEnvironmentEvent, + type AgentEnvironmentProvider, + type AgentTurnInput, + createAgentEnvironmentProviderRegistry, + providerAsExecutor, + providerAsSandboxClient, + sandboxClientAsProvider, +} from './environment-provider' +import { createExecutor } from './supervise/runtime' +import type { AgentSpec, ExecutorContext, UsageEvent } from './supervise/types' +import type { SandboxClient } from './types' + +async function collect(iterable: AsyncIterable): Promise { + const out: T[] = [] + for await (const value of iterable) out.push(value) + return out +} + +describe('environment provider adapters', () => { + it('adapts a neutral provider to SandboxClient without losing profile/backend/session data', async () => { + let created: unknown + let turn: AgentTurnInput | undefined + const provider: AgentEnvironmentProvider = { + name: 'fake-provider', + capabilities: () => fakeCapabilities(), + async create(input) { + created = input + return fakeEnvironment({ + dispatch: async () => ({ + id: 'provider-session', + provider: 'fake-provider', + metadata: { status: 'running', alreadyExisted: true }, + }), + stream: async function* (input: AgentTurnInput): AsyncIterable { + turn = input + yield { + type: 'result', + data: { finalText: `ok:${input.prompt}` }, + usage: { inputTokens: 2, outputTokens: 3, cost: 0.01 }, + } + }, + }) + }, + } + + const client = providerAsSandboxClient(provider) + const box = await client.create({ + backend: { type: 'codex' as BackendType, profile: { name: 'worker' } }, + environment: 'universal', + git: { url: 'https://example.com/repo.git', ref: 'main' }, + env: { A: '1' }, + name: 'box-name', + idempotencyKey: 'create-1', + }) + const events = await collect(box.streamPrompt('hello', { sessionId: 's1', turnId: 't1' })) + const dispatched = await box.dispatchPrompt?.('detached') + + expect(created).toMatchObject({ + profile: { name: 'worker' }, + backend: 'codex', + workspace: { + environment: 'universal', + repoUrl: 'https://example.com/repo.git', + gitRef: 'main', + }, + env: { A: '1' }, + name: 'box-name', + idempotencyKey: 'create-1', + }) + expect(turn).toMatchObject({ prompt: 'hello', sessionId: 's1', turnId: 't1' }) + expect(dispatched).toMatchObject({ + sessionId: 'provider-session', + status: 'running', + alreadyExisted: true, + }) + expect(events[0]).toMatchObject({ + type: 'llm_call', + data: { inputTokens: 2, outputTokens: 3, totalCostUsd: 0.01 }, + }) + expect(events.at(-1)).toMatchObject({ type: 'result', data: { finalText: 'ok:hello' } }) + }) + + it('adapts a SandboxClient to a neutral provider with create/stream/workspace methods', async () => { + let createOptions: CreateSandboxOptions | undefined + let streamedPrompt: unknown + const box = { + id: 'sbx-1', + name: 'sandbox-one', + status: 'running', + metadata: { team: 'eng' }, + async *streamPrompt(prompt: string): AsyncIterable { + streamedPrompt = prompt + yield { + type: 'result', + data: { + finalText: 'sandbox-result', + usage: { inputTokens: 4, outputTokens: 5, totalCostUsd: 0.02 }, + }, + } as SandboxEvent + }, + async read(path: string): Promise { + return `read:${path}` + }, + async write(): Promise {}, + async exec(command: string): Promise<{ exitCode: number; stdout: string; stderr: string }> { + return { exitCode: 0, stdout: `ran:${command}`, stderr: '' } + }, + async dispatchPrompt(): Promise { + return { sessionId: 'sandbox-session', status: 'running', alreadyExisted: false } + }, + async delete(): Promise {}, + } as unknown as SandboxInstance + const client: SandboxClient = { + async create(options?: CreateSandboxOptions): Promise { + createOptions = options + return box + }, + describePlacement() { + return { kind: 'sibling', sandboxId: 'sbx-1' } + }, + } + + const provider = sandboxClientAsProvider(client) + const environment = await provider.create({ + profile: { name: 'worker' }, + backend: 'codex', + workspace: { + environment: 'universal', + repoUrl: 'https://example.com/repo.git', + gitRef: 'main', + }, + env: { A: '1' }, + secrets: ['SECRET_NAME'], + idempotencyKey: 'create-2', + }) + const events = await collect(environment.stream({ prompt: 'go' })) + + expect(createOptions).toMatchObject({ + backend: { type: 'codex', profile: { name: 'worker' } }, + environment: 'universal', + git: { url: 'https://example.com/repo.git', ref: 'main' }, + env: { A: '1' }, + secrets: ['SECRET_NAME'], + idempotencyKey: 'create-2', + }) + expect(streamedPrompt).toBe('go') + expect(events[0]).toMatchObject({ + type: 'result', + usage: { inputTokens: 4, outputTokens: 5, cost: 0.02 }, + }) + expect(await environment.read?.('out.txt')).toBe('read:out.txt') + expect(await environment.exec?.('echo hi')).toMatchObject({ + exitCode: 0, + stdout: 'ran:echo hi', + }) + await expect(environment.dispatch?.({ prompt: 'detached' })).resolves.toMatchObject({ + id: 'sandbox-session', + provider: 'tangle-sandbox', + metadata: { status: 'running', alreadyExisted: false }, + }) + expect(await environment.placement?.()).toMatchObject({ kind: 'sandbox', sandboxId: 'sbx-1' }) + }) + + it('fails loudly when a sandbox exec result has no exit code', async () => { + const box = { + id: 'sbx-1', + status: 'running', + async *streamPrompt(): AsyncIterable { + yield { type: 'result', data: { finalText: 'ok' } } as SandboxEvent + }, + async exec(): Promise { + return { stdout: 'missing code' } + }, + } as unknown as SandboxInstance + const client: SandboxClient = { + async create(): Promise { + return box + }, + } + + const environment = await sandboxClientAsProvider(client).create({ profile: 'worker' }) + + await expect(environment.exec?.('echo hi')).rejects.toThrow(/no exit code/) + }) + + it('rejects provider prompt streams that end without a terminal event', async () => { + const provider: AgentEnvironmentProvider = { + name: 'fake-provider', + capabilities: () => fakeCapabilities(), + async create() { + return fakeEnvironment({ + stream: async function* (): AsyncIterable { + yield { type: 'message.part.updated', data: { delta: 'partial' } } + }, + }) + }, + } + const client = providerAsSandboxClient(provider) + const box = await client.create({ + backend: { type: 'codex' as BackendType, profile: 'worker' }, + }) + + await expect(box.prompt('hello')).rejects.toThrow(/terminal result/) + }) + + it('adapts a provider to an ExecutorFactory and reports real usage', async () => { + const provider: AgentEnvironmentProvider = { + name: 'fake-provider', + capabilities: () => fakeCapabilities(), + async create() { + return fakeEnvironment({ + stream: async function* (): AsyncIterable { + yield { type: 'message.part.updated', data: { delta: 'hello ' } } + yield { + type: 'result', + data: { finalText: 'hello world' }, + usage: { inputTokens: 7, outputTokens: 11, cost: 0.03 }, + } + }, + }) + }, + } + const factory = providerAsExecutor(provider) + const spec: AgentSpec = { profile: { name: 'worker' } as AgentProfile, harness: null } + const ctx: ExecutorContext = { signal: new AbortController().signal, seams: {} } + const executor = factory(spec, ctx) + + const usage = await collect(executor.execute('task', ctx.signal) as AsyncIterable) + const artifact = executor.resultArtifact() + + expect(usage).toEqual([ + { kind: 'tokens', input: 7, output: 11 }, + { kind: 'cost', usd: 0.03 }, + { kind: 'iteration' }, + ]) + expect(artifact.out).toMatchObject({ content: 'hello world' }) + expect(artifact.spent).toMatchObject({ + iterations: 1, + tokens: { input: 7, output: 11 }, + usd: 0.03, + }) + }) + + it('plugs a provider into createExecutor as backend data', async () => { + const provider: AgentEnvironmentProvider = { + name: 'package-provider', + capabilities: () => fakeCapabilities(), + async create() { + return fakeEnvironment({ + stream: async function* (): AsyncIterable { + yield { type: 'result', data: { finalText: 'from-package' } } + }, + }) + }, + } + const factory = createExecutor({ backend: 'provider', provider }) + const spec: AgentSpec = { profile: { name: 'worker' } as AgentProfile, harness: null } + const ctx: ExecutorContext = { signal: new AbortController().signal, seams: {} } + const executor = factory(spec, ctx) + + await collect(executor.execute('task', ctx.signal) as AsyncIterable) + + expect(executor.resultArtifact().out).toMatchObject({ content: 'from-package' }) + }) + + it('resolves a named provider through the runtime registry', async () => { + let created: unknown + const provider: AgentEnvironmentProvider = { + name: 'named-provider', + capabilities: () => fakeCapabilities(), + async create(input) { + created = input + return fakeEnvironment({ + stream: async function* (): AsyncIterable { + yield { type: 'result', data: { finalText: 'from-named-provider' } } + }, + }) + }, + } + const registry = createAgentEnvironmentProviderRegistry([provider]) + const factory = createExecutor({ + backend: 'provider', + provider: 'named-provider', + registry, + defaults: { + backend: 'codex', + workspace: { cwd: '/repo' }, + }, + }) + const spec: AgentSpec = { profile: { name: 'worker' } as AgentProfile, harness: null } + const ctx: ExecutorContext = { signal: new AbortController().signal, seams: {} } + const executor = factory(spec, ctx) + + await collect(executor.execute('task', ctx.signal) as AsyncIterable) + + expect(created).toMatchObject({ + profile: { name: 'worker' }, + backend: 'codex', + workspace: { cwd: '/repo' }, + }) + expect(executor.resultArtifact().out).toMatchObject({ content: 'from-named-provider' }) + expect(registry.names()).toEqual(['named-provider']) + }) +}) + +function fakeEnvironment( + overrides: Partial & Pick, +): AgentEnvironment { + const { stream, ...rest } = overrides + return { + id: 'env-1', + provider: 'fake-provider', + status: async () => 'running', + destroy: async () => {}, + ...rest, + stream, + } +} + +function fakeCapabilities() { + return { + profile: { + namedProfiles: true, + systemPrompt: true, + instructions: true, + tools: true, + permissions: true, + mcp: true, + subagents: true, + resources: { + files: true, + instructions: true, + tools: true, + skills: true, + agents: true, + commands: true, + }, + hooks: true, + modes: 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: true, + } +} diff --git a/src/runtime/environment-provider.ts b/src/runtime/environment-provider.ts new file mode 100644 index 00000000..a5e37425 --- /dev/null +++ b/src/runtime/environment-provider.ts @@ -0,0 +1,1150 @@ +import type { + AgentProfile, + AgentProfileValidationResult, + InputPart, + TokenUsage, +} from '@tangle-network/agent-interface' +import type { + AgentEnvironment, + AgentEnvironmentCapabilities, + AgentEnvironmentEvent, + AgentEnvironmentProvider, + AgentEnvironmentQuery, + AgentEnvironmentStatus, + AgentEnvironmentSummary, + AgentProfileRef, + AgentSession, + AgentSessionRef, + AgentSessionStatus, + AgentTurnInput, + AgentTurnResult, + CheckpointRef, + CheckpointRequest, + CreateAgentEnvironmentInput, + ExecRequest, + ExecResult, + ForkRequest, + PlacementInfo, + ResourceRequest, +} from '@tangle-network/agent-interface/environment-provider' +import type { + BackendType, + CreateSandboxOptions, + PromptOptions, + PromptResult, + SandboxEvent, + ExecResult as SandboxExecResult, + SandboxInstance, +} from '@tangle-network/sandbox' +import { ValidationError } from '../errors' +import type { + Executor, + ExecutorContext, + ExecutorFactory, + ExecutorResult, + Runtime, + Spend, + UsageEvent, +} from './supervise/types' +import type { LoopSandboxPlacement, SandboxClient } from './types' +import { zeroTokenUsage } from './util' + +export type { + AgentEnvironment, + AgentEnvironmentCapabilities, + AgentEnvironmentEvent, + AgentEnvironmentProvider, + AgentEnvironmentQuery, + AgentEnvironmentStatus, + AgentEnvironmentSummary, + AgentProfileRef, + AgentSession, + AgentSessionRef, + AgentSessionStatus, + AgentTurnInput, + AgentTurnResult, + CheckpointRef, + CheckpointRequest, + CreateAgentEnvironmentInput, + ExecRequest, + ExecResult, + ForkRequest, + PlacementInfo, + ResourceRequest, + WorkspaceRequest, +} from '@tangle-network/agent-interface/environment-provider' + +/** Provider object or registry name accepted by runtime provider adapters. + * @experimental */ +export type AgentEnvironmentProviderRef = AgentEnvironmentProvider | string + +/** In-memory registry for named `AgentEnvironmentProvider` instances. + * @experimental */ +export interface AgentEnvironmentProviderRegistry { + register(provider: AgentEnvironmentProvider, options?: { replace?: boolean }): void + has(name: string): boolean + get(name: string): AgentEnvironmentProvider | undefined + require(name: string): AgentEnvironmentProvider + names(): string[] + providers(): AgentEnvironmentProvider[] + capabilities(name: string): Promise +} + +/** Create a registry that resolves provider names to concrete provider instances. + * @experimental */ +export function createAgentEnvironmentProviderRegistry( + providers: Iterable = [], +): AgentEnvironmentProviderRegistry { + const entries = new Map() + + const registry: AgentEnvironmentProviderRegistry = { + register(provider, options = {}): void { + if (!provider.name) { + throw new ValidationError('agent environment provider registry: provider.name required') + } + if (!options.replace && entries.has(provider.name)) { + throw new ValidationError( + `agent environment provider registry: provider "${provider.name}" already registered`, + ) + } + entries.set(provider.name, provider) + }, + has(name): boolean { + return entries.has(name) + }, + get(name): AgentEnvironmentProvider | undefined { + return entries.get(name) + }, + require(name): AgentEnvironmentProvider { + const provider = entries.get(name) + if (!provider) { + const available = Array.from(entries.keys()).sort() + const suffix = available.length > 0 ? `; available: ${available.join(', ')}` : '' + throw new ValidationError( + `agent environment provider registry: provider "${name}" is not registered${suffix}`, + ) + } + return provider + }, + names(): string[] { + return Array.from(entries.keys()).sort() + }, + providers(): AgentEnvironmentProvider[] { + return registry.names().map((name) => registry.require(name)) + }, + async capabilities(name): Promise { + return registry.require(name).capabilities() + }, + } + + for (const provider of providers) registry.register(provider) + return registry +} + +/** Resolve a provider instance or registry name, failing loudly when a name is unknown. + * @experimental */ +export function resolveAgentEnvironmentProvider( + provider: AgentEnvironmentProviderRef, + registry?: AgentEnvironmentProviderRegistry, +): AgentEnvironmentProvider { + if (typeof provider !== 'string') return provider + if (!registry) { + throw new ValidationError( + `agent environment provider "${provider}" requires an AgentEnvironmentProviderRegistry`, + ) + } + return registry.require(provider) +} + +/** Options for exposing an `AgentEnvironmentProvider` through the legacy sandbox client port. + * @experimental */ +export interface ProviderAsSandboxClientOptions { + defaults?: Partial + requireTerminalEvent?: boolean + mapCreateOptions?: ( + options: CreateSandboxOptions | undefined, + ) => Partial +} + +/** Adapt a neutral environment provider to the `SandboxClient` interface used by existing loop paths. + * @experimental */ +export function providerAsSandboxClient( + provider: AgentEnvironmentProvider, + options: ProviderAsSandboxClientOptions = {}, +): SandboxClient { + return { + async create(createOptions?: CreateSandboxOptions): Promise { + const mapped = { + ...(options.defaults ?? {}), + ...createInputFromSandboxOptions(createOptions), + ...(options.mapCreateOptions?.(createOptions) ?? {}), + } + if (mapped.profile === undefined) { + throw new ValidationError( + `providerAsSandboxClient(${provider.name}): profile required in defaults or CreateSandboxOptions.backend.profile`, + ) + } + const environment = await provider.create(mapped as CreateAgentEnvironmentInput) + return environmentAsSandboxInstance(environment, { + requireTerminalEvent: options.requireTerminalEvent ?? true, + }) + }, + } +} + +/** Options for wrapping the current Tangle sandbox client as an environment provider. + * @experimental */ +export interface SandboxClientProviderOptions { + name?: string + defaultBackend?: BackendType + capabilities?: + | AgentEnvironmentCapabilities + | (() => AgentEnvironmentCapabilities | Promise) + validateProfile?: ( + profile: AgentProfileRef, + ) => AgentProfileValidationResult | Promise + mapCreateInput?: (input: CreateAgentEnvironmentInput) => CreateSandboxOptions +} + +/** Adapt a `SandboxClient` into the shared `AgentEnvironmentProvider` contract. + * @experimental */ +export function sandboxClientAsProvider( + client: SandboxClient, + options: SandboxClientProviderOptions = {}, +): AgentEnvironmentProvider { + const providerName = options.name ?? 'tangle-sandbox' + return { + name: providerName, + capabilities: async () => { + if (options.capabilities) { + return typeof options.capabilities === 'function' + ? options.capabilities() + : options.capabilities + } + return defaultTangleSandboxCapabilities() + }, + ...(options.validateProfile ? { validateProfile: options.validateProfile } : {}), + async create(input: CreateAgentEnvironmentInput): Promise { + const createOptions = + options.mapCreateInput?.(input) ?? + sandboxOptionsFromCreateInput(input, options.defaultBackend ?? 'opencode') + const box = await client.create(createOptions) + return sandboxInstanceAsEnvironment(box, providerName, client) + }, + ...(hasGet(client) + ? { + async get(id: string): Promise { + const box = await client.get(id) + return box ? sandboxInstanceAsEnvironment(box, providerName, client) : null + }, + } + : {}), + ...(hasList(client) + ? { + async list(query?: AgentEnvironmentQuery): Promise { + const boxes = await client.list(query?.providerOptions) + return boxes.map((box) => ({ + id: String(box.id), + provider: providerName, + name: typeof box.name === 'string' ? box.name : undefined, + status: statusFromUnknown(readBoxStatus(box)), + metadata: readBoxMetadata(box), + })) + }, + } + : {}), + } +} + +/** Options for running a provider as a supervise-mode executor. + * @experimental */ +export interface ProviderExecutorOptions { + defaults?: Partial + runtime?: Runtime + destroyOnSettle?: boolean + requireTerminalEvent?: boolean + taskToTurn?: (task: unknown, specProfile: AgentProfile) => AgentTurnInput +} + +/** Adapt an environment provider into an `ExecutorFactory` for `createExecutor`. + * @experimental */ +export function providerAsExecutor( + provider: AgentEnvironmentProvider, + options: ProviderExecutorOptions = {}, +): ExecutorFactory { + return (spec, ctx) => createProviderExecutor(provider, spec.profile, ctx, options) +} + +function createProviderExecutor( + provider: AgentEnvironmentProvider, + profile: AgentProfile, + ctx: ExecutorContext, + options: ProviderExecutorOptions, +): Executor { + const controller = new AbortController() + const abortIfSignalled = () => { + if (ctx.signal.aborted) controller.abort() + } + abortIfSignalled() + if (!ctx.signal.aborted) ctx.signal.addEventListener('abort', abortIfSignalled, { once: true }) + + let environment: AgentEnvironment | undefined + let artifact: ExecutorResult | undefined + + return { + runtime: options.runtime ?? (provider.name as Runtime), + execute(task, signal): AsyncIterable { + return streamProviderExecutor({ + provider, + profile, + task, + signal, + controller, + options, + onEnvironment: (env) => { + environment = env + }, + onArtifact: (next) => { + artifact = next + }, + }) + }, + async teardown(_grace): Promise<{ destroyed: boolean }> { + controller.abort() + await environment?.destroy?.() + return { destroyed: true } + }, + resultArtifact(): ExecutorResult { + if (!artifact) { + throw new ValidationError( + `providerAsExecutor(${provider.name}): resultArtifact() read before stream drained`, + ) + } + return artifact + }, + } +} + +interface StreamProviderExecutorArgs { + provider: AgentEnvironmentProvider + profile: AgentProfile + task: unknown + signal: AbortSignal + controller: AbortController + options: ProviderExecutorOptions + onEnvironment: (environment: AgentEnvironment) => void + onArtifact: (artifact: ExecutorResult) => void +} + +async function* streamProviderExecutor( + args: StreamProviderExecutorArgs, +): AsyncIterable { + const started = Date.now() + const linked = mergeAbortSignals(args.signal, args.controller.signal) + const environment = await args.provider.create({ + ...(args.options.defaults ?? {}), + profile: args.profile, + signal: linked, + }) + args.onEnvironment(environment) + + const turn = + args.options.taskToTurn?.(args.task, args.profile) ?? taskToTurnInput(args.task, linked) + const events: AgentEnvironmentEvent[] = [] + const tokens = zeroTokenUsage() + let usd = 0 + let text = '' + let terminal = false + try { + for await (const event of environment.stream({ ...turn, signal: linked })) { + events.push(event) + text += textFromEnvironmentEvent(event) + const usage = usageFromEnvironmentEvent(event) + if (usage.input || usage.output) { + tokens.input += usage.input + tokens.output += usage.output + yield { kind: 'tokens', input: usage.input, output: usage.output } + } + if (usage.usd) { + usd += usage.usd + yield { kind: 'cost', usd: usage.usd } + } + if (isTerminalEnvironmentEvent(event)) terminal = true + } + if ((args.options.requireTerminalEvent ?? true) && !terminal) { + throw new ValidationError( + `providerAsExecutor(${args.provider.name}): stream ended without a terminal result/done/status event`, + ) + } + yield { kind: 'iteration' } + const result = resultFromEvents(events, text) + const spent: Spend = { + iterations: 1, + tokens, + usd, + ms: Date.now() - started, + } + args.onArtifact({ + outRef: contentRef(`provider:${args.provider.name}`, result), + out: result, + spent, + }) + } finally { + if (args.options.destroyOnSettle ?? true) await environment.destroy?.() + } +} + +function createInputFromSandboxOptions( + options: CreateSandboxOptions | undefined, +): Partial { + const profile = options?.backend?.profile as AgentProfileRef | undefined + const backend = options?.backend?.type + return { + ...(profile !== undefined ? { profile } : {}), + ...(backend ? { backend } : {}), + workspace: { + ...(options?.environment ? { environment: options.environment } : {}), + ...(options?.image ? { image: options.image } : {}), + ...(options?.git?.url ? { repoUrl: options.git.url } : {}), + ...(options?.git?.ref ? { gitRef: options.git.ref } : {}), + }, + ...(options?.resources ? { resources: options.resources as ResourceRequest } : {}), + ...(options?.env ? { env: options.env } : {}), + ...(options?.secrets ? { secrets: options.secrets } : {}), + ...(options?.metadata ? { metadata: options.metadata } : {}), + ...(options?.name ? { name: options.name } : {}), + ...(options?.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}), + providerOptions: { sandboxCreateOptions: options ?? {} }, + } +} + +function sandboxOptionsFromCreateInput( + input: CreateAgentEnvironmentInput, + defaultBackend: BackendType, +): CreateSandboxOptions { + const backendType = (input.backend ?? defaultBackend) as BackendType + const workspace = input.workspace ?? {} + const providerOptions = input.providerOptions?.sandboxCreateOptions + const base = + providerOptions && typeof providerOptions === 'object' + ? ({ ...(providerOptions as CreateSandboxOptions) } as CreateSandboxOptions) + : ({} satisfies CreateSandboxOptions) + return { + ...base, + ...(workspace.environment ? { environment: workspace.environment } : {}), + ...(workspace.image ? { image: workspace.image } : {}), + ...(workspace.repoUrl ? { git: { url: workspace.repoUrl, ref: workspace.gitRef } } : {}), + ...(input.resources ? { resources: input.resources as CreateSandboxOptions['resources'] } : {}), + ...(input.env ? { env: input.env } : {}), + ...(Array.isArray(input.secrets) ? { secrets: input.secrets } : {}), + ...(input.metadata ? { metadata: input.metadata } : {}), + ...(input.name ? { name: input.name } : {}), + ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}), + backend: { + ...(base.backend ?? {}), + type: backendType, + profile: input.profile, + }, + } +} + +function environmentAsSandboxInstance( + environment: AgentEnvironment, + options: { requireTerminalEvent: boolean }, +): SandboxInstance { + const box = { + id: environment.id, + name: environment.name, + status: 'running', + async refresh(): Promise { + await environment.refresh?.() + }, + async *streamPrompt( + message: string | InputPart[], + promptOptions?: PromptOptions, + ): AsyncGenerator { + let terminal = false + const input = turnInputFromPrompt(message, promptOptions) + for await (const event of environment.stream(input)) { + if (isTerminalEnvironmentEvent(event)) terminal = true + const usageEvent = usageSandboxEvent(event) + if (usageEvent) yield usageEvent + yield sandboxEventFromEnvironmentEvent(event) + } + if (options.requireTerminalEvent && !terminal) { + throw new ValidationError( + `providerAsSandboxClient(${environment.provider}): stream ended without a terminal result/done/status event`, + ) + } + }, + async prompt( + message: string | InputPart[], + promptOptions?: PromptOptions, + ): Promise { + const events: AgentEnvironmentEvent[] = [] + let text = '' + let usage: TokenUsage | undefined + let terminal = false + for await (const event of environment.stream(turnInputFromPrompt(message, promptOptions))) { + events.push(event) + if (isTerminalEnvironmentEvent(event)) terminal = true + text += textFromEnvironmentEvent(event) + usage = mergeTokenUsage(usage, event.usage) + } + if (options.requireTerminalEvent && !terminal) { + throw new ValidationError( + `providerAsSandboxClient(${environment.provider}): prompt ended without a terminal result/done/status event`, + ) + } + return { + response: resultFromEvents(events, text).content, + success: true, + durationMs: 0, + ...(usage ? { usage } : {}), + } + }, + ...(environment.dispatch + ? { + async dispatchPrompt(message: string | InputPart[], promptOptions?: PromptOptions) { + const session = await environment.dispatch?.( + turnInputFromPrompt(message, promptOptions), + ) + if (!session) + throw new ValidationError('providerAsSandboxClient: dispatch returned no session') + return sandboxDispatchResultFromSessionRef(session) + }, + } + : {}), + ...(environment.session + ? { + session(id: string) { + return sessionAsSandboxSession(environment.session?.(id)) + }, + } + : {}), + ...(environment.read ? { read: environment.read.bind(environment) } : {}), + ...(environment.write ? { write: environment.write.bind(environment) } : {}), + ...(environment.exec + ? { + exec: environment.exec.bind(environment), + } + : {}), + ...(environment.checkpoint + ? { + async checkpoint(checkpointOptions?: CheckpointRequest) { + const checkpoint = await environment.checkpoint?.(checkpointOptions) + return { checkpointId: checkpoint?.id, id: checkpoint?.id } + }, + } + : {}), + ...(environment.fork + ? { + async fork(checkpointId: string, forkOptions?: ForkRequest) { + const forked = await environment.fork?.({ id: checkpointId }, forkOptions) + if (!forked) + throw new ValidationError('providerAsSandboxClient: fork returned no environment') + return environmentAsSandboxInstance(forked, options) + }, + } + : {}), + async delete(): Promise { + await environment.destroy?.() + }, + } + return box as unknown as SandboxInstance +} + +function sandboxInstanceAsEnvironment( + box: SandboxInstance, + providerName: string, + client: SandboxClient, +): AgentEnvironment { + const environment: AgentEnvironment = { + id: String(box.id), + provider: providerName, + ...(typeof box.name === 'string' ? { name: box.name } : {}), + 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), + )) { + yield environmentEventFromSandboxEvent(event) + } + }, + ...(hasDispatchPrompt(box) + ? { + async dispatch(input: AgentTurnInput): Promise { + const dispatched = await box.dispatchPrompt( + promptFromTurnInput(input), + promptOptionsFromTurnInput(input), + ) + return sessionRefFromSandboxDispatch(dispatched, providerName) + }, + } + : {}), + ...(hasSession(box) + ? { + session(id: string): AgentSession { + return sandboxSessionAsAgentSession(box.session(id)) + }, + } + : {}), + ...(hasRead(box) ? { read: box.read.bind(box) } : {}), + ...(hasWrite(box) ? { write: box.write.bind(box) } : {}), + ...(hasExec(box) + ? { + async exec(command: string, options?: ExecRequest): Promise { + return execResultFromSandboxExecResult(await box.exec(command, options as never)) + }, + } + : {}), + ...(hasCheckpoint(box) + ? { + async checkpoint(options?: CheckpointRequest): Promise { + const result = await box.checkpoint(options as never) + return { id: checkpointIdFromResult(result), provider: providerName } + }, + } + : {}), + ...(hasFork(box) + ? { + async fork(checkpoint: CheckpointRef, options?: ForkRequest): Promise { + const forked = await box.fork(checkpoint.id, options as never) + return sandboxInstanceAsEnvironment(forked, providerName, client) + }, + } + : {}), + async placement(): Promise { + return placementInfoFromLoopPlacement(client.describePlacement?.(box), box) + }, + async refresh(): Promise { + await maybeRefresh(box) + }, + async destroy(): Promise { + await destroyBox(box) + }, + } + return environment +} + +function sandboxSessionAsAgentSession(session: SandboxSessionLike): AgentSession { + return { + id: session.id, + async status(): Promise { + const status = await session.status() + if (!status) return null + return sessionStatusFromUnknown((status as { status?: unknown }).status) + }, + async *events(options?: { + since?: string + signal?: AbortSignal + }): AsyncIterable { + for await (const event of session.events(options)) + yield environmentEventFromSandboxEvent(event) + }, + async result(): Promise { + return agentTurnResultFromPromptResult(await session.result()) + }, + async prompt(input: AgentTurnInput): Promise { + return agentTurnResultFromPromptResult( + await session.prompt(promptFromTurnInput(input), promptOptionsFromTurnInput(input)), + ) + }, + cancel(): Promise { + return session.cancel() + }, + } +} + +function sessionAsSandboxSession(session: AgentSession | undefined): unknown { + if (!session) throw new ValidationError('providerAsSandboxClient: session(id) returned undefined') + return { + id: session.id, + status: session.status.bind(session), + async *events(options?: { + since?: string + signal?: AbortSignal + }): AsyncGenerator { + for await (const event of session.events(options)) + yield sandboxEventFromEnvironmentEvent(event) + }, + async result(): Promise { + return promptResultFromAgentTurnResult(await session.result()) + }, + async prompt(message: string | InputPart[], options?: PromptOptions): Promise { + return promptResultFromAgentTurnResult( + await session.prompt(turnInputFromPrompt(message, options)), + ) + }, + cancel: session.cancel.bind(session), + } +} + +function environmentEventFromSandboxEvent(event: SandboxEvent): AgentEnvironmentEvent { + const data = + event.data && typeof event.data === 'object' + ? (event.data as Record) + : ({} as Record) + return { + type: String(event.type), + data, + ...(event.id ? { id: event.id } : {}), + usage: tokenUsageFromData(data), + providerEvent: event, + } +} + +function sandboxEventFromEnvironmentEvent(event: AgentEnvironmentEvent): SandboxEvent { + return { + type: event.type, + data: { + ...event.data, + ...(event.usage ? { usage: tokenUsageData(event.usage) } : {}), + }, + ...(event.id ? { id: event.id } : {}), + } +} + +function usageSandboxEvent(event: AgentEnvironmentEvent): SandboxEvent | undefined { + if (!event.usage || isUsageType(event.type)) return undefined + const usage = tokenUsageData(event.usage) + if ( + usage.inputTokens === undefined && + usage.outputTokens === undefined && + usage.totalCostUsd === undefined + ) { + return undefined + } + return { type: 'llm_call', data: usage } +} + +function tokenUsageData(usage: TokenUsage): Record { + return { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + totalCostUsd: usage.cost, + } +} + +function turnInputFromPrompt( + message: string | InputPart[], + options?: PromptOptions, +): AgentTurnInput { + return { + ...(typeof message === 'string' ? { prompt: message } : { parts: message }), + ...(options?.sessionId ? { sessionId: options.sessionId } : {}), + ...(options?.model ? { model: options.model } : {}), + ...(options?.timeoutMs ? { timeoutMs: options.timeoutMs } : {}), + ...(options?.executionId ? { executionId: options.executionId } : {}), + ...(options?.lastEventId ? { lastEventId: options.lastEventId } : {}), + ...(options?.turnId ? { turnId: options.turnId } : {}), + ...(options?.detach !== undefined ? { detach: options.detach } : {}), + ...(options?.context ? { context: options.context } : {}), + ...(options?.signal ? { signal: options.signal } : {}), + ...(options?.backend ? { providerOptions: { backend: options.backend } } : {}), + } +} + +function promptFromTurnInput(input: AgentTurnInput): string | InputPart[] { + if (input.parts) return input.parts + return input.prompt ?? '' +} + +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 taskToTurnInput(task: unknown, signal: AbortSignal): AgentTurnInput { + return { prompt: taskToPrompt(task), signal } +} + +function taskToPrompt(task: unknown): string { + if (typeof task === 'string') return task + if (task && typeof task === 'object') { + const record = task as Record + for (const key of ['prompt', 'content', 'task', 'message']) { + if (typeof record[key] === 'string') return record[key] + } + } + return JSON.stringify(task) +} + +function resultFromEvents( + events: AgentEnvironmentEvent[], + fallbackText: string, +): { content: string; events: AgentEnvironmentEvent[] } { + for (let i = events.length - 1; i >= 0; i -= 1) { + const event = events[i] + const text = event ? resultTextFromData(event.data) : undefined + if (text !== undefined) return { content: text, events } + } + return { content: fallbackText, events } +} + +function textFromEnvironmentEvent(event: AgentEnvironmentEvent): string { + if ( + typeof event.normalized === 'object' && + event.normalized && + event.normalized.type === 'message.part.updated' + ) { + return typeof event.normalized.delta === 'string' ? event.normalized.delta : '' + } + const data = event.data + for (const key of ['delta', 'chunk', 'content', 'text']) { + if (typeof data[key] === 'string' && !isTerminalEnvironmentEvent(event)) return data[key] + } + return '' +} + +function resultTextFromData(data: Record): string | undefined { + for (const key of ['finalText', 'text', 'response', 'resultSummary', 'content']) { + if (typeof data[key] === 'string') return data[key] + } + return undefined +} + +function isTerminalEnvironmentEvent(event: AgentEnvironmentEvent): boolean { + if (event.type === 'result' || event.type === 'done' || event.type === 'final') return true + if (event.type.endsWith('.completed') || event.type.endsWith('.failed')) return true + if (event.type === 'status') { + const status = event.data.status + return status === 'completed' || status === 'failed' || status === 'cancelled' + } + return false +} + +function isUsageType(type: string): boolean { + return type === 'llm_call' || type === 'usage' || type === 'cost.usage' +} + +function usageFromEnvironmentEvent(event: AgentEnvironmentEvent): { + input: number + output: number + usd: number +} { + const usage = event.usage ?? tokenUsageFromData(event.data) + return { + input: finiteNumber(usage?.inputTokens) ?? 0, + output: finiteNumber(usage?.outputTokens) ?? 0, + usd: + finiteNumber(usage?.cost) ?? + finiteNumber(event.data.costUsd) ?? + finiteNumber(event.data.totalCostUsd) ?? + 0, + } +} + +function tokenUsageFromData(data: Record): TokenUsage | undefined { + const usageRecord = + data.usage && typeof data.usage === 'object' + ? (data.usage as Record) + : data.tokenUsage && typeof data.tokenUsage === 'object' + ? (data.tokenUsage as Record) + : data + const inputTokens = + finiteNumber(usageRecord.inputTokens) ?? + finiteNumber(usageRecord.tokensIn) ?? + finiteNumber(usageRecord.prompt_tokens) + const outputTokens = + finiteNumber(usageRecord.outputTokens) ?? + finiteNumber(usageRecord.tokensOut) ?? + finiteNumber(usageRecord.completion_tokens) + const cost = + finiteNumber(usageRecord.cost) ?? + finiteNumber(usageRecord.costUsd) ?? + finiteNumber(usageRecord.totalCostUsd) ?? + finiteNumber(data.costUsd) ?? + finiteNumber(data.totalCostUsd) + if (inputTokens === undefined && outputTokens === undefined && cost === undefined) + return undefined + return { + inputTokens: inputTokens ?? 0, + outputTokens: outputTokens ?? 0, + ...(cost !== undefined ? { cost } : {}), + } +} + +function mergeTokenUsage( + left: TokenUsage | undefined, + right: TokenUsage | undefined, +): TokenUsage | undefined { + if (!left) return right + if (!right) return left + return { + inputTokens: left.inputTokens + right.inputTokens, + outputTokens: left.outputTokens + right.outputTokens, + ...(left.cost !== undefined || right.cost !== undefined + ? { cost: (left.cost ?? 0) + (right.cost ?? 0) } + : {}), + } +} + +function finiteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function promptResultFromAgentTurnResult(result: AgentTurnResult): PromptResult { + return { + response: result.text, + success: result.success, + durationMs: 0, + ...(result.error ? { error: result.error } : {}), + ...(result.usage ? { usage: result.usage } : {}), + } +} + +function agentTurnResultFromPromptResult(result: PromptResult): AgentTurnResult { + const record = result as unknown as Record + const text = + typeof record.response === 'string' + ? record.response + : typeof record.text === 'string' + ? record.text + : typeof record.finalText === 'string' + ? record.finalText + : '' + const success = typeof record.success === 'boolean' ? record.success : true + return { + text, + success, + ...(typeof record.error === 'string' ? { error: record.error } : {}), + usage: tokenUsageFromData(record), + } +} + +function sandboxDispatchResultFromSessionRef(session: AgentSessionRef): Record { + 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, + } +} + +function sessionRefFromSandboxDispatch(dispatched: unknown, providerName: string): AgentSessionRef { + const record = + dispatched && typeof dispatched === 'object' + ? (dispatched as Record) + : undefined + const id = record?.sessionId ?? record?.id + if (typeof id !== 'string' || id.length === 0) { + throw new ValidationError('sandboxClientAsProvider: dispatch returned no session id') + } + if (!record) { + throw new ValidationError('sandboxClientAsProvider: dispatch returned no session record') + } + return { + id, + provider: providerName, + metadata: { + ...(record.status ? { status: record.status } : {}), + ...(record.alreadyExisted !== undefined ? { alreadyExisted: record.alreadyExisted } : {}), + }, + } +} + +function execResultFromSandboxExecResult(result: SandboxExecResult): ExecResult { + const record = result as unknown as Record + const exitCode = finiteNumber(record.exitCode) ?? finiteNumber(record.code) + if (exitCode === undefined) { + throw new ValidationError('sandboxClientAsProvider: exec returned no exit code') + } + return { + exitCode, + stdout: typeof record.stdout === 'string' ? record.stdout : '', + stderr: typeof record.stderr === 'string' ? record.stderr : '', + } +} + +function statusFromUnknown(status: unknown): AgentEnvironmentStatus { + if (status === 'pending' || status === 'provisioning' || status === 'running') return status + if (status === 'stopped' || status === 'failed' || status === 'expired') return status + if (status === 'completed') return 'stopped' + if (status === 'cancelled') return 'stopped' + return 'unknown' +} + +function sessionStatusFromUnknown(status: unknown): AgentSessionStatus | null { + if (status === 'completed' || status === 'cancelled') return status + return statusFromUnknown(status) +} + +function readBoxStatus(box: SandboxInstance): unknown { + return (box as unknown as { status?: unknown }).status +} + +function readBoxMetadata(box: SandboxInstance): Record | undefined { + const metadata = (box as unknown as { metadata?: unknown }).metadata + return metadata && typeof metadata === 'object' + ? (metadata as Record) + : undefined +} + +async function maybeRefresh(box: SandboxInstance): Promise { + const refresh = (box as unknown as { refresh?: () => Promise }).refresh + if (typeof refresh === 'function') await refresh.call(box) +} + +async function destroyBox(box: SandboxInstance): Promise { + const deleteBox = (box as unknown as { delete?: () => Promise }).delete + if (typeof deleteBox === 'function') await deleteBox.call(box) +} + +function placementInfoFromLoopPlacement( + placement: LoopSandboxPlacement | undefined, + box: SandboxInstance, +): PlacementInfo { + if (!placement) return { kind: 'sandbox', sandboxId: String(box.id) } + return { + kind: placement.kind === 'fleet' ? 'fleet' : 'sandbox', + ...(placement.sandboxId ? { sandboxId: placement.sandboxId } : { sandboxId: String(box.id) }), + ...(placement.fleetId ? { fleetId: placement.fleetId } : {}), + ...(placement.machineId ? { machineId: placement.machineId } : {}), + } +} + +function checkpointIdFromResult(result: unknown): string { + const record = result && typeof result === 'object' ? (result as Record) : {} + const id = record.checkpointId ?? record.id + if (typeof id !== 'string' || id.length === 0) { + throw new ValidationError('sandboxClientAsProvider: checkpoint returned no checkpoint id') + } + return id +} + +function defaultTangleSandboxCapabilities(): AgentEnvironmentCapabilities { + return { + profile: { + namedProfiles: true, + systemPrompt: true, + instructions: true, + tools: true, + permissions: true, + mcp: true, + subagents: true, + resources: { + files: true, + instructions: true, + tools: true, + skills: true, + agents: true, + commands: true, + }, + hooks: true, + modes: 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: true, + } +} + +function mergeAbortSignals(a: AbortSignal, b: AbortSignal): AbortSignal { + const controller = new AbortController() + const abort = () => controller.abort() + if (a.aborted || b.aborted) controller.abort() + else { + a.addEventListener('abort', abort, { once: true }) + b.addEventListener('abort', abort, { once: true }) + } + return controller.signal +} + +function contentRef(prefix: string, value: unknown): string { + let str: string + try { + str = JSON.stringify(value) ?? String(value) + } catch { + str = String(value) + } + let hash = 0x811c9dc5 + for (let i = 0; i < str.length; i += 1) { + hash ^= str.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + return `${prefix}:${(hash >>> 0).toString(16).padStart(8, '0')}` +} + +function hasGet( + client: SandboxClient, +): client is SandboxClient & { get(id: string): Promise } { + return typeof (client as { get?: unknown }).get === 'function' +} + +function hasList( + client: SandboxClient, +): client is SandboxClient & { list(options?: unknown): Promise } { + return typeof (client as { list?: unknown }).list === 'function' +} + +function hasDispatchPrompt(box: SandboxInstance): box is SandboxInstance & { + dispatchPrompt(message: string | InputPart[], options?: PromptOptions): Promise +} { + return typeof (box as { dispatchPrompt?: unknown }).dispatchPrompt === 'function' +} + +function hasSession( + box: SandboxInstance, +): box is SandboxInstance & { session(id: string): SandboxSessionLike } { + return typeof (box as { session?: unknown }).session === 'function' +} + +function hasRead(box: SandboxInstance): box is SandboxInstance & { + read(path: string, options?: { sessionId?: string }): Promise +} { + return typeof (box as { read?: unknown }).read === 'function' +} + +function hasWrite( + box: SandboxInstance, +): box is SandboxInstance & { write(path: string, content: string): Promise } { + return typeof (box as { write?: unknown }).write === 'function' +} + +function hasExec(box: SandboxInstance): box is SandboxInstance & { + exec(command: string, options?: unknown): Promise +} { + return typeof (box as { exec?: unknown }).exec === 'function' +} + +function hasCheckpoint( + box: SandboxInstance, +): box is SandboxInstance & { checkpoint(options?: unknown): Promise } { + return typeof (box as { checkpoint?: unknown }).checkpoint === 'function' +} + +function hasFork(box: SandboxInstance): box is SandboxInstance & { + fork(checkpointId: string, options?: unknown): Promise +} { + return typeof (box as { fork?: unknown }).fork === 'function' +} + +interface SandboxSessionLike { + readonly id: string + status(): Promise + events(options?: { since?: string; signal?: AbortSignal }): AsyncIterable + result(): Promise + prompt(message: string | InputPart[], options?: PromptOptions): Promise + cancel(): Promise +} diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 72c497a3..b4c70256 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -63,6 +63,40 @@ export { sentinelCompletion, stopSentinel, } from './completion' +export { + type AgentEnvironment, + type AgentEnvironmentCapabilities, + type AgentEnvironmentEvent, + type AgentEnvironmentProvider, + type AgentEnvironmentProviderRef, + type AgentEnvironmentProviderRegistry, + type AgentEnvironmentQuery, + type AgentEnvironmentStatus, + type AgentEnvironmentSummary, + type AgentProfileRef, + type AgentSession, + type AgentSessionRef, + type AgentSessionStatus, + type AgentTurnInput, + type AgentTurnResult, + type CheckpointRef, + type CheckpointRequest, + type CreateAgentEnvironmentInput, + createAgentEnvironmentProviderRegistry, + type ExecRequest, + type ExecResult, + type ForkRequest, + type PlacementInfo, + type ProviderAsSandboxClientOptions, + type ProviderExecutorOptions, + providerAsExecutor, + providerAsSandboxClient, + type ResourceRequest, + resolveAgentEnvironmentProvider, + type SandboxClientProviderOptions, + sandboxClientAsProvider, + type WorkspaceRequest, +} from './environment-provider' export { type HarvestCorpusOptions, type HarvestFailure, @@ -379,6 +413,7 @@ export { createExecutor, createExecutorRegistry, type ExecutorConfig, + type ProviderSeam, type ToolSpec, } from './supervise/runtime' export { createScope, settledToIteration } from './supervise/scope' diff --git a/src/runtime/supervise/runtime.ts b/src/runtime/supervise/runtime.ts index 999d9184..4545983b 100644 --- a/src/runtime/supervise/runtime.ts +++ b/src/runtime/supervise/runtime.ts @@ -29,6 +29,13 @@ import { estimateCost, isModelPriced } from '@tangle-network/agent-eval' import type { BackendType, SandboxEvent } from '@tangle-network/sandbox' import { ValidationError } from '../../errors' import type { LocalHarness } from '../../mcp/local-harness' +import { + type AgentEnvironmentProvider, + type AgentEnvironmentProviderRegistry, + type ProviderExecutorOptions, + providerAsExecutor, + resolveAgentEnvironmentProvider, +} from '../environment-provider' import { routerChatWithUsage, type ToolSpec } from '../router-client' import type { RunLoopOptions } from '../run-loop' import { runLoop } from '../run-loop' @@ -141,11 +148,20 @@ export interface BridgeSeam { maxTurns?: number } +/** Generic environment provider executor config. External packages implement + * `AgentEnvironmentProvider`; this built-in wrapper lets `createExecutor` + * consume them as backend data while preserving the existing usage channel. */ +export interface ProviderSeam extends ProviderExecutorOptions { + provider: AgentEnvironmentProvider | string + registry?: AgentEnvironmentProviderRegistry +} + const routerSeamKey = 'router' const sandboxSeamKey = 'sandbox' const cliSeamKey = 'cli' const bridgeSeamKey = 'bridge' const cliWorktreeSeamKey = 'cli-worktree' +const providerSeamKey = 'provider' // ── Content-addressed result pointers (the B1 replay source) ─────────────────── @@ -1141,6 +1157,7 @@ export type ExecutorConfig = | ({ backend: 'bridge' } & BridgeSeam) | ({ backend: 'cli' } & CliSeam) | ({ backend: 'cli-worktree' } & CliWorktreeSeam) + | ({ backend: 'provider' } & ProviderSeam) | ({ backend: 'sandbox'; harness?: BackendType } & SandboxSeam) /** @@ -1166,6 +1183,14 @@ export function createExecutor(config: ExecutorConfig): ExecutorFactory return cliExecutor(spec, seamed) case 'cli-worktree': return cliWorktreeExecutor(spec, seamed) + case 'provider': { + const providerSeam = readSeam(seamed, providerSeamKey, 'provider') + const provider = resolveAgentEnvironmentProvider( + providerSeam.provider, + providerSeam.registry, + ) + return providerAsExecutor(provider, providerSeam)(spec, seamed) + } case 'sandbox': { // The sandbox executor requires a concrete harness; a spec-level harness // wins, else the config names it (fail-loud inside if both are absent). From 44ed090ac0bdf2dc681851f98642ce820af43b64 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Fri, 26 Jun 2026 13:35:18 -0600 Subject: [PATCH 2/3] docs(runtime): refresh provider API links --- docs/api/runtime.md | 70 ++++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/docs/api/runtime.md b/docs/api/runtime.md index 15ff0231..fe07c326 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -886,7 +886,7 @@ Minimum confidence a PROBABILISTIC verdict must clear to end. Default 0.8. ### AgentEnvironmentProviderRegistry -Defined in: runtime/environment-provider.ts:83 +Defined in: [runtime/environment-provider.ts:83](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L83) **`Experimental`** @@ -898,7 +898,7 @@ In-memory registry for named `AgentEnvironmentProvider` instances. > **register**(`provider`, `options?`): `void` -Defined in: runtime/environment-provider.ts:84 +Defined in: [runtime/environment-provider.ts:84](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L84) **`Experimental`** @@ -922,7 +922,7 @@ Defined in: runtime/environment-provider.ts:84 > **has**(`name`): `boolean` -Defined in: runtime/environment-provider.ts:85 +Defined in: [runtime/environment-provider.ts:85](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L85) **`Experimental`** @@ -940,7 +940,7 @@ Defined in: runtime/environment-provider.ts:85 > **get**(`name`): `AgentEnvironmentProvider` \| `undefined` -Defined in: runtime/environment-provider.ts:86 +Defined in: [runtime/environment-provider.ts:86](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L86) **`Experimental`** @@ -958,7 +958,7 @@ Defined in: runtime/environment-provider.ts:86 > **require**(`name`): `AgentEnvironmentProvider` -Defined in: runtime/environment-provider.ts:87 +Defined in: [runtime/environment-provider.ts:87](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L87) **`Experimental`** @@ -976,7 +976,7 @@ Defined in: runtime/environment-provider.ts:87 > **names**(): `string`[] -Defined in: runtime/environment-provider.ts:88 +Defined in: [runtime/environment-provider.ts:88](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L88) **`Experimental`** @@ -988,7 +988,7 @@ Defined in: runtime/environment-provider.ts:88 > **providers**(): `AgentEnvironmentProvider`[] -Defined in: runtime/environment-provider.ts:89 +Defined in: [runtime/environment-provider.ts:89](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L89) **`Experimental`** @@ -1000,7 +1000,7 @@ Defined in: runtime/environment-provider.ts:89 > **capabilities**(`name`): `Promise`\<`AgentEnvironmentCapabilities`\> -Defined in: runtime/environment-provider.ts:90 +Defined in: [runtime/environment-provider.ts:90](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L90) **`Experimental`** @@ -1018,7 +1018,7 @@ Defined in: runtime/environment-provider.ts:90 ### ProviderAsSandboxClientOptions -Defined in: runtime/environment-provider.ts:161 +Defined in: [runtime/environment-provider.ts:161](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L161) **`Experimental`** @@ -1030,7 +1030,7 @@ Options for exposing an `AgentEnvironmentProvider` through the legacy sandbox cl > `optional` **defaults?**: `Partial`\<`CreateAgentEnvironmentInput`\> -Defined in: runtime/environment-provider.ts:162 +Defined in: [runtime/environment-provider.ts:162](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L162) **`Experimental`** @@ -1038,7 +1038,7 @@ Defined in: runtime/environment-provider.ts:162 > `optional` **requireTerminalEvent?**: `boolean` -Defined in: runtime/environment-provider.ts:163 +Defined in: [runtime/environment-provider.ts:163](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L163) **`Experimental`** @@ -1046,7 +1046,7 @@ Defined in: runtime/environment-provider.ts:163 > `optional` **mapCreateOptions?**: (`options`) => `Partial`\<`CreateAgentEnvironmentInput`\> -Defined in: runtime/environment-provider.ts:164 +Defined in: [runtime/environment-provider.ts:164](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L164) **`Experimental`** @@ -1064,7 +1064,7 @@ Defined in: runtime/environment-provider.ts:164 ### SandboxClientProviderOptions -Defined in: runtime/environment-provider.ts:197 +Defined in: [runtime/environment-provider.ts:197](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L197) **`Experimental`** @@ -1076,7 +1076,7 @@ Options for wrapping the current Tangle sandbox client as an environment provide > `optional` **name?**: `string` -Defined in: runtime/environment-provider.ts:198 +Defined in: [runtime/environment-provider.ts:198](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L198) **`Experimental`** @@ -1084,7 +1084,7 @@ Defined in: runtime/environment-provider.ts:198 > `optional` **defaultBackend?**: `BackendType` -Defined in: runtime/environment-provider.ts:199 +Defined in: [runtime/environment-provider.ts:199](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L199) **`Experimental`** @@ -1092,7 +1092,7 @@ Defined in: runtime/environment-provider.ts:199 > `optional` **capabilities?**: `AgentEnvironmentCapabilities` \| (() => `AgentEnvironmentCapabilities` \| `Promise`\<`AgentEnvironmentCapabilities`\>) -Defined in: runtime/environment-provider.ts:200 +Defined in: [runtime/environment-provider.ts:200](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L200) **`Experimental`** @@ -1100,7 +1100,7 @@ Defined in: runtime/environment-provider.ts:200 > `optional` **validateProfile?**: (`profile`) => `AgentProfileValidationResult` \| `Promise`\<`AgentProfileValidationResult`\> -Defined in: runtime/environment-provider.ts:203 +Defined in: [runtime/environment-provider.ts:203](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L203) **`Experimental`** @@ -1118,7 +1118,7 @@ Defined in: runtime/environment-provider.ts:203 > `optional` **mapCreateInput?**: (`input`) => `CreateSandboxOptions` -Defined in: runtime/environment-provider.ts:206 +Defined in: [runtime/environment-provider.ts:206](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L206) **`Experimental`** @@ -1136,7 +1136,7 @@ Defined in: runtime/environment-provider.ts:206 ### ProviderExecutorOptions -Defined in: runtime/environment-provider.ts:261 +Defined in: [runtime/environment-provider.ts:261](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L261) **`Experimental`** @@ -1152,7 +1152,7 @@ Options for running a provider as a supervise-mode executor. > `optional` **defaults?**: `Partial`\<`CreateAgentEnvironmentInput`\> -Defined in: runtime/environment-provider.ts:262 +Defined in: [runtime/environment-provider.ts:262](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L262) **`Experimental`** @@ -1160,7 +1160,7 @@ Defined in: runtime/environment-provider.ts:262 > `optional` **runtime?**: `Runtime` -Defined in: runtime/environment-provider.ts:263 +Defined in: [runtime/environment-provider.ts:263](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L263) **`Experimental`** @@ -1168,7 +1168,7 @@ Defined in: runtime/environment-provider.ts:263 > `optional` **destroyOnSettle?**: `boolean` -Defined in: runtime/environment-provider.ts:264 +Defined in: [runtime/environment-provider.ts:264](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L264) **`Experimental`** @@ -1176,7 +1176,7 @@ Defined in: runtime/environment-provider.ts:264 > `optional` **requireTerminalEvent?**: `boolean` -Defined in: runtime/environment-provider.ts:265 +Defined in: [runtime/environment-provider.ts:265](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L265) **`Experimental`** @@ -1184,7 +1184,7 @@ Defined in: runtime/environment-provider.ts:265 > `optional` **taskToTurn?**: (`task`, `specProfile`) => `AgentTurnInput` -Defined in: runtime/environment-provider.ts:266 +Defined in: [runtime/environment-provider.ts:266](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L266) **`Experimental`** @@ -8408,7 +8408,7 @@ Generic environment provider executor config. External packages implement > `optional` **defaults?**: `Partial`\<`CreateAgentEnvironmentInput`\> -Defined in: runtime/environment-provider.ts:262 +Defined in: [runtime/environment-provider.ts:262](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L262) **`Experimental`** @@ -8420,7 +8420,7 @@ Defined in: runtime/environment-provider.ts:262 > `optional` **runtime?**: `Runtime` -Defined in: runtime/environment-provider.ts:263 +Defined in: [runtime/environment-provider.ts:263](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L263) **`Experimental`** @@ -8432,7 +8432,7 @@ Defined in: runtime/environment-provider.ts:263 > `optional` **destroyOnSettle?**: `boolean` -Defined in: runtime/environment-provider.ts:264 +Defined in: [runtime/environment-provider.ts:264](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L264) **`Experimental`** @@ -8444,7 +8444,7 @@ Defined in: runtime/environment-provider.ts:264 > `optional` **requireTerminalEvent?**: `boolean` -Defined in: runtime/environment-provider.ts:265 +Defined in: [runtime/environment-provider.ts:265](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L265) **`Experimental`** @@ -8456,7 +8456,7 @@ Defined in: runtime/environment-provider.ts:265 > `optional` **taskToTurn?**: (`task`, `specProfile`) => `AgentTurnInput` -Defined in: runtime/environment-provider.ts:266 +Defined in: [runtime/environment-provider.ts:266](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L266) **`Experimental`** @@ -12296,7 +12296,7 @@ Every message on the one typed pipe. UP (child→parent): question / settled / f > **AgentEnvironmentProviderRef** = `AgentEnvironmentProvider` \| `string` -Defined in: runtime/environment-provider.ts:79 +Defined in: [runtime/environment-provider.ts:79](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L79) **`Experimental`** @@ -13629,7 +13629,7 @@ passes. Ground truth — the driver ends directly, no validation. The check read > **createAgentEnvironmentProviderRegistry**(`providers?`): [`AgentEnvironmentProviderRegistry`](#agentenvironmentproviderregistry) -Defined in: runtime/environment-provider.ts:95 +Defined in: [runtime/environment-provider.ts:95](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L95) **`Experimental`** @@ -13651,7 +13651,7 @@ Create a registry that resolves provider names to concrete provider instances. > **resolveAgentEnvironmentProvider**(`provider`, `registry?`): `AgentEnvironmentProvider` -Defined in: runtime/environment-provider.ts:146 +Defined in: [runtime/environment-provider.ts:146](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L146) **`Experimental`** @@ -13677,7 +13677,7 @@ Resolve a provider instance or registry name, failing loudly when a name is unkn > **providerAsSandboxClient**(`provider`, `options?`): [`SandboxClient`](#sandboxclient-1) -Defined in: runtime/environment-provider.ts:171 +Defined in: [runtime/environment-provider.ts:171](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L171) **`Experimental`** @@ -13703,7 +13703,7 @@ Adapt a neutral environment provider to the `SandboxClient` interface used by ex > **sandboxClientAsProvider**(`client`, `options?`): `AgentEnvironmentProvider` -Defined in: runtime/environment-provider.ts:211 +Defined in: [runtime/environment-provider.ts:211](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L211) **`Experimental`** @@ -13729,7 +13729,7 @@ Adapt a `SandboxClient` into the shared `AgentEnvironmentProvider` contract. > **providerAsExecutor**(`provider`, `options?`): `ExecutorFactory`\<`unknown`\> -Defined in: runtime/environment-provider.ts:271 +Defined in: [runtime/environment-provider.ts:271](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/environment-provider.ts#L271) **`Experimental`** From fc773495f56580ff1fc59b3c1bda4194f3fee7aa Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Fri, 26 Jun 2026 13:40:31 -0600 Subject: [PATCH 3/3] fix(mcp): persist restored delegation state before returning --- docs/api/mcp.md | 2 +- src/mcp/task-queue.ts | 32 +++++++++++++++++----------- tests/mcp/task-queue-durable.test.ts | 5 +++++ 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/docs/api/mcp.md b/docs/api/mcp.md index 6fde5af2..1cbb79b3 100644 --- a/docs/api/mcp.md +++ b/docs/api/mcp.md @@ -7254,7 +7254,7 @@ client writes to it) and the server-side stream (the test reads from it). > **hashIdempotencyInput**(`value`): `string` -Defined in: [mcp/task-queue.ts:799](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/task-queue.ts#L799) +Defined in: [mcp/task-queue.ts:805](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/task-queue.ts#L805) **`Experimental`** diff --git a/src/mcp/task-queue.ts b/src/mcp/task-queue.ts index c79dc9b4..7c9420b0 100644 --- a/src/mcp/task-queue.ts +++ b/src/mcp/task-queue.ts @@ -292,7 +292,7 @@ export class DelegationTaskQueue { static async restore(options: DelegationTaskQueueOptions = {}): Promise { const queue = new DelegationTaskQueue(options) const loaded = await queue.store.loadAll() - queue.rehydrate(loaded) + await queue.rehydrate(loaded) return queue } @@ -513,17 +513,18 @@ export class DelegationTaskQueue { if (truncated) record.traceTruncated = true } - private rehydrate(loaded: DelegationRecord[]): void { + private async rehydrate(loaded: DelegationRecord[]): Promise { const records = [...loaded].sort((a, b) => a.startedAt.localeCompare(b.startedAt)) for (const record of records) { this.records.set(record.taskId, record) if (record.idempotencyKey) this.byIdempotencyKey.set(record.idempotencyKey, record.taskId) } + const restoreWrites: Promise[] = [] for (const record of this.records.values()) { if (isTerminal(record.status)) continue if (record.detachedSessionRef && this.resumeDelegate) { record.status = 'running' - this.persist(record) + restoreWrites.push(this.persist(record)) this.startResume(record, record.detachedSessionRef, this.resumeDelegate) continue } @@ -535,9 +536,12 @@ export class DelegationTaskQueue { : 'delegation driver restarted while the task was in flight; the run was not detached and cannot be resumed', kind: 'DriverRestartError', } - this.persist(record) + restoreWrites.push(this.persist(record)) } - this.enforceRetention() + const retentionWrite = this.enforceRetention() + if (retentionWrite) restoreWrites.push(retentionWrite) + await Promise.all(restoreWrites) + if (this.persistFailure) throw this.persistFailure } private startResume( @@ -637,8 +641,8 @@ export class DelegationTaskQueue { ]) } - private persist(record: DelegationRecord): void { - if (this.persistFailure) return + private persist(record: DelegationRecord): Promise { + if (this.persistFailure) return Promise.resolve() const snapshot = structuredClone(record) this.persistTail = this.persistTail.then(async () => { if (this.persistFailure) return @@ -648,10 +652,11 @@ export class DelegationTaskQueue { this.failPersistence(err) } }) + return this.persistTail } - private persistRemoval(taskIds: string[]): void { - if (this.persistFailure || taskIds.length === 0) return + private persistRemoval(taskIds: string[]): Promise | undefined { + if (this.persistFailure || taskIds.length === 0) return undefined this.persistTail = this.persistTail.then(async () => { if (this.persistFailure) return try { @@ -660,6 +665,7 @@ export class DelegationTaskQueue { this.failPersistence(err) } }) + return this.persistTail } private failPersistence(cause: unknown): void { @@ -675,14 +681,14 @@ export class DelegationTaskQueue { this.onPersistError(error) } - private enforceRetention(): void { - if (!Number.isFinite(this.maxTerminalRecords)) return + private enforceRetention(): Promise | undefined { + if (!Number.isFinite(this.maxTerminalRecords)) return undefined const terminal: DelegationRecord[] = [] for (const record of this.records.values()) { if (isTerminal(record.status)) terminal.push(record) } const excess = terminal.length - this.maxTerminalRecords - if (excess <= 0) return + if (excess <= 0) return undefined terminal.sort((a, b) => (a.completedAt ?? a.startedAt).localeCompare(b.completedAt ?? b.startedAt), ) @@ -696,7 +702,7 @@ export class DelegationTaskQueue { this.byIdempotencyKey.delete(record.idempotencyKey) } } - this.persistRemoval(evicted.map((record) => record.taskId)) + return this.persistRemoval(evicted.map((record) => record.taskId)) } } diff --git a/tests/mcp/task-queue-durable.test.ts b/tests/mcp/task-queue-durable.test.ts index 1e9214d1..7440b68b 100644 --- a/tests/mcp/task-queue-durable.test.ts +++ b/tests/mcp/task-queue-durable.test.ts @@ -168,6 +168,11 @@ describe('DelegationTaskQueue durable mode', () => { expect(status?.status).toBe('failed') expect(status?.error?.message).toContain('sess-xyz') expect(status?.error?.message).toContain('needs a resumeDelegate') + + const third = await DelegationTaskQueue.restore({ + store: new FileDelegationStore({ filePath }), + }) + expect(third.status(taskId)?.status).toBe('failed') }) it('settles a resumed record as failed when the driver tick throws', async () => {