diff --git a/docs/api/index.md b/docs/api/index.md index 1208613..1671e79 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -3874,6 +3874,156 @@ Defined in: [otel-export.ts:608](https://github.com/tangle-network/agent-runtime *** +### ResolveAgentBackendOptions + +Defined in: [resolve-agent-backend.ts:50](https://github.com/tangle-network/agent-runtime/blob/main/src/resolve-agent-backend.ts#L50) + +#### Extends + +- `OpenAICompatPassthrough` + +#### Type Parameters + +##### TInput + +`TInput` *extends* [`AgentBackendInput`](#agentbackendinput) = [`AgentBackendInput`](#agentbackendinput) + +#### Properties + +##### tools? + +> `optional` **tools?**: readonly [`OpenAIChatTool`](#openaichattool)[] + +Defined in: [backends.ts:222](https://github.com/tangle-network/agent-runtime/blob/main/src/backends.ts#L222) + +OpenAI Chat Completions `tools[]` definitions surfaced to the model on +every request. Omit to send a tool-free request (existing behavior). +The runtime makes no assumption about the dispatcher — calls stream out +as `tool_call` events and the caller is responsible for executing them +and feeding `tool_result` messages back on a follow-up turn. + +###### Inherited from + +`OpenAICompatPassthrough.tools` + +##### toolChoice? + +> `optional` **toolChoice?**: [`OpenAIChatToolChoice`](#openaichattoolchoice) + +Defined in: [backends.ts:228](https://github.com/tangle-network/agent-runtime/blob/main/src/backends.ts#L228) + +OpenAI Chat Completions `tool_choice`. Default `undefined` (request +omits the field; provider falls back to its own default — typically +`'auto'`). + +###### Inherited from + +`OpenAICompatPassthrough.toolChoice` + +##### responseFormat? + +> `optional` **responseFormat?**: [`OpenAIChatResponseFormat`](#openaichatresponseformat) + +Defined in: [backends.ts:232](https://github.com/tangle-network/agent-runtime/blob/main/src/backends.ts#L232) + +OpenAI Chat Completions `response_format`. Omit for provider default text. + +###### Inherited from + +`OpenAICompatPassthrough.responseFormat` + +##### fetchImpl? + +> `optional` **fetchImpl?**: (`input`, `init?`) => `Promise`\<`Response`\> + +Defined in: [backends.ts:233](https://github.com/tangle-network/agent-runtime/blob/main/src/backends.ts#L233) + +###### Parameters + +###### input + +`string` \| `URL` \| `Request` + +###### init? + +`RequestInit` + +###### Returns + +`Promise`\<`Response`\> + +###### Inherited from + +`OpenAICompatPassthrough.fetchImpl` + +##### retry? + +> `optional` **retry?**: `BackendRetryPolicy` + +Defined in: [backends.ts:234](https://github.com/tangle-network/agent-runtime/blob/main/src/backends.ts#L234) + +###### Inherited from + +`OpenAICompatPassthrough.retry` + +##### kind + +> **kind**: [`AgentBackendKind`](#agentbackendkind) + +Defined in: [resolve-agent-backend.ts:53](https://github.com/tangle-network/agent-runtime/blob/main/src/resolve-agent-backend.ts#L53) + +The chat transport to resolve. + +##### apiKey + +> **apiKey**: `string` + +Defined in: [resolve-agent-backend.ts:59](https://github.com/tangle-network/agent-runtime/blob/main/src/resolve-agent-backend.ts#L59) + +Bearer credential for the OpenAI-compat kinds. Empty string is valid for a +loopback-anonymous cli-bridge; a `router`/`tcloud` route with an empty key +is a caller bug the product surfaces before calling in. + +##### baseUrl + +> **baseUrl**: `string` + +Defined in: [resolve-agent-backend.ts:61](https://github.com/tangle-network/agent-runtime/blob/main/src/resolve-agent-backend.ts#L61) + +Base URL for the OpenAI-compat kinds. cli-bridge's is its `/v1`. + +##### model + +> **model**: `string` + +Defined in: [resolve-agent-backend.ts:63](https://github.com/tangle-network/agent-runtime/blob/main/src/resolve-agent-backend.ts#L63) + +Model id sent on every request. cli-bridge rejects a request without it. + +##### label? + +> `optional` **label?**: `string` + +Defined in: [resolve-agent-backend.ts:65](https://github.com/tangle-network/agent-runtime/blob/main/src/resolve-agent-backend.ts#L65) + +`kind` label stamped on the resolved backend + its traces. Defaults to `kind`. + +##### sandboxBackend? + +> `optional` **sandboxBackend?**: () => [`AgentExecutionBackend`](#agentexecutionbackend)\<`TInput`\> + +Defined in: [resolve-agent-backend.ts:71](https://github.com/tangle-network/agent-runtime/blob/main/src/resolve-agent-backend.ts#L71) + +`sandbox` kind: the product's own domain backend. Required for that kind — +the substrate owns no product sandbox shape, so a `sandbox` resolution with +no seam is a caller bug, not a silent fallback. + +###### Returns + +[`AgentExecutionBackend`](#agentexecutionbackend)\<`TInput`\> + +*** + ### RuntimeHookEvent Defined in: [runtime-hooks.ts:36](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime-hooks.ts#L36) @@ -6337,6 +6487,16 @@ Mode → configured runner. Partial: only register the modes a *** +### AgentBackendKind + +> **AgentBackendKind** = `"router"` \| `"tcloud"` \| `"cli-bridge"` \| `"sandbox"` + +Defined in: [resolve-agent-backend.ts:37](https://github.com/tangle-network/agent-runtime/blob/main/src/resolve-agent-backend.ts#L37) + +The transport a chat backend runs on. + +*** + ### RuntimeHookPhase > **RuntimeHookPhase** = `"before"` \| `"after"` \| `"error"` \| `"event"` @@ -8315,6 +8475,33 @@ Map a `KnowledgeReadinessReport` to a three-state branch (`ready` / `blocked` / *** +### resolveAgentBackend() + +> **resolveAgentBackend**\<`TInput`\>(`opts`): [`AgentExecutionBackend`](#agentexecutionbackend)\<`TInput`\> + +Defined in: [resolve-agent-backend.ts:78](https://github.com/tangle-network/agent-runtime/blob/main/src/resolve-agent-backend.ts#L78) + +Resolve the `AgentExecutionBackend` for the chosen `kind`. Reuse this instead +of hand-rolling the `createOpenAICompatibleBackend` branch in each product. + +#### Type Parameters + +##### TInput + +`TInput` *extends* [`AgentBackendInput`](#agentbackendinput) = [`AgentBackendInput`](#agentbackendinput) + +#### Parameters + +##### opts + +[`ResolveAgentBackendOptions`](#resolveagentbackendoptions)\<`TInput`\> + +#### Returns + +[`AgentExecutionBackend`](#agentexecutionbackend)\<`TInput`\> + +*** + ### applyRunRecordDefaults() > **applyRunRecordDefaults**(`records`, `scenarioId`, `controlFailureClass`): `RunRecord`[] diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index f92cbea..8320698 100644 --- a/docs/api/primitive-catalog.md +++ b/docs/api/primitive-catalog.md @@ -7,7 +7,7 @@ # Primitive catalog — the never-stale anti-reinvention inventory -> **GENERATED** from `@tangle-network/agent-runtime@0.82.0` and `@tangle-network/agent-eval@0.100.0` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. +> **GENERATED** from `@tangle-network/agent-runtime@0.83.0` and `@tangle-network/agent-eval@0.100.0` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. ## 1. agent-runtime — own public surface @@ -15,7 +15,7 @@ Every subpath this package declares in `package.json` `exports`. Reach for these ### Root — task lifecycle, conversation, RSI verbs, observability -Import from `@tangle-network/agent-runtime` — 208 exports. +Import from `@tangle-network/agent-runtime` — 211 exports. | Symbol | Kind | Summary | |---|---|---| @@ -61,6 +61,7 @@ Import from `@tangle-network/agent-runtime` — 208 exports. | `readinessServerSentEvent` | function | Serialize a `KnowledgeReadinessReport` as a Server-Sent Event string. | | `reflectiveGenerator` | function | Cheap no-sandbox `CandidateGenerator` (the `shots=1` setting): draft surface edits via the improvement adapter and apply them as one coherent candidate. | | `researchLoopRunner` | function | `research` mode — research-in-a-loop with valid-only KB growth. | +| `resolveAgentBackend` | function | Resolve the `AgentExecutionBackend` for the chosen `kind`. Reuse this instead | | `resolveChatModel` | function | Resolve a chat model by precedence: the first candidate carrying a | | `resolveRouterBaseUrl` | function | Resolve the router base URL from env, normalised — no trailing `/v1` or `/`. | | `runAgentTask` | function | Single-shot task lifecycle for adapter-driven tasks: readiness-gated, emits the runtime lifecycle event vocabulary, session-store pluggable. | @@ -127,6 +128,7 @@ Import from `@tangle-network/agent-runtime` — 208 exports. | `ToolLoopAssistantToolCall` | interface | One OpenAI-shaped tool-call entry carried on an assistant message. | | `ToolLoopCall` | interface | Bounded turn-level tool-dispatch loop. | | `VerifyResult` | interface | Outcome of verifying a candidate worktree. `feedback` (compiler errors, | +| `AgentBackendKind` | type | The transport a chat backend runs on. | | `AgentEvalErrorCode` | type | Error taxonomy for `@tangle-network/agent-eval`. | | `ImproveSurface` | type | The agent-profile lever `improve` optimizes. Mirrors the AgentProfile-law | | `OpenAIChatResponseFormat` | type | `response_format` parameter for OpenAI-compatible chat endpoints. Use | @@ -141,7 +143,7 @@ Import from `@tangle-network/agent-runtime` — 208 exports. | `ToolLoopStopReason` | type | Why the loop stopped. `completed` = model finished naturally; `stuck-loop` = | | `Verifier` | type | Verifies the edited worktree. Sync or async; throws only on a setup fault | -**Undocumented supporting types** (add a TSDoc line at the declaration to earn a table row): `AgentAdapter`, `AgentBackendContext`, `AgentBackendInput`, `AgentExecutionBackend`, `AgenticGeneratorOptions`, `AgentKnowledgeProvider`, `AgentTaskContext`, `AgentTaskRunResult`, `AgentTaskSpec`, `BackendCallPolicy`, `ChatTurnHooks`, `ChatTurnResult`, `ControlBudget`, `ControlEvalResult`, `ControlRunResult`, `ControlStep`, `Conversation`, `ConversationDriveState`, `ConversationJournal`, `ConversationParticipant`, `ConversationPolicy`, `ConversationResult`, `ConversationTurn`, `D1StmtLike`, `DataAcquisitionPlan`, `DelegatedLoopResult`, `EvalRunEvent`, `EvalRunGeneration`, `EvalRunsExportConfig`, `EvalRunsExportResult`, `HaltContext`, `HaltSignal`, `ImprovementDriverOptions`, `ImproveOptions`, `ImproveResult`, `KnowledgeReadinessReport`, `KnowledgeRequirement`, `LoopRunnerCliArgs`, `LoopRunnerCliResult`, `OtelAttribute`, `OtelExporter`, `OtelSpan`, `PersonaConversationResult`, `ResearchLoopResult`, `ResearchLoopRunnerOptions`, `ResolvedChatModel`, `RunChatTurnInput`, `RunConversationOptions`, `RunDelegatedLoopOptions`, `RunPersonaConfig`, `RunPersonaConversationOptions`, `RuntimeDecisionEvidenceRef`, `RuntimeDecisionPoint`, `RuntimeEventCollector`, `RuntimeHookContext`, `RuntimeHookErrorContext`, `RuntimeHookEvent`, `RuntimeRunHandle`, `RuntimeRunPersistenceAdapter`, `RuntimeRunRow`, `RuntimeSessionStore`, `RuntimeStreamEventCollector`, `RuntimeTelemetryOptions`, `RunToolLoopOptions`, `SanitizedKnowledgeReadinessReport`, `StreamToolLoopOptions`, `ToolLoopResult`, `VetoedFact`, `WorktreeLoopRunnerOptions`, `AgentRuntimeEvent`, `AgentRuntimeEventSink`, `AgentTaskStatus`, `AuthSource`, `ControlDecision`, `ConversationStreamEvent`, `DelegatedLoopMode`, `DelegatedLoopRegistry`, `DelegatedLoopRunner`, `ForwardHeaderName`, `HaltPredicate`, `HaltReason`, `RuntimeDecisionKind`, `RuntimeHookTarget`, `RuntimeStreamEvent`, `StreamToolLoopYield`, `ToolLoopEvent`, `TurnOrder`. +**Undocumented supporting types** (add a TSDoc line at the declaration to earn a table row): `AgentAdapter`, `AgentBackendContext`, `AgentBackendInput`, `AgentExecutionBackend`, `AgenticGeneratorOptions`, `AgentKnowledgeProvider`, `AgentTaskContext`, `AgentTaskRunResult`, `AgentTaskSpec`, `BackendCallPolicy`, `ChatTurnHooks`, `ChatTurnResult`, `ControlBudget`, `ControlEvalResult`, `ControlRunResult`, `ControlStep`, `Conversation`, `ConversationDriveState`, `ConversationJournal`, `ConversationParticipant`, `ConversationPolicy`, `ConversationResult`, `ConversationTurn`, `D1StmtLike`, `DataAcquisitionPlan`, `DelegatedLoopResult`, `EvalRunEvent`, `EvalRunGeneration`, `EvalRunsExportConfig`, `EvalRunsExportResult`, `HaltContext`, `HaltSignal`, `ImprovementDriverOptions`, `ImproveOptions`, `ImproveResult`, `KnowledgeReadinessReport`, `KnowledgeRequirement`, `LoopRunnerCliArgs`, `LoopRunnerCliResult`, `OtelAttribute`, `OtelExporter`, `OtelSpan`, `PersonaConversationResult`, `ResearchLoopResult`, `ResearchLoopRunnerOptions`, `ResolveAgentBackendOptions`, `ResolvedChatModel`, `RunChatTurnInput`, `RunConversationOptions`, `RunDelegatedLoopOptions`, `RunPersonaConfig`, `RunPersonaConversationOptions`, `RuntimeDecisionEvidenceRef`, `RuntimeDecisionPoint`, `RuntimeEventCollector`, `RuntimeHookContext`, `RuntimeHookErrorContext`, `RuntimeHookEvent`, `RuntimeRunHandle`, `RuntimeRunPersistenceAdapter`, `RuntimeRunRow`, `RuntimeSessionStore`, `RuntimeStreamEventCollector`, `RuntimeTelemetryOptions`, `RunToolLoopOptions`, `SanitizedKnowledgeReadinessReport`, `StreamToolLoopOptions`, `ToolLoopResult`, `VetoedFact`, `WorktreeLoopRunnerOptions`, `AgentRuntimeEvent`, `AgentRuntimeEventSink`, `AgentTaskStatus`, `AuthSource`, `ControlDecision`, `ConversationStreamEvent`, `DelegatedLoopMode`, `DelegatedLoopRegistry`, `DelegatedLoopRunner`, `ForwardHeaderName`, `HaltPredicate`, `HaltReason`, `RuntimeDecisionKind`, `RuntimeHookTarget`, `RuntimeStreamEvent`, `StreamToolLoopYield`, `ToolLoopEvent`, `TurnOrder`. ### Vertical agent — manifest + improvement adapter diff --git a/docs/canonical-api.md b/docs/canonical-api.md index 3576673..260e1bb 100644 --- a/docs/canonical-api.md +++ b/docs/canonical-api.md @@ -2,7 +2,7 @@ -> **Version 0.82.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. +> **Version 0.83.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/package.json b/package.json index de54c7d..3b07a91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-runtime", - "version": "0.82.0", + "version": "0.83.0", "description": "Shared task-lifecycle skeleton for agents: a recursive loop kernel for chat turns, one-shot tasks, and multi-attempt loops, with trace capture and eval-gated self-improvement. Domain behavior lives in adapters; scoring and ship-gates in @tangle-network/agent-eval.", "homepage": "https://github.com/tangle-network/agent-runtime#readme", "repository": { diff --git a/src/index.ts b/src/index.ts index fcf8a8d..25c4941 100644 --- a/src/index.ts +++ b/src/index.ts @@ -179,6 +179,8 @@ export { } from './otel-export' // ── Readiness ───────────────────────────────────────────────────────── export { decideKnowledgeReadiness } from './readiness' +export type { AgentBackendKind, ResolveAgentBackendOptions } from './resolve-agent-backend' +export { resolveAgentBackend } from './resolve-agent-backend' // ── Run loop ───────────────────────────────────────────────────────── export { applyRunRecordDefaults, runAgentTask, runAgentTaskStream } from './run' // ── Runtime hooks ──────────────────────────────────────────────────── diff --git a/src/otel-export.ts b/src/otel-export.ts index b03a69a..18119b8 100644 --- a/src/otel-export.ts +++ b/src/otel-export.ts @@ -59,7 +59,7 @@ interface OtlpExport { resourceSpans: OtlpResourceSpans[] } -const SCOPE = { name: '@tangle-network/agent-runtime', version: '0.82.0' } +const SCOPE = { name: '@tangle-network/agent-runtime', version: '0.83.0' } /** * Current (non-deprecated) OpenTelemetry GenAI semantic-convention keys. diff --git a/src/resolve-agent-backend.test.ts b/src/resolve-agent-backend.test.ts new file mode 100644 index 0000000..2a7b8d0 --- /dev/null +++ b/src/resolve-agent-backend.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentExecutionBackend } from './types' + +const createOpenAICompatibleBackend = vi.fn((config: unknown): AgentExecutionBackend => { + return { + kind: (config as { kind?: string }).kind ?? 'tcloud', + stream: async function* () {}, + __config: config, + } as unknown as AgentExecutionBackend +}) + +vi.mock('./backends', () => ({ createOpenAICompatibleBackend })) + +const { resolveAgentBackend } = await import('./resolve-agent-backend') + +const configOf = (backend: AgentExecutionBackend): Record => + (backend as unknown as { __config: Record }).__config + +const base = { apiKey: 'sk-x', baseUrl: 'https://router.tangle.tools/v1', model: 'kimi' } + +describe('resolveAgentBackend', () => { + it("kind 'tcloud' builds createOpenAICompatibleBackend with kind defaulting to the kind name", () => { + const backend = resolveAgentBackend({ kind: 'tcloud', ...base }) + expect(configOf(backend)).toEqual({ ...base, kind: 'tcloud' }) + }) + + it("kind 'router' routes through createOpenAICompatibleBackend (same endpoint as tcloud)", () => { + const backend = resolveAgentBackend({ kind: 'router', ...base }) + expect(configOf(backend)).toEqual({ ...base, kind: 'router' }) + }) + + it("kind 'cli-bridge' routes through createOpenAICompatibleBackend and forwards model", () => { + const backend = resolveAgentBackend({ + kind: 'cli-bridge', + apiKey: '', + baseUrl: 'http://127.0.0.1:3355/v1', + model: 'claude-code/sonnet', + }) + // cli-bridge REQUIRES model — the whole reason it uses this factory. + expect(configOf(backend)).toEqual({ + apiKey: '', + baseUrl: 'http://127.0.0.1:3355/v1', + model: 'claude-code/sonnet', + kind: 'cli-bridge', + }) + }) + + it('honors an explicit label over the kind name', () => { + const backend = resolveAgentBackend({ + kind: 'tcloud', + ...base, + label: 'insurance-canonical-tcloud', + }) + expect((configOf(backend) as { kind: string }).kind).toBe('insurance-canonical-tcloud') + }) + + it('forwards openai-compat passthrough (tools, fetchImpl) when set', () => { + const tools = [ + { type: 'function', function: { name: 'submit', description: 'd', parameters: {} } }, + ] as never + const fetchImpl = (async () => new Response()) as unknown as typeof fetch + const backend = resolveAgentBackend({ kind: 'cli-bridge', ...base, tools, fetchImpl }) + const cfg = configOf(backend) + expect(cfg.tools).toBe(tools) + expect(cfg.fetchImpl).toBe(fetchImpl) + }) + + it('omits passthrough fields that were never set (keeps the tool-free request shape)', () => { + const backend = resolveAgentBackend({ kind: 'tcloud', ...base }) + const cfg = configOf(backend) + expect('tools' in cfg).toBe(false) + expect('toolChoice' in cfg).toBe(false) + expect('responseFormat' in cfg).toBe(false) + expect('fetchImpl' in cfg).toBe(false) + expect('retry' in cfg).toBe(false) + }) + + it("kind 'sandbox' invokes the caller's sandboxBackend seam", () => { + const sandbox = { + kind: 'product-sandbox', + stream: async function* () {}, + } as AgentExecutionBackend + const sandboxBackend = vi.fn(() => sandbox) + const backend = resolveAgentBackend({ kind: 'sandbox', ...base, sandboxBackend }) + expect(backend).toBe(sandbox) + expect(sandboxBackend).toHaveBeenCalledOnce() + expect(createOpenAICompatibleBackend).not.toHaveBeenCalledWith( + expect.objectContaining({ kind: 'sandbox' }), + ) + }) + + it("kind 'sandbox' fails loud when no sandboxBackend seam is provided", () => { + expect(() => resolveAgentBackend({ kind: 'sandbox', ...base })).toThrow( + /requires opts\.sandboxBackend/, + ) + }) +}) diff --git a/src/resolve-agent-backend.ts b/src/resolve-agent-backend.ts new file mode 100644 index 0000000..16201c4 --- /dev/null +++ b/src/resolve-agent-backend.ts @@ -0,0 +1,109 @@ +/** + * The product-facing backend selector for `runChatThroughRuntime` / + * `runAgentTaskStream`: one call turns a `--backend {router,tcloud,cli-bridge, + * sandbox}` choice into the `AgentExecutionBackend` the chat leg runs on. + * + * It is the `AgentExecutionBackend` sibling of `resolveSandboxClient` (which + * resolves the `SandboxClient` a `runLoop` drives). Both exist for the same + * reason: every in-process eval product hand-rolled the identical + * "`backend-name` → `createOpenAICompatibleBackend`" branch, and the copies + * drift. This is the single generic resolver they share. + * + * - `router` / `tcloud` / `cli-bridge` → OpenAI-compatible chat completions. + * All three speak `POST {baseUrl}/chat/completions` in OpenAI's SSE shape — + * the router (a.k.a. tcloud) IS that endpoint, and cli-bridge fronts a + * harness CLI behind the same protocol at its own `/v1`. They differ only + * in `baseUrl` / `apiKey` and the `kind` label a product wants on its + * traces. cli-bridge REQUIRES `model` in the request body, so it MUST route + * through `createOpenAICompatibleBackend` (which sends it), never a + * transport that drops the field. + * - `sandbox` → the caller's own domain backend. The sandbox variant carries + * product specifics (system prompt, workspace id, in-box D1 executor) that + * do NOT belong in the substrate, so the product passes a `sandboxBackend()` + * seam that this resolver simply invokes. + * + * This resolver is PURE backend selection. Product concerns — credit hard-cuts, + * fetch-capture shims, D1 platform wiring — stay as product-side WRAPPERS + * around the returned backend. The OpenAI-compat passthrough fields (`tools`, + * `toolChoice`, `responseFormat`, `fetchImpl`, `retry`) are forwarded verbatim + * so a product can advertise its app tools or install a capturing fetch without + * re-opening the branch this consolidation closes. + */ + +import { createOpenAICompatibleBackend } from './backends' +import type { AgentBackendInput, AgentExecutionBackend } from './types' + +/** The transport a chat backend runs on. */ +export type AgentBackendKind = 'router' | 'tcloud' | 'cli-bridge' | 'sandbox' + +/** + * OpenAI-compat passthrough forwarded to `createOpenAICompatibleBackend` for + * the `router` / `tcloud` / `cli-bridge` kinds. Mirrors that factory's optional + * inputs so a product keeps its tool advertising / capture-fetch without + * re-implementing the backend branch. + */ +type OpenAICompatPassthrough = Pick< + Parameters[0], + 'tools' | 'toolChoice' | 'responseFormat' | 'fetchImpl' | 'retry' +> + +export interface ResolveAgentBackendOptions + extends OpenAICompatPassthrough { + /** The chat transport to resolve. */ + kind: AgentBackendKind + /** + * Bearer credential for the OpenAI-compat kinds. Empty string is valid for a + * loopback-anonymous cli-bridge; a `router`/`tcloud` route with an empty key + * is a caller bug the product surfaces before calling in. + */ + apiKey: string + /** Base URL for the OpenAI-compat kinds. cli-bridge's is its `/v1`. */ + baseUrl: string + /** Model id sent on every request. cli-bridge rejects a request without it. */ + model: string + /** `kind` label stamped on the resolved backend + its traces. Defaults to `kind`. */ + label?: string + /** + * `sandbox` kind: the product's own domain backend. Required for that kind — + * the substrate owns no product sandbox shape, so a `sandbox` resolution with + * no seam is a caller bug, not a silent fallback. + */ + sandboxBackend?: () => AgentExecutionBackend +} + +/** + * Resolve the `AgentExecutionBackend` for the chosen `kind`. Reuse this instead + * of hand-rolling the `createOpenAICompatibleBackend` branch in each product. + */ +export function resolveAgentBackend( + opts: ResolveAgentBackendOptions, +): AgentExecutionBackend { + switch (opts.kind) { + case 'router': + case 'tcloud': + case 'cli-bridge': { + const passthrough: OpenAICompatPassthrough = {} + // Forward only the fields a caller actually set — an explicit + // `tools: []` / `undefined` would otherwise reach the factory and change + // its request shape (some providers reject an empty `tools` array). + if (opts.tools !== undefined) passthrough.tools = opts.tools + if (opts.toolChoice !== undefined) passthrough.toolChoice = opts.toolChoice + if (opts.responseFormat !== undefined) passthrough.responseFormat = opts.responseFormat + if (opts.fetchImpl !== undefined) passthrough.fetchImpl = opts.fetchImpl + if (opts.retry !== undefined) passthrough.retry = opts.retry + return createOpenAICompatibleBackend({ + apiKey: opts.apiKey, + baseUrl: opts.baseUrl, + model: opts.model, + kind: opts.label ?? opts.kind, + ...passthrough, + }) + } + case 'sandbox': { + if (!opts.sandboxBackend) { + throw new Error("resolveAgentBackend: kind 'sandbox' requires opts.sandboxBackend") + } + return opts.sandboxBackend() + } + } +}