Compare commits

..
1 Commits
Author SHA1 Message Date
Dax Raad 9b887030e6 feat(tui): filter subagents by activity 2026-07-24 01:50:20 -04:00
106 changed files with 2496 additions and 4569 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/ai": patch
---
Report OpenAI prompt cache write tokens in normalized usage.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/ai": patch
---
Improve Anthropic and Bedrock prompt reuse with layered cache breakpoints that roll through long tool loops.
+5 -6
View File
@@ -54,7 +54,7 @@ Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g.
A route is the registered, runnable composition of four orthogonal pieces:
- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenResponses.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`.
- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`.
- **`Endpoint`** (`src/route/endpoint.ts`) — URL construction. The host, path, and route query live on the endpoint. `Endpoint.path("/chat/completions", { baseURL })` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`).
- **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Provider facades configure credentials onto the route before model selection, usually via `Auth.bearer(apiKey)` or `Auth.header(name, apiKey)`. Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result.
- **`Framing`** (`src/route/framing.ts`) — bytes → frames. SSE (`Framing.sse`) is shared; Bedrock keeps its AWS event-stream framing as a typed `Framing<object>` value alongside its protocol.
@@ -158,14 +158,13 @@ packages/ai/src/
protocols/
shared.ts ProviderShared toolkit used inside protocol impls
openai-chat.ts protocol + route (compose OpenAIChat.protocol)
open-responses.ts provider-neutral Responses protocol baseline
openai-responses.ts OpenAI tools/events/transports composed over OpenResponses
openai-responses.ts
anthropic-messages.ts
gemini.ts
bedrock-converse.ts
bedrock-event-stream.ts framing for AWS event-stream binary frames
openai-compatible-chat.ts route that reuses OpenAIChat.protocol, no canonical URL
openai-compatible-responses.ts deployment adapter that reuses OpenResponses.protocol, no canonical URL
openai-compatible-responses.ts route that reuses OpenAIResponses.protocol, no canonical URL
utils/ per-protocol helpers (auth, cache, media, tool-stream, ...)
providers/
openai-compatible.ts generic Chat helper + family model helpers
@@ -176,7 +175,7 @@ packages/ai/src/
tool-runtime.ts narrow one-call typed tool dispatcher
```
The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata. `OpenAIResponses` composes the provider-neutral `OpenResponses` protocol; the baseline never imports the OpenAI extension.
The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata.
### Shared protocol helpers
@@ -241,7 +240,7 @@ const get_weather = tool({
const tools = { get_weather, get_time, ... }
const events = yield* LLM.stream(
LLMRequest.update(request, { tools: Tool.toDefinitions(tools) }),
LLM.updateRequest(request, { tools: Tool.toDefinitions(tools) }),
).pipe(Stream.runCollect)
const call = Array.from(events).find(LLMEvent.is.toolCall)
+2 -3
View File
@@ -315,8 +315,7 @@ const longer = {
}
```
There is no `LLM.updateRequest(...)` helper. The current Schema-backed implementation
uses `LLMRequest.update(...)` when canonical request data must be derived.
There is no `LLM.updateRequest(...)` helper and no request Schema class.
### Conversation history
@@ -437,7 +436,7 @@ const call = Array.from(events).find(LLMEvent.is.toolCall)
if (call && !call.providerExecuted) {
const dispatched = yield * ToolRuntime.dispatch(tools, call)
const followUp = LLMRequest.update(request, {
const followUp = LLM.updateRequest(request, {
messages: [...request.messages, Message.assistant([call]), Message.tool({ ...call, result: dispatched.result })],
})
// Caller must invoke the provider again and repeat the loop.
+5 -7
View File
@@ -207,9 +207,7 @@ Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "aut
### Auto placement
`"auto"` places up to four breakpoints — the last tool definition, the first system part, the last system part when distinct, and the final message boundary. These expose successively larger reusable prefixes for tools, the base agent, project instructions, and the active conversation. The rolling final-message boundary is the load-bearing detail in tool loops: it advances on every request so the previous cache entry stays within Anthropic's 20-block lookback.
Tools precede every system and conversation block in the provider prefix, so tool definitions must remain byte-stable and deterministically ordered for downstream breakpoints to remain reusable.
`"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit.
The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless.
@@ -237,7 +235,7 @@ cache: {
### Manual hints
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints, counts them against Anthropic and Bedrock's four-breakpoint limit, and only fills the remaining slots.
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints; it only fills gaps.
```ts
LLM.request({
@@ -253,8 +251,8 @@ LLM.request({
| Protocol | `cache: "auto"` |
| ----------------------- | ------------------------------------------------------------------------- |
| Anthropic Messages | emits up to 4 `cache_control` markers (4-breakpoint cap enforced) |
| Bedrock Converse | emits up to 4 `cachePoint` blocks (4-breakpoint cap enforced) |
| Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) |
| Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) |
| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
@@ -302,7 +300,7 @@ OpenAI Chat and OpenAI Responses are separate semantic entrypoints:
- `@opencode-ai/ai/providers/google-vertex/responses`
- `@opencode-ai/ai/providers/google-vertex/messages`
Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; the Responses adapter at `providers/openai-compatible/responses` uses the provider-neutral Open Responses protocol. OpenAI Responses extends that baseline with OpenAI tools, event variants, metadata, defaults, and transports. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths.
Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; compatible Responses is separate at `providers/openai-compatible/responses`. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths.
Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`.
+24 -24
View File
@@ -1,6 +1,6 @@
# LLM Provider Parity Status
Last reviewed: 2026-07-24
Last reviewed: 2026-07-17
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
@@ -13,26 +13,26 @@ This file tracks the gap between the native `@opencode-ai/ai` package and the AI
## Current Implementation Snapshot
| Native slice | Source | Current state | Main gaps |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. |
| OpenAI Responses HTTP | `src/protocols/open-responses.ts`, `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Extends the Open Responses baseline with hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
| Open Responses-compatible | `src/protocols/open-responses.ts`, `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the provider-neutral Open Responses protocol. The deployment adapter does not inherit OpenAI tools, events, metadata, or defaults. | No named family profiles or recorded deployment coverage yet. |
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. |
| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. |
| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
| Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. |
| Vertex Chat | `src/protocols/openai-chat.ts`, `src/providers/google-vertex-chat.ts` | Usable for MaaS models through OpenAI-compatible Chat Completions with explicit OAuth tokens or ADC and project/location endpoint derivation. | Core runner/catalog mapping and recorded provider coverage are missing; MaaS family-specific request parity needs review. |
| Vertex Responses | `src/protocols/open-responses.ts`, `src/providers/google-vertex-responses.ts` | Usable for Grok models through Open Responses with explicit OAuth tokens or ADC, project/location endpoint derivation, and an explicit `store: false` Vertex default. | Core runner/catalog mapping and recorded provider coverage are missing; stateful continuation is not supported by Vertex. |
| Vertex Messages | `src/protocols/anthropic-messages.ts`, `src/providers/google-vertex-messages.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. |
| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. |
| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. |
| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. |
| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. |
| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. |
| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. |
| Native slice | Source | Current state | Main gaps |
| ---------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. |
| OpenAI Responses HTTP | `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Supports hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
| OpenAI-compatible Responses | `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the OpenAI Responses wire protocol. | No named family profiles or recorded deployment coverage yet. |
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. |
| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. |
| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
| Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. |
| Vertex Chat | `src/protocols/openai-chat.ts`, `src/providers/google-vertex-chat.ts` | Usable for MaaS models through OpenAI-compatible Chat Completions with explicit OAuth tokens or ADC and project/location endpoint derivation. | Core runner/catalog mapping and recorded provider coverage are missing; MaaS family-specific request parity needs review. |
| Vertex Responses | `src/protocols/openai-responses.ts`, `src/providers/google-vertex-responses.ts` | Usable for Grok models through OpenAI-compatible Responses with explicit OAuth tokens or ADC, project/location endpoint derivation, and storage disabled by default. | Core runner/catalog mapping and recorded provider coverage are missing; stateful continuation is not supported by Vertex. |
| Vertex Messages | `src/protocols/anthropic-messages.ts`, `src/providers/google-vertex-messages.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. |
| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. |
| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. |
| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. |
| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. |
| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. |
| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. |
## V2 Runner Status
@@ -65,7 +65,7 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
## Highest-Risk Gaps
1. Runner support is narrower than the LLM package. The package has native provider facades for Google, Azure, and Bedrock, but the V2 Session runner only maps OpenAI, Anthropic, and explicit OpenAI-compatible Chat from `aisdk` catalog metadata.
2. The Open Responses adapter is available through a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it.
2. OpenAI-compatible Responses is available as a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it.
3. Bedrock native auth is not AI SDK parity. The AI SDK plugin uses the default AWS provider chain, profile, container credentials, and Bedrock bearer token env behavior. Native Bedrock currently expects explicit credentials or bearer auth on the facade.
4. Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages now have native package entrypoints, but the core runner does not map catalog metadata to them yet and recorded provider coverage is still missing.
5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review.
@@ -83,13 +83,13 @@ These are implementation/API slices, not separate npm packages.
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
| OpenAI-compatible Responses | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic OpenAI-compatible `/responses`. |
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex OpenAI-compatible Responses for Grok models. |
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. |
+2 -2
View File
@@ -1,5 +1,5 @@
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
import { LLM, LLMClient, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/ai/route"
import { OpenAI } from "@opencode-ai/ai/providers"
@@ -116,7 +116,7 @@ const streamWithTools = Effect.gen(function* () {
// A durable agent would persist these messages before starting another
// raw model turn. This tutorial keeps the boundary visible instead.
const followUp = LLMRequest.update(request, {
const followUp = LLM.updateRequest(request, {
messages: [
...request.messages,
Message.assistant([event]),
+25 -61
View File
@@ -2,31 +2,32 @@
// the policy designates. Runs once at compile time, before the per-protocol
// body builder, so the existing inline-hint lowering path handles the rest.
//
// The default `"auto"` shape places breakpoints at the last tool definition,
// the first and last distinct system parts, and the conversation tail. This
// exposes reusable tool, base-agent, project, and session prefixes while
// advancing the tail after each tool result keeps the previous cache entry
// within Anthropic's 20-block lookback during long agent turns.
// The default `"auto"` shape places one breakpoint at the last tool definition,
// one at the last system part, and one at the latest user message. This
// matches what production agent harnesses (LangChain's caching middleware,
// kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the
// latest user message stays put while a single turn explodes into many
// assistant/tool round-trips, so caching at that boundary lets every
// intra-turn API call hit the prefix.
//
// Manual `cache: CacheHint` placements on individual parts are preserved and
// count against the four-breakpoint budget; auto only fills remaining slots.
// Manual `cache: CacheHint` placements on individual parts are preserved
// this function only fills gaps the caller left empty.
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
const AUTO: CachePolicyObject = {
tools: true,
system: true,
messages: { tail: 1 },
messages: "latest-user-message",
}
const NONE: CachePolicyObject = {}
const BREAKPOINT_CAP = 4
// Resolution rules:
// - undefined → "auto" — caching is on by default. The math favors it:
// Anthropic 5m-cache write is 1.25x base, read is 0.1x,
// so a single reuse within 5 minutes already wins.
// - "auto" → tools + first/last system + final message boundary.
// - "auto" → tools + system + latest user msg.
// - "none" → no auto placement; manual `CacheHint`s still flow.
// - object form → exactly what the caller asked for.
const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
@@ -43,32 +44,18 @@ const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"]
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
interface Budget {
remaining: number
}
const markLastTool = (
tools: ReadonlyArray<ToolDefinition>,
hint: CacheHint,
budget: Budget,
): ReadonlyArray<ToolDefinition> => {
const markLastTool = (tools: ReadonlyArray<ToolDefinition>, hint: CacheHint): ReadonlyArray<ToolDefinition> => {
if (tools.length === 0) return tools
const last = tools.length - 1
if (tools[last]!.cache || budget.remaining === 0) return tools
budget.remaining -= 1
if (tools[last]!.cache) return tools
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
}
const markSystemBoundaries = (system: LLMRequest["system"], hint: CacheHint, budget: Budget): LLMRequest["system"] => {
const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMRequest["system"] => {
if (system.length === 0) return system
let changed = false
const next = system.map((part, index) => {
if ((index !== 0 && index !== system.length - 1) || part.cache || budget.remaining === 0) return part
budget.remaining -= 1
changed = true
return { ...part, cache: hint }
})
return changed ? next : system
const last = system.length - 1
if (system[last]!.cache) return system
return system.map((part, i) => (i === last ? { ...part, cache: hint } : part))
}
const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number =>
@@ -77,20 +64,14 @@ const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]
// Mark the last text part of `messages[index]`. If no text part exists, mark
// the last content part regardless of type — that's the breakpoint position
// in tool-result-only messages too.
const markMessageAt = (
messages: ReadonlyArray<Message>,
index: number,
hint: CacheHint,
budget: Budget,
): ReadonlyArray<Message> => {
const markMessageAt = (messages: ReadonlyArray<Message>, index: number, hint: CacheHint): ReadonlyArray<Message> => {
if (index < 0 || index >= messages.length) return messages
const target = messages[index]!
if (target.content.length === 0) return messages
const lastTextIndex = target.content.findLastIndex((part) => part.type === "text")
const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1
const existing = target.content[markAt]!
if (("cache" in existing && existing.cache) || budget.remaining === 0) return messages
budget.remaining -= 1
if ("cache" in existing && existing.cache) return messages
const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part))
const next = new Message({ ...target, content: nextContent })
// Single pass over `messages`, substituting the one updated entry. Long
@@ -105,42 +86,25 @@ const markMessages = (
messages: ReadonlyArray<Message>,
strategy: NonNullable<CachePolicyObject["messages"]>,
hint: CacheHint,
budget: Budget,
): ReadonlyArray<Message> => {
if (messages.length === 0) return messages
if (strategy === "latest-user-message")
return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint, budget)
if (strategy === "latest-assistant")
return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint, budget)
if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint)
if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint)
const start = Math.max(0, messages.length - strategy.tail)
let next = messages
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint, budget)
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint)
return next
}
const countHints = (request: LLMRequest) =>
request.tools.reduce((count, tool) => count + (tool.cache === undefined ? 0 : 1), 0) +
request.system.reduce((count, part) => count + (part.cache === undefined ? 0 : 1), 0) +
request.messages.reduce(
(count, message) =>
count +
message.content.reduce(
(contentCount, part) => contentCount + ("cache" in part && part.cache !== undefined ? 1 : 0),
0,
),
0,
)
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request
const hint = makeHint(policy.ttlSeconds)
const budget = { remaining: Math.max(0, BREAKPOINT_CAP - countHints(request)) }
const tools = policy.tools ? markLastTool(request.tools, hint, budget) : request.tools
const system = policy.system ? markSystemBoundaries(request.system, hint, budget) : request.system
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint, budget) : request.messages
const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools
const system = policy.system ? markLastSystem(request.system, hint) : request.system
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages
if (tools === request.tools && system === request.system && messages === request.messages) return request
return LLMRequest.update(request, { tools, system, messages })
+20 -2
View File
@@ -9,13 +9,24 @@ import {
LLMRequest,
LLMResponse,
Message,
type ModelInput as SchemaModelInput,
SystemPart,
ToolChoice,
ToolDefinition,
type ContentPart,
ToolResultPart,
} from "./schema"
import { make as makeTool, toDefinitions, type ToolSchema } from "./tool"
export type ModelInput = SchemaModelInput
export type MessageInput = Message.Input
export type ToolChoiceInput = ToolChoice.Input
export type ToolChoiceMode = ToolChoice.Mode
export type ToolResultInput = Parameters<typeof ToolResultPart.make>[0]
/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */
export type RequestInput = Omit<
ConstructorParameters<typeof LLMRequest>[0],
@@ -23,9 +34,9 @@ export type RequestInput = Omit<
> & {
readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
readonly messages?: ReadonlyArray<Message | Message.Input>
readonly messages?: ReadonlyArray<Message | MessageInput>
readonly tools?: ReadonlyArray<ToolDefinition.Input>
readonly toolChoice?: ToolChoice.Input
readonly toolChoice?: ToolChoiceInput
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ConstructorParameters<typeof LLMRequest>[0]["providerOptions"]
readonly http?: HttpOptions.Input
@@ -35,6 +46,10 @@ export const generate = LLMClient.generate
export const stream = LLMClient.stream
export const requestInput = (input: LLMRequest): RequestInput => ({
...LLMRequest.input(input),
})
export const request = (input: RequestInput) => {
const {
system: requestSystem,
@@ -59,6 +74,9 @@ export const request = (input: RequestInput) => {
})
}
export const updateRequest = (input: LLMRequest, patch: Partial<RequestInput>) =>
request({ ...requestInput(input), ...patch })
const GENERATE_OBJECT_TOOL_NAME = "generate_object"
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
+46 -114
View File
@@ -9,12 +9,10 @@ import {
LLMEvent,
Usage,
type CacheHint,
type FinishReasonDetails,
type FinishReason,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ProviderOptions,
type ProviderMetadata,
type ToolCallPart,
type ToolDefinition,
@@ -33,29 +31,6 @@ const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderS
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages"
export type ThinkingInput =
| {
readonly type: "adaptive"
readonly display?: "summarized" | "omitted"
}
| {
readonly type: "disabled"
}
| ({ readonly type: "enabled" } & (
| { readonly budgetTokens: number; readonly budget_tokens?: number }
| { readonly budgetTokens?: number; readonly budget_tokens: number }
))
export interface OptionsInput {
readonly [key: string]: unknown
readonly thinking?: ThinkingInput
readonly effort?: string
}
export type ProviderOptionsInput = ProviderOptions & {
readonly anthropic?: OptionsInput
}
// =============================================================================
// Request Body Schema
// =============================================================================
@@ -289,12 +264,7 @@ type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState {
readonly tools: ToolStream.State<number>
readonly reasoningSignatures: Readonly<Record<number, string>>
readonly usage?: Usage
readonly pendingFinish?: {
readonly reason: FinishReasonDetails
readonly providerMetadata?: ProviderMetadata
}
readonly lifecycle: Lifecycle.State
}
@@ -567,38 +537,37 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
return messages
})
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
const input = request.providerOptions?.anthropic
return {
thinking: yield* resolveThinking(input?.thinking),
effort: typeof input?.effort === "string" ? input.effort : undefined,
}
})
const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthropic
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
if (!ProviderShared.isRecord(input)) return undefined
if (input.type === "adaptive") {
const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) {
const thinking = anthropicOptions(request)?.thinking
if (!ProviderShared.isRecord(thinking)) return undefined
if (thinking.type === "adaptive") {
const display =
input.display === "summarized"
thinking.display === "summarized"
? ("summarized" as const)
: input.display === "omitted"
: thinking.display === "omitted"
? ("omitted" as const)
: undefined
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
}
if (input.type === "disabled") return { type: "disabled" as const }
if (input.type !== "enabled") return undefined
if (thinking.type === "disabled") return { type: "disabled" as const }
if (thinking.type !== "enabled") return undefined
const budget =
typeof input.budgetTokens === "number"
? input.budgetTokens
: typeof input.budget_tokens === "number"
? input.budget_tokens
typeof thinking.budgetTokens === "number"
? thinking.budgetTokens
: typeof thinking.budget_tokens === "number"
? thinking.budget_tokens
: undefined
if (budget === undefined)
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
if (budget === undefined) return yield* invalid("Anthropic thinking provider option requires budgetTokens")
return { type: "enabled" as const, budget_tokens: budget }
})
const outputConfig = (request: LLMRequest) => {
const effort = anthropicOptions(request)?.effort
return typeof effort === "string" ? { effort } : undefined
}
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
@@ -618,7 +587,8 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
),
)
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
const toolChoice = tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
const toolChoice =
tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
const system =
request.system.length === 0
? undefined
@@ -633,7 +603,6 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
)
}
const options = yield* resolveOptions(request)
return {
model: request.model.id,
system,
@@ -646,8 +615,8 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_p: generation?.topP,
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: options.thinking,
output_config: options.effort === undefined ? undefined : { effort: options.effort },
thinking: yield* lowerThinking(request),
output_config: outputConfig(request),
}
})
@@ -769,10 +738,6 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
tools: ToolStream.start(state.tools, event.index, {
id: block.id ?? String(event.index),
name: block.name ?? "",
input:
block.input !== undefined && (!ProviderShared.isRecord(block.input) || Object.keys(block.input).length > 0)
? ProviderShared.encodeJson(block.input)
: undefined,
providerExecuted: block.type === "server_tool_use",
}),
},
@@ -787,31 +752,20 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
]
}
if (block.type === "text" && block.text !== undefined) {
if (block.type === "text" && block.text) {
const events: LLMEvent[] = []
const id = `text-${event.index ?? 0}`
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id)
return [
{ ...state, lifecycle: block.text ? Lifecycle.textDelta(lifecycle, events, id, block.text) : lifecycle },
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, block.text) },
events,
]
}
if (block.type === "thinking" && block.thinking !== undefined) {
if (block.type === "thinking" && block.thinking) {
const events: LLMEvent[] = []
const id = `reasoning-${event.index ?? 0}`
const providerMetadata = block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature })
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata)
return [
{
...state,
lifecycle: block.thinking
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, providerMetadata)
: lifecycle,
reasoningSignatures:
event.index === undefined || block.signature === undefined
? state.reasoningSignatures
: { ...state.reasoningSignatures, [event.index]: block.signature },
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, block.thinking),
},
events,
]
@@ -820,7 +774,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
// Redacted thinking surfaces as an empty reasoning part carrying the opaque
// payload as `redactedData` metadata (same model as Vercel's
// @ai-sdk/anthropic). The existing content_block_stop closes the part.
if (block.type === "redacted_thinking" && block.data !== undefined) {
if (block.type === "redacted_thinking" && block.data) {
const events: LLMEvent[] = []
return [
{
@@ -868,13 +822,18 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
}
if (delta?.type === "signature_delta" && delta.signature) {
const index = event.index ?? 0
const events: LLMEvent[] = []
return [
{
...state,
reasoningSignatures: { ...state.reasoningSignatures, [index]: delta.signature },
lifecycle: Lifecycle.reasoningEnd(
state.lifecycle,
events,
`reasoning-${event.index ?? 0}`,
anthropicMetadata({ signature: delta.signature }),
),
},
NO_EVENTS,
events,
] satisfies StepResult
}
@@ -905,53 +864,31 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
const signature = state.reasoningSignatures[event.index]
const lifecycle = resultEvents.length
? Lifecycle.stepStart(state.lifecycle, events)
: Lifecycle.reasoningEnd(
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
events,
`reasoning-${event.index}`,
signature === undefined ? undefined : anthropicMetadata({ signature }),
)
events.push(...resultEvents)
const reasoningSignatures = { ...state.reasoningSignatures }
delete reasoningSignatures[event.index]
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
})
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
const usage = mergeUsage(state.usage, mapUsage(event.usage))
return [
{
...state,
usage,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.delta?.stop_reason),
raw: event.delta?.stop_reason ?? undefined,
},
providerMetadata:
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
? undefined
: anthropicMetadata({ stopSequence: event.delta.stop_sequence }),
},
},
NO_EVENTS,
]
}
const onMessageStop = (state: ParserState): StepResult => {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: state.pendingFinish?.reason ?? {
normalized: "unknown",
raw: undefined,
reason: {
normalized: mapFinishReason(event.delta?.stop_reason),
raw: event.delta?.stop_reason ?? undefined,
},
usage: state.usage,
providerMetadata: state.pendingFinish?.providerMetadata,
usage,
providerMetadata: event.delta?.stop_sequence
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
: undefined,
})
return [{ ...state, lifecycle }, events]
return [{ ...state, lifecycle, usage }, events]
}
// Prefix `error.type` so overloads, rate limits, and quota errors are visible
@@ -976,7 +913,6 @@ const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
if (event.type === "message_stop") return Effect.succeed(onMessageStop(state))
if (event.type === "error") return onError(event)
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
@@ -997,11 +933,7 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(AnthropicEvent),
initial: () => ({
tools: ToolStream.empty<number>(),
reasoningSignatures: {},
lifecycle: Lifecycle.initial(),
}),
initial: () => ({ tools: ToolStream.empty<number>(), lifecycle: Lifecycle.initial() }),
step,
},
})
+20 -55
View File
@@ -66,15 +66,14 @@ const BedrockToolResultBlock = Schema.Struct({
type BedrockToolResultBlock = Schema.Schema.Type<typeof BedrockToolResultBlock>
const BedrockReasoningBlock = Schema.Struct({
reasoningContent: Schema.Union([
Schema.Struct({
reasoningText: Schema.Struct({
reasoningContent: Schema.Struct({
reasoningText: Schema.optional(
Schema.Struct({
text: Schema.String,
signature: Schema.optional(Schema.String),
}),
}),
Schema.Struct({ redactedContent: Schema.String }),
]),
),
}),
})
const BedrockUserBlock = Schema.Union([
@@ -155,12 +154,6 @@ const BedrockUsageSchema = Schema.Struct({
})
type BedrockUsageSchema = Schema.Schema.Type<typeof BedrockUsageSchema>
const BedrockStreamException = Schema.Struct({
message: Schema.optional(Schema.String),
originalMessage: Schema.optional(Schema.String),
originalStatusCode: Schema.optional(Schema.Number),
})
// Streaming event shape — the AWS event stream wraps each JSON payload by its
// `:event-type` header (e.g. `messageStart`, `contentBlockDelta`). We
// reconstruct that wrapping in `decodeFrames` below so the event schema can
@@ -188,11 +181,6 @@ const BedrockEvent = Schema.Struct({
Schema.Struct({
text: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String),
// Blob fields in Bedrock's JSON event stream are base64 strings.
redactedContent: Schema.optional(Schema.String),
// Vercel's Bedrock provider exposes the same delta under
// Anthropic's shorter `data` spelling.
data: Schema.optional(Schema.String),
}),
),
}),
@@ -212,11 +200,11 @@ const BedrockEvent = Schema.Struct({
metrics: Schema.optional(Schema.Unknown),
}),
),
internalServerException: Schema.optional(BedrockStreamException),
modelStreamErrorException: Schema.optional(BedrockStreamException),
validationException: Schema.optional(BedrockStreamException),
throttlingException: Schema.optional(BedrockStreamException),
serviceUnavailableException: Schema.optional(BedrockStreamException),
internalServerException: Schema.optional(Schema.Struct({ message: Schema.String })),
modelStreamErrorException: Schema.optional(Schema.Struct({ message: Schema.String })),
validationException: Schema.optional(Schema.Struct({ message: Schema.String })),
throttlingException: Schema.optional(Schema.Struct({ message: Schema.String })),
serviceUnavailableException: Schema.optional(Schema.Struct({ message: Schema.String })),
})
type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
@@ -272,13 +260,6 @@ const reasoningSignature = (part: ReasoningPart) => {
)
}
const reasoningRedactedData = (part: ReasoningPart) => {
const bedrock = part.providerMetadata?.bedrock
return ProviderShared.isRecord(bedrock) && typeof bedrock.redactedData === "string"
? bedrock.redactedData
: undefined
}
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
toolUse: {
toolUseId: part.id,
@@ -368,13 +349,11 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
if (part.type === "reasoning") {
const signature = reasoningSignature(part)
const redactedData = reasoningRedactedData(part)
if (signature === undefined && redactedData !== undefined) {
content.push({ reasoningContent: { redactedContent: redactedData } })
continue
}
content.push({ reasoningContent: { reasoningText: { text: part.text, signature } } })
content.push({
reasoningContent: {
reasoningText: { text: part.text, signature: reasoningSignature(part) },
},
})
continue
}
if (part.type === "tool-call") {
@@ -540,26 +519,12 @@ const step = (state: ParserState, event: BedrockEvent) =>
const index = event.contentBlockDelta.contentBlockIndex
const reasoning = event.contentBlockDelta.delta.reasoningContent
const events: LLMEvent[] = []
const redactedData = reasoning.redactedContent ?? reasoning.data
const providerMetadata = reasoning.signature
? bedrockMetadata({ signature: reasoning.signature })
: redactedData !== undefined
? bedrockMetadata({ redactedData })
: undefined
const lifecycle =
reasoning.text !== undefined || providerMetadata !== undefined
? Lifecycle.reasoningDelta(
state.lifecycle,
events,
`reasoning-${index}`,
reasoning.text ?? "",
providerMetadata,
)
: state.lifecycle
return [
{
...state,
lifecycle,
lifecycle: reasoning.text
? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text)
: state.lifecycle,
reasoningSignatures: reasoning.signature
? { ...state.reasoningSignatures, [index]: reasoning.signature }
: state.reasoningSignatures,
@@ -633,7 +598,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
}
if (event.metadata) {
const usage = mapUsage(event.metadata.usage) ?? state.pendingFinish?.usage
const usage = mapUsage(event.metadata.usage)
return [
{
...state,
@@ -660,7 +625,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({
message: exception[1]?.message ?? exception[1]?.originalMessage ?? "Bedrock Converse stream error",
message: exception[1]?.message ?? "Bedrock Converse stream error",
code: exception[0],
}),
})
@@ -53,22 +53,8 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
})
cursor = { buffer: cursor.buffer, offset: cursor.offset + totalLength }
const messageType = decoded.headers[":message-type"]?.value
if (messageType === "error") {
const code = decoded.headers[":error-code"]?.value
const message = decoded.headers[":error-message"]?.value
return yield* ProviderShared.eventError(
route,
[code, message].filter((value): value is string => typeof value === "string").join(": ") ||
"Bedrock Converse event-stream error",
)
}
const eventType =
messageType === "event"
? decoded.headers[":event-type"]?.value
: messageType === "exception"
? decoded.headers[":exception-type"]?.value
: undefined
if (decoded.headers[":message-type"]?.value !== "event") continue
const eventType = decoded.headers[":event-type"]?.value
if (typeof eventType !== "string") continue
const payload = utf8.decode(decoded.body)
if (!payload) continue
+9 -25
View File
@@ -11,7 +11,6 @@ import {
type JsonSchema,
type LLMRequest,
type MediaPart,
type ProviderOptions,
type ProviderMetadata,
type TextPart,
type ToolCallPart,
@@ -27,18 +26,6 @@ const ADAPTER = "gemini"
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
export interface OptionsInput {
readonly [key: string]: unknown
readonly thinkingConfig?: {
readonly thinkingBudget?: number
readonly includeThoughts?: boolean
}
}
export type ProviderOptionsInput = ProviderOptions & {
readonly gemini?: OptionsInput
}
// =============================================================================
// Request Body Schema
// =============================================================================
@@ -216,9 +203,7 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
const functionCallId = (providerMetadata: ProviderMetadata | undefined) => {
const google = providerMetadata?.google
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string"
? google.functionCallId
: undefined
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string" ? google.functionCallId : undefined
}
const lowerToolCall = (part: ToolCallPart) => ({
@@ -315,22 +300,21 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
return contents
})
const resolveOptions = (request: LLMRequest) => {
const value = request.providerOptions?.gemini?.thinkingConfig
if (!ProviderShared.isRecord(value)) return {}
const thinkingConfig = {
const geminiOptions = (request: LLMRequest) => request.providerOptions?.gemini
const thinkingConfig = (request: LLMRequest) => {
const value = geminiOptions(request)?.thinkingConfig
if (!ProviderShared.isRecord(value)) return undefined
const result = {
thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
}
return {
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
}
return Object.values(result).some((item) => item !== undefined) ? result : undefined
}
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const hasTools = request.tools.length > 0
const generation = request.generation
const options = resolveOptions(request)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const generationConfig = {
maxOutputTokens: generation?.maxTokens,
@@ -338,7 +322,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
topP: generation?.topP,
topK: generation?.topK,
stopSequences: generation?.stop,
thinkingConfig: options.thinkingConfig,
thinkingConfig: thinkingConfig(request),
}
return {
-1
View File
@@ -6,4 +6,3 @@ export * as OpenAIImages from "./openai-images"
export * as OpenAICompatibleChat from "./openai-compatible-chat"
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
export * as OpenAIResponses from "./openai-responses"
export * as OpenResponses from "./open-responses"
-956
View File
@@ -1,956 +0,0 @@
import { Effect, Schema } from "effect"
import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol"
import {
LLMError,
LLMEvent,
Usage,
type FinishReason,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ProviderMetadata,
type ReasoningPart,
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolContent,
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { classifyProviderFailure } from "../provider-error"
import { OpenResponsesOptions } from "./utils/open-responses-options"
import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "open-responses"
const NAME = "Open Responses"
const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
export const PATH = "/responses"
// =============================================================================
// Request Body Schema
// =============================================================================
const OpenResponsesInputText = Schema.Struct({
type: Schema.tag("input_text"),
text: Schema.String,
})
const OpenResponsesInputImage = Schema.Struct({
type: Schema.tag("input_image"),
image_url: Schema.String,
})
const OpenResponsesInputFile = Schema.Struct({
type: Schema.tag("input_file"),
filename: Schema.String,
file_data: Schema.String,
mime_type: Schema.optional(Schema.String),
})
const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile])
export type MediaInput = Schema.Schema.Type<typeof MediaInput>
const OpenResponsesInputContent = Schema.Union([OpenResponsesInputText, MediaInput])
const OpenResponsesOutputText = Schema.Struct({
type: Schema.tag("output_text"),
text: Schema.String,
})
const OpenResponsesReasoningSummaryText = Schema.Struct({
type: Schema.tag("summary_text"),
text: Schema.String,
})
const OpenResponsesReasoningItem = Schema.Struct({
type: Schema.tag("reasoning"),
id: Schema.optionalKey(Schema.String),
summary: Schema.Array(OpenResponsesReasoningSummaryText),
encrypted_content: optionalNull(Schema.String),
})
const OpenResponsesItemReference = Schema.Struct({
type: Schema.tag("item_reference"),
id: Schema.String,
})
// `function_call_output.output` accepts either a plain string or an ordered
// array of content items so tools can return images and files in addition to text.
// https://www.openresponses.org/reference
const OpenResponsesFunctionCallOutputContent = Schema.Union([
OpenResponsesInputText,
OpenResponsesInputImage,
OpenResponsesInputFile,
])
const OpenResponsesFunctionCallOutput = Schema.Union([
Schema.String,
Schema.Array(OpenResponsesFunctionCallOutputContent),
])
const OpenResponsesInputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenResponsesOutputText) }),
OpenResponsesReasoningItem,
OpenResponsesItemReference,
Schema.Struct({
type: Schema.tag("function_call"),
call_id: Schema.String,
name: Schema.String,
arguments: Schema.String,
}),
Schema.Struct({
type: Schema.tag("function_call_output"),
call_id: Schema.String,
output: OpenResponsesFunctionCallOutput,
}),
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof OpenResponsesInputItem>
// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
// multiple streamed summary parts into the same item before flushing.
type OpenResponsesReasoningInput = {
type: "reasoning"
id: string
summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null
}
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
export const Tool = Schema.Struct({
type: Schema.tag("function"),
name: Schema.String,
description: Schema.String,
parameters: JsonObject,
strict: Schema.optional(Schema.Boolean),
})
export const ToolChoice = Schema.Union([
Schema.Literals(["auto", "none", "required"]),
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
])
// Fields shared between the HTTP body and the WebSocket `response.create`
// message. The HTTP body adds `stream: true`; the WebSocket message adds
// `type: "response.create"`. Defining the shared shape once keeps the two
// transports in sync without a destructure-and-strip dance.
export const coreFields = {
model: Schema.String,
input: Schema.Array(OpenResponsesInputItem),
instructions: Schema.optional(Schema.String),
tools: optionalArray(Tool),
tool_choice: Schema.optional(ToolChoice),
store: Schema.optional(Schema.Boolean),
service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema),
prompt_cache_key: Schema.optional(Schema.String),
include: optionalArray(OpenResponsesOptions.ResponseIncludableSchema),
reasoning: Schema.optional(
Schema.Struct({
effort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
summary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
}),
),
text: Schema.optional(
Schema.Struct({
verbosity: Schema.optional(OpenResponsesOptions.TextVerbositySchema),
}),
),
max_output_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
}
const OpenResponsesBody = Schema.Struct({
...coreFields,
stream: Schema.Literal(true),
})
export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
const OpenResponsesUsage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens_details: optionalNull(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
}),
),
output_tokens: Schema.optional(Schema.Number),
output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })),
total_tokens: Schema.optional(Schema.Number),
})
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
export const StreamItem = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
id: Schema.optional(Schema.String),
call_id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
encrypted_content: optionalNull(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
// The Responses schema puts streaming error details at the top level and
// response failures under `response.error`. WebSocket failures use an
// event-level `error` envelope, so accept all three shapes here.
// https://www.openresponses.org/specification
const OpenResponsesErrorPayload = Schema.Struct({
code: optionalNull(Schema.String),
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
export const Event = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
delta: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
summary_index: Schema.optional(Schema.Number),
item: Schema.optional(StreamItem),
response: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
id: Schema.optional(Schema.String),
service_tier: optionalNull(Schema.String),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
usage: optionalNull(OpenResponsesUsage),
error: optionalNull(OpenResponsesErrorPayload),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
code: optionalNull(Schema.String),
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type Event = Schema.Schema.Type<typeof Event>
export interface Extension {
readonly id: string
readonly name: string
readonly lowerMedia?: (input: {
readonly part: MediaPart
readonly media: ProviderShared.ValidatedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
}
const BASE: Extension = { id: ADAPTER, name: NAME }
export interface ParserState {
readonly id: string
readonly name: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
}
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
interface ReasoningStreamItem {
readonly encryptedContent: string | null | undefined
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
// strings, but typing the map as `Record<number, ...>` documents intent
// and matches the wire field.
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
}
// =============================================================================
// Request Lowering
// =============================================================================
export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (
protocolName: string,
tool: ToolDefinition,
inputSchema: JsonSchema,
) {
if (tool.native !== undefined)
return yield* ProviderShared.invalidRequest(`${protocolName} does not support provider-native tool ${tool.name}`)
return {
type: "function" as const,
name: tool.name,
description: tool.description,
parameters: ToolSchemaProjection.responses(inputSchema),
// TODO: Read this from Responses tool options so direct LLM callers can opt into strict schemas.
strict: false,
}
})
export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
ProviderShared.matchToolChoice(protocolName, toolChoice, {
auto: () => "auto" as const,
none: () => "none" as const,
required: () => "required" as const,
tool: (toolName) => ({ type: "function" as const, name: toolName }),
})
const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
type: "function_call",
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
})
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
return undefined
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
return {
type: "reasoning",
id: metadata.itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId
: undefined
}
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
extension: Extension,
) {
const media = yield* ProviderShared.validateMedia(extension.name, part, MEDIA_MIMES)
const extended = extension.lowerMedia?.({ part, media, request })
if (extended) return extended
if (media.mime === "application/pdf") {
return {
type: "input_file" as const,
filename: part.filename ?? "document.pdf",
file_data: media.dataUrl,
}
}
return { type: "input_image" as const, image_url: media.dataUrl }
})
const lowerUserContent = Effect.fn("OpenResponses.lowerUserContent")(function* (
part: LLMRequest["messages"][number]["content"][number],
request: LLMRequest,
extension: Extension,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMedia(part, request, extension)
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
})
// Tool results may carry structured text, images, and files. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("OpenResponses.lowerToolResultContentItem")(function* (
item: ToolContent,
request: LLMRequest,
extension: Extension,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
extension,
)
})
const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(function* (
part: ToolResultPart,
request: LLMRequest,
extension: Extension,
) {
// Text/json/error results are encoded as a plain string for backward
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<ToolContent> = part.result.value
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const system: OpenResponsesInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const input: OpenResponsesInputItem[] = [...system]
const store = OpenResponsesOptions.resolve(request).store
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
for (const message of request.messages) {
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
const previous = input.at(-1)
if (previous && "role" in previous && previous.role === "user")
input[input.length - 1] = {
role: "user",
content: [...previous.content, { type: "input_text", text: part.text }],
}
else input.push({ role: "user", content: [{ type: "input_text", text: part.text }] })
continue
}
if (message.role === "user") {
input.push({
role: "user",
content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension)),
})
continue
}
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const flushText = () => {
if (content.length === 0) return
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
content.splice(0, content.length)
}
for (const part of message.content) {
if (part.type === "text") {
content.push(part)
continue
}
if (part.type === "reasoning") {
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey)
if (!reasoning) continue
if (store !== false) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
reasoningReferences.add(reasoning.id)
continue
}
const existing = reasoningItems[reasoning.id]
if (existing) {
existing.summary.push(...reasoning.summary)
if (typeof reasoning.encrypted_content === "string")
existing.encrypted_content = reasoning.encrypted_content
continue
}
const replay = {
type: reasoning.type,
summary: reasoning.summary,
encrypted_content: reasoning.encrypted_content,
}
reasoningItems[reasoning.id] = replay
input.push(replay)
continue
}
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part))
continue
}
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const itemID = hostedToolItemID(part, providerMetadataKey)
if (store !== false && itemID && !hostedToolReferences.has(itemID))
input.push({ type: "item_reference", id: itemID })
if (store === false && part.result.type === "content") {
const content: ReadonlyArray<ToolContent> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
})
}
if (itemID) hostedToolReferences.add(itemID)
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
"text",
"reasoning",
"tool-call",
"tool-result",
])
}
flushText()
continue
}
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part, request, extension),
})
}
}
// With store:false, Responses APIs only accept previous reasoning items when the
// complete item has encrypted state. Summary blocks for one item may carry
// that state only on the last block, so filter after they have been joined.
return store === false
? input.filter(
(item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
)
: input
})
const lowerOptions = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
return {
...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
...(options.include ? { include: options.include } : {}),
...(options.reasoningEffort || options.reasoningSummary
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
: {}),
...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}),
...(options.serviceTier ? { service_tier: options.serviceTier } : {}),
}
}
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (
request: LLMRequest,
extension: Extension = BASE,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
model: request.model.id,
input: yield* lowerMessages(request, extension),
tools:
request.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(
extension.name,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined,
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
...lowerOptions(request),
}
})
// =============================================================================
// Stream Parsing
// =============================================================================
// Responses APIs report `input_tokens` (inclusive total) with a
// cached-read and cache-write subsets, and `output_tokens` (inclusive total)
// with a `reasoning_tokens` subset. Pass the totals through and derive the
// non-cached breakdown.
const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
if (!usage) return undefined
const cached = usage.input_tokens_details?.cached_tokens
const cacheWrite = usage.input_tokens_details?.cache_write_tokens
const reasoning = usage.output_tokens_details?.reasoning_tokens
const nonCached = ProviderShared.subtractTokens(usage.input_tokens, ProviderShared.sumTokens(cached, cacheWrite))
return new Usage({
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens),
providerMetadata: { [providerMetadataKey]: usage },
})
}
const mapFinishReason = (event: Event, hasFunctionCall: boolean): FinishReason => {
const reason = event.response?.incomplete_details?.reason
if (reason === undefined || reason === null) {
if (hasFunctionCall) return "tool-calls"
if (event.type === "response.incomplete") return "unknown"
return "stop"
}
if (reason === "max_output_tokens") return "length"
if (reason === "content_filter") return "content-filter"
return hasFunctionCall ? "tool-calls" : "unknown"
}
export const providerMetadata = (state: ParserState, metadata: Record<string, unknown>): ProviderMetadata => ({
[state.providerMetadataKey]: metadata,
})
const isReasoningItem = (item: StreamItem): item is StreamItem & { type: "reasoning"; id: string } =>
item.type === "reasoning" && typeof item.id === "string" && item.id.length > 0
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
// `response.completed` / `response.incomplete` are clean finishes that emit a
// `finish` event; `response.failed` is a hard failure. All three end the stream,
// so keep this set aligned with `step` and the protocol's terminal predicate.
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
const onOutputTextDelta = (state: ParserState, event: Event): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
events,
]
}
const onOutputTextDone = (state: ParserState, event: Event): StepResult => {
const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events]
}
export const onReasoningDelta = (state: ParserState, event: Event): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const itemID = event.item_id ?? "reasoning-0"
const id =
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
},
events,
]
}
export const onReasoningDone = (state: ParserState, _event: Event): StepResult => [state, NO_EVENTS]
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs stream reasoning items in a stable order:
// `output_item.added` (reasoning) →
// `reasoning_summary_part.added` (index=0) →
// `reasoning_summary_text.delta` →
// `reasoning_summary_part.done` (index=0) →
// (repeat for index>0) →
// `output_item.done` (reasoning).
// The handlers below rely on this ordering: `onOutputItemAdded` seeds the
// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0`
// short-circuits when the entry already exists, and higher-index handlers
// fold against the same entry. Behaviour for out-of-order events is
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item && isReasoningItem(item)) {
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
reasoningItems: {
...state.reasoningItems,
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
},
},
events,
]
}
if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
{
...state,
lifecycle,
tools: ToolStream.start(state.tools, item.id, {
id: item.call_id ?? item.id,
name: item.name ?? "",
input: item.arguments ?? "",
providerMetadata: metadata,
}),
},
[
...events,
LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata: metadata }),
],
]
}
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} }
if (event.summary_index === 0) {
if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
state.lifecycle,
events,
`${event.item_id}:0`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
),
reasoningItems: {
...state.reasoningItems,
[event.item_id]: { ...item, summaryParts: { 0: "active" } },
},
},
events,
]
}
const events: LLMEvent[] = []
const closed = Object.entries(item.summaryParts)
.filter((entry) => entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) =>
Lifecycle.reasoningEnd(
lifecycle,
events,
`${event.item_id}:${entry[0]}`,
providerMetadata(state, { itemId: event.item_id }),
),
state.lifecycle,
)
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
closed,
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
),
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...Object.fromEntries(
Object.entries(item.summaryParts).map((entry) =>
entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
),
),
[event.summary_index]: "active",
},
},
},
},
events,
]
}
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
...state,
lifecycle:
state.store !== false
? Lifecycle.reasoningEnd(
state.lifecycle,
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id }),
)
: state.lifecycle,
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...item.summaryParts,
[event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
},
},
},
},
events,
]
}
const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgumentsDelta")(function* (
state: ParserState,
event: Event,
) {
if (!event.item_id || !event.delta) return [state, NO_EVENTS] satisfies StepResult
const result = ToolStream.appendExisting(
state.id,
state.tools,
event.item_id,
event.delta,
`${state.name} tool argument delta is missing its tool call`,
)
if (ToolStream.isError(result)) return yield* result
const events: LLMEvent[] = []
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...result.events)
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
})
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (state: ParserState, event: Event) {
const item = event.item
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id) return onOutputTextDone(state, { ...event, item_id: item.id })
if (item.type === "function_call") {
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const tools = state.tools[item.id]
? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name })
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id)
: yield* ToolStream.finishWithInput(state.id, tools, item.id, item.arguments)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
{
...state,
lifecycle,
hasFunctionCall:
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
tools: result.tools,
},
events,
] satisfies StepResult
}
if (isReasoningItem(item)) {
const events: LLMEvent[] = []
const metadata = reasoningMetadata(state, item)
const reasoningItem = state.reasoningItems[item.id]
if (reasoningItem) {
const lifecycle = Object.entries(reasoningItem.summaryParts)
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
state.lifecycle,
)
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
}
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
return [{ ...state, lifecycle }, events] satisfies StepResult
}
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
events,
] satisfies StepResult
}
return [state, NO_EVENTS] satisfies StepResult
})
const onResponseFinish = (state: ParserState, event: Event): StepResult => {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: {
normalized: mapFinishReason(event, state.hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage, state.providerMetadataKey),
providerMetadata:
event.response?.id || event.response?.service_tier
? providerMetadata(state, {
responseId: event.response.id,
serviceTier: event.response.service_tier,
})
: undefined,
})
return [{ ...state, lifecycle }, events]
}
// Build a single human-readable message from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
// the bare message — production rate limits and context-length failures used
// to be indistinguishable from generic stream drops.
const providerErrorMessage = (event: Event, fallback: string): string => {
const nested = event.error ?? event.response?.error ?? undefined
const message = event.message || nested?.message || undefined
const code = event.code || nested?.code || undefined
if (message && code) return `${code}: ${message}`
return message || code || fallback
}
const providerError = (state: ParserState, event: Event, fallback: string) => {
const code = event.code || event.error?.code || event.response?.error?.code || undefined
const message = providerErrorMessage(event, fallback)
return new LLMError({
module: state.id,
method: "stream",
reason: classifyProviderFailure({ message, code }),
})
}
export const step = (state: ParserState, event: Event) => {
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event))
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta")
return Effect.succeed(onReasoningDelta(state, event))
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done")
return Effect.succeed(onReasoningDone(state, event))
if (event.type === "response.reasoning_summary_part.added")
return Effect.succeed(onReasoningSummaryPartAdded(state, event))
if (event.type === "response.reasoning_summary_part.done")
return Effect.succeed(onReasoningSummaryPartDone(state, event))
if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
if (event.type === "response.completed" || event.type === "response.incomplete")
return Effect.succeed(onResponseFinish(state, event))
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
if (event.type === "error") return providerError(state, event, `${state.name} stream error`)
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
// =============================================================================
// Protocol
// =============================================================================
/**
* The provider-neutral Open Responses protocol. Provider-specific Responses
* implementations compose this baseline with their own tools and event variants.
*/
export const initial = (request: LLMRequest, extension: Extension = BASE): ParserState => ({
id: extension.id,
name: extension.name,
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store,
})
export const protocol = Protocol.make({
id: ADAPTER,
body: {
schema: OpenResponsesBody,
from: fromRequest,
},
stream: {
event: Protocol.jsonEvent(Event),
initial,
step,
terminal,
},
})
export const httpTransport = HttpTransport.sseJson.with<OpenResponsesBody>()
export * as OpenResponses from "./open-responses"
+11 -13
View File
@@ -131,7 +131,6 @@ const OpenAIChatUsage = Schema.Struct({
prompt_tokens_details: optionalNull(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
}),
),
completion_tokens_details: optionalNull(
@@ -397,13 +396,14 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
return messages
})
const lowerOptions = (request: LLMRequest) => {
const options = OpenAIOptions.resolve(request)
const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) {
const store = OpenAIOptions.store(request)
const reasoningEffort = OpenAIOptions.reasoningEffort(request)
return {
...(options.store !== undefined ? { store: options.store } : {}),
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
...(store !== undefined ? { store } : {}),
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
}
}
})
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
@@ -434,7 +434,7 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR
presence_penalty: generation?.presencePenalty,
seed: generation?.seed,
stop: generation?.stop,
...lowerOptions(request),
...(yield* lowerOptions(request)),
}
})
@@ -454,22 +454,20 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
}
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
// cached-read and cache-write subsets, and `completion_tokens` (inclusive
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
// through and derive the non-cached breakdown so the `LLM.Usage` contract is
// `cached_tokens` subset, and `completion_tokens` (inclusive total) with
// a `reasoning_tokens` subset. We pass the inclusive totals through and
// derive the non-cached breakdown so the `LLM.Usage` contract is
// satisfied on both sides.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
const cached = usage.prompt_tokens_details?.cached_tokens
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens
const reasoning = usage.completion_tokens_details?.reasoning_tokens
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, ProviderShared.sumTokens(cached, cacheWrite))
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached)
return new Usage({
inputTokens: usage.prompt_tokens,
outputTokens: usage.completion_tokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
providerMetadata: { openai: usage },
@@ -1,22 +1,23 @@
import { Route, type RouteRoutedModelInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { OpenResponses } from "./open-responses"
import { OpenAIResponses } from "./openai-responses"
const ADAPTER = "openai-compatible-responses"
export type OpenAICompatibleResponsesModelInput = RouteRoutedModelInput
/**
* Deployment adapter for providers that expose an Open Responses-compatible
* `/responses` endpoint. Provider helpers configure identity, endpoint, and
* auth while the semantic protocol remains provider-neutral.
* Route for providers that expose an OpenAI Responses-compatible `/responses`
* endpoint. Provider helpers configure identity, endpoint, and auth before
* model selection while this route reuses the OpenAI Responses protocol.
*/
export const route = Route.make({
id: ADAPTER,
providerMetadataKey: "openresponses",
protocol: OpenResponses.protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: OpenResponses.httpTransport,
providerMetadataKey: "openai",
protocol: OpenAIResponses.protocol,
endpoint: Endpoint.path(OpenAIResponses.PATH),
transport: OpenAIResponses.httpTransport,
defaults: { providerOptions: { openai: { store: false } } },
})
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
File diff suppressed because it is too large Load Diff
+7 -10
View File
@@ -14,17 +14,14 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
return { ...state, stepStarted: true }
}
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
if (state.text.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.textStart({ id, providerMetadata }))
return { ...stepped, text: new Set([...stepped.text, id]) }
}
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
const started = textStart(state, events, id)
events.push(LLMEvent.textDelta({ id, text }))
return started
const stepped = stepStart(state, events)
if (stepped.text.has(id)) {
events.push(LLMEvent.textDelta({ id, text }))
return stepped
}
events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
return { ...stepped, text: new Set([...stepped.text, id]) }
}
export const reasoningStart = (
@@ -1,65 +0,0 @@
import { Schema } from "effect"
import { TextVerbosity, type LLMRequest } from "../../schema"
export const ResponseIncludables = [
"file_search_call.results",
"web_search_call.results",
"web_search_call.action.sources",
"message.input_image.image_url",
"computer_call_output.output.image_url",
"code_interpreter_call.outputs",
"reasoning.encrypted_content",
"message.output_text.logprobs",
] as const
export type ResponseIncludable = (typeof ResponseIncludables)[number]
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
export type ServiceTier = (typeof ServiceTiers)[number]
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
const INCLUDABLES = new Set<string>(ResponseIncludables)
const SERVICE_TIERS = new Set<string>(ServiceTiers)
const isTextVerbosity = (value: unknown): value is Schema.Schema.Type<typeof TextVerbosity> =>
typeof value === "string" && TEXT_VERBOSITY.has(value)
const isServiceTier = (value: unknown): value is ServiceTier => typeof value === "string" && SERVICE_TIERS.has(value)
export const ReasoningEffort = Schema.String
export const TextVerbositySchema = TextVerbosity
export const ResponseIncludableSchema = Schema.Literals(ResponseIncludables)
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
export interface Resolved {
readonly instructions?: string
readonly store?: boolean
readonly promptCacheKey?: string
readonly reasoningEffort?: string
readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable>
readonly textVerbosity?: Schema.Schema.Type<typeof TextVerbosity>
readonly serviceTier?: ServiceTier
}
export const resolve = (request: LLMRequest): Resolved => {
const input = request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"]
const include = Array.isArray(input?.include)
? input.include.filter((entry): entry is ResponseIncludable => INCLUDABLES.has(entry))
: []
const reasoningSummary = input?.reasoningSummary
return {
instructions: typeof input?.instructions === "string" ? input.instructions : undefined,
store: typeof input?.store === "boolean" ? input.store : undefined,
promptCacheKey: typeof input?.promptCacheKey === "string" ? input.promptCacheKey : undefined,
reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined,
reasoningSummary:
reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed"
? reasoningSummary
: undefined,
include: include.length > 0 ? include : undefined,
textVerbosity: isTextVerbosity(input?.textVerbosity) ? input.textVerbosity : undefined,
serviceTier: isServiceTier(input?.serviceTier) ? input.serviceTier : undefined,
}
}
export * as OpenResponsesOptions from "./open-responses-options"
@@ -1,23 +1,85 @@
import { ReasoningEfforts } from "../../schema"
import { OpenResponsesOptions } from "./open-responses-options"
import { Schema } from "effect"
import type { LLMRequest, TextVerbosity as TextVerbosityValue } from "../../schema"
import { ReasoningEfforts, TextVerbosity } from "../../schema"
export const OpenAIReasoningEfforts = ReasoningEfforts
export type OpenAIReasoningEffort = string
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
export const OpenAIResponseIncludables = [
"file_search_call.results",
"web_search_call.results",
"web_search_call.action.sources",
"message.input_image.image_url",
"computer_call_output.output.image_url",
"code_interpreter_call.outputs",
"reasoning.encrypted_content",
"message.output_text.logprobs",
] as const
export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number]
export const OpenAIServiceTiers = ["auto", "default", "flex", "priority"] as const
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number]
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbositySchema
export const OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludableSchema
export const OpenAIServiceTier = OpenResponsesOptions.ServiceTierSchema
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
const SERVICE_TIERS = new Set<string>(OpenAIServiceTiers)
export const OpenAIReasoningEffort = Schema.String
export const OpenAITextVerbosity = TextVerbosity
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
export const OpenAIServiceTier = Schema.Literals(OpenAIServiceTiers)
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => typeof effort === "string"
export const resolve = OpenResponsesOptions.resolve
const isTextVerbosity = (value: unknown): value is TextVerbosityValue =>
typeof value === "string" && TEXT_VERBOSITY.has(value)
const options = (request: LLMRequest) => request.providerOptions?.openai
export const store = (request: LLMRequest): boolean | undefined => {
const value = options(request)?.store
return typeof value === "boolean" ? value : undefined
}
export const reasoningEffort = (request: LLMRequest): string | undefined => {
const value = options(request)?.reasoningEffort
return typeof value === "string" ? value : undefined
}
export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
options(request)?.reasoningSummary === "auto" ? "auto" : undefined
// Resolve the OpenAI Responses `include` field. Filters out unknown
// includable values defensively so a typo in upstream config drops the
// invalid entry instead of poisoning the wire body. An empty array (either
// passed directly or produced by filtering) is treated as "no include" and
// returns undefined so the request body omits the field entirely.
export const include = (request: LLMRequest): ReadonlyArray<OpenAIResponseIncludable> | undefined => {
const value = options(request)?.include
if (!Array.isArray(value)) return undefined
const filtered = value.filter((entry): entry is OpenAIResponseIncludable => INCLUDABLES.has(entry))
return filtered.length > 0 ? filtered : undefined
}
export const promptCacheKey = (request: LLMRequest) => {
const value = options(request)?.promptCacheKey
return typeof value === "string" ? value : undefined
}
export const textVerbosity = (request: LLMRequest) => {
const value = options(request)?.textVerbosity
return isTextVerbosity(value) ? value : undefined
}
export const serviceTier = (request: LLMRequest) => {
const value = options(request)?.serviceTier
return typeof value === "string" && SERVICE_TIERS.has(value) ? (value as OpenAIServiceTier) : undefined
}
export const instructions = (request: LLMRequest) => {
const value = options(request)?.instructions
return typeof value === "string" ? value : undefined
}
export * as OpenAIOptions from "./openai-options"
@@ -63,8 +63,6 @@ const openAI = (schema: JsonSchema): JsonSchema => {
return isRecord(normalized) ? normalized : { type: "object" }
}
const responses = openAI
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
const modelCompatibility = (
@@ -85,5 +83,4 @@ export const ToolSchemaProjection = {
modelCompatibility,
moonshot,
openAI,
responses,
} as const
@@ -5,17 +5,12 @@ import type { ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
export const id = ProviderID.make("anthropic-compatible")
export type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly provider?: string
readonly baseURL: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
@@ -25,7 +20,6 @@ export type Settings = ProviderPackage.Settings &
) & {
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
export const routes = [AnthropicMessages.route]
@@ -67,7 +61,6 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
}
+1 -11
View File
@@ -6,19 +6,11 @@ import { ProviderID, type ModelID } from "../schema"
import { AnthropicMessages } from "../protocols/anthropic-messages"
import { AnthropicCompatible } from "./anthropic-compatible"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
export const id = ProviderID.make("anthropic")
export const routes = [AnthropicMessages.route]
export type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
export type Settings = ProviderPackage.Settings &
(
@@ -26,7 +18,6 @@ export type Settings = ProviderPackage.Settings &
| { readonly apiKey?: never; readonly authToken?: string }
) & {
readonly baseURL?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
const auth = (options: ProviderAuthOption<"optional">) => {
@@ -61,6 +52,5 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
}
@@ -6,13 +6,9 @@ import { Route, type RouteDefaultsInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import { ProviderID, type ModelID } from "../schema"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
const VERSION = "vertex-2023-10-16" as const
// models.dev uses this provider id even though the API contract is Anthropic Messages.
@@ -23,7 +19,6 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
@@ -32,7 +27,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
readonly providerOptions?: ProviderOptions
}
const route = Route.make({
@@ -25,7 +25,6 @@ export interface Settings extends ProviderPackage.Settings {
const route = OpenAICompatibleResponses.route.with({
id: "google-vertex-responses",
provider: id,
providerOptions: { openresponses: { store: false } },
})
export const routes = [route]
+2 -6
View File
@@ -4,12 +4,9 @@ import { Auth } from "../route/auth"
import { Route, type RouteDefaultsInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { ProviderID, type ModelID } from "../schema"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared"
export type GeminiOptionsInput = Gemini.OptionsInput
export type GeminiProviderOptionsInput = Gemini.ProviderOptionsInput
export const id = ProviderID.make("google-vertex")
export type Config = RouteDefaultsInput &
@@ -17,7 +14,6 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: Gemini.ProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
@@ -28,7 +24,7 @@ export type Settings = ProviderPackage.Settings &
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: Gemini.ProviderOptionsInput
readonly providerOptions?: ProviderOptions
}
const route = Route.make({
+2 -5
View File
@@ -2,13 +2,11 @@ import type { RouteDefaultsInput } from "../route/client"
import { Auth } from "../route/auth"
import type { ProviderAuthOption } from "../route/auth-options"
import type { ProviderPackage } from "../provider-package"
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema"
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID, type ProviderOptions } from "../schema"
import { Gemini } from "../protocols/gemini"
import { GoogleImages } from "../protocols/google-images"
export type { GoogleImageOptions } from "../protocols/google-images"
export type GeminiOptionsInput = Gemini.OptionsInput
export type GeminiProviderOptionsInput = Gemini.ProviderOptionsInput
export const id = ProviderID.make("google")
@@ -17,13 +15,12 @@ export const routes = [Gemini.route]
export type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: Gemini.ProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Gemini.ProviderOptionsInput
readonly providerOptions?: ProviderOptions
}
const auth = (options: ProviderAuthOption<"optional">) => {
@@ -1,20 +0,0 @@
import type { ResponseIncludable, ServiceTier } from "../protocols/utils/open-responses-options"
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
export interface OpenResponsesOptionsInput {
readonly [key: string]: unknown
readonly instructions?: string
readonly store?: boolean
readonly promptCacheKey?: string
readonly reasoningEffort?: ReasoningEffort
readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable>
readonly textVerbosity?: TextVerbosity
readonly serviceTier?: ServiceTier
}
export type OpenResponsesProviderOptionsInput = ProviderOptions & {
readonly openresponses?: OpenResponsesOptionsInput
}
export * as OpenResponsesProviderOptions from "./open-responses-options"
@@ -3,9 +3,7 @@ import { OpenAICompatibleResponses } from "../protocols/openai-compatible-respon
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options"
export type { OpenResponsesOptionsInput, OpenResponsesProviderOptionsInput } from "./open-responses-options"
import type { OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("openai-compatible")
@@ -13,14 +11,13 @@ export type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly provider?: string
readonly baseURL: string
readonly providerOptions?: OpenResponsesProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: OpenResponsesProviderOptionsInput
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const routes = [OpenAICompatibleResponses.route]
+15 -3
View File
@@ -1,10 +1,22 @@
import type { ProviderOptions } from "../schema"
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
import { mergeProviderOptions } from "../schema"
import type { OpenResponsesOptionsInput } from "./open-responses-options"
import type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options"
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options"
export type OpenAIOptionsInput = OpenResponsesOptionsInput
export interface OpenAIOptionsInput {
readonly [key: string]: unknown
readonly store?: boolean
readonly promptCacheKey?: string
readonly reasoningEffort?: ReasoningEffort
readonly reasoningSummary?: "auto"
// OpenAI Responses `include` wire field. Mirrors the official SDK's
// `ResponseIncludable[]` union exactly so AI SDK callers and direct
// native-SDK callers share one shape and no translation is required.
readonly include?: ReadonlyArray<OpenAIResponseIncludable>
readonly textVerbosity?: TextVerbosity
readonly serviceTier?: OpenAIServiceTier
}
export type OpenAIProviderOptionsInput = ProviderOptions & {
readonly openai?: OpenAIOptionsInput
+1 -2
View File
@@ -12,8 +12,7 @@ import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
* Examples:
*
* - `OpenAIChat.protocol` — chat completions style
* - `OpenResponses.protocol` — provider-neutral Responses API baseline
* - `OpenAIResponses.protocol` — OpenAI extensions to that baseline
* - `OpenAIResponses.protocol` — responses API
* - `AnthropicMessages.protocol` — messages API with content blocks
* - `Gemini.protocol` — generateContent
* - `BedrockConverse.protocol` — Converse with binary event-stream framing
+5 -5
View File
@@ -251,11 +251,11 @@ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
// Auto-placement policy for prompt caching. The protocol-neutral lowering step
// reads this and injects `CacheHint`s at the configured boundaries; the
// per-protocol body builders then translate those hints into wire markers as
// usual. `"auto"` is the recommended default for agent loops — it places
// breakpoints at the last tool definition, the first and last distinct system
// parts, and the conversation tail. The rolling message breakpoint keeps a
// prior cache entry within Anthropic/Bedrock's 20-block lookback during long
// tool loops.
// usual. `"auto"` is the recommended default for agent loops — it places one
// breakpoint at the last tool definition, one at the last system part, and one
// at the latest user message. The combination of provider invalidation
// hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block
// lookback means three trailing breakpoints reliably cover the static prefix.
//
// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
// object form to override individual choices.
+3 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMRequest, LLMResponse } from "../src"
import { LLM, LLMResponse } from "../src"
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route"
import { Model } from "../src/schema"
import { testEffect } from "./lib/effect"
@@ -141,7 +141,7 @@ describe("llm route", () => {
Effect.gen(function* () {
const llm = yield* LLMClient.Service
const prepared = yield* llm.prepare(
LLMRequest.update(request, { model: updateModel(request.model, { route: configuredGemini }) }),
LLM.updateRequest(request, { model: updateModel(request.model, { route: configuredGemini }) }),
)
expect(prepared.route).toBe("gemini-fake")
@@ -174,7 +174,7 @@ describe("llm route", () => {
})
const prepared = yield* (yield* LLMClient.Service).prepare(
LLMRequest.update(request, { model: updateModel(request.model, { route: duplicate }) }),
LLM.updateRequest(request, { model: updateModel(request.model, { route: duplicate }) }),
)
expect(prepared.body).toEqual({ body: "late-default" })
+2 -26
View File
@@ -137,26 +137,15 @@ Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deploym
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: Auth.header("api-key", "override") })
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku")
Anthropic.configure({
apiKey: "anthropic-key",
providerOptions: {
anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 }, effort: "high" },
},
}).model("claude-haiku")
// @ts-expect-error Anthropic model selectors only accept model ids.
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {})
// @ts-expect-error Anthropic package settings accept only one auth source.
Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" })
// @ts-expect-error Enabled Anthropic thinking requires a token budget.
Anthropic.configure({ providerOptions: { anthropic: { thinking: { type: "enabled" } } } })
// @ts-expect-error Anthropic thinking budgets must be numbers.
Anthropic.configure({ providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: "large" } } } })
AnthropicCompatible.configure({
apiKey: "messages-key",
baseURL: "https://messages.example.com/v1",
provider: "example",
providerOptions: { anthropic: { thinking: { type: "disabled" } } },
}).model("compatible-model")
// @ts-expect-error Anthropic-compatible providers require a base URL.
AnthropicCompatible.configure({ apiKey: "messages-key" })
@@ -170,19 +159,10 @@ AnthropicCompatible.model("compatible-model", {
})
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash")
Google.configure({
apiKey: "google-key",
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
}).model("gemini-2.5-flash")
// @ts-expect-error Google model selectors only accept model ids.
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash", {})
// @ts-expect-error Gemini thinking budgets must be numbers.
Google.configure({ providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } } })
GoogleVertex.configure({
apiKey: "vertex-key",
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } },
}).model("gemini-3.5-flash")
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash")
GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash")
GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash")
// @ts-expect-error Vertex Gemini model selectors only accept model ids.
@@ -228,11 +208,7 @@ GoogleVertexResponses.configure({
project: "project",
})
GoogleVertexMessages.configure({
accessToken: "vertex-token",
project: "project",
providerOptions: { anthropic: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" } },
}).model("claude-sonnet-4-6")
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6")
// @ts-expect-error Vertex Messages package settings do not accept API keys.
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6")
+8 -68
View File
@@ -39,8 +39,8 @@ describe("applyCachePolicy", () => {
}),
)
// A single system block is both the first and last boundary, so the auto
// policy deduplicates it and still marks the conversation tail.
// No explicit cache field → auto policy fires → last system part + latest
// user message both get cache_control markers.
expect(prepared.body).toMatchObject({
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
@@ -48,15 +48,12 @@ describe("applyCachePolicy", () => {
}),
)
it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () =>
it.effect("'auto' marks the last tool, last system part, and latest user message on Anthropic", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: [
{ type: "text", text: "Base agent" },
{ type: "text", text: "Project instructions" },
],
system: "Sys A",
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
messages: [
Message.user("first user"),
@@ -69,10 +66,7 @@ describe("applyCachePolicy", () => {
expect(prepared.body).toMatchObject({
tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
system: [
{ type: "text", text: "Base agent", cache_control: { type: "ephemeral" } },
{ type: "text", text: "Project instructions", cache_control: { type: "ephemeral" } },
],
system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }],
messages: [
{ role: "user", content: [{ type: "text", text: "first user" }] },
{ role: "assistant", content: [{ type: "text", text: "assistant reply" }] },
@@ -126,10 +120,7 @@ describe("applyCachePolicy", () => {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: bedrockModel,
system: [
{ type: "text", text: "Base agent" },
{ type: "text", text: "Project instructions" },
],
system: "Sys",
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")],
cache: "auto",
@@ -140,12 +131,7 @@ describe("applyCachePolicy", () => {
toolConfig: {
tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }],
},
system: [
{ text: "Base agent" },
{ cachePoint: { type: "default" } },
{ text: "Project instructions" },
{ cachePoint: { type: "default" } },
],
system: [{ text: "Sys" }, { cachePoint: { type: "default" } }],
messages: [
{ role: "user", content: [{ text: "first user" }] },
{ role: "assistant", content: [{ text: "reply" }] },
@@ -207,55 +193,9 @@ describe("applyCachePolicy", () => {
}),
)
const body = prepared.body as {
system: Array<{ text: string; cache_control?: unknown }>
messages: Array<{ content: Array<{ cache_control?: unknown }> }>
}
const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> }
expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" })
expect(body.messages[0]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
}),
)
it.effect("auto policy stays within the four-breakpoint cap when preserving manual hints", () =>
Effect.gen(function* () {
const request = LLM.request({
model: anthropicModel,
system: [
{ type: "text", text: "Base agent" },
{
type: "text",
text: "Manual context",
cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }),
},
{ type: "text", text: "Project instructions" },
],
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
prompt: "hi",
cache: "auto",
})
const applied = applyCachePolicy(request)
expect(applied.tools[0]?.cache).toBeDefined()
expect(applied.system.map((part) => part.cache !== undefined)).toEqual([true, true, true])
const tail = applied.messages[0]!.content[0]!
expect("cache" in tail ? tail.cache : undefined).toBeUndefined()
expect(applyCachePolicy(applied)).toBe(applied)
const prepared = yield* LLMClient.prepare(request)
const body = prepared.body as {
tools: Array<{ cache_control?: unknown }>
system: Array<{ cache_control?: unknown }>
messages: Array<{ content: Array<{ cache_control?: unknown }> }>
}
const marked = [
...body.tools.map((tool) => tool.cache_control),
...body.system.map((part) => part.cache_control),
...body.messages.flatMap((message) => message.content.map((part) => part.cache_control)),
].filter((cache) => cache !== undefined)
expect(marked).toHaveLength(4)
expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
}),
)
+1 -9
View File
@@ -11,13 +11,7 @@ import {
XAI,
} from "@opencode-ai/ai/providers"
import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot"
import {
OpenAIChat,
OpenAICompatibleChat,
OpenAICompatibleResponses,
OpenAIResponses,
OpenResponses,
} from "@opencode-ai/ai/protocols"
import { OpenAIChat, OpenAICompatibleChat, OpenAICompatibleResponses, OpenAIResponses } from "@opencode-ai/ai/protocols"
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
describe("public exports", () => {
@@ -80,9 +74,7 @@ describe("public exports", () => {
test("protocol barrels expose supported low-level routes", () => {
expect(OpenAIChat.route.id).toBe("openai-chat")
expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat")
expect(OpenResponses.protocol.id).toBe("open-responses")
expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses")
expect(OpenAICompatibleResponses.route.protocol).toBe("open-responses")
expect(OpenAIResponses.route.id).toBe("openai-responses")
expect(OpenAIResponses.webSocketRoute.id).toBe("openai-responses-websocket")
expect(AnthropicMessages.route.id).toBe("anthropic-messages")
File diff suppressed because one or more lines are too long
+3 -12
View File
@@ -2,16 +2,7 @@ import { describe, expect, test } from "bun:test"
import { CacheHint, LLM, LLMResponse } from "../src"
import * as OpenAIChat from "../src/protocols/openai-chat"
import * as OpenAIResponses from "../src/protocols/openai-responses"
import {
GenerationOptions,
LLMRequest,
Message,
Model,
ToolCallPart,
ToolChoice,
ToolDefinition,
ToolResultPart,
} from "../src/schema"
import { LLMRequest, Message, Model, ToolCallPart, ToolChoice, ToolDefinition, ToolResultPart } from "../src/schema"
const chatRoute = OpenAIChat.route
const responsesRoute = OpenAIResponses.route
@@ -40,8 +31,8 @@ describe("llm constructors", () => {
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }),
prompt: "Say hello.",
})
const updated = LLMRequest.update(base, {
generation: GenerationOptions.make({ maxTokens: 20 }),
const updated = LLM.updateRequest(base, {
generation: { maxTokens: 20 },
messages: [...base.messages, Message.assistant("Hi.")],
})
+4 -18
View File
@@ -59,7 +59,7 @@ describe("provider package entrypoints", () => {
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
providerOptions: { openai: { reasoningEffort: "low", store: true } },
})
expect(String(selected.provider)).toBe("example")
@@ -72,7 +72,7 @@ describe("provider package entrypoints", () => {
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(selected.route.defaults.providerOptions).toEqual({
openresponses: { reasoningEffort: "low", store: true },
openai: { reasoningEffort: "low", store: true },
})
})
@@ -85,7 +85,6 @@ describe("provider package entrypoints", () => {
headers: { "x-application": "opencode" },
body: { metadata: { user_id: "user_1" } },
limits: { context: 200_000, output: 64_000 },
providerOptions: { anthropic: { effort: "low" } },
})
expect(String(selected.provider)).toBe("example")
@@ -97,19 +96,6 @@ describe("provider package entrypoints", () => {
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(selected.route.defaults.providerOptions).toEqual({ anthropic: { effort: "low" } })
})
test("maps Anthropic provider options onto the executable model", async () => {
const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
const selected = Anthropic.model("claude-sonnet-4-6", {
apiKey: "fixture",
providerOptions: { anthropic: { thinking: { type: "adaptive" } } },
})
expect(selected.route.defaults.providerOptions).toEqual({
anthropic: { thinking: { type: "adaptive" } },
})
})
test("requires an Anthropic-compatible base URL at runtime", async () => {
@@ -249,12 +235,12 @@ describe("provider package entrypoints", () => {
path: "/chat/completions",
})
expect(responses.route.id).toBe("google-vertex-responses")
expect(responses.route.protocol).toBe("open-responses")
expect(responses.route.protocol).toBe("openai-responses")
expect(responses.route.endpoint).toMatchObject({
baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
path: "/responses",
})
expect(responses.route.defaults.providerOptions).toEqual({ openresponses: { store: false } })
expect(responses.route.defaults.providerOptions).toEqual({ openai: { store: false } })
})
test("rejects conflicting Vertex auth settings at runtime", async () => {
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, LLMRequest, Message, ToolCallPart, ToolDefinition } from "../../src"
import { CacheHint, LLM } from "../../src"
import { LLMClient } from "../../src/route"
import * as Anthropic from "../../src/providers/anthropic"
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
@@ -24,39 +24,6 @@ const cacheRequest = LLM.request({
generation: { maxTokens: 16, temperature: 0 },
})
const lookup = ToolDefinition.make({
name: "lookup",
description: "Look up a fixture value.",
inputSchema: {
type: "object",
properties: { index: { type: "number" } },
required: ["index"],
additionalProperties: false,
},
})
const longToolTurn = [
Message.user("Run the fixture lookups."),
...Array.from({ length: 11 }, (_, index) => {
const id = `lookup_${index}`
return [
Message.assistant(ToolCallPart.make({ id, name: lookup.name, input: { index } })),
Message.tool({
id,
name: lookup.name,
result: `Fixture result ${index}. `.repeat(80),
}),
]
}).flat(),
]
const longToolTurnRequest = LLM.request({
id: "recorded_anthropic_cache_long_tool_turn",
model,
system: LARGE_CACHEABLE_SYSTEM,
messages: longToolTurn,
tools: [lookup],
generation: { maxTokens: 16, temperature: 0 },
})
const recorded = recordedTests({
prefix: "anthropic-messages-cache",
provider: "anthropic",
@@ -83,28 +50,4 @@ describe("Anthropic Messages cache recorded", () => {
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
}),
)
recorded.effect.with("keeps a long tool turn inside the cache lookback", { tags: ["cache", "tool"] }, () =>
Effect.gen(function* () {
const first = yield* LLMClient.generate(longToolTurnRequest)
const firstRead = first.usage?.cacheReadInputTokens ?? 0
const firstWrite = first.usage?.cacheWriteInputTokens ?? 0
const firstCached = firstRead + firstWrite
// The prefix may already be warm when recording, so either a read or a
// write establishes that Anthropic recognized the cache boundary.
expect(firstCached).toBeGreaterThan(0)
const second = yield* LLMClient.generate(
LLMRequest.update(longToolTurnRequest, {
messages: [
...longToolTurn,
Message.assistant("The fixture lookups are complete."),
Message.user("Reply exactly: OK"),
],
}),
)
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(firstCached)
expect(second.usage?.cacheWriteInputTokens ?? 0).toBeLessThan(firstCached)
}),
)
})
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { CacheHint, LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios"
@@ -60,7 +60,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers adaptive thinking settings with effort", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLMRequest.update(request, {
LLM.updateRequest(request, {
providerOptions: {
anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
},
@@ -74,42 +74,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("normalizes enabled and disabled thinking settings", () =>
Effect.gen(function* () {
const enabled = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 } } },
}),
)
const legacy = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2_048 } } },
}),
)
const disabled = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "disabled" } } },
}),
)
expect(enabled.body.thinking).toEqual({ type: "enabled", budget_tokens: 1_024 })
expect(legacy.body.thinking).toEqual({ type: "enabled", budget_tokens: 2_048 })
expect(disabled.body.thinking).toEqual({ type: "disabled" })
}),
)
it.effect("rejects enabled thinking without a budget", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled" } } },
}),
).pipe(Effect.flip)
expect(error.message).toContain("Anthropic thinking provider option requires budgetTokens")
}),
)
it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
@@ -506,7 +470,6 @@ describe("Anthropic Messages route", () => {
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { anthropic: { signature: "sig_1" } },
})
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toBeUndefined()
expect(response.message.content).toEqual([
{ type: "text", text: "Hello!" },
{ type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } },
@@ -519,139 +482,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("requires message_stop before completing a streamed message", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Provider stream ended without a terminal finish event",
})
}),
)
it.effect("round-trips omitted thinking carried only by a signature delta", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "thinking", thinking: "", signature: "" },
},
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } },
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
])
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ model, messages: [response.message], cache: "none" }),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "thinking", thinking: "", signature: "sig_1" }] },
])
}),
)
it.effect("retains a thinking signature supplied in content_block_start", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "thinking", thinking: "", signature: "sig_1" },
},
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
])
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { anthropic: { signature: "sig_1" } },
})
}),
)
it.effect("retains complete tool input from content_block_start", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } },
},
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.toolCalls).toMatchObject([
{ id: "call_1", name: "lookup", input: { query: "weather" } },
])
}),
)
it.effect("retains empty text blocks", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.message.content).toEqual([{ type: "text", text: "" }])
}),
)
it.effect("parses redacted thinking into empty reasoning with redactedData metadata", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -682,8 +512,8 @@ describe("Anthropic Messages route", () => {
// contents are provider-owned and must be replayed without inspection.
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(
Effect.provide(
@@ -721,7 +551,7 @@ describe("Anthropic Messages route", () => {
response.message,
Message.tool({ id: "call_1", name: "lookup", result: "sunny", resultType: "text" }),
],
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
cache: "none",
}),
)
@@ -763,7 +593,6 @@ describe("Anthropic Messages route", () => {
delta: { stop_reason: "model_context_window_exceeded" },
usage: { output_tokens: 1 },
},
{ type: "message_stop" },
),
),
),
@@ -781,7 +610,6 @@ describe("Anthropic Messages route", () => {
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "message_delta", delta: { stop_reason: "pause_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
@@ -800,11 +628,10 @@ describe("Anthropic Messages route", () => {
{ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: ':"weather"}' } },
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
const usage = new Usage({
@@ -986,13 +813,10 @@ describe("Anthropic Messages route", () => {
{ type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Found it." } },
{ type: "content_block_stop", index: 2 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
{ type: "message_stop" },
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [
ToolDefinition.make({ name: "web_search", description: "Web search", inputSchema: { type: "object" } }),
],
LLM.updateRequest(request, {
tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
@@ -1050,13 +874,10 @@ describe("Anthropic Messages route", () => {
},
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [
ToolDefinition.make({ name: "web_search", description: "Web search", inputSchema: { type: "object" } }),
],
LLM.updateRequest(request, {
tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
@@ -1172,10 +993,7 @@ describe("Anthropic Messages route", () => {
content: [
{ type: "text", text: "What is in this image?" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
{
type: "document",
source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" },
},
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
],
@@ -2,16 +2,7 @@ import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import {
CacheHint,
GenerationOptions,
LLM,
LLMRequest,
Message,
ToolCallPart,
ToolChoice,
ToolDefinition,
} from "../../src"
import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
import { LLMClient } from "../../src/route"
import { AmazonBedrock } from "../../src/providers"
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
@@ -43,26 +34,6 @@ const eventFrame = (type: string, payload: object) =>
body: utf8Encoder.encode(JSON.stringify(payload)),
})
const exceptionFrame = (type: string, payload: object) =>
codec.encode({
headers: {
":message-type": { type: "string", value: "exception" },
":exception-type": { type: "string", value: type },
":content-type": { type: "string", value: "application/json" },
},
body: utf8Encoder.encode(JSON.stringify(payload)),
})
const errorFrame = (code: string, message: string) =>
codec.encode({
headers: {
":message-type": { type: "string", value: "error" },
":error-code": { type: "string", value: code },
":error-message": { type: "string", value: message },
},
body: new Uint8Array(),
})
const concat = (frames: ReadonlyArray<Uint8Array>) => {
const total = frames.reduce((sum, frame) => sum + frame.length, 0)
const out = new Uint8Array(total)
@@ -115,9 +86,7 @@ describe("Bedrock Converse route", () => {
it.effect("passes topK through additionalModelRequestFields as top_k", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLMRequest.update(baseRequest, {
generation: GenerationOptions.make({ maxTokens: 64, temperature: 0, topK: 40 }),
}),
LLM.updateRequest(baseRequest, { generation: { maxTokens: 64, temperature: 0, topK: 40 } }),
)
// Converse's inferenceConfig has no topK; Anthropic/Nova read it from
@@ -154,13 +123,13 @@ describe("Bedrock Converse route", () => {
it.effect("prepares tool config with toolSpec and toolChoice", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLMRequest.update(baseRequest, {
LLM.updateRequest(baseRequest, {
tools: [
ToolDefinition.make({
{
name: "lookup",
description: "Lookup data",
inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
}),
},
],
toolChoice: ToolChoice.make({ type: "required" }),
}),
@@ -188,13 +157,13 @@ describe("Bedrock Converse route", () => {
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLMRequest.update(baseRequest, {
LLM.updateRequest(baseRequest, {
tools: [
ToolDefinition.make({
{
name: "lookup",
description: "Lookup data",
inputSchema: { type: "object", properties: { query: { type: "string" } } },
}),
},
],
toolChoice: ToolChoice.make({ type: "none" }),
}),
@@ -383,19 +352,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("preserves usage across later metadata events without usage", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStop", { stopReason: "end_turn" }],
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
["metadata", { metrics: { latencyMs: 100 } }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -413,8 +369,8 @@ describe("Bedrock Converse route", () => {
["messageStop", { stopReason: "tool_use" }],
)
const response = yield* LLMClient.generate(
LLMRequest.update(baseRequest, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
LLM.updateRequest(baseRequest, {
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedBytes(body)))
@@ -511,170 +467,12 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("preserves reasoning signatures when contentBlockStop is missing", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
["messageStart", { role: "assistant" }],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } },
],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["messageStop", { stopReason: "end_turn" }],
),
),
),
)
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toEqual({
type: "reasoning-delta",
id: "reasoning-0",
text: "",
providerMetadata: { bedrock: { signature: "sig_1" } },
})
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Let me think.",
providerMetadata: { bedrock: { signature: "sig_1" } },
},
])
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({ model, messages: [response.message], cache: "none" }),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ reasoningContent: { reasoningText: { text: "Let me think.", signature: "sig_1" } } }],
},
])
}),
)
it.effect("preserves signature-only reasoning blocks", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { bedrock: { signature: "sig_1" } } },
])
}),
)
it.effect("accepts Vercel-compatible redacted reasoning data deltas", () =>
Effect.gen(function* () {
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { data: redactedData } } }],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toEqual({
type: "reasoning-delta",
id: "reasoning-0",
text: "",
providerMetadata: { bedrock: { redactedData } },
})
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData } } },
])
}),
)
it.effect("round-trips streamed redacted reasoning with tool use into a continuation request", () =>
Effect.gen(function* () {
// Bedrock represents redactedContent blobs as base64 strings on its JSON
// wire. The provider owns the payload and requires byte-exact replay.
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
const response = yield* LLMClient.generate(
LLMRequest.update(baseRequest, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
["messageStart", { role: "assistant" }],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: redactedData } } },
],
["contentBlockStop", { contentBlockIndex: 0 }],
[
"contentBlockStart",
{
contentBlockIndex: 1,
start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
},
],
["contentBlockDelta", { contentBlockIndex: 1, delta: { toolUse: { input: '{"query":"weather"}' } } }],
["contentBlockStop", { contentBlockIndex: 1 }],
["messageStop", { stopReason: "tool_use" }],
),
),
),
)
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toEqual({
type: "reasoning-delta",
id: "reasoning-0",
text: "",
providerMetadata: { bedrock: { redactedData } },
})
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({
model,
messages: [
Message.user("Say hello."),
response.message,
Message.tool({ id: "tool_1", name: "lookup", result: "sunny", resultType: "text" }),
],
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ text: "Say hello." }] },
{
role: "assistant",
content: [
{ reasoningContent: { redactedContent: redactedData } },
{ toolUse: { toolUseId: "tool_1", name: "lookup", input: { query: "weather" } } },
],
},
{
role: "user",
content: [{ toolResult: { toolUseId: "tool_1", content: [{ text: "sunny" }], status: "success" } }],
},
])
}),
)
it.effect("classifies throttlingException as a rate limit", () =>
Effect.gen(function* () {
const body = concat([
eventFrame("messageStart", { role: "assistant" }),
exceptionFrame("throttlingException", { message: "Slow down" }),
])
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
["throttlingException", { message: "Slow down" }],
)
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "Slow down" })
@@ -685,7 +483,7 @@ describe("Bedrock Converse route", () => {
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(exceptionFrame("validationException", { message: "Input is too long for requested model" })),
fixedBytes(eventStreamBody(["validationException", { message: "Input is too long for requested model" }])),
),
Effect.flip,
)
@@ -698,44 +496,12 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("uses originalMessage from model stream exception frames", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(
exceptionFrame("modelStreamErrorException", {
originalMessage: "Upstream model failed",
originalStatusCode: 500,
}),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "Upstream model failed" })
}),
)
it.effect("fails unmodeled AWS event-stream errors", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(fixedBytes(errorFrame("BadStream", "Stream failed"))),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "BadStream: Stream failed",
})
}),
)
it.effect("rejects requests with no auth path", () =>
Effect.gen(function* () {
const unsignedModel = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
const error = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: unsignedModel })).pipe(
const error = yield* LLMClient.generate(LLM.updateRequest(baseRequest, { model: unsignedModel })).pipe(
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
Effect.flip,
)
@@ -754,7 +520,7 @@ describe("Bedrock Converse route", () => {
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
},
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
const prepared = yield* LLMClient.prepare(LLMRequest.update(baseRequest, { model: signed }))
const prepared = yield* LLMClient.prepare(LLM.updateRequest(baseRequest, { model: signed }))
expect(prepared.route).toBe("bedrock-converse")
expect(prepared.model).toBe(signed)
@@ -939,7 +705,6 @@ describe("Bedrock Converse route", () => {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({
model,
cache: "none",
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { path: "report.pdf" } })]),
Message.tool({
+8 -29
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import * as Gemini from "../../src/protocols/gemini"
import { ProviderShared } from "../../src/protocols/shared"
@@ -36,27 +36,6 @@ describe("Gemini route", () => {
}),
)
it.effect("normalizes Gemini thinking options", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
}),
)
const filtered = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } },
}),
)
expect(prepared.body.generationConfig?.thinkingConfig).toEqual({
thinkingBudget: 0,
includeThoughts: false,
})
expect(filtered.body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false })
}),
)
it.effect("lowers chronological system updates to wrapped user text in order", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
@@ -261,7 +240,7 @@ describe("Gemini route", () => {
id: "req_tool_choice_none",
model,
prompt: "Say hello.",
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
toolChoice: { type: "none" },
}),
)
@@ -431,8 +410,8 @@ describe("Gemini route", () => {
],
})
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
const reasoning = response.events.find((event) => event.type === "reasoning-start")
@@ -522,8 +501,8 @@ describe("Gemini route", () => {
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
})
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
const usage = new Usage({
@@ -589,8 +568,8 @@ describe("Gemini route", () => {
],
})
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
+25 -35
View File
@@ -1,18 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import {
HttpOptions,
LLM,
LLMError,
LLMEvent,
LLMRequest,
Message,
Model,
ToolCallPart,
ToolDefinition,
Usage,
} from "../../src"
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
import * as OpenAIChat from "../../src/protocols/openai-chat"
@@ -173,7 +162,7 @@ describe("OpenAI Chat route", () => {
it.effect("adds native query params to the Chat Completions URL", () =>
LLMClient.generate(
LLMRequest.update(request, {
LLM.updateRequest(request, {
model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }),
}),
).pipe(
@@ -193,7 +182,7 @@ describe("OpenAI Chat route", () => {
it.effect("uses Azure api-key header for static OpenAI Chat keys", () =>
LLMClient.generate(
LLMRequest.update(request, {
LLM.updateRequest(request, {
model: Azure.configure({
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
apiKey: "azure-key",
@@ -219,15 +208,15 @@ describe("OpenAI Chat route", () => {
it.effect("applies serializable HTTP overlays after payload lowering", () =>
LLMClient.generate(
LLMRequest.update(request, {
LLM.updateRequest(request, {
model: model.route
.with({ auth: Auth.bearer("fresh-key"), headers: { authorization: "Bearer stale" } })
.model({ id: model.id }),
http: HttpOptions.make({
http: {
body: { metadata: { source: "test" } },
headers: { authorization: "Bearer request", "x-custom": "yes" },
query: { debug: "1" },
}),
},
}),
).pipe(
Effect.provide(
@@ -550,7 +539,7 @@ describe("OpenAI Chat route", () => {
prompt_tokens: 5,
completion_tokens: 2,
total_tokens: 7,
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
prompt_tokens_details: { cached_tokens: 1 },
completion_tokens_details: { reasoning_tokens: 0 },
}),
)
@@ -558,9 +547,8 @@ describe("OpenAI Chat route", () => {
const usage = new Usage({
inputTokens: 5,
outputTokens: 2,
nonCachedInputTokens: 2,
nonCachedInputTokens: 4,
cacheReadInputTokens: 1,
cacheWriteInputTokens: 2,
reasoningTokens: 0,
totalTokens: 7,
providerMetadata: {
@@ -568,7 +556,7 @@ describe("OpenAI Chat route", () => {
prompt_tokens: 5,
completion_tokens: 2,
total_tokens: 7,
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
prompt_tokens_details: { cached_tokens: 1 },
completion_tokens_details: { reasoning_tokens: 0 },
},
},
@@ -630,7 +618,7 @@ describe("OpenAI Chat route", () => {
it.effect("parses and replays a configured custom reasoning field", () =>
Effect.gen(function* () {
const custom = Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: custom })).pipe(
const response = yield* LLMClient.generate(LLM.updateRequest(request, { model: custom })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
@@ -650,7 +638,9 @@ describe("OpenAI Chat route", () => {
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model: custom, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" }])
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" },
])
}),
)
@@ -661,8 +651,8 @@ describe("OpenAI Chat route", () => {
{ type: "reasoning.encrypted", data: "opaque", format: "anthropic-claude-v1", index: 1 },
]
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(
Effect.provide(
@@ -1034,8 +1024,8 @@ describe("OpenAI Chat route", () => {
deltaChunk({}, "tool_calls"),
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
@@ -1077,8 +1067,8 @@ describe("OpenAI Chat route", () => {
deltaChunk({}, "tool_calls"),
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
@@ -1099,8 +1089,8 @@ describe("OpenAI Chat route", () => {
deltaChunk({}, "tool_calls"),
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
@@ -1117,8 +1107,8 @@ describe("OpenAI Chat route", () => {
deltaChunk({}, "tool_calls"),
)
const error = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
@@ -1135,8 +1125,8 @@ describe("OpenAI Chat route", () => {
}),
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
)
const input = LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
const input = LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
})
const events: LLMEvent[] = []
const streamError = yield* LLMClient.stream(input).pipe(
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src"
import { LLM, Message, ToolCallPart } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
@@ -53,9 +53,9 @@ describe("OpenAI-compatible Chat route", () => {
it.effect("prepares generic Chat target", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
toolChoice: ToolChoice.make({ type: "required" }),
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
toolChoice: { type: "required" },
}),
)
@@ -1,22 +1,17 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } from "../../src"
import { LLM } from "../../src"
import { configure } from "../../src/providers/openai-compatible-responses"
import { OpenAI } from "../../src/providers"
import { OpenResponses } from "../../src/protocols/open-responses"
import { OpenAICompatibleResponses } from "../../src/protocols/openai-compatible-responses"
import { OpenAIResponses } from "../../src/protocols/openai-responses"
import { LLMClient } from "../../src/route"
import { it } from "../lib/effect"
import { fixedResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
describe("Open Responses-compatible route", () => {
it.effect("uses the Open Responses baseline for a configured deployment", () =>
describe("OpenAI-compatible Responses route", () => {
it.effect("reuses the OpenAI Responses protocol for a configured deployment", () =>
Effect.gen(function* () {
expect(OpenAICompatibleResponses.route.body).toBe(OpenResponses.protocol.body)
expect(OpenAICompatibleResponses.route.transport).toBe(OpenResponses.httpTransport)
expect(OpenAICompatibleResponses.route.body).not.toBe(OpenAIResponses.protocol.body)
expect(OpenAICompatibleResponses.route.body).toBe(OpenAIResponses.protocol.body)
expect(OpenAICompatibleResponses.route.transport).toBe(OpenAIResponses.httpTransport)
const model = configure({
apiKey: "test-key",
@@ -32,7 +27,7 @@ describe("Open Responses-compatible route", () => {
)
expect(prepared.route).toBe("openai-compatible-responses")
expect(prepared.protocol).toBe("open-responses")
expect(prepared.protocol).toBe("openai-responses")
expect(prepared.model).toMatchObject({
id: "example-model",
provider: "example",
@@ -50,67 +45,9 @@ describe("Open Responses-compatible route", () => {
{ role: "system", content: "You are concise." },
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
],
store: false,
stream: true,
})
}),
)
it.effect("rejects OpenAI-native tools", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const error = yield* LLMClient.prepare(
LLM.request({ model, prompt: "Draw.", tools: [OpenAI.imageGeneration()] }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("Open Responses does not support provider-native tool image_generation")
}),
)
it.effect("reads standard options from the Open Responses namespace", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
}).model("example-model")
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Think." }))
expect(prepared.body).toMatchObject({
reasoning: { effort: "low" },
store: true,
})
}),
)
it.effect("does not interpret OpenAI hosted-tool items", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "web_search_call", id: "ws_1", status: "completed", action: { query: "news" } },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.toolCalls).toEqual([])
expect(response.events.find(LLMEvent.is.finish)).toMatchObject({
providerMetadata: { openresponses: { responseId: "resp_1" } },
})
}),
)
})
@@ -1,18 +1,7 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
LLM,
LLMError,
LLMEvent,
LLMRequest,
Message,
Model,
ToolCallPart,
ToolDefinition,
ToolResultPart,
Usage,
} from "../../src"
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, ToolResultPart, Usage } from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
@@ -107,7 +96,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers semantic service tier options", () =>
Effect.gen(function* () {
const input = LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "priority" } } })
const input = LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "priority" } } })
expect(input.providerOptions).toEqual({ openai: { serviceTier: "priority" } })
const prepared = yield* LLMClient.prepare(input)
@@ -119,7 +108,7 @@ describe("OpenAI Responses route", () => {
it.effect("passes through custom OpenAI reasoning effort strings", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLMRequest.update(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }),
LLM.updateRequest(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }),
)
expect(prepared.body.reasoning).toEqual({ effort: "experimental" })
@@ -129,7 +118,7 @@ describe("OpenAI Responses route", () => {
it.effect("omits unsupported semantic service tiers", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }),
LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }),
)
expect(prepared.body).not.toHaveProperty("service_tier")
@@ -139,9 +128,9 @@ describe("OpenAI Responses route", () => {
it.effect("flattens top-level object unions in function schemas", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLMRequest.update(request, {
LLM.updateRequest(request, {
tools: [
ToolDefinition.make({
{
name: "read",
description: "Read a path or resource.",
inputSchema: {
@@ -163,7 +152,7 @@ describe("OpenAI Responses route", () => {
},
],
},
}),
},
],
}),
)
@@ -218,7 +207,7 @@ describe("OpenAI Responses route", () => {
it.effect("prepares OpenAI Responses WebSocket target", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLMRequest.update(request, {
LLM.updateRequest(request, {
model: OpenAIResponses.webSocketRoute
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4.1-mini" }),
@@ -302,7 +291,7 @@ describe("OpenAI Responses route", () => {
it.effect("adds native query params to the Responses URL", () =>
Effect.gen(function* () {
yield* LLMClient.generate(
LLMRequest.update(request, {
LLM.updateRequest(request, {
model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }),
}),
).pipe(
@@ -324,7 +313,7 @@ describe("OpenAI Responses route", () => {
it.effect("uses Azure api-key header for static OpenAI Responses keys", () =>
Effect.gen(function* () {
yield* LLMClient.generate(
LLMRequest.update(request, {
LLM.updateRequest(request, {
model: Azure.configure({
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
apiKey: "azure-key",
@@ -351,7 +340,7 @@ describe("OpenAI Responses route", () => {
it.effect("loads OpenAI default auth from Effect Config", () =>
LLMClient.generate(
LLMRequest.update(request, {
LLM.updateRequest(request, {
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/" }).responses("gpt-4.1-mini"),
}),
).pipe(
@@ -372,7 +361,7 @@ describe("OpenAI Responses route", () => {
it.effect("lets explicit auth override OpenAI default API key auth", () =>
LLMClient.generate(
LLMRequest.update(request, {
LLM.updateRequest(request, {
model: OpenAI.configure({
baseURL: "https://api.openai.test/v1/",
auth: Auth.bearer("oauth-token"),
@@ -832,7 +821,7 @@ describe("OpenAI Responses route", () => {
input_tokens: 5,
output_tokens: 2,
total_tokens: 7,
input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
input_tokens_details: { cached_tokens: 1 },
output_tokens_details: { reasoning_tokens: 0 },
},
},
@@ -842,9 +831,8 @@ describe("OpenAI Responses route", () => {
const usage = new Usage({
inputTokens: 5,
outputTokens: 2,
nonCachedInputTokens: 2,
nonCachedInputTokens: 4,
cacheReadInputTokens: 1,
cacheWriteInputTokens: 2,
reasoningTokens: 0,
totalTokens: 7,
providerMetadata: {
@@ -852,7 +840,7 @@ describe("OpenAI Responses route", () => {
input_tokens: 5,
output_tokens: 2,
total_tokens: 7,
input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
input_tokens_details: { cached_tokens: 1 },
output_tokens_details: { reasoning_tokens: 0 },
},
},
@@ -901,7 +889,12 @@ describe("OpenAI Responses route", () => {
const unknown = yield* generate({})
const custom = yield* generate({ reason: "provider_limit" })
expect([length.finishReason, contentFilter.finishReason, unknown.finishReason, custom.finishReason]).toEqual([
expect([
length.finishReason,
contentFilter.finishReason,
unknown.finishReason,
custom.finishReason,
]).toEqual([
{ normalized: "length", raw: "max_output_tokens" },
{ normalized: "content-filter", raw: "content_filter" },
{ normalized: "unknown", raw: undefined },
@@ -1006,7 +999,7 @@ describe("OpenAI Responses route", () => {
it.effect("streams each reasoning summary part as a separate block", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { openai: { store: false } } }),
LLM.updateRequest(request, { providerOptions: { openai: { store: false } } }),
).pipe(
Effect.provide(
fixedResponse(
@@ -1061,7 +1054,7 @@ describe("OpenAI Responses route", () => {
it.effect("closes reasoning summary parts when storage is not disabled", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { openai: { store: true } } }),
LLM.updateRequest(request, { providerOptions: { openai: { store: true } } }),
).pipe(
Effect.provide(
fixedResponse(
@@ -1388,8 +1381,8 @@ describe("OpenAI Responses route", () => {
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
const usage = new Usage({
@@ -1474,8 +1467,8 @@ describe("OpenAI Responses route", () => {
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
+7 -5
View File
@@ -3,7 +3,6 @@ import { Effect, Schema } from "effect"
import {
LLM,
LLMEvent,
LLMRequest,
LLMResponse,
Message,
ToolRuntime,
@@ -12,6 +11,7 @@ import {
toDefinitions,
type ContentPart,
type FinishReason,
type LLMRequest,
type Model,
} from "../src"
import { LLMClient } from "../src/route"
@@ -91,7 +91,7 @@ const restroomImage = () =>
export const runWeatherToolLoop = (request: LLMRequest) =>
Effect.gen(function* () {
const tools = { [weatherToolName]: weatherRuntimeTool }
let next = LLMRequest.update(request, { tools: toDefinitions(tools) })
let next = LLM.updateRequest(request, { tools: toDefinitions(tools) })
const events: LLMEvent[] = []
for (let step = 0; step < 10; step++) {
@@ -108,7 +108,7 @@ export const runWeatherToolLoop = (request: LLMRequest) =>
ToolRuntime.dispatch(tools, call).pipe(Effect.map((result) => [call, result] as const)),
)
events.push(...dispatched.flatMap(([, result]) => result.events))
next = LLMRequest.update(next, {
next = LLM.updateRequest(next, {
messages: [
...next.messages,
Message.assistant(assistantContent(response.events)),
@@ -123,8 +123,10 @@ export const runWeatherToolLoop = (request: LLMRequest) =>
const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
events.reduce(LLMResponse.reduce, LLMResponse.empty()).message.content
export const expectFinish = (events: ReadonlyArray<LLMEvent>, reason: FinishReason) =>
expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: reason } })
export const expectFinish = (
events: ReadonlyArray<LLMEvent>,
reason: FinishReason,
) => expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: reason } })
export const expectWeatherToolCall = (response: LLMResponse) =>
expect(response.toolCalls).toMatchObject([
+2 -5
View File
@@ -539,7 +539,6 @@ describe("LLMClient tools", () => {
},
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } },
{ type: "message_stop" },
)
: sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
@@ -547,7 +546,6 @@ describe("LLMClient tools", () => {
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } },
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
{ headers: { "content-type": "text/event-stream" } },
)
@@ -555,7 +553,7 @@ describe("LLMClient tools", () => {
)
yield* TestToolRuntime.runTools({
request: LLMRequest.update(baseRequest, {
request: LLM.updateRequest(baseRequest, {
model: AnthropicMessages.route
.with({ auth: Auth.header("x-api-key", "test") })
.model({ id: "claude-sonnet-4-5" }),
@@ -803,7 +801,6 @@ describe("LLMClient tools", () => {
{ type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } },
{ type: "content_block_stop", index: 2 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
{ type: "message_stop" },
),
{ headers: { "content-type": "text/event-stream" } },
)
@@ -811,7 +808,7 @@ describe("LLMClient tools", () => {
)
const events = Array.from(
yield* TestToolRuntime.runTools({
request: LLMRequest.update(baseRequest, {
request: LLM.updateRequest(baseRequest, {
model: AnthropicMessages.route
.with({ auth: Auth.header("x-api-key", "test") })
.model({ id: "claude-sonnet-4-5" }),
+7 -6
View File
@@ -72,6 +72,7 @@ Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: {
const runtime = CodeMode.make({ tools, limits: { timeoutMs: 30_000 } })
runtime.catalog() // structured tool descriptions
runtime.instructions() // model-facing syntax and tool guide
runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
```
@@ -144,13 +145,13 @@ safe refusal to the model; its optional cause remains private.
## Discovery
`runtime.catalog()` returns structured descriptors — exact path, description, and generated TypeScript signature — for
every visible tool. Hosts render their own model-facing instructions from these descriptors; `CodeMode.searchSignature`
and `CodeMode.toolExpression(path)` supply the exact callable forms.
Generated instructions contain a tool catalog with a default budget of 2,000 estimated tokens. Configure it with
`discovery: { catalogBudget }`. Every namespace remains visible, and the instructions say whether the catalog is
complete or partial.
The synchronous `search(...)` built-in is always available. It supports exact-path lookup, namespace-scoped search,
empty-query browsing, and pagination, and returns callable paths with full signatures. Search counts toward
`maxToolCalls`.
The synchronous `search(...)` built-in is always available and advertised when the catalog is partial. It supports
exact-path lookup, namespace-scoped search, empty-query browsing, and pagination, and returns callable paths with full
signatures. Search counts toward `maxToolCalls`.
## Execution Limits
+14 -5
View File
@@ -5,8 +5,6 @@ import type { Tools } from "./tools.js"
/** A tool call admitted during an execution. */
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
/** Signature-construction helpers for host-owned catalog instructions. */
export { searchSignature, toolExpression } from "./tool-runtime.js"
/** Resource budgets enforced independently during each CodeMode program execution. */
export type ExecutionLimits = {
@@ -24,6 +22,12 @@ export type ExecutionLimits = {
readonly maxOutputBytes?: number
}
/** Controls how much of the tool catalog is inlined in agent instructions. */
export type DiscoveryOptions = {
/** Approximate token budget (chars/4, default 2000) for full catalog entries. */
readonly catalogBudget?: number
}
export type ResolvedExecutionLimits = {
readonly timeoutMs: number | undefined
readonly maxToolCalls: number | undefined
@@ -40,7 +44,7 @@ export type ExecuteOptions<Provided extends Record<string, unknown> = {}> = {
limits?: ExecutionLimits
/** Observes decoded tool input immediately before tool execution. */
onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect<void, never, Services<Provided>>
/** Observes each admitted tool call as it succeeds, fails, or is interrupted. */
/** Observes each admitted tool call as it settles, with outcome and duration. */
onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect<void, never, Services<Provided>>
}
@@ -48,7 +52,10 @@ export type ExecuteOptions<Provided extends Record<string, unknown> = {}> = {
export type DataValue = Schema.Json
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
export type Options<Provided extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Provided>, "code">
export type Options<Provided extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Provided>, "code"> & {
/** Progressive-disclosure configuration for the agent-facing tool catalog. */
readonly discovery?: DiscoveryOptions
}
/** Schema for a host tool input containing CodeMode source. */
export const Input = Schema.Struct({ code: Schema.String })
@@ -109,6 +116,7 @@ export type Result = typeof Result.Type
/** Reusable confined runtime over explicit tools. */
export type Runtime<R = never> = {
readonly catalog: () => ReadonlyArray<ToolDescription>
readonly instructions: () => string
readonly execute: (code: string) => Effect.Effect<Result, never, R>
}
@@ -139,10 +147,11 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
): Runtime<Services<Provided>> => {
const tools = (options.tools ?? {}) as Tools<Services<Provided>>
const limits = resolveExecutionLimits(options.limits)
const prepared = ToolRuntime.prepare(tools)
const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget)
return {
catalog: () => prepared.catalog,
instructions: () => prepared.instructions,
execute: (code) => executeWithLimits<Provided>({ ...options, code }, limits, prepared.searchIndex),
}
}
-1
View File
@@ -1,5 +1,4 @@
export * as CodeMode from "./codemode.js"
export * as Tool from "./tool.js"
export * as OpenAPI from "./openapi/index.js"
export { searchSignature, toolExpression } from "./codemode.js"
export { ToolError, toolError } from "./tool-error.js"
+170 -26
View File
@@ -1,4 +1,4 @@
import { Cause, Effect, Exit, Schema } from "effect"
import { Cause, Effect, Schema } from "effect"
import { ToolError, toolError } from "./tool-error.js"
import {
decodeInput as decodeToolInput,
@@ -20,6 +20,7 @@ import {
CodeModeURLSearchParams,
} from "./values.js"
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0)
export type Services<T> = ServicesOf<T, []>
@@ -52,7 +53,7 @@ export type ToolCallEnded = {
readonly name: string
readonly input: unknown
readonly durationMs: number
readonly outcome: "success" | "failure" | "interrupted"
readonly outcome: "success" | "failure"
readonly message?: string
}
@@ -69,6 +70,7 @@ export type ToolDescription = {
export type SafeObject = Record<string, unknown>
const defaultCatalogBudget = 2_000
const defaultSearchLimit = 10
const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
@@ -88,7 +90,7 @@ const SearchOutput = Schema.Struct({
remaining: NonNegativeInt,
next: Schema.NullOr(Schema.Struct({ offset: NonNegativeInt })),
})
export const toolExpression = (path: string) =>
const toolExpression = (path: string) =>
"tools" +
path
.split(".")
@@ -337,6 +339,7 @@ const visibleTools = <R>(tools: Tools<R>) =>
export type DiscoveryPlan = {
readonly catalog: ReadonlyArray<ToolDescription>
readonly instructions: string
readonly searchIndex: ReadonlyArray<SearchEntry>
}
@@ -420,12 +423,17 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
}),
})
/** Exact callable signature of the built-in `search` function, for host-owned instructions. */
export const searchSignature = (() => {
const searchSignature = (() => {
const tool = makeSearchTool([])
return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}`
})()
const catalogLine = (tool: ToolDescription) => {
const line = tool.description.split("\n", 1)[0]!.trim()
const description = line.length > 120 ? line.slice(0, 119) + "..." : line
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
}
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
description,
namespace: path.split(".", 1)[0]!,
@@ -443,10 +451,146 @@ const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescript
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description))
export const prepare = <R>(tools: Tools<R>): DiscoveryPlan => {
// Budget signatures round-robin so every namespace remains visible.
export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
}
const visible = visibleTools(tools)
const described = visible.map(({ description }) => description)
const namespaces = new Map<string, Array<ToolDescription>>()
for (const tool of described) {
const [namespace = tool.path] = tool.path.split(".")
const group = namespaces.get(namespace) ?? []
group.push(tool)
namespaces.set(namespace, group)
}
const ordered = [...namespaces].sort(([left], [right]) => compareText(left, right))
const selections = ordered.map(([namespace, group]) => ({
namespace,
picked: new Set<ToolDescription>(),
queue: [...group].sort(
(left, right) =>
estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || compareText(left.path, right.path),
),
}))
let used = 0
let active = selections.filter((selection) => selection.queue.length > 0)
while (active.length > 0) {
const stillActive: typeof active = []
for (const selection of active) {
const tool = selection.queue[0]!
const cost = estimateTokens(catalogLine(tool))
if (used + cost > catalogBudget) continue
selection.queue.shift()
selection.picked.add(tool)
used += cost
if (selection.queue.length > 0) stillActive.push(selection)
}
active = stillActive
}
const shown = new Map<string, ReadonlySet<ToolDescription>>(
selections.map(({ namespace, picked }) => [namespace, picked]),
)
const totalShown = selections.reduce((total, { picked }) => total + picked.size, 0)
const complete = totalShown === described.length
const empty = described.length === 0
const intro = [
empty
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime."
: complete
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the tools listed below; surrounding agent tools are not available."
: "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the tools listed or searchable below; surrounding agent tools are not available.",
...(empty
? []
: ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
]
const workflow = empty
? []
: [
"",
"## Workflow",
"",
...(complete
? [
"1. Pick a tool from the list under `## Available tools` - each line is the exact call signature; use it as-is rather than guessing segments.",
"2. Call it using the exact signature shown: `const result = await tools.<namespace>.<tool>(input)`; bracket notation and quotes are part of the path.",
"3. Return only the fields you need from structured results; narrow unknown results before reading fields, and avoid returning large raw payloads.",
]
: [
'1. If needed, discover tools with the built-in search function: `return search({ query: "<intent + key nouns>" })`.',
"2. In the next execution, copy a returned path exactly, call it, and return only the needed fields.",
]),
]
const rules = empty
? []
: [
"",
"## Rules",
"",
complete
? "- Only tools listed here are available; surrounding agent tools are not implicitly exposed."
: "- Only tools listed here or returned by the built-in `search` function are available; surrounding agent tools are not implicitly exposed.",
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
"- A result typed `Promise<unknown>` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.",
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
"- Execution ends when the program returns; pending promises are interrupted, so await every call whose completion matters.",
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
...(complete
? []
: [
'- Browse one namespace: `search({ query: "", namespace: "<name>" })`.',
"- If search returns `next`, repeat the same search with `offset: next.offset`.",
]),
]
const language = [
"",
"## Language",
"",
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
"Modules/imports, classes, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use tools for external operations. Use await with try/catch.",
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
]
const toolSection: Array<string> = [""]
if (empty) {
toolSection.push("## Available tools", "", "No tools are currently available.")
} else {
toolSection.push(
complete
? "## Available tools (COMPLETE list - every tool is shown below with its full call signature)"
: `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with search(...))`,
"",
)
for (const [namespace, group] of ordered) {
const picked = shown.get(namespace)!
const count = `${group.length} tool${group.length === 1 ? "" : "s"}`
const label =
picked.size === group.length
? count
: picked.size === 0
? `${count}, none shown`
: `${count}, ${picked.size} shown`
toolSection.push(`- ${namespace} (${label})`)
for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool))
}
if (!complete) {
toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`)
}
}
const lines = [...intro, ...workflow, ...rules, ...language, ...toolSection]
return {
catalog: visible.map(({ description }) => description),
catalog: described,
instructions: lines.join("\n"),
searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)),
}
}
@@ -468,7 +612,7 @@ const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Tool<R> =>
const node = lookup(root, segments)
if (node === undefined) {
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${segments.join(".")}'.`, [
"The tool may have been removed or renamed. Use search to find available tools.",
"Use search({ query }) to find available described tools.",
])
}
if (node.tool === undefined) {
@@ -495,19 +639,22 @@ export const make = <R>(
const root = toolTrie(tools)
const searchTool = makeSearchTool(searchIndex)
// End hooks observe settled success or failure; interruption emits neither outcome.
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
const onEnd = hooks?.onToolCallEnd
if (onEnd === undefined) return effect
const startedAt = Date.now()
return effect.pipe(
Effect.onExit((exit) => {
const durationMs = Date.now() - startedAt
if (Exit.isSuccess(exit)) return onEnd({ ...call, durationMs, outcome: "success" })
if (Cause.hasInterruptsOnly(exit.cause)) return onEnd({ ...call, durationMs, outcome: "interrupted" })
const error = Cause.squash(exit.cause)
Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })),
Effect.tapError((error) => {
const message =
error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed"
return onEnd({ ...call, durationMs, outcome: "failure", message })
return onEnd({
...call,
durationMs: Date.now() - startedAt,
outcome: "failure",
message,
})
}),
)
}
@@ -525,6 +672,12 @@ export const make = <R>(
calls.push(call)
}
const recordAndObserve = (name: string, input: unknown) =>
Effect.sync(() => {
recordCall({ name })
return calls.length - 1
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
const executeTool = (name: string, tool: Tool<R>, externalArgs: Array<unknown>) =>
Effect.gen(function* () {
if (externalArgs.length !== 1)
@@ -532,20 +685,11 @@ export const make = <R>(
const input = yield* Effect.try({
try: () => decodeToolInput(tool, externalArgs[0]),
catch: (cause) =>
new ToolRuntimeError(
"InvalidToolInput",
`Invalid input for tool '${name}': ${String(cause)}`,
name === "search" ? [] : ["The signature may have changed. Use search to get the current signature."],
),
new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
})
const index = yield* Effect.sync(() => {
recordCall({ name })
return calls.length - 1
})
const call = { index, name, input }
const index = yield* recordAndObserve(name, input)
return yield* observeEnd(
Effect.gen(function* () {
if (hooks?.onToolCallStart !== undefined) yield* hooks.onToolCallStart(call)
const raw = yield* runHost(Effect.suspend(() => tool.execute(input)))
const result = yield* Effect.try({
try: () => decodeToolOutput(tool, raw),
@@ -553,7 +697,7 @@ export const make = <R>(
})
return yield* decodeOutput(result, name)
}),
call,
{ index, name, input },
)
})
+182 -93
View File
@@ -189,12 +189,7 @@ describe("CodeMode tool-call observation", () => {
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
execute: ({ query }) =>
query === "boom"
? Effect.fail(toolError("Lookup refused"))
: query === "defect"
? Effect.die("broken")
: Effect.succeed(query),
execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
})
const runtime = CodeMode.make({
@@ -220,98 +215,14 @@ describe("CodeMode tool-call observation", () => {
expect(success.ok).toBe(true)
const failure = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "boom" })`))
expect(failure.ok).toBe(false)
const defect = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "defect" })`))
expect(defect.ok).toBe(false)
expect(events).toStrictEqual([
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "success" },
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Lookup refused" },
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Tool execution failed" },
])
})
test("observes interrupted calls", async () => {
const events: Array<string> = []
const call = Tool.make({
description: "Interrupt",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.interrupt,
})
const exit = await Effect.runPromiseExit(
CodeMode.make({
tools: { host: { call } },
onToolCallStart: () => Effect.sync(() => events.push("start")),
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
}).execute("return await tools.host.call({})"),
)
expect(exit._tag).toBe("Failure")
expect(events).toEqual(["start", "end:interrupted"])
})
test("observes running calls interrupted during completion", async () => {
const events: Array<string> = []
const call = Tool.make({
description: "Pending",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.never,
})
const result = await Effect.runPromise(
CodeMode.make({
tools: { host: { call } },
onToolCallStart: () => Effect.sync(() => events.push("start")),
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
}).execute('tools.host.call({}); return "done"'),
)
expect(result).toMatchObject({ ok: true, value: "done" })
expect(events).toEqual(["start", "end:interrupted"])
})
test("ends calls interrupted during start observation", async () => {
const events: Array<string> = []
const call = Tool.make({
description: "Unused",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("unused"),
})
const exit = await Effect.runPromiseExit(
CodeMode.make({
tools: { host: { call } },
onToolCallStart: () => Effect.interrupt,
onToolCallEnd: (call) => Effect.sync(() => events.push(call.outcome)),
}).execute("return await tools.host.call({})"),
)
expect(exit._tag).toBe("Failure")
expect(events).toEqual(["interrupted"])
})
test("observes calls interrupted by the execution timeout", async () => {
const outcomes: Array<string> = []
const call = Tool.make({
description: "Pending",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.never,
})
const result = await Effect.runPromise(
CodeMode.make({
tools: { host: { call } },
limits: { timeoutMs: 10 },
onToolCallEnd: (call) => Effect.sync(() => outcomes.push(call.outcome)),
}).execute("return await tools.host.call({})"),
)
expect(result).toMatchObject({ ok: false, error: { kind: "TimeoutExceeded" } })
expect(outcomes).toEqual(["interrupted"])
})
})
describe("CodeMode console capture", () => {
@@ -681,7 +592,7 @@ describe("CodeMode public contract", () => {
expect(second).toStrictEqual({ ok: true, value: 1, logs: ["hi"], toolCalls: [{ name: "host.echo" }] })
})
test("describes the catalog and keeps the search built-in registered", async () => {
test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => {
const runtime = CodeMode.make({ tools })
expect(runtime.catalog()).toStrictEqual([
{
@@ -690,7 +601,16 @@ describe("CodeMode public contract", () => {
signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
},
])
expect(runtime.instructions()).toContain("Available tools (COMPLETE list")
expect(runtime.instructions()).toContain("- orders (1 tool)")
expect(runtime.instructions()).toContain(
" - tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}> // Look up an order by ID",
)
// A fully inlined catalog does not advertise search in the instructions...
expect(runtime.instructions()).not.toContain("search(")
// ...but the search built-in stays available, so a speculative call still works with the
// same signature as the inline catalog.
const result = await Effect.runPromise(runtime.execute(`return search({ query: "order" })`))
expect(result.ok).toBe(true)
if (result.ok) {
@@ -726,7 +646,22 @@ describe("CodeMode public contract", () => {
const second = CodeMode.make({ tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } } })
expect(first.catalog()).toStrictEqual(second.catalog())
expect(first.instructions()).toBe(second.instructions())
expect(first.catalog().map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
for (const catalogBudget of [0, 10, 20, 40]) {
expect(
CodeMode.make({
tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } },
discovery: { catalogBudget },
}).instructions(),
).toBe(
CodeMode.make({
tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } },
discovery: { catalogBudget },
}).instructions(),
)
}
})
test("renders bracket notation for tool names that are not JavaScript identifiers", async () => {
@@ -745,6 +680,9 @@ describe("CodeMode public contract", () => {
signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise<string>',
},
])
expect(runtime.instructions()).toContain(
'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise<string>',
)
const search = await Effect.runPromise(runtime.execute(`return search({ query: "resolve library id" })`))
expect(search.ok).toBe(true)
@@ -775,6 +713,88 @@ describe("CodeMode public contract", () => {
if (exact.ok) expect(exact.value).toMatchObject({ remaining: 0, next: null })
})
test("instructions use markdown sections with placeholder-only call forms", () => {
const runtime = CodeMode.make({ tools })
const instructions = runtime.instructions()
// Sections in order: workflow at the top, catalog at the bottom.
expect(instructions).toContain("## Workflow")
expect(instructions).toContain("## Rules")
expect(instructions).toContain("## Language")
expect(instructions.indexOf("## Workflow")).toBeLessThan(instructions.indexOf("## Rules"))
expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Language"))
expect(instructions.indexOf("## Language")).toBeLessThan(
instructions.indexOf("\n## Available tools (COMPLETE list"),
)
expect(instructions).not.toContain("JSON.parse(res)")
expect(instructions).toContain("Return only the fields you need")
expect(instructions).toContain("avoid returning large raw payloads")
expect(instructions).toContain("Do not infer or normalize tool names")
expect(instructions).toContain("bracket notation and quotes are part of the path")
expect(instructions).toContain("surrounding agent tools are not available")
expect(instructions).toContain("Only tools listed here are available")
// Placeholders use generic namespace/tool/field names only - no fabricated real tools
// and no real catalog tools cherry-picked into example lines.
expect(instructions).toContain("`const result = await tools.<namespace>.<tool>(input)`")
expect(instructions).toContain("Return only the fields you need from structured results")
expect(instructions).toContain("check that it is a non-null object and not an array")
expect(instructions).not.toContain("result.<field>")
expect(instructions).not.toContain("data.<field>")
expect(instructions).not.toContain("total_count")
expect(instructions).not.toContain("list_issues")
expect(instructions).not.toContain("tools.orders.lookup({")
// COMPLETE: step 1 picks from the inlined list; search is not advertised.
expect(instructions).toContain("1. Pick a tool from the list under `## Available tools`")
expect(instructions).not.toContain("Browse one namespace")
const partial = CodeMode.make({ tools, discovery: { catalogBudget: 0 } }).instructions()
// PARTIAL: the workflow starts with search (with query-style guidance that is clearly
// a query string, never a tool name) and the browse-namespace rule appears.
expect(partial).toContain(
'1. If needed, discover tools with the built-in search function: `return search({ query: "<intent + key nouns>" })`.',
)
expect(partial).toContain("In the next execution, copy a returned path exactly")
expect(partial).toContain("Only tools listed here or returned by the built-in `search` function")
expect(partial).toContain('- Browse one namespace: `search({ query: "", namespace: "<name>" })`.')
expect(partial).toContain("repeat the same search with `offset: next.offset`")
expect(partial).toContain(" limit?: number,\n offset?: number,")
expect(partial).not.toContain("total_count")
expect(partial).not.toContain("tools.orders.lookup({")
})
test("the language section describes the restricted runtime without overclaiming", () => {
const instructions = CodeMode.make({ tools }).instructions()
expect(instructions).toContain("restricted JavaScript language for calling tools")
expect(instructions).toContain("not a general-purpose runtime")
expect(instructions).not.toContain("Standard modern JavaScript works")
expect(instructions).not.toContain("TypeScript type annotations")
for (const missing of ["Modules/imports", "classes", "fetch"]) {
expect(instructions).toContain(missing)
}
expect(instructions).not.toContain("generators")
expect(instructions).not.toContain("new Promise(...) are unavailable")
expect(instructions).not.toContain("promise chaining")
expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers")
expect(instructions).not.toContain("host globals")
expect(instructions).toContain("Use tools for external operations")
expect(instructions).toContain(
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
)
expect(instructions).toContain(
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
)
})
test("zero tools keep minimal sections and the no-tools notice", () => {
const runtime = CodeMode.make({})
const instructions = runtime.instructions()
expect(instructions).toContain("No tools are currently available.")
expect(instructions).toContain("## Language")
expect(instructions).toContain("## Available tools")
expect(instructions).not.toContain("## Workflow")
expect(instructions).not.toContain("## Rules")
expect(instructions).not.toContain("search(")
})
test("uses one ranked search returning complete tools for large catalogs", async () => {
const upload = Tool.make({
description: "Upload one readable local file to the current Discord thread",
@@ -790,7 +810,13 @@ describe("CodeMode public contract", () => {
})
const runtime = CodeMode.make({
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
discovery: { catalogBudget: 0 },
})
expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 3 shown; find the rest with search(...))")
expect(runtime.instructions()).toContain("- thread (2 tools, none shown)")
expect(runtime.instructions()).toContain("- orders (1 tool, none shown)")
expect(runtime.instructions()).toContain("Search returns complete callable signatures:\n- search(input: {")
expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/)
const result = await Effect.runPromise(
runtime.execute(`
@@ -1075,6 +1101,64 @@ describe("CodeMode public contract", () => {
if (exhausted.ok) expect(exhausted.value).toStrictEqual({ items: [], remaining: 0, next: null })
})
test("inlines round-robin across namespaces so one expensive namespace cannot starve the rest", () => {
const cheap = Tool.make({
description: "Cheap",
input: Schema.Struct({ q: Schema.String }),
output: Schema.String,
execute: () => Effect.succeed("ok"),
})
const expensive = Tool.make({
description:
"An expensive tool whose description alone consumes far more than the remaining inline catalog byte budget for this runtime",
input: Schema.Struct({
someRatherLongParameterName: Schema.String,
anotherEvenLongerParameterName: Schema.Number,
}),
output: Schema.String,
execute: () => Effect.succeed("ok"),
})
// Round 1 places alpha.cheap (~17 estimated tokens) and beta.cheap (~17); in round 2
// alpha.expensive does not fit, which marks only alpha done - it must NOT prevent
// other namespaces from inlining (beta already got its line in the same round).
const runtime = CodeMode.make({
tools: { alpha: { cheap, expensive }, beta: { cheap } },
discovery: { catalogBudget: 40 },
})
const instructions = runtime.instructions()
expect(instructions).toContain("Available tools (PARTIAL - 2 of 3 shown; find the rest with search(...))")
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
expect(instructions).toContain(" - tools.alpha.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
expect(instructions).not.toContain("tools.alpha.expensive(")
// Fully shown namespaces read cleanly (no "shown" annotation).
expect(instructions).toContain("- beta (1 tool)")
expect(instructions).toContain(" - tools.beta.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
expect(instructions).toContain("Search returns complete callable signatures:\n- search(input: {")
})
test("charges inline JSDoc against the catalog token budget", () => {
const documented = Tool.make({
description: "Look up a record",
input: {
type: "object",
properties: {
id: { type: "string", description: "A detailed identifier description. ".repeat(20) },
},
required: ["id"],
} as const,
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: { records: { lookup: documented } },
discovery: { catalogBudget: 40 },
})
expect(runtime.catalog()[0]?.signature).toContain("/** A detailed identifier description.")
expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 1 shown; find the rest with search(...))")
expect(runtime.instructions()).not.toContain("tools.records.lookup(input:")
})
test("decodes tool input and output before exposing either side", async () => {
const observed: Array<unknown> = []
const transformed = Tool.make({
@@ -1135,7 +1219,7 @@ describe("CodeMode public contract", () => {
expect(result).toStrictEqual({ ok: true, value: null, toolCalls: [] })
})
test("rejects invalid configuration and search limits", async () => {
test("rejects invalid configuration and discovery limits", async () => {
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError)
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow(
RangeError,
@@ -1143,8 +1227,13 @@ describe("CodeMode public contract", () => {
expect(() => CodeMode.execute({ code: "return 1", limits: { maxToolCalls: -1 } })).toThrow(RangeError)
expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError)
expect(() => CodeMode.make({ tools, discovery: { catalogBudget: -1 } })).toThrow(RangeError)
const result = await Effect.runPromise(
CodeMode.make({ tools }).execute(`return search({ query: "order", limit: 0.5 })`),
CodeMode.make({
tools,
discovery: { catalogBudget: 0 },
}).execute(`return search({ query: "order", limit: 0.5 })`),
)
expect(result.ok).toBe(false)
if (result.ok) return
+13 -7
View File
@@ -395,15 +395,15 @@ describe("JSDoc signatures in catalogs and search results", () => {
}
})
test("the catalog uses the same JSDoc signatures as search", async () => {
const catalog = runtime.catalog()
test("the inline catalog uses the same JSDoc signatures", async () => {
const instructions = runtime.instructions()
const github = (await search("list issues repository")).items.find(
({ path }) => path === "tools.github.list_issues",
)!
const orders = (await search("look up order")).items.find(({ path }) => path === "tools.orders.lookup")!
expect(catalog.map(({ signature }) => signature)).toContain(github.signature)
expect(catalog.map(({ signature }) => signature)).toContain(orders.signature)
expect(github.signature).toContain("/** Repository owner */")
expect(instructions).toContain(` - ${github.signature} // List issues in a repository`)
expect(instructions).toContain(` - ${orders.signature} // Look up an order`)
expect(instructions).toContain("/** Repository owner */")
})
})
@@ -423,10 +423,16 @@ describe("non-identifier tool paths", () => {
})
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
test("catalog signatures use bracket notation for dashed tool names", () => {
expect(runtime.catalog()[0]?.signature).toBe(
test("inline catalog uses bracket notation for dashed tool names", () => {
const instructions = runtime.instructions()
expect(instructions).toContain(
'tools.context7["resolve-library-id"](input: {\n query: string,\n libraryName: string,\n}): Promise<unknown>',
)
expect(instructions).toContain("Do not infer or normalize tool names")
expect(instructions).toContain("bracket notation and quotes are part of the path")
expect(instructions).not.toContain("tools.context7.resolve-library-id")
expect(instructions).not.toContain("tools.context7.resolve_library_id")
})
test("search results return callable bracket-notation paths and signatures", async () => {
+1 -28
View File
@@ -30,6 +30,7 @@ describe("dotted tool names", () => {
expect(catalog).toHaveLength(1)
expect(catalog[0]?.path).toBe("api.issues.list")
expect(catalog[0]?.signature).toStartWith("tools.api.issues.list(input:")
expect(runtime.instructions()).toContain("tools.api.issues.list(input:")
})
test("the advertised dotted path is executable", async () => {
@@ -85,9 +86,6 @@ describe("callable namespaces", () => {
const diagnostic = await failure(runtime, `return await tools.issues.missing({})`)
expect(diagnostic.kind).toBe("UnknownTool")
expect(diagnostic.message).toContain("Unknown tool 'issues.missing'")
expect(diagnostic.suggestions).toEqual([
"The tool may have been removed or renamed. Use search to find available tools.",
])
})
test("a namespace without its own tool stays non-callable", async () => {
@@ -98,31 +96,6 @@ describe("callable namespaces", () => {
})
})
describe("tool input diagnostics", () => {
const runtime = CodeMode.make({
tools: {
"notes.echo": Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
execute: ({ text }) => Effect.succeed(text),
}),
},
})
test("a schema mismatch suggests searching for the current signature", async () => {
const diagnostic = await failure(runtime, `return await tools.notes.echo({ message: "hello" })`)
expect(diagnostic.kind).toBe("InvalidToolInput")
expect(diagnostic.suggestions).toEqual(["The signature may have changed. Use search to get the current signature."])
})
test("a wrong argument count keeps the existing error without a stale-signature hint", async () => {
const diagnostic = await failure(runtime, `return await tools.notes.echo()`)
expect(diagnostic.kind).toBe("InvalidToolInput")
expect(diagnostic.suggestions).toBeUndefined()
})
})
describe("blocked member names on tool paths", () => {
const runtime = CodeMode.make({
tools: {
+12 -11
View File
@@ -1,7 +1,7 @@
export * as Catalog from "./catalog"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Array, Context, Effect, Layer, Order, pipe } from "effect"
import { Array, Context, Effect, Layer, Option, Order, pipe } from "effect"
import { Catalog } from "@opencode-ai/schema/catalog"
import { ModelV2 } from "./model"
import { ProviderV2 } from "./provider"
@@ -242,22 +242,23 @@ const layer = Layer.effect(
)
const pick = (items: typeof candidates) => {
if (!Array.isReadonlyArrayNonEmpty(items)) return
const maxCost = Math.max(...items.map((item) => item.cost), 0.01)
const maxAge = Math.max(...items.map((item) => item.age), 0.01)
const selected = Array.min(
return pipe(
items,
Order.mapInput(
Order.Number,
(item: (typeof candidates)[number]) =>
(item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2,
),
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
Array.map((item) => projectModel(item.model, provider)),
Array.head,
)
return projectModel(selected.model, provider)
}
const small = candidates.filter((item) => item.small)
return pick(small.length > 0 ? small : candidates)
return Option.getOrUndefined(
pipe(
candidates,
Array.filter((item) => item.small),
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
),
)
}),
},
}
+2 -3
View File
@@ -1,7 +1,6 @@
export * as CodeMode from "./codemode"
import { Context, Effect, Layer, Scope } from "effect"
import { CodeModeCatalog } from "./codemode/catalog"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { PermissionV2 } from "./permission"
import { ExecuteTool } from "./tool/execute"
@@ -10,7 +9,7 @@ import { Wildcard } from "./util/wildcard"
export interface Materialization {
readonly tool?: Any
readonly catalog?: ReadonlyArray<CodeModeCatalog.Entry>
readonly instructions?: string
}
export interface Interface {
@@ -68,7 +67,7 @@ const layer = Layer.effect(
if (executeRule?.resource === "*" && executeRule.effect === "deny") return {}
return {
tool: ExecuteTool.create(registrations),
catalog: ExecuteTool.catalog(registrations),
instructions: ExecuteTool.instructions(registrations),
}
}),
})
-103
View File
@@ -1,103 +0,0 @@
export * as CodeModeCatalog from "./catalog"
import { Schema } from "effect"
export const Entry = Schema.Struct({
path: Schema.String,
description: Schema.String,
signature: Schema.String,
})
export type Entry = typeof Entry.Type
const Listing = Schema.Struct({
path: Schema.String,
line: Schema.String,
})
const Namespace = Schema.Struct({
name: Schema.String,
count: Schema.Number,
entries: Schema.Array(Listing),
})
export const Summary = Schema.Struct({
total: Schema.Number,
shown: Schema.Number,
namespaces: Schema.Array(Namespace),
})
export type Summary = typeof Summary.Type
const DESCRIPTION_LIMIT = 120
const CHARACTERS_PER_TOKEN = 4
const INLINE_BUDGET = 2_000
// Keep every namespace searchable, then select full listings one per namespace per round,
// considering shorter listings first until the inline budget is exhausted.
export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET): Summary {
const namespaces = [...Map.groupBy(entries, (entry) => entry.path.split(".", 1)[0] ?? entry.path)]
.sort(([left], [right]) => {
if (left < right) return -1
if (left > right) return 1
return 0
})
.map(([name, namespaceEntries]) => {
const listings = namespaceEntries
.map((entry) => {
const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? ""
const description =
firstLine.length > DESCRIPTION_LIMIT
? firstLine.slice(0, DESCRIPTION_LIMIT - 3) + "..."
: firstLine
const suffix = description.length === 0 ? "" : ` // ${description}`
return { path: entry.path, line: ` - ${entry.signature}${suffix}` }
})
.toSorted((left, right) => {
if (left.path < right.path) return -1
if (left.path > right.path) return 1
return 0
})
return {
name,
listings,
selectionOrder: rankListings(listings),
selectedListings: new Set<typeof Listing.Type>(),
}
})
const active = new Set(namespaces)
let remaining = budget
while (active.size > 0) {
for (const namespace of active) {
const candidate = namespace.selectionOrder[namespace.selectedListings.size]
if (!candidate || candidate.cost > remaining) {
active.delete(namespace)
continue
}
namespace.selectedListings.add(candidate.listing)
remaining -= candidate.cost
if (namespace.selectedListings.size === namespace.selectionOrder.length) active.delete(namespace)
}
}
const namespaceSummaries = namespaces.map((namespace) => ({
name: namespace.name,
count: namespace.listings.length,
entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)),
}))
return {
total: entries.length,
shown: namespaceSummaries.reduce((total, namespace) => total + namespace.entries.length, 0),
namespaces: namespaceSummaries,
}
}
function rankListings(listings: ReadonlyArray<typeof Listing.Type>) {
return listings
.map((listing) => ({ listing, cost: Math.round(listing.line.length / CHARACTERS_PER_TOKEN) }))
.toSorted((left, right) => {
if (left.cost !== right.cost) return left.cost - right.cost
if (left.listing.path < right.listing.path) return -1
if (left.listing.path > right.listing.path) return 1
return 0
})
}
+14 -129
View File
@@ -1,139 +1,24 @@
export * as CodeModeInstructions from "./instructions"
import { searchSignature, toolExpression } from "@opencode-ai/codemode"
import { Effect, Schema } from "effect"
import { Instructions } from "../instructions/index"
import { CodeModeCatalog } from "./catalog"
// prettier-ignore
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
Inside Code Mode, \`tools\` contains only the tools shown below${hasMoreTools ? " or returned by `search`" : ""}; surrounding top-level agent tools are not available and must not be called from the code.${hasMoreTools ? `
## Search
Use \`search\` to discover exact paths and signatures for additional tools:
- ${searchSignature}` : ""}
## Available tools`
export function render(catalog: CodeModeCatalog.Summary) {
if (catalog.total === 0) return "No tools are currently available."
const tools = catalog.namespaces.flatMap((namespace) => {
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
const label =
namespace.entries.length === namespace.count
? count
: namespace.entries.length === 0
? `${count}, none shown`
: `${count}, ${namespace.entries.length} shown`
return [`- ${namespace.name} (${label})`, ...namespace.entries.map((entry) => entry.line)]
})
return `${prompt(catalog.shown < catalog.total)}
${tools.join("\n")}`
}
export function update(previous: CodeModeCatalog.Summary, current: CodeModeCatalog.Summary) {
const full = `The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.
${render(current)}`
const previousComplete = previous.shown === previous.total
const currentComplete = current.shown === current.total
if (previousComplete !== currentComplete) return full
const diff = Instructions.diffByKey(
previous.namespaces.flatMap((namespace) => namespace.entries),
current.namespaces.flatMap((namespace) => namespace.entries),
(entry) => entry.path,
(before, after) => before.line !== after.line,
)
const entriesChanged = diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0
if (!currentComplete) {
if (entriesChanged) return full
const namespaces = Instructions.diffByKey(
previous.namespaces,
current.namespaces,
(namespace) => namespace.name,
(before, after) => before.count !== after.count,
)
const changed = namespaces.added.length > 0 || namespaces.removed.length > 0 || namespaces.changed.length > 0
if (!changed) return full
const parts = ["The Code Mode tool catalog has changed."]
if (namespaces.added.length > 0) {
parts.push(
`New tool namespaces are available: ${namespaces.added
.map((namespace) => `\`${namespace.name}\` (${namespace.count} tools)`)
.join(", ")}.`,
)
}
if (namespaces.changed.length > 0) {
parts.push(
`The following namespace inventories changed; search them again before relying on previous results: ${namespaces.changed
.map((change) => `\`${change.current.name}\` now has ${change.current.count} tools`)
.join(", ")}.`,
)
}
if (namespaces.removed.length > 0) {
parts.push(
`The following tool namespaces are no longer available and must not be used: ${namespaces.removed
.map((namespace) => `\`${namespace.name}\``)
.join(", ")}.`,
)
}
const delta = parts.join("\n\n")
if (delta.length < full.length) return delta
return full
}
if (!entriesChanged) return full
const parts = ["The Code Mode tool catalog has changed."]
if (diff.added.length > 0) {
parts.push(
[
"New tools are available in addition to those previously listed:",
...diff.added.map((entry) => entry.line),
].join("\n"),
)
}
if (diff.changed.length > 0) {
parts.push(
[
"Changed tool listings supersede the previously listed ones:",
...diff.changed.map((change) => change.current.line),
].join("\n"),
)
}
if (diff.removed.length > 0) {
parts.push(
`The following tools are no longer available and must not be called: ${diff.removed
.map((entry) => toolExpression(entry.path))
.join(", ")}.`,
)
}
const delta = parts.join("\n\n")
if (delta.length < full.length) return delta
return full
}
const key = Instructions.Key.make("core/codemode")
const codec = Schema.toCodecJson(CodeModeCatalog.Summary)
const codec = Schema.toCodecJson(Schema.String)
const render = {
initial: (current: string) => current,
changed: (_previous: string, current: string) =>
[
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.",
current,
].join("\n\n"),
removed: () => "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
}
export const make = (entries?: ReadonlyArray<CodeModeCatalog.Entry>): Instructions.Instructions => {
const catalog = CodeModeCatalog.summarize(entries ?? [])
return Instructions.make({
export const make = (content?: string): Instructions.Instructions =>
Instructions.make({
key,
codec,
read: Effect.succeed(catalog.total === 0 ? Instructions.removed : catalog),
render: {
initial: render,
changed: update,
removed: () => "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
},
read: Effect.succeed(content ?? Instructions.removed),
render,
})
}
-5
View File
@@ -218,11 +218,6 @@ const layer = Layer.effect(
if (result.effect === "allow") return
const item = yield* create(request(input), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe(
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
// must not convert a user's decline into model-facing tool output. The decline
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
// it into ToolFailure and the model continues.
Effect.catchTag("PermissionV2.DeclinedError", (error) => Effect.die(error)),
Effect.ensuring(
Effect.sync(() => {
+1 -1
View File
@@ -97,7 +97,7 @@ const layer = Layer.effect(
agent: { ...agent, info: agent.info },
instructions: Instructions.combine([
loaded.builtins,
CodeModeInstructions.make(loaded.toolSet.codeModeCatalog),
CodeModeInstructions.make(loaded.toolSet.codeModeInstructions),
loaded.discovery,
loaded.skills,
loaded.references,
@@ -40,7 +40,8 @@ export const layer = Layer.effect(
// Drain failures are already logged and durably recorded by the execution layer.
yield* Effect.ignore(execution.resume(sessionID))
}),
{ concurrency: "unbounded", discard: true },
// Each suspension is consumed atomically right before its drain; at most four drains run at once.
{ concurrency: 4, discard: true },
)
}),
})
+4 -28
View File
@@ -2,14 +2,11 @@ export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest, type ToolContent } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Context, Effect, Layer, Result } from "effect"
import { Context, Effect, Layer } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app"
import { ModelV2 } from "../model"
import { PermissionV2 } from "../permission"
import { PluginHooks } from "../plugin/hooks"
import { QuestionTool } from "../tool/question"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "../tool/registry"
import { SessionContext } from "./context"
import { SessionModelHeaders } from "./model-headers"
@@ -17,32 +14,13 @@ import { MAX_STEPS_PROMPT } from "./runner/max-steps"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
export type ExecuteError = ToolOutputStore.Error | PermissionV2.DeclinedError | QuestionTool.CancelledError
// User declines dive under the leaves' blanket `mapError` as defects (the deliberate
// tunnel entered in PermissionV2.assert and the question tool), so a user's "no" can
// never become model-facing tool output. They resurface as typed failures exactly once,
// here at the seam the runner executes through.
const declineDefect = (cause: Cause.Cause<ToolOutputStore.Error>) => {
const decline = cause.reasons.flatMap((reason) =>
Cause.isDieReason(reason) &&
(reason.defect instanceof PermissionV2.DeclinedError || reason.defect instanceof QuestionTool.CancelledError)
? [reason.defect]
: [],
)[0]
return decline ? Result.succeed(decline) : Result.fail(cause)
}
interface Prepared {
readonly request: LLMRequest
/**
* One request-scoped execution operation. Unknown, hook-removed, and
* step-limit-violating calls fail individually through the same seam.
*/
readonly executeTool: (
input: ToolRegistry.ExecuteInput,
) => Effect.Effect<ToolRegistry.ToolOutcome, ExecuteError>
readonly executeTool: ToolRegistry.ToolSet["execute"]
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */
readonly stepLimitReached: boolean
}
@@ -156,7 +134,7 @@ export const layer = Layer.effect(
tools: hookedTools,
toolChoice: stepLimitReached ? "none" : undefined,
})
const executeTool: Prepared["executeTool"] = (executeInput) => {
const executeTool: ToolRegistry.ToolSet["execute"] = (executeInput) => {
if (stepLimitReached)
return Effect.succeed({
status: "error",
@@ -167,9 +145,7 @@ export const layer = Layer.effect(
status: "error",
error: { type: "tool.unknown", message: `Tool is not available for this request: ${executeInput.call.name}` },
})
return toolSet
.execute(executeInput)
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
return toolSet.execute(executeInput)
}
return {
request,
+60 -91
View File
@@ -1,6 +1,6 @@
export * as SessionPending from "./pending"
import { and, asc, eq, or } from "drizzle-orm"
import { and, asc, eq } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import {
Compaction,
@@ -26,13 +26,6 @@ type DatabaseService = Database.Interface["db"]
export { Compaction, Delivery, Info, Message, Synthetic, SyntheticData, User, UserData }
/**
* Which pending input `promote` may consume: "steer" promotes steers only (a step
* boundary mid-work), while "input" also allows one queued input when no steers are
* waiting (the idle boundary, where the Session picks up fresh work).
*/
export type Promotable = "input" | "steer"
const decodeUser = Schema.decodeUnknownSync(UserData)
const encodeUser = Schema.encodeSync(UserData)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
@@ -362,32 +355,16 @@ export const list = Effect.fn("SessionPending.list")(function* (db: DatabaseServ
return rows.map(fromRow)
})
/**
* Which pending rows count: "any" counts every row including compaction, while
* delivery scopes are blocked behind a pending compaction barrier. "input" means
* any model-facing input, steered or queued.
*/
export type Scope = "any" | "input" | Delivery
export const has = Effect.fn("SessionPending.has")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
scope: Scope,
delivery: Delivery,
) {
if (scope !== "any" && (yield* compaction(db, sessionID))) return false
if (yield* compaction(db, sessionID)) return false
const row = yield* db
.select({ id: SessionPendingTable.id })
.from(SessionPendingTable)
.where(
and(
eq(SessionPendingTable.session_id, sessionID),
scope === "any"
? undefined
: scope === "input"
? or(eq(SessionPendingTable.delivery, "steer"), eq(SessionPendingTable.delivery, "queue"))
: eq(SessionPendingTable.delivery, scope),
),
)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, delivery)))
.limit(1)
.get()
.pipe(Effect.orDie)
@@ -416,74 +393,66 @@ const publish = Effect.fn("SessionPending.publish")(function* (
events: EventV2.Interface,
sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionPendingTable.$inferSelect>,
) {
if (yield* compaction(db, sessionID)) return 0
yield* Effect.forEach(
rows,
(row) => {
const entry = fromRow(row)
if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
return events
.publish(SessionEvent.InputPromoted, {
sessionID,
inputID: entry.id,
})
.pipe(
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? promotedFromHistory(db, sessionID, entry.id).pipe(
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
)
: Effect.die(defect),
),
)
},
{ discard: true },
)
return rows.length
})
/**
* Promotes pending input into visible messages and returns the promoted count.
* Steers always go first; only the "input" scope may fall through to one queued
* input, and it then collects steers that arrived during promotion.
*/
export const promote = Effect.fn("SessionPending.promote")(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: SessionSchema.ID,
scope: Promotable,
) {
return yield* inboxLocks.withLock(sessionID)(
Effect.gen(function* () {
if (yield* compaction(db, sessionID)) return 0
const steers = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie)
if (steers.length > 0 || scope === "steer") return yield* publish(db, events, sessionID, steers)
const queued = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "queue")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!queued) return 0
const promoted = yield* publish(db, events, sessionID, [queued])
const arrivedSteers = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie)
return promoted + (yield* publish(db, events, sessionID, arrivedSteers))
yield* Effect.forEach(
rows,
(row) => {
const entry = fromRow(row)
if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
return events
.publish(SessionEvent.InputPromoted, {
sessionID,
inputID: entry.id,
})
.pipe(
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? promotedFromHistory(db, sessionID, entry.id).pipe(
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
)
: Effect.die(defect),
),
)
},
{ discard: true },
)
return rows.length
}),
)
})
export const promoteSteers = Effect.fn("SessionPending.promoteSteers")(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: SessionSchema.ID,
) {
if (yield* compaction(db, sessionID)) return 0
const rows = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie)
return yield* publish(db, events, sessionID, rows)
})
export const promoteNextQueued = Effect.fn("SessionPending.promoteNextQueued")(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: SessionSchema.ID,
) {
if (yield* compaction(db, sessionID)) return false
const row = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "queue")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true))
})
+1 -1
View File
@@ -20,7 +20,7 @@ export type RunError =
/** Runs one local continuation from already-recorded Session history. */
export interface Interface {
/** Drains eligible durable work. Explicit runs make one model call even when no work is eligible. */
/** Drains eligible durable work. Explicit runs perform one physical attempt even when no work is eligible. */
readonly drain: (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
+253 -291
View File
@@ -1,7 +1,8 @@
export * as SessionRunnerLLM from "./llm"
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent, type ToolCall } from "@opencode-ai/ai"
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Semaphore, Stream } from "effect"
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { PermissionV2 } from "../../permission"
@@ -27,58 +28,6 @@ import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry"
import { SessionUsage } from "../usage"
/** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */
type CallOutcome = Data.TaggedEnum<{
Completed: { readonly needsContinuation: boolean; readonly step: number }
Retry: { readonly step: number }
Restart: { readonly step: number; readonly recoveredOverflow: boolean }
}>
const CallOutcome = Data.taggedEnum<CallOutcome>()
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
const isDecline = (
error: SessionModelRequest.ExecuteError,
): error is PermissionV2.DeclinedError | QuestionTool.CancelledError =>
error._tag === "PermissionV2.DeclinedError" || error._tag === "QuestionTool.CancelledError"
/**
* Classifies how the owned tool fibers ended. Interrupts abort the step; a user decline
* settles its own call and then aborts the step; a defect from a tool implementation
* becomes a failed tool call the model can read; a typed infrastructure failure must
* fail the assistant and then the drain.
*/
const classifyToolExits = (
settled: Exit.Exit<Array<Exit.Exit<void, SessionModelRequest.ExecuteError>>, never>,
calls: ReadonlyArray<ToolCall>,
) => {
// Exits align with calls by construction: one owned fiber per accepted local call.
const exits = settled._tag === "Success" ? settled.value : []
const declines = exits.flatMap((exit, index) =>
exit._tag === "Failure"
? exit.cause.reasons.flatMap((reason) =>
Cause.isFailReason(reason) && isDecline(reason.error) ? [{ call: calls[index], reason: reason.error }] : [],
)
: [],
)
const causes =
settled._tag === "Failure" ? [settled.cause] : exits.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
// The first non-interrupt, non-decline failure, rebuilt without decline reasons so the
// drain's error channel never carries a decline.
const failure = causes.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
const reasons = cause.reasons.flatMap((reason): Array<Cause.Reason<ToolOutputStore.Error>> =>
Cause.isFailReason(reason) ? (isDecline(reason.error) ? [] : [Cause.makeFailReason(reason.error)]) : [reason],
)
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
})[0]
return {
interrupted: causes.some(Cause.hasInterrupts),
declines,
failure,
infraError: failure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(failure)),
}
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -94,138 +43,77 @@ const layer = Layer.effect(
// Title generation is a side effect of the first step; it must not delay step continuation.
// Tracked per process so repeated wakes before the second user message arrives don't
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
const titleStarted = new Set<SessionSchema.ID>()
const titleAttempted = new Set<SessionSchema.ID>()
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
/**
* Drains eligible manual compaction and user input until the Session becomes idle.
* Execution lifecycle is published per busy period by SessionExecution, not here.
*/
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
}) {
if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "any"))) return
yield* settleStaleToolCalls(input.sessionID)
yield* runPendingCompaction(input.sessionID)
if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "input"))) return
do {
yield* runSteps(input.sessionID)
} while (yield* SessionPending.has(db, input.sessionID, "input"))
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
return session
})
/**
* Runs logical steps until no tool result or newly admitted steer requires another
* model call. Queued inputs remain pending until the current model work reaches idle.
*/
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (sessionID: SessionSchema.ID) {
// Fresh work may promote queued input; later steps absorb steers only.
let promotable: SessionPending.Promotable = "input"
let step = 1
while (true) {
const result = yield* runStep(sessionID, promotable, step)
yield* startTitleOnce(sessionID)
yield* runPendingCompaction(sessionID)
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
promotable = "steer"
step = result.step + 1
}
})
/** Completes one logical model step, transparently retrying or rebuilding after compaction. */
const runStep = Effect.fnUntraced(function* (
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
sessionID: SessionSchema.ID,
promotable: SessionPending.Promotable,
step: number,
) {
// Minting message identity before any attempt lets retries resume the same durable
// message. A compaction restart re-mints: the old message is stranded behind the new
// compaction boundary, so the rebuilt step needs identity inside the new epoch.
let assistantMessageID = SessionMessage.ID.create()
const retry = yield* Schedule.toStepWithSleep(
SessionRunnerRetry.schedule(events, sessionID, () => assistantMessageID),
)
/**
* Consumes one retry allowance: sleeps the scheduled backoff, or publishes
* Step.Failed and fails once attempts are exhausted. The step loop performs
* the retry itself on the next iteration.
*/
const waitForRetry = (failure: SessionRunnerRetry.RetryableFailure) =>
retry(failure).pipe(
Effect.as(CallOutcome.Retry({ step: failure.step })),
Pull.catchDone(() =>
events
.publish(SessionEvent.Step.Failed, {
sessionID,
assistantMessageID,
error: failure.error,
})
.pipe(Effect.andThen(Effect.fail(failure.cause))),
),
)
let currentPromotable: SessionPending.Promotable | undefined = promotable
let currentStep = step
// Overflow recovery is one-shot: a call after recovery must not recover another overflow.
let recoverOverflow = true
while (true) {
const outcome = yield* callModel(
sessionID,
currentPromotable,
currentStep,
recoverOverflow,
assistantMessageID,
).pipe(Effect.catchTag("SessionRunner.RetryableFailure", waitForRetry))
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: outcome.step }
if (outcome._tag === "Restart") {
if (outcome.recoveredOverflow) recoverOverflow = false
assistantMessageID = SessionMessage.ID.create()
for (const message of yield* store.context(sessionID)) {
if (message.type !== "assistant") continue
for (const tool of message.content) {
if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID,
assistantMessageID: message.id,
callID: tool.id,
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
executed: tool.executed === true,
})
}
// Neither a retry nor a compaction restart re-promotes input.
currentPromotable = undefined
currentStep = outcome.step
}
})
/**
* Prepares and runs at most one model call, executes its local tools, and durably
* settles the step. Compaction may instead request that the logical step restart.
*/
const callModel = Effect.fn("SessionRunner.callModel")(function* (
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
const isUserDeclined = (cause: Cause.Cause<unknown>) =>
cause.reasons.some(
(reason) =>
Cause.isDieReason(reason) &&
(reason.defect instanceof PermissionV2.DeclinedError || reason.defect instanceof QuestionTool.CancelledError),
)
const attemptStep = Effect.fn("SessionRunner.attemptStep")(function* (
sessionID: SessionSchema.ID,
promotable: SessionPending.Promotable | undefined,
promotion: SessionPending.Delivery | undefined,
step: number,
recoverOverflow: boolean,
assistantMessageID: SessionMessage.ID,
recoverOverflow?: typeof compaction.compact,
assistantMessageID?: SessionMessage.ID,
) {
const selected = yield* context.select(sessionID)
// Establish what the model knows before admitting what the user said, so
// a blocked first step leaves pending inputs untouched.
yield* InstructionState.prepare(db, events, selected.instructions, selected.session.id)
const promoted = promotable ? yield* SessionPending.promote(db, events, selected.session.id, promotable) : 0
// Promoted input opens a fresh step allowance.
const currentStep = promoted > 0 ? 1 : step
let currentStep = step
if (promotion) {
let promoted = 0
if (promotion === "steer") promoted = yield* SessionPending.promoteSteers(db, events, selected.session.id)
if (promotion === "queue") {
promoted += Number(yield* SessionPending.promoteNextQueued(db, events, selected.session.id))
promoted += yield* SessionPending.promoteSteers(db, events, selected.session.id)
}
if (promoted > 0) currentStep = 1
}
const loaded = yield* context.load(selected)
const { session, agent } = loaded
const session = loaded.session
const agent = loaded.agent
const resolved = loaded.model
const model = resolved.model
// Make room: history must fit the context window before the call. A pending manual
// compaction owns this instead; the runner executes it between steps.
const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost }
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed")
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
return yield* new StepFailedError({ error: compacted.error })
}
const prepared = yield* modelRequests.prepare({
context: loaded,
step: currentStep,
})
// Every local tool call forked here is owned until it reaches one durable settlement.
const toolRuns: Array<{
readonly call: ToolCall
readonly fiber: Fiber.Fiber<void, SessionModelRequest.ExecuteError>
}> = []
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
let needsContinuation = false
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(events, {
@@ -243,6 +131,46 @@ const layer = Layer.effect(
// mid-event.
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent) => serialized(publisher.publish(event))
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(prepared.request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
if (LLMEvent.is.providerError(event)) {
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
overflowFailure = event
return
}
}
yield* publish(event)
if (LLMEvent.is.toolInputError(event)) {
if (!prepared.stepLimitReached) needsContinuation = true
return
}
if (event.type !== "tool-call" || event.providerExecuted) return
// Unavailable calls fail individually through the same execution seam;
// continuation depends only on remaining Step allowance.
if (!prepared.stepLimitReached) needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
ownedToolFibers.push(
yield* Effect.uninterruptibleMask((restore) =>
restore(
prepared.executeTool({
sessionID: session.id,
agent: agent.id,
messageID: assistantMessageID,
call: event,
progress: (update) => serialized(publisher.progress(event.id, update)),
}),
).pipe(
Effect.flatMap((execution) => serialized(publisher.toolExecution(event.id, event.name, execution))),
),
).pipe(FiberSet.run(toolFibers)),
)
}),
),
Effect.ensuring(serialized(publisher.flush())),
)
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
cost: SessionUsage.calculateCost(resolved.cost, settlement.tokens),
@@ -274,55 +202,9 @@ const layer = Layer.effect(
)
})
// The stream is defined here but runs inside the settlement mask below: publish each
// event durably, fork one fiber per local tool call, and hold back a virgin
// context-overflow provider error so settlement may recover it via compaction.
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(prepared.request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
if (LLMEvent.is.providerError(event)) {
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
overflowFailure = event
return
}
}
yield* publish(event)
if (LLMEvent.is.toolInputError(event)) {
if (!prepared.stepLimitReached) needsContinuation = true
return
}
if (event.type !== "tool-call" || event.providerExecuted) return
// Unavailable calls fail individually through the same execution seam;
// continuation depends only on remaining Step allowance.
if (!prepared.stepLimitReached) needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
toolRuns.push({
call: event,
fiber: yield* Effect.uninterruptibleMask((restore) =>
restore(
prepared.executeTool({
sessionID: session.id,
agent: agent.id,
messageID: assistantMessageID,
call: event,
progress: (update) => serialized(publisher.progress(event.id, update)),
}),
).pipe(
Effect.flatMap((execution) => serialized(publisher.toolExecution(event.id, event.name, execution))),
),
).pipe(Effect.forkScoped),
})
}),
),
Effect.ensuring(serialized(publisher.flush())),
)
// Settle: only the stream itself is interruptible (restore); every line after it is
// protected so a started call always reaches one durable outcome.
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
// Gather the evidence: how did the provider stream end?
const stream = yield* restore(providerStream).pipe(Effect.exit)
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
// Note: Exit.hasInterrupts is a type guard whose false branch unsoundly narrows
@@ -335,9 +217,10 @@ const layer = Layer.effect(
recoverOverflow &&
!publisher.hasRetryEvidence() &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(compaction.compact(compactionInput))).status === "completed"
(yield* restore(recoverOverflow({ session, messages: loaded.messages, model, cost: resolved.cost })))
.status === "completed"
)
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: true })
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
// An unrecovered held-back overflow becomes the step's durable provider error. A
// thrown LLM failure records the assistant failure unless a provider error was
@@ -347,11 +230,9 @@ const layer = Layer.effect(
if (llmFailure && !publisher.hasProviderError()) {
const error = toSessionError(llmFailure)
if (SessionRunnerRetry.isRetryable(llmFailure) && !publisher.hasRetryEvidence()) {
// RetryScheduled and Step.Failed fold onto an existing assistant message, so
// Step.Started must be durable before the failure escapes.
yield* serialized(publisher.startAssistant())
return yield* new SessionRunnerRetry.RetryableFailure({
cause: llmFailure,
assistantMessageID: yield* publisher.startAssistant(),
error,
step: currentStep,
})
@@ -361,54 +242,68 @@ const layer = Layer.effect(
// Provider error events only arrive from the stream, so the flag is final here.
const providerFailed = publisher.hasProviderError()
// Settle every owned tool run: await all exits, not just the first failure,
// before publishing the terminal step event.
if (streamInterrupted) yield* interruptTools
// Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain
// the individual fibers and await all exits before publishing the terminal step event.
if (streamInterrupted) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(
Effect.forEach(toolRuns, (run) => Fiber.await(run.fiber), { concurrency: "unbounded" }),
Effect.forEach(ownedToolFibers, Fiber.await, { concurrency: "unbounded" }),
).pipe(Effect.exit)
if (settled._tag === "Failure") yield* interruptTools
const tools = classifyToolExits(
settled,
toolRuns.map((run) => run.call),
)
const settledCauses =
settled._tag === "Failure"
? [settled.cause]
: settled.value.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
const toolsInterrupted = settledCauses.some(Cause.hasInterrupts)
const userDeclined = settledCauses.some(isUserDeclined)
// A declined call settles durably with its reason before the generic sweeps.
for (const decline of tools.declines)
yield* serialized(
publisher.failTool(decline.call.id, {
type: "aborted",
message:
decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
}),
)
if (tools.declines.length > 0 || streamInterrupted || tools.interrupted) {
if (settled._tag === "Failure") yield* FiberSet.clear(toolFibers)
if (userDeclined || streamInterrupted || toolsInterrupted) {
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
}
if (tools.failure !== undefined) {
const error = toSessionError(tools.infraError ?? Cause.squash(tools.failure))
// A settled tool fiber failure is one of two things. A defect from a tool
// implementation becomes a failed tool call the model can read, and the step still
// settles so the model may recover. A typed infrastructure failure (tool output
// could not be persisted) also fails the assistant and then fails the drain.
const settledFailure = settledCauses.find((cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause))
const infraError =
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
if (settledFailure !== undefined) {
const failure = infraError ?? Cause.squash(settledFailure)
const error = toSessionError(failure)
yield* serialized(publisher.failUnsettledTools(error))
if (tools.infraError !== undefined) yield* serialized(publisher.failAssistant(error))
if (infraError !== undefined) yield* serialized(publisher.failAssistant(error))
}
// Fail unresolved calls before the terminal step event. Local calls have joined, so
// these sweeps only close calls that could not produce a truthful settlement.
if (providerFailed)
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
const resultMissing = {
type: "tool.result-missing",
message: "Provider did not return a tool result",
} as const
if (llmFailure && !providerFailed) yield* serialized(publisher.failUnsettledTools(resultMissing, "hosted"))
// A clean stream that still left hosted calls unresolved fails the step itself.
if (stream._tag === "Success" && !providerFailed) {
const hostedResultMissing = yield* serialized(publisher.failUnsettledTools(resultMissing, "hosted"))
if (hostedResultMissing && !publisher.stepSettlement())
yield* serialized(publisher.failAssistant(resultMissing))
}
if (llmFailure && !providerFailed)
yield* serialized(
publisher.failUnsettledTools(
{
type: "tool.result-missing",
message: "Provider did not return a tool result",
},
true,
),
)
const hostedResultMissing =
stream._tag === "Success" && !providerFailed
? yield* serialized(
publisher.failUnsettledTools(
{ type: "tool.result-missing", message: "Provider did not return a tool result" },
true,
),
)
: false
if (hostedResultMissing && !publisher.stepSettlement())
yield* serialized(
publisher.failAssistant({
type: "tool.result-missing",
message: "Provider did not return a tool result",
}),
)
const stepFailure = publisher.stepFailure()
const stepSettlement = publisher.stepSettlement()
@@ -424,17 +319,68 @@ const layer = Layer.effect(
}
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
if ((tools.interrupted || tools.infraError !== undefined) && tools.failure)
return yield* Effect.failCause(tools.failure)
if (tools.interrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
if (userDeclined) return yield* Effect.interrupt
if ((toolsInterrupted || infraError !== undefined) && settledFailure)
return yield* Effect.failCause(settledFailure)
if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
return CallOutcome.Completed({ needsContinuation, step: currentStep })
return {
_tag: "Completed",
needsContinuation,
step: currentStep,
} as const
}),
)
}, Effect.scoped)
/** Executes a previously admitted manual compaction request, if one is pending. */
const runStep = Effect.fnUntraced(function* (
sessionID: SessionSchema.ID,
promotion: SessionPending.Delivery | undefined,
step: number,
) {
// Compaction restarts rebuild the request from compacted history without re-promoting.
// Overflow recovery is one-shot: a post-compaction attempt must not recover another
// overflow, so the recovery hook is dropped after it fires.
let recoverOverflow: typeof compaction.compact | undefined = compaction.compact
let currentPromotion = promotion
let currentStep = step
let assistantMessageID: SessionMessage.ID | undefined
while (true) {
const attempt = yield* Effect.suspend(() =>
attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID),
).pipe(
Effect.tapError((error) =>
error instanceof SessionRunnerRetry.RetryableFailure
? Effect.sync(() => {
currentStep = error.step
assistantMessageID = error.assistantMessageID
currentPromotion = undefined
})
: Effect.void,
),
Effect.retryOrElse(SessionRunnerRetry.schedule(events, sessionID), (error) => {
if (!(error instanceof SessionRunnerRetry.RetryableFailure)) return Effect.fail(error)
return events
.publish(SessionEvent.Step.Failed, {
sessionID,
assistantMessageID: error.assistantMessageID,
error: error.error,
})
.pipe(Effect.andThen(Effect.fail(error.cause)))
}),
)
if (attempt._tag === "Completed")
return {
needsContinuation: attempt.needsContinuation,
step: attempt.step,
}
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
yield* Effect.yieldNow
currentPromotion = undefined
currentStep = attempt.step
}
})
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID,
) {
@@ -453,53 +399,69 @@ const layer = Layer.effect(
}),
).pipe(Effect.exit)
if (Exit.isSuccess(compacted)) return
const unsettled = yield* SessionPending.compaction(db, sessionID)
if (unsettled)
yield* events.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
error: Cause.hasInterruptsOnly(compacted.cause)
? { type: "aborted", message: "Compaction cancelled" }
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
inputID: unsettled.id,
})
return yield* Effect.failCause(compacted.cause)
if (Exit.isFailure(compacted)) {
const unsettled = yield* SessionPending.compaction(db, sessionID)
if (unsettled)
yield* events.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
error: Cause.hasInterruptsOnly(compacted.cause)
? { type: "aborted", message: "Compaction cancelled" }
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
inputID: unsettled.id,
})
return yield* Effect.failCause(compacted.cause)
}
}),
)
})
/** Closes stale tool calls left active by an earlier interrupted drain. */
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
sessionID: SessionSchema.ID,
) {
for (const message of yield* store.context(sessionID)) {
if (message.type !== "assistant") continue
for (const tool of message.content) {
if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID,
assistantMessageID: message.id,
callID: tool.id,
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
executed: tool.executed === true,
})
// Execution lifecycle is published per busy period by SessionExecution, not per drain here.
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
}) {
yield* runPendingCompaction(input.sessionID)
const hasSteer = yield* SessionPending.has(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionPending.has(db, input.sessionID, "queue")
if (!input.force && !hasSteer && !hasQueue) return
yield* failInterruptedTools(input.sessionID)
let promotion: SessionPending.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
let shouldRun = input.force || hasSteer || hasQueue
while (shouldRun) {
let needsContinuation = true
let step = 1
// Repeat steps while continuation is needed. A step needs continuation only
// when it recorded local tool calls whose results the model has not yet seen;
// a provider error suppresses it. Pending steers also continue the loop so
// interjections are answered before the session goes idle.
while (needsContinuation) {
const result = yield* runStep(input.sessionID, promotion, step)
// Steer/queue promotion inside runStep has already made the pending input a visible
// user message by this point, so the first-user-message check below is reliable.
if (!titleAttempted.has(input.sessionID)) {
titleAttempted.add(input.sessionID)
forkTitle(title.generateForFirstPrompt(yield* getSession(input.sessionID)).pipe(Effect.ignore))
}
needsContinuation = result.needsContinuation
step = result.step + 1
if (needsContinuation) {
yield* runPendingCompaction(input.sessionID)
promotion = "steer"
continue
}
yield* runPendingCompaction(input.sessionID)
promotion = "steer"
needsContinuation = yield* SessionPending.has(db, input.sessionID, "steer")
}
yield* runPendingCompaction(input.sessionID)
const hasSteer = yield* SessionPending.has(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionPending.has(db, input.sessionID, "queue")
shouldRun = hasSteer || hasQueue
promotion = hasSteer ? "steer" : hasQueue ? "queue" : undefined
}
})
/** Fires title generation once per process after the first step makes a user message visible. */
const startTitleOnce = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
if (titleStarted.has(sessionID)) return
titleStarted.add(sessionID)
forkTitle(title.generateForFirstPrompt(yield* getSession(sessionID)).pipe(Effect.ignore))
})
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
return session
})
return Service.of({ drain })
}),
)
@@ -20,7 +20,7 @@ type Input = {
readonly model: ModelV2.Ref
readonly providerMetadataKey: string
readonly snapshot?: Snapshot.ID
readonly assistantMessageID: SessionMessage.ID
readonly assistantMessageID?: SessionMessage.ID
}
const record = (value: unknown): Record<string, unknown> =>
@@ -50,7 +50,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
>()
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) =>
tool.progress === undefined ? {} : { metadata: tool.progress }
const assistantMessageID = input.assistantMessageID
let assistantMessageID = input.assistantMessageID
let stepStarted = false
let stepFailed = false
let providerFailed = false
@@ -64,7 +64,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
| undefined
const startAssistant = Effect.fnUntraced(function* () {
if (stepStarted) return assistantMessageID
if (stepStarted && assistantMessageID !== undefined) return assistantMessageID
assistantMessageID ??= SessionMessage.ID.create()
stepStarted = true
yield* events.publish(SessionEvent.Step.Started, {
sessionID: input.sessionID,
@@ -76,7 +77,9 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
return assistantMessageID
})
const currentAssistantMessageID = () =>
stepStarted ? Effect.succeed(assistantMessageID) : Effect.die(new Error("Tool event before assistant step start"))
assistantMessageID === undefined
? Effect.die(new Error("Tool event before assistant step start"))
: Effect.succeed(assistantMessageID)
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.providerMetadataKey]
const fragments = (
name: string,
@@ -232,27 +235,21 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
yield* flushFragments()
})
const failTool = Effect.fnUntraced(function* (callID: string, error: SessionError.Error) {
const tool = tools.get(callID)
if (!tool || tool.settled) return false
tool.settled = true
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
error,
...failureSnapshot(tool),
executed: tool.providerExecuted,
})
return true
})
const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
let failed = false
for (const [callID, tool] of tools) {
if (tool.settled || (mode === "hosted" && !tool.providerExecuted) || (mode === "uncalled" && tool.called))
continue
failed = (yield* failTool(callID, error)) || failed
tool.settled = true
failed = true
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
error,
...failureSnapshot(tool),
executed: tool.providerExecuted,
})
}
return failed
})
@@ -283,9 +280,9 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
error: SessionError.Error,
scope: "hosted" | "all" = "all",
hostedOnly = false,
) {
return yield* failTools(error, scope)
return yield* failTools(error, hostedOnly ? "hosted" : "all")
})
const assistantMessageIDForTool = (callID: string) => {
@@ -531,7 +528,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
toolExecution,
flush,
failAssistant,
failTool,
publishStepFailure,
failUnsettledTools,
hasProviderError: () => providerFailed,
+16 -10
View File
@@ -7,9 +7,11 @@ import { EventV2 } from "../../event"
import { SessionEvent } from "../event"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
import type { SessionRunner } from "./index"
export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{
readonly cause: LLMError
readonly assistantMessageID: SessionMessage.ID
readonly error: SessionError.Error
readonly step: number
}> {}
@@ -41,20 +43,24 @@ const retryAfter = (failure: RetryableFailure) => {
return undefined
}
export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID, assistantMessageID: () => SessionMessage.ID) =>
export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID) =>
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
Schedule.setInputType<RetryableFailure>(),
Schedule.setInputType<RetryableFailure | SessionRunner.RunError>(),
Schedule.passthrough,
Schedule.while(({ input }) => input instanceof RetryableFailure),
Schedule.modifyDelay(({ input: failure, duration: delay }) => {
const minimum = retryAfter(failure)
const minimum = failure instanceof RetryableFailure ? retryAfter(failure) : undefined
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
}),
Schedule.tap((metadata) =>
events.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: assistantMessageID(),
attempt: metadata.attempt + 1,
at: metadata.now + Duration.toMillis(metadata.duration),
error: metadata.input.error,
}),
metadata.input instanceof RetryableFailure
? events.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: metadata.input.assistantMessageID,
attempt: metadata.attempt + 1,
at: metadata.now + Duration.toMillis(metadata.duration),
error: metadata.input.error,
})
: Effect.void,
),
)
+1 -1
View File
@@ -24,7 +24,7 @@ const source = {
}
```
Leaves own resolution, permission, and side-effect ordering. Translate only expected typed errors into `ToolFailure`; do not use `catchCause`, because interruption and defects must survive. User declines from `PermissionV2.assert` and question dismissals travel as defects beneath leaf `mapError` blankets and resurface as typed failures at `SessionModelRequest.executeTool`; leaves must never catch or convert them. A decline with feedback (`PermissionV2.CorrectedError`) stays typed so the leaf converts it into `ToolFailure` and the model continues.
Leaves own resolution, permission, and side-effect ordering. Translate only expected typed errors into `ToolFailure`; do not use `catchCause`, because interruption and defects must survive.
## Registration
+30 -32
View File
@@ -3,7 +3,7 @@ export type { Registration } from "./tool"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import type { ToolContent } from "@opencode-ai/ai"
import { Effect, Ref, Schema, Semaphore } from "effect"
import { Effect, Ref, Schema } from "effect"
import { execute, make, toLLMDefinition, type Content, type Metadata, type Registration } from "./tool"
const ExecuteFile = Schema.Struct({
@@ -34,11 +34,10 @@ type CollectedFiles = {
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
'Call Code Mode tools through `tools` using only exact paths and signatures from the current catalog or `search`. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
"Run JavaScript in a confined Code Mode runtime through { code }.",
"Call Code Mode tools through `tools` using the exact paths and signatures from the instructions.",
"Use `search({ query })` to discover exact signatures when needed.",
"Await important calls and use `Promise.all` for independent calls.",
].join("\n")
export const create = (registrations: ReadonlyMap<string, Registration>) => {
@@ -51,11 +50,12 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
const callIndex = yield* Ref.make(0)
const files = yield* Ref.make<Array<CollectedFiles>>([])
const calls = yield* Ref.make<Array<ExecuteCall>>([])
const lock = Semaphore.makeUnsafe(1)
const updateCalls = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
lock.withPermit(
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
)
// TODO: Publish live call-list updates once V2 has a generic tool progress API.
const finalCalls = Ref.get(calls).pipe(
Effect.map((items) =>
items.map((call) => (call.status === "running" ? { ...call, status: "error" as const } : call)),
),
)
const result = yield* runtime(
registrations,
(name, registration, input) =>
@@ -66,7 +66,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
agent: context.agent,
messageID: context.messageID,
callID: context.callID,
progress: () => Effect.void,
progress: context.progress,
}).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
const outputFileParts = outputFiles(executed.content)
if (outputFileParts.length > 0)
@@ -74,28 +74,26 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
return executed.output
}),
{
onToolCallStart: ({ index, name, input }) => {
const shown = displayInput(input)
return updateCalls((items) => {
onToolCallStart: ({ index, name, input }) =>
Effect.gen(function* () {
const shown = displayInput(input)
yield* Ref.update(calls, (items) => {
const next = [...items]
next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
return next
})
}),
onToolCallEnd: ({ index, outcome }) =>
Ref.update(calls, (items) => {
const current = items[index]
if (!current) return items
const next = [...items]
next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
next[index] = { ...current, status: outcome === "success" ? "completed" : "error" }
return next
})
},
onToolCallEnd: ({ index, name, input, outcome }) => {
const shown = displayInput(input)
return updateCalls((items) => {
const next = [...items]
next[index] = {
...(items[index] ?? { tool: name, ...(shown ? { input: shown } : {}) }),
status: outcome === "success" ? "completed" : "error",
}
return next
})
},
}),
},
).execute(code)
const toolCalls = yield* Ref.get(calls)
const toolCalls = yield* finalCalls
const collected = (yield* Ref.get(files))
.toSorted((left, right) => left.index - right.index)
.flatMap((item) => item.files)
@@ -128,8 +126,8 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
})
}
export const catalog = (registrations: ReadonlyMap<string, Registration>) => {
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()
export const instructions = (registrations: ReadonlyMap<string, Registration>) => {
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).instructions()
}
function runtime(
+15 -35
View File
@@ -7,7 +7,6 @@ import path from "path"
import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { PermissionV2 } from "../permission"
@@ -26,16 +25,11 @@ export const Input = Schema.Struct({
})
export const Output = Schema.Array(FileSystem.Entry)
type EncodedOutput = typeof Output.Encoded
type ModelOutput = typeof Output.Encoded
/** Format raw search results into the concise line-oriented output models expect. */
export const toModelContent = (entries: EncodedOutput, truncated = false) => {
const lines = entries.length === 0 ? ["No files found"] : entries.map((item) => item.path)
if (truncated)
lines.push(
"",
`(Results are truncated: showing first ${entries.length} results. Consider using a more specific path or pattern.)`,
)
export const toModelOutput = (output: ModelOutput) => {
const lines = output.length === 0 ? ["No files found"] : output.map((item) => item.path)
return lines.join("\n")
}
@@ -46,7 +40,6 @@ export const Plugin = {
const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const mutation = yield* LocationMutation.Service
const permission = yield* PermissionV2.Service
yield* ctx.tool
@@ -60,16 +53,6 @@ export const Plugin = {
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = { type: "tool" as const, messageID: context.messageID, callID: context.callID }
const target = yield* mutation.resolve({ path: input.path ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [input.pattern],
@@ -81,42 +64,39 @@ export const Plugin = {
},
sessionID: context.sessionID,
agent: context.agent,
source,
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const cwd = path.resolve(location.directory, input.path ?? ".")
yield* fs
.stat(target.canonical)
.stat(cwd)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
),
)
const root = path.resolve(location.directory, input.path ?? ".")
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const entries = yield* ripgrep
return yield* ripgrep
.glob({
cwd: target.canonical,
cwd,
pattern: input.pattern,
limit: limit + 1,
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
})
.pipe(
Effect.map((result) =>
result.map((entry) =>
FileSystem.Entry.make({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(root, entry.path))),
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
}),
),
),
)
return { entries: entries.slice(0, limit), truncated: entries.length > limit }
}).pipe(
Effect.map((result) => ({
output: result.entries,
content: toModelContent(
result.entries.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
result.truncated,
Effect.map((output) => ({
output,
content: toModelOutput(
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
),
metadata: { count: result.entries.length, truncated: result.truncated },
metadata: { count: output.length },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
-3
View File
@@ -91,9 +91,6 @@ export const Plugin = {
.pipe(Effect.orDie),
),
Effect.flatMap((state) => {
// Deliberate defect tunnel (see PermissionV2.assert): a dismissal must dodge
// leaf `mapError` blankets so it never becomes model-facing tool output; it
// resurfaces as a typed failure at SessionModelRequest.executeTool.
if (state.status === "cancelled") return Effect.die(new CancelledError())
const output = {
answers: input.questions.map((_, index): QuestionV2.Answer => {
+4 -5
View File
@@ -3,7 +3,6 @@ export * as ToolRegistry from "./registry"
import { type ToolCall, type ToolContent, type ToolDefinition } from "@opencode-ai/ai"
import { Context, Effect, Layer, Schema, Scope, Semaphore } from "effect"
import type { AgentV2 } from "../agent"
import { CodeModeCatalog } from "../codemode/catalog"
import { Image } from "../image"
import { PermissionV2 } from "../permission"
import { SessionMessage } from "../session/message"
@@ -45,13 +44,13 @@ export interface Interface {
}
/**
* One request-scoped snapshot pairing the Code Mode catalog and advertised
* One request-scoped snapshot pairing Code Mode instructions and advertised
* definitions with captured tools. A model request executes exactly the tool
* values it advertised even if registration changes while it is in flight.
*/
export interface ToolSet {
readonly definitions: ReadonlyArray<ToolDefinition>
readonly codeModeCatalog?: ReadonlyArray<CodeModeCatalog.Entry>
readonly codeModeInstructions?: string
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolOutcome, ToolOutputStore.Error>
}
@@ -325,9 +324,9 @@ const registryLayer = Layer.effect(
const codeModeMaterialization = yield* codeMode.materialize(permissions)
const codemodeTool = codeModeMaterialization.tool
return {
...(codeModeMaterialization.catalog === undefined
...(codeModeMaterialization.instructions === undefined
? {}
: { codeModeCatalog: codeModeMaterialization.catalog }),
: { codeModeInstructions: codeModeMaterialization.instructions }),
definitions: [
// Definitions are prompt-cache prefix bytes, so order only after effective registrations settle.
...Array.from(direct)
+2 -7
View File
@@ -22,13 +22,8 @@ describe("CodeMode", () => {
const materialized = yield* codeMode.materialize()
expect(materialized.tool).toBeDefined()
expect(materialized.catalog).toStrictEqual([
{
path: "echo",
description: "Echo text",
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
},
])
expect(materialized.instructions).toContain("Echo text")
expect(materialized.instructions).toContain("tools.echo(input:")
}).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(CodeMode.node))),
)
})
-175
View File
@@ -1,175 +0,0 @@
import { describe, expect, test } from "bun:test"
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
const entry = (path: string, description: string, signature?: string): CodeModeCatalog.Entry => ({
path,
description,
signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
})
const lookup = entry(
"orders.lookup",
"Look up an order by ID",
"tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
)
const render = (entries: ReadonlyArray<CodeModeCatalog.Entry>, budget?: number) =>
CodeModeInstructions.render(CodeModeCatalog.summarize(entries, budget))
const update = (
previous: ReadonlyArray<CodeModeCatalog.Entry>,
current: ReadonlyArray<CodeModeCatalog.Entry>,
budget?: number,
) =>
CodeModeInstructions.update(CodeModeCatalog.summarize(previous, budget), CodeModeCatalog.summarize(current, budget))
describe("CodeModeCatalog.summarize", () => {
test("retains namespace inventory without retaining tools outside the inline budget", () => {
const catalog = CodeModeCatalog.summarize(
Array.from({ length: 10_000 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`)),
0,
)
expect(catalog).toEqual({
total: 10_000,
shown: 0,
namespaces: [{ name: "bulk", count: 10_000, entries: [] }],
})
})
test("retains every namespace when no full tool listing fits", () => {
const catalog = CodeModeCatalog.summarize(
[entry("alpha.one", "One"), entry("beta.two", "Two"), entry("gamma.three", "Three")],
0,
)
expect(catalog.namespaces.map((namespace) => namespace.name)).toEqual(["alpha", "beta", "gamma"])
expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true)
})
test("retains only the rendered portion of inline descriptions", () => {
const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)])
expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary")
})
test("limits inline descriptions to 120 characters", () => {
const catalog = CodeModeCatalog.summarize([entry("alpha.one", "x".repeat(121))])
const description = catalog.namespaces[0]?.entries[0]?.line.split(" // ")[1]
expect(description).toHaveLength(120)
expect(description).toEndWith("...")
})
})
describe("CodeModeInstructions.render", () => {
test("inlines complete catalogs without search guidance", () => {
const instructions = render([lookup])
expect(instructions).toContain("## Available tools")
expect(instructions).toContain("- orders (1 tool)")
expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`)
expect(instructions).not.toContain("## Search")
expect(instructions).toContain("The Code Mode tool catalog below is complete.")
expect(instructions).toContain(
"`tools` contains only the tools shown below; surrounding top-level agent tools are not available and must not be called from the code.",
)
})
test("adds search guidance when the catalog exceeds the budget", () => {
const partial = render([lookup], 0)
expect(partial).toContain("## Available tools")
expect(partial).toContain("- orders (1 tool, none shown)")
expect(partial).toContain("## Search")
expect(partial).toContain("The Code Mode tool catalog below is partial.")
expect(partial).toContain(
"`tools` contains only the tools shown below or returned by `search`; surrounding top-level agent tools are not available and must not be called from the code.",
)
expect(partial).toContain("- search(input: {")
expect(partial).toContain(" limit?: number,\n offset?: number,")
expect(partial).not.toContain("tools.orders.lookup(input:")
})
test("budgets signatures round-robin so every namespace remains visible", () => {
const cheapAlpha = entry("alpha.cheap", "Cheap")
const cheapBeta = entry("beta.cheap", "Cheap")
const expensive = entry(
"alpha.expensive",
"Expensive",
`tools.alpha.expensive(input: {\n aVeryLongParameterName: string,\n anotherEvenLongerParameterName: number,\n yetAnotherExtremelyVerboseParameterName: string,\n}): Promise<string>`,
)
// Round 1 places alpha.cheap and beta.cheap; in round 2 alpha.expensive does not fit,
// which marks only alpha done - it must NOT prevent other namespaces from inlining.
const instructions = render([cheapAlpha, expensive, cheapBeta], 40)
expect(instructions).toContain("## Search")
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
expect(instructions).toContain(` - ${cheapAlpha.signature} // Cheap`)
expect(instructions).not.toContain("tools.alpha.expensive(")
expect(instructions).toContain("- beta (1 tool)")
expect(instructions).toContain(` - ${cheapBeta.signature} // Cheap`)
})
test("charges inline JSDoc in signatures against the catalog token budget", () => {
const documented = entry(
"records.lookup",
"Look up a record",
`tools.records.lookup(input: {\n /** ${"A detailed identifier description. ".repeat(20).trim()} */\n id: string,\n}): Promise<string>`,
)
const instructions = render([documented], 40)
expect(instructions).toContain("- records (1 tool, none shown)")
expect(instructions).not.toContain("tools.records.lookup(input:")
})
test("renders only the no-tools notice for an empty catalog", () => {
expect(render([])).toBe("No tools are currently available.")
})
})
describe("CodeModeInstructions.update", () => {
const echo = entry("notes.echo", "Echo text")
test("renders additions, changes, and removals as a compact semantic delta", () => {
const changed = { ...echo, signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>" }
const added = entry("notes.list", "List notes")
const text = update([echo, lookup], [changed, added])
expect(text).toContain("The Code Mode tool catalog has changed.")
expect(text).toContain(`New tools are available in addition to those previously listed:\n - ${added.signature}`)
expect(text).toContain(
`Changed tool listings supersede the previously listed ones:\n - ${changed.signature} // Echo text`,
)
expect(text).toContain("The following tools are no longer available and must not be called: tools.orders.lookup.")
expect(text).not.toContain("## Available tools")
})
test("names removed tools with exact callable expressions including bracket notation", () => {
const dashed = entry("context7.resolve-library-id", "Resolve a library ID")
const text = update([echo, dashed], [echo])
expect(text).toContain(
'The following tools are no longer available and must not be called: tools.context7["resolve-library-id"].',
)
})
test("restates the full catalog when the rendering mode crosses full and compact", () => {
const wide = Array.from({ length: 40 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`))
const text = update([echo], [echo, ...wide], 30)
expect(text).toContain(
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.",
)
expect(text).toContain("## Search")
expect(text).toContain("## Available tools")
})
test("falls back to full replacement when the delta is larger than the catalog", () => {
const previous = Array.from({ length: 200 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`))
const text = update([...previous, echo], [echo])
expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.")
expect(text).toContain("## Available tools")
expect(text).not.toContain("## Search")
expect(text).not.toContain("The following tools are no longer available")
})
test("renders namespace-only deltas without persisting hidden tool entries", () => {
const alpha = Array.from({ length: 10 }, (_, index) => entry(`alpha.tool${index}`, `Tool ${index}`))
const text = update(alpha, [...alpha, entry("alpha.tool10", "Tool 10")], 0)
expect(text).toContain("`alpha` now has 11 tools")
expect(text).toContain("search them again before relying on previous results")
expect(text).not.toContain("tools.alpha.tool10(input:")
expect(text).not.toContain("## Available tools")
})
})
@@ -1,6 +1,5 @@
import { describe, expect } from "bun:test"
import { CodeMode } from "@opencode-ai/core/codemode"
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Tool } from "@opencode-ai/core/tool/tool"
@@ -8,45 +7,8 @@ import { Effect, Schema } from "effect"
import { it } from "../lib/effect"
import { readInitial, readUpdate } from "../lib/instructions"
const echo: CodeModeCatalog.Entry = {
path: "notes.echo",
description: "Echo text",
signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>",
}
const lookup: CodeModeCatalog.Entry = {
path: "orders.lookup",
description: "Look up an order",
signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<unknown>",
}
describe("CodeModeInstructions", () => {
it.effect("renders the initial catalog, semantic deltas, and removal", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make([echo]))
expect(initialized.text).toContain("## Available tools")
expect(initialized.text).not.toContain("## Search")
expect(initialized.text).toContain(` - ${echo.signature} // Echo text`)
const added = yield* readUpdate(CodeModeInstructions.make([echo, lookup]), initialized)
expect(added.text).toContain("The Code Mode tool catalog has changed.")
expect(added.text).toContain("New tools are available in addition to those previously listed:")
expect(added.text).toContain(` - ${lookup.signature} // Look up an order`)
expect(added.text).not.toContain("## Available tools")
const removed = yield* readUpdate(CodeModeInstructions.make([echo]), { values: added.values })
expect(removed.text).toBe(
"The Code Mode tool catalog has changed.\n\n" +
"The following tools are no longer available and must not be called: tools.orders.lookup.",
)
expect(yield* readUpdate(CodeModeInstructions.make(), initialized)).toMatchObject({
text: "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
})
}),
)
it.effect("stores a canonical sorted snapshot so registration order does not churn history", () => {
it.effect("treats equivalent registration orders as an instruction no-op", () => {
const alpha = Tool.make({
description: "Alpha tool",
input: Schema.Struct({}),
@@ -59,25 +21,46 @@ describe("CodeModeInstructions", () => {
output: Schema.String,
execute: () => Effect.succeed({ output: "zeta" }),
})
const layer = AppNodeBuilder.build(CodeMode.node)
const codeModeLayer = AppNodeBuilder.build(CodeMode.node)
return Effect.gen(function* () {
const codeMode = yield* CodeMode.Service
const initialized = yield* Effect.scoped(
Effect.gen(function* () {
yield* codeMode.register(Tool.registrationEntries({ zeta, alpha }, { namespace: "tools" }))
return yield* readInitial(CodeModeInstructions.make((yield* codeMode.materialize()).catalog))
const materialization = yield* codeMode.materialize()
return yield* readInitial(CodeModeInstructions.make(materialization.instructions))
}),
)
const reordered = yield* Effect.scoped(
Effect.gen(function* () {
yield* codeMode.register(Tool.registrationEntries({ alpha, zeta }, { namespace: "tools" }))
return yield* readUpdate(CodeModeInstructions.make((yield* codeMode.materialize()).catalog), initialized)
const materialization = yield* codeMode.materialize()
return yield* readUpdate(CodeModeInstructions.make(materialization.instructions), initialized)
}),
)
expect(reordered.changed).toBe(false)
expect(reordered.text).toBe("")
}).pipe(Effect.provide(layer))
}).pipe(Effect.provide(codeModeLayer))
})
it.effect("renders catalog changes and removal", () => {
let catalog: string | undefined = "Initial Code Mode catalog"
return Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make(catalog))
expect(initialized.text).toBe("Initial Code Mode catalog")
catalog = "Updated Code Mode catalog"
expect(yield* readUpdate(CodeModeInstructions.make(catalog), initialized)).toMatchObject({
text: "The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\nUpdated Code Mode catalog",
})
catalog = undefined
expect(yield* readUpdate(CodeModeInstructions.make(catalog), initialized)).toMatchObject({
text: "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
})
})
})
})
+26
View File
@@ -64,3 +64,29 @@ describe("Npm.add", () => {
expect(entries.fallback.entrypoint).toEndWith("/index.js")
})
})
describe("Npm.install", () => {
test("respects omit from project .npmrc", async () => {
await using tmp = await tmpdir()
await writePackage(tmp.path, {
name: "fixture",
dependencies: {
"prod-pkg": "file:./prod-pkg",
},
devDependencies: {
"dev-pkg": "file:./dev-pkg",
},
})
await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\n")
await fs.mkdir(path.join(tmp.path, "prod-pkg"))
await fs.mkdir(path.join(tmp.path, "dev-pkg"))
await writePackage(path.join(tmp.path, "prod-pkg"), { name: "prod-pkg" })
await writePackage(path.join(tmp.path, "dev-pkg"), { name: "dev-pkg" })
await Npm.install(tmp.path)
await expect(fs.stat(path.join(tmp.path, "node_modules", "prod-pkg"))).resolves.toBeDefined()
await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow()
})
})
+8 -8
View File
@@ -195,9 +195,9 @@ describe("SessionV2.create", () => {
text: "First",
resume: false,
})
yield* SessionPending.promote(db, events, parent.id, "steer")
yield* SessionPending.promoteSteers(db, events, parent.id)
yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
yield* SessionPending.promote(db, events, parent.id, "steer")
yield* SessionPending.promoteSteers(db, events, parent.id)
const forked = yield* session.fork({ sessionID: parent.id })
const parentContext = yield* session.context(parent.id)
@@ -232,13 +232,13 @@ describe("SessionV2.create", () => {
text: "Parent changed",
resume: false,
})
yield* SessionPending.promote(db, events, parent.id, "steer")
yield* SessionPending.promoteSteers(db, events, parent.id)
yield* session.prompt({
sessionID: forked.id,
text: "Child continues",
resume: false,
})
yield* SessionPending.promote(db, events, forked.id, "steer")
yield* SessionPending.promoteSteers(db, events, forked.id)
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
@@ -263,13 +263,13 @@ describe("SessionV2.create", () => {
text: "First",
resume: false,
})
yield* SessionPending.promote(db, events, parent.id, "steer")
yield* SessionPending.promoteSteers(db, events, parent.id)
const second = yield* session.prompt({
sessionID: parent.id,
text: "Second",
resume: false,
})
yield* SessionPending.promote(db, events, parent.id, "steer")
yield* SessionPending.promoteSteers(db, events, parent.id)
const assistantMessageID = SessionMessage.ID.create()
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
yield* events.publish(SessionEvent.Step.Started, {
@@ -414,7 +414,7 @@ describe("SessionV2.create", () => {
text: "Hello",
resume: false,
})
yield* SessionPending.promote(db, events, created.id, "steer")
yield* SessionPending.promoteSteers(db, events, created.id)
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
@@ -440,7 +440,7 @@ describe("SessionV2.create", () => {
text: "Replay lifecycle",
resume: false,
})
yield* SessionPending.promote(sourceDb, sourceEvents, created.id, "steer")
yield* SessionPending.promoteSteers(sourceDb, sourceEvents, created.id)
const serialized = (yield* sourceDb
.select()
.from(EventTable)
@@ -105,31 +105,6 @@ describe("SessionExecution lifecycle", () => {
}),
)
it.effect("starts every suspended execution without waiting for earlier drains to finish", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const sessionIDs = Array.from({ length: 5 }, (_, index) => SessionV2.ID.make(`ses_resume_concurrent_${index}`))
yield* seedSessions(database, sessionIDs, { time_suspended: Date.now() })
const fourStarted = yield* Deferred.make<void>()
const started: SessionV2.ID[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, ({ sessionID }) =>
Effect.sync(() => {
started.push(sessionID)
if (started.length === 4) Deferred.doneUnsafe(fourStarted, Effect.void)
}).pipe(Effect.andThen(Effect.never)),
)
const execution = Context.get(context, SessionExecution.Service)
const restart = Context.get(context, SessionRestart.Service)
yield* restart.resumeSuspendedSessions.pipe(Effect.forkIn(scope))
yield* Deferred.await(fourStarted)
expect([...(yield* execution.active)].toSorted()).toEqual(sessionIDs.toSorted())
}),
)
it.effect("resumes each suspended Session at most once", () =>
Effect.gen(function* () {
const database = yield* Database.Service
@@ -140,11 +115,9 @@ describe("SessionExecution lifecycle", () => {
const drained: string[] = []
const scope = yield* Scope.make()
const context = yield* buildExecution(scope, ({ sessionID }) => Effect.sync(() => void drained.push(sessionID)))
const execution = Context.get(context, SessionExecution.Service)
const restart = Context.get(context, SessionRestart.Service)
yield* restart.resumeSuspendedSessions
yield* Effect.forEach([first, second], execution.awaitIdle, { discard: true })
expect(drained.toSorted()).toEqual([first, second])
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
+3 -13
View File
@@ -59,11 +59,7 @@ const client = Layer.mock(LLMClient.Service)({
LLMEvent.textStart({ id: "generate" }),
LLMEvent.textDelta({ id: "generate", text: "Transient answer" }),
LLMEvent.textEnd({ id: "generate" }),
LLMEvent.stepFinish({
index: 0,
reason: { normalized: "stop" },
usage: { inputTokens: 100, outputTokens: 10 },
}),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 100, outputTokens: 10 } }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
if (!response) throw new Error("Incomplete generate response")
@@ -101,13 +97,7 @@ const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
const tools = Layer.mock(ToolRegistry.Service, {
snapshot: () =>
Effect.succeed({
codeModeCatalog: [
{
path: "captured.lookup",
description: "Captured Code Mode catalog",
signature: "tools.captured.lookup(input: {}): Promise<string>",
},
],
codeModeInstructions: "Captured Code Mode catalog",
definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
execute: () => Effect.die(new Error("unused")),
}),
@@ -303,7 +293,7 @@ it.effect("generates from fresh settled Session context without durable mutation
)
expect(instructionUpdates).toHaveLength(1)
expect(instructionUpdates?.[0]).toContain("Changed context")
expect(instructionUpdates?.[0]).toContain("tools.captured.lookup(input: {}): Promise<string>")
expect(instructionUpdates?.[0]).toContain("Captured Code Mode catalog")
expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"])
expect(
requests[0]?.messages.flatMap((message) =>
+12 -25
View File
@@ -216,7 +216,7 @@ describe("SessionV2.prompt", () => {
text: "boundary",
resume: false,
})
yield* SessionPending.promote(db, events, sessionID, "steer")
yield* SessionPending.promoteSteers(db, events, sessionID)
const stale = SessionMessage.ID.make("msg_stale_assistant")
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
yield* events.publish(SessionEvent.RevertEvent.Staged, {
@@ -248,7 +248,7 @@ describe("SessionV2.prompt", () => {
text: "boundary",
resume: false,
})
yield* SessionPending.promote(db, events, sessionID, "steer")
yield* SessionPending.promoteSteers(db, events, sessionID)
yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
revert: { messageID: boundary.id, files: [] },
@@ -448,7 +448,7 @@ describe("SessionV2.prompt", () => {
yield* session.prompt({ sessionID, text: "First", resume: false })
yield* session.prompt({ sessionID, text: "Second", resume: false })
yield* SessionPending.promote(db, events, sessionID, "steer")
yield* SessionPending.promoteSteers(db, events, sessionID)
const streamed = Array.from(yield* Fiber.join(fiber))
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
@@ -625,7 +625,7 @@ describe("SessionV2.prompt", () => {
})
yield* Effect.all(
[SessionPending.promote(db, events, sessionID, "steer"), SessionPending.promote(db, events, sessionID, "steer")],
[SessionPending.promoteSteers(db, events, sessionID), SessionPending.promoteSteers(db, events, sessionID)],
{ concurrency: "unbounded" },
)
@@ -855,7 +855,7 @@ describe("SessionV2.prompt", () => {
},
})
yield* SessionPending.promote(db, events, sessionID, "steer")
yield* SessionPending.promoteSteers(db, events, sessionID)
expect(yield* session.messages({ sessionID })).toMatchObject([
{
@@ -880,7 +880,7 @@ describe("SessionV2.prompt", () => {
const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], {
concurrency: "unbounded",
})
yield* SessionPending.promote(database.db, events, sessionID, "steer")
yield* SessionPending.promoteSteers(database.db, events, sessionID)
const promotedRetry = yield* session.synthetic(input)
const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip)
@@ -892,7 +892,7 @@ describe("SessionV2.prompt", () => {
}),
)
it.effect("keeps queued input pending until the idle boundary", () =>
it.effect("keeps synthetic queue input pending until the queue boundary", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -907,15 +907,9 @@ describe("SessionV2.prompt", () => {
})
expect(input.delivery).toBe("queue")
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(true)
expect(
yield* SessionPending.promote(db, events, sessionID, "steer"),
).toBe(0)
expect(yield* SessionPending.promoteSteers(db, events, sessionID)).toBe(0)
expect(yield* session.messages({ sessionID })).toEqual([])
expect(
yield* SessionPending.promote(db, events, sessionID, "input"),
).toBe(1)
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
expect(yield* SessionPending.promoteNextQueued(db, events, sessionID)).toBe(true)
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: input.id, type: "synthetic", text: "Queued completion" },
])
@@ -941,7 +935,7 @@ describe("SessionV2.prompt", () => {
resume: false,
})
yield* SessionPending.promote(db, events, sessionID, "steer")
yield* SessionPending.promoteSteers(db, events, sessionID)
expect(
(yield* session.messages({ sessionID, order: "asc" })).map((message) =>
@@ -984,14 +978,10 @@ describe("SessionV2.pending", () => {
{ id: second.id, type: "user", delivery: "steer" },
])
expect(
yield* SessionPending.promote(db, events, sessionID, "input"),
).toBe(2)
yield* SessionPending.promoteSteers(db, events, sessionID)
expect(yield* session.pending(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
expect(
yield* SessionPending.promote(db, events, sessionID, "input"),
).toBe(1)
yield* SessionPending.promoteNextQueued(db, events, sessionID)
expect(yield* session.pending(sessionID)).toEqual([])
}),
)
@@ -1003,12 +993,9 @@ describe("SessionV2.pending", () => {
const { db } = yield* Database.Service
const barrier = yield* session.compact({ sessionID })
expect(yield* SessionPending.has(db, sessionID, "any")).toBe(true)
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
expect(yield* session.pending(sessionID)).toMatchObject([{ id: barrier.id, type: "compaction" }])
yield* SessionPending.settleCompaction(db, { sessionID })
expect(yield* SessionPending.has(db, sessionID, "any")).toBe(false)
expect(yield* session.pending(sessionID)).toEqual([])
}),
)
@@ -45,7 +45,6 @@ const capture = (providerMetadataKey = "anthropic", options?: { readonly interru
providerID: ProviderV2.ID.opencode,
},
providerMetadataKey,
assistantMessageID: SessionMessage.ID.create(),
}),
}
}
@@ -515,7 +515,7 @@ describe("ToolRegistry", () => {
}),
)
it.effect("executes and reports progress for codemode tools advertised in a model request", () =>
it.effect("executes codemode tools advertised in a model request", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const executed: string[] = []
@@ -526,17 +526,14 @@ describe("ToolRegistry", () => {
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }, context) =>
Effect.sync(() => executed.push(`old:${text}`)).pipe(
Effect.andThen(context.progress({ stage: "old" })),
Effect.as({ output: { text } }),
),
execute: ({ text }) =>
Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ output: { text } })),
}),
})
.pipe(Scope.provide(scope))
const toolSet = yield* service.snapshot()
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
expect(toolSet.codeModeCatalog?.[0]?.signature).toContain("tools.echo")
expect(toolSet.codeModeInstructions).toContain("tools.echo")
expect(execute?.description).toContain("confined Code Mode runtime")
expect(execute?.description).not.toContain("Echo text")
yield* Scope.close(scope, Exit.void)
@@ -549,7 +546,6 @@ describe("ToolRegistry", () => {
}),
})
const progress: ToolRegistry.Progress[] = []
const execution = yield* toolSet.execute({
...call("execute"),
call: {
@@ -558,15 +554,10 @@ describe("ToolRegistry", () => {
name: "execute",
input: { code: 'return await tools.echo({ text: "request" })' },
},
progress: (update) => Effect.sync(() => progress.push(update)),
})
expect(execution).toMatchObject({ status: "completed", content: [{ type: "text" }] })
expect(executed).toEqual(["old:request"])
expect(progress).toEqual([
{ toolCalls: [{ tool: "echo", status: "running", input: { text: "request" } }] },
{ toolCalls: [{ tool: "echo", status: "completed", input: { text: "request" } }] },
])
}),
)
})
+17 -20
View File
@@ -844,19 +844,12 @@ describe("SessionRunnerLLM", () => {
output: Schema.String,
execute: () => Effect.sync(() => executed.push(name)).pipe(Effect.as({ output: name })),
})
const catalog = (name: string) => [
{
path: `catalog.${name.toLowerCase()}`,
description: `Code Mode catalog ${name}`,
signature: `tools.catalog.${name.toLowerCase()}(input: {}): Promise<string>`,
},
]
const session = yield* setup
codeModeMaterializations = [
{ catalog: catalog("A"), tool: execute("A") },
{ catalog: catalog("B"), tool: execute("B") },
{ catalog: catalog("C"), tool: execute("C") },
{ catalog: catalog("D"), tool: execute("D") },
{ instructions: "Code Mode catalog A", tool: execute("A") },
{ instructions: "Code Mode catalog B", tool: execute("B") },
{ instructions: "Code Mode catalog C", tool: execute("C") },
{ instructions: "Code Mode catalog D", tool: execute("D") },
]
yield* admit(session, "Use Code Mode")
responses = [reply.tool("call-execute", "execute", {}), reply.stop()]
@@ -1043,7 +1036,9 @@ describe("SessionRunnerLLM", () => {
input: Schema.Struct({}),
output: Schema.Struct({ value: Schema.String }),
execute: () =>
Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ output: { value: "advertised" } })),
Effect.sync(() => executions.push("advertised")).pipe(
Effect.as({ output: { value: "advertised" } }),
),
}),
},
{ codemode: false },
@@ -1072,7 +1067,9 @@ describe("SessionRunnerLLM", () => {
input: Schema.Struct({}),
output: Schema.Struct({ value: Schema.String }),
execute: () =>
Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ output: { value: "replacement" } })),
Effect.sync(() => executions.push("replacement")).pipe(
Effect.as({ output: { value: "replacement" } }),
),
}),
},
{ codemode: false },
@@ -3102,7 +3099,7 @@ describe("SessionRunnerLLM", () => {
streamFailure = undefined
streamGate = undefined
streamStarted = undefined
yield* session.wait(sessionID)
yield* Effect.yieldNow
expect(requests).toHaveLength(2)
expect(userTexts(requests[1]!)).toEqual(["Start working", "Recover with this"])
@@ -3114,7 +3111,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const events = yield* EventV2.Service
yield* admit(session, "Recover interrupted tool")
yield* SessionPending.promote((yield* Database.Service).db, events, sessionID, "steer")
yield* SessionPending.promoteSteers((yield* Database.Service).db, events, sessionID)
const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
@@ -3171,7 +3168,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const events = yield* EventV2.Service
yield* admit(session, "Recover interrupted hosted tool")
yield* SessionPending.promote((yield* Database.Service).db, events, sessionID, "steer")
yield* SessionPending.promoteSteers((yield* Database.Service).db, events, sessionID)
const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
@@ -3222,7 +3219,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const events = yield* EventV2.Service
yield* admit(session, "Recover interrupted tool input")
yield* SessionPending.promote((yield* Database.Service).db, events, sessionID, "steer")
yield* SessionPending.promoteSteers((yield* Database.Service).db, events, sessionID)
const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
@@ -3569,7 +3566,7 @@ describe("SessionRunnerLLM", () => {
{
type: "tool",
id: "call-declined",
state: { status: "error", error: { type: "aborted", message: "The user declined this tool call" } },
state: { status: "error", error: { message: "Tool execution interrupted" } },
},
],
},
@@ -3721,7 +3718,7 @@ describe("SessionRunnerLLM", () => {
{
type: "tool",
id: "call-question",
state: { status: "error", error: { type: "aborted", message: "The user dismissed this question" } },
state: { status: "error", error: { type: "aborted", message: "Tool execution interrupted" } },
},
],
},
@@ -4223,7 +4220,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("retries a model call without consuming the logical agent step", () =>
it.effect("retries a physical attempt without consuming the logical agent step", () =>
Effect.gen(function* () {
const session = yield* setup
const agents = yield* AgentV2.Service
+1 -56
View File
@@ -4,7 +4,7 @@ import { Tool } from "@opencode-ai/core/tool/tool"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Deferred, Effect, Fiber, Schema } from "effect"
import { Effect, Schema } from "effect"
const context = {
sessionID: Session.ID.make("ses_execute"),
@@ -14,18 +14,6 @@ const context = {
progress: () => Effect.void,
}
test("execute describes invariant Code Mode behavior", () => {
expect(ExecuteTool.create(new Map()).description).toBe(
[
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
'Call Code Mode tools through `tools` using only exact paths and signatures from the current catalog or `search`. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
].join("\n"),
)
})
test("canonical execution distinguishes declared, model-only, and raw schema outputs", async () => {
const declared = Tool.make({
description: "Declared",
@@ -143,46 +131,3 @@ test("execute supports callable namespace tools", async () => {
})
expect(result.content).toEqual([{ type: "text", text: '[\n "admin",\n "created"\n]' }])
})
test("execute marks every admitted child call failed when interrupted", async () => {
const child = Tool.make({
description: "Wait forever",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.String,
execute: () => Effect.never,
})
const execute = ExecuteTool.create(new Map([["wait", { tool: child, name: "wait", permission: "wait" }]]))
const updates: Tool.Metadata[] = []
await Effect.runPromise(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const fiber = yield* Tool.execute(
execute,
{ code: "return await Promise.all([tools.wait({ id: 1 }), tools.wait({ id: 2 })])" },
{
...context,
progress: (update) =>
Effect.gen(function* () {
updates.push(update)
if (updates.length > 1) return
yield* Deferred.succeed(started, undefined)
yield* Effect.never
}),
},
).pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Effect.yieldNow
yield* Effect.yieldNow
yield* Fiber.interrupt(fiber)
}),
)
expect(updates[0]).toEqual({ toolCalls: [{ tool: "wait", status: "running", input: { id: 1 } }] })
expect(updates.at(-1)).toEqual({
toolCalls: [
{ tool: "wait", status: "error", input: { id: 1 } },
{ tool: "wait", status: "error", input: { id: 2 } },
],
})
})
+16 -103
View File
@@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -25,27 +24,27 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
deps: [
ToolRegistry.toolsNode,
FSUtil.node,
Ripgrep.node,
Location.node,
LocationMutation.node,
PermissionV2.node,
],
deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
})
const grepToolNode = makeLocationNode({
name: "test/grep-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
})
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: () => Effect.void,
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const sessionID = SessionV2.ID.make("ses_search_tool_test")
const withTools = <A, E, R>(
directory: string,
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
assertions?: PermissionV2.AssertInput[],
) =>
const withTools = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(
@@ -55,23 +54,7 @@ const withTools = <A, E, R>(
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
[
PermissionV2.node,
Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => {
assertions?.push(input)
}),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
),
],
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
]),
),
@@ -103,16 +86,13 @@ describe("search tools", () => {
const glob = yield* executeTool(registry, call("glob", { pattern: "*" }))
const grep = yield* executeTool(registry, call("grep", { pattern: "needle" }))
expect(glob.metadata).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT, truncated: true })
expect(glob.metadata).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(grep.metadata).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(glob.content).toHaveLength(1)
expect(grep.content).toHaveLength(1)
const globText = glob.content?.[0]?.type === "text" ? glob.content[0].text : ""
const grepText = grep.content?.[0]?.type === "text" ? grep.content[0].text : ""
expect(globText.split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT + 2)
expect(globText).toEndWith(
`(Results are truncated: showing first ${FileSystem.DEFAULT_SEARCH_LIMIT} results. Consider using a more specific path or pattern.)`,
)
expect(globText.split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
expect(grepText).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
}),
)
@@ -142,71 +122,4 @@ describe("search tools", () => {
),
)
}
it.live("requires external_directory approval for an explicit external glob path", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
const assertions: PermissionV2.AssertInput[] = []
return Effect.promise(() => fs.writeFile(path.join(outside.path, "outside.txt"), "outside\n")).pipe(
Effect.andThen(
withTools(
active.path,
(registry) => executeTool(registry, call("glob", { path: outside.path, pattern: "*.txt" })),
assertions,
),
),
Effect.tap((result) =>
Effect.sync(() => {
expect(result.status).toBe("completed")
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
expect(assertions[0]?.resources).toEqual([
path.join(outside.path, "*").replaceAll("\\", "/"),
])
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("globs through an in-location external symlink without external approval", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
if (process.platform === "win32") return Effect.void
const assertions: PermissionV2.AssertInput[] = []
return Effect.promise(async () => {
await fs.writeFile(path.join(outside.path, "outside.txt"), "outside\n")
await fs.symlink(outside.path, path.join(active.path, "linked"))
}).pipe(
Effect.andThen(
withTools(
active.path,
(registry) => executeTool(registry, call("glob", { path: "linked", pattern: "*.txt" })),
assertions,
),
),
Effect.tap((result) =>
Effect.sync(() => {
expect(result.status).toBe("completed")
expect(assertions.map((input) => input.action)).toEqual(["glob"])
expect(result).toMatchObject({
output: [{ path: path.join("linked", "outside.txt"), type: "file" }],
content: [{ type: "text", text: path.join(active.path, "linked", "outside.txt") }],
})
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
})
+1 -1
View File
@@ -406,7 +406,7 @@ describe("SubagentTool", () => {
},
})
const database = yield* Database.Service
yield* SessionPending.promote(database.db, events, parent.id, "steer")
yield* SessionPending.promoteSteers(database.db, events, parent.id)
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
expect(synthetic).toHaveLength(1)
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
+2 -2
View File
@@ -81,8 +81,8 @@ function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody {
}
function monoBody(body: RunEntryBody): RunEntryBody {
if (body.type === "none" || body.type === "text" || body.type === "markdown") return body
if (body.type === "code") return textBody(body.content)
if (body.type === "none" || body.type === "text") return body
if (body.type === "code" || body.type === "markdown") return textBody(body.content)
const snapshot = body.snapshot
if (snapshot.kind === "code") return textBody(`${snapshot.title}\n${snapshot.content}`)
if (snapshot.kind === "diff") {
+23 -12
View File
@@ -767,19 +767,23 @@ export function RunSubagentSelectBody(props: {
onRows?: (rows: number) => void
mono?: boolean
}) {
const [active, setActive] = createSignal(true)
const entries = createMemo<SubagentEntry[]>(() =>
props.tabs().map((item) => {
const title = item.description || item.title || item.label
return {
category: "",
display: title,
description: title === item.label ? undefined : item.label,
footer: subagentStatusLabel(item.status),
keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`,
sessionID: item.sessionID,
current: props.current() === item.sessionID,
}
}),
props
.tabs()
.filter((item) => (active() ? item.status === "running" : item.status !== "running"))
.map((item) => {
const title = item.description || item.title || item.label
return {
category: "",
display: title,
description: title === item.label ? undefined : item.label,
footer: subagentStatusLabel(item.status),
keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`,
sessionID: item.sessionID,
current: props.current() === item.sessionID,
}
}),
)
const controller = createSearchablePanelController({
entries,
@@ -788,6 +792,12 @@ export function RunSubagentSelectBody(props: {
onSelect: (item) => props.onSelect(item.sessionID),
isCurrent: (item) => item.current,
closeOnFirstUp: true,
onKey(event) {
if (event.name.toLowerCase() !== "tab") return false
event.preventDefault()
setActive((value) => !value)
return true
},
onRows: props.onRows,
})
@@ -801,6 +811,7 @@ export function RunSubagentSelectBody(props: {
theme={props.theme}
inputRef={controller.inputRef}
onQuery={controller.setQuery}
hint={`tab show ${active() ? "inactive" : "active"}`}
mono={props.mono}
>
<RunFooterMenu
+1 -3
View File
@@ -104,8 +104,7 @@ type RunFooterOptions = {
export function resolveRunAgent(agents: RunAgent[], current: string | undefined) {
const selectable = agents.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
if (current === undefined) return selectable.at(0)
return selectable.find((agent) => agent.id === current)
return selectable.find((agent) => agent.id === current) ?? selectable.at(0)
}
const PERMISSION_ROWS = 12
@@ -328,7 +327,6 @@ export class RunFooter implements FooterApi {
providers: footer.providers,
currentAgent: footer.currentAgent,
currentAgentID: footer.currentAgentID,
currentAgentExplicit: () => selectedAgentID() !== undefined,
currentModel: footer.currentModel,
variants: footer.variants,
currentVariant: footer.currentVariant,
+57 -106
View File
@@ -29,11 +29,10 @@ import { RunPromptBody, createPromptState } from "./footer.prompt"
import { RunPermissionBody } from "./footer.permission"
import { RunFormBody } from "./footer.form"
import { createFormBodyState, type FormBodyState } from "./form.shared"
import { footerStatuslinePolicy } from "./footer.width"
import { footerWidthPolicy } from "./footer.width"
import { Keymap } from "../context/keymap"
import { modelInfo } from "./variant.shared"
import { monoShortcut } from "./mono"
import { stringWidth } from "../util/string-width"
import type {
FooterPromptRoute,
@@ -80,7 +79,6 @@ type RunFooterViewProps = {
providers: () => RunProvider[] | undefined
currentAgent: () => string
currentAgentID: () => string | undefined
currentAgentExplicit: () => boolean
currentModel: () => RunInput["model"]
variants: () => string[]
currentVariant: () => string | undefined
@@ -118,6 +116,7 @@ type RunFooterViewProps = {
export function RunFooterView(props: RunFooterViewProps) {
const term = useTerminalDimensions()
const width = createMemo(() => term().width)
const responsive = createMemo(() => footerWidthPolicy(width()))
const active = createMemo<FooterView>(() => props.view?.() ?? { type: "prompt" })
const subagent = createMemo<FooterSubagentState>(() => {
return (
@@ -411,19 +410,19 @@ export function RunFooterView(props: RunFooterViewProps) {
return shell() ? "Shell mode" : ""
})
const activityMeta = createMemo(() => {
if (!footerDetails()) return ""
if (!footerDetails() || !responsive().statusline.showActivityMeta || usage().length === 0) {
return ""
}
return props.mono ? usage().replaceAll(" · ", " - ") : usage()
})
const agentStatus = createMemo(() => {
if (!footerDetails() || !prompt() || shell() || !props.currentAgentExplicit()) return undefined
return props.currentAgent()
})
const modelStatus = createMemo(() => {
const current = model() ?? props.state().model.trim()
if (!footerDetails() || !prompt() || shell() || !current) return
if (!footerDetails() || !prompt() || shell() || !responsive().statusline.showModel || !current) return
return {
agent: props.currentAgent(),
model: current,
variant: props.currentVariant(),
variant: responsive().statusline.showModelVariant ? props.currentVariant() : undefined,
}
})
const statusColor = createMemo(() => {
@@ -442,26 +441,32 @@ export function RunFooterView(props: RunFooterViewProps) {
return theme().muted
})
const statuslineBackground = createMemo(() => theme().status)
const contextHintCandidates = createMemo(() => {
if (!footerDetails() || !prompt() || shell()) {
const hasActivityMeta = createMemo(() => activityMeta().length > 0)
const hasModelStatus = createMemo(() => Boolean(modelStatus()))
const contextHints = createMemo(() => {
if (!footerDetails() || !prompt() || shell() || !responsive().statusline.showContextHints) {
return []
}
const items: Array<{ key: string; label: string }> = []
const items: Array<{ kind: string; key: string; label: string }> = []
if (foregroundSubagents() && backgroundShortcut()) {
items.push({ key: backgroundShortcut(), label: "background" })
items.push({ kind: "background", key: backgroundShortcut(), label: "background" })
}
if (queuedPrompts().length > 0 && queuedShortcut()) {
items.push({ key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
items.push({ kind: "queued", key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
}
if (activeTabs().length > 0 && subagentShortcut()) {
items.push({ key: subagentShortcut(), label: "subagents" })
items.push({ kind: "subagents", key: subagentShortcut(), label: "subagents" })
}
return items
const limit = responsive().statusline.contextHintLimit
return limit === undefined ? items : items.slice(0, limit)
})
const hasContextHints = createMemo(() => contextHints().length > 0)
const commandHint = createMemo(() => {
if (!prompt()) return
if (!prompt() || !responsive().statusline.showCommandHint) {
return
}
if (shell()) {
return { key: "esc", label: "normal" }
@@ -471,49 +476,6 @@ export function RunFooterView(props: RunFooterViewProps) {
return { key: command(), label: "cmd" }
}
})
const commandHintWidth = createMemo(() => {
const hint = commandHint()
return hint ? stringWidth(`${hint.key} ${hint.label}`) : 0
})
const statuslineText = createMemo(() =>
busy() && !exiting() && (footerDetails() || armed())
? `${interruptLabel() ? `${interruptLabel()} ` : ""}${statusText()}`
: statusText(),
)
const statuslineMainWidth = createMemo(() => {
const mode = modeLabel()
const modeWidth = mode ? stringWidth(mode) + (props.mono ? 1 : 2) : 0
const spinnerWidth = footerDetails() && busy() && !exiting() ? stringWidth(spin().frames[0] ?? "") + 1 : 0
return modeWidth + Math.max(12, (props.mono ? 1 : 2) + spinnerWidth + stringWidth(statuslineText()))
})
const visibleModeLabel = createMemo(() => {
const mode = modeLabel()
if (!mode || width() - commandHintWidth() < stringWidth(mode) + (props.mono ? 1 : 2)) return undefined
return mode
})
const statuslineMainAvailable = createMemo(() => {
const mode = visibleModeLabel()
return width() - commandHintWidth() - (mode ? stringWidth(mode) + (props.mono ? 1 : 2) : 0)
})
const statuslineLayout = createMemo(() => {
const agent = agentStatus()
const info = modelStatus()
return footerStatuslinePolicy({
width: width(),
mainWidth: statuslineMainWidth(),
commandWidth: commandHint() ? commandHintWidth() : undefined,
agentWidth: agent ? stringWidth(agent) : undefined,
contextWidths: contextHintCandidates().map((item) => stringWidth(`${item.key} ${item.label}`)),
modelWidth: info ? stringWidth(info.model) : undefined,
variantWidth: info?.variant ? stringWidth(` ${info.variant}`) : undefined,
usageWidth: activityMeta() ? stringWidth(activityMeta()) : undefined,
})
})
const contextHints = createMemo(() => contextHintCandidates().slice(0, statuslineLayout().contextCount))
const hasStatuslineInfo = createMemo(() => {
const layout = statuslineLayout()
return layout.showUsage || layout.showAgent || layout.showModel
})
const sectionSeparator = () => <span style={{ fg: theme().muted }}>{props.mono ? "- " : "· "}</span>
createEffect(() => {
@@ -914,7 +876,7 @@ export function RunFooterView(props: RunFooterViewProps) {
flexShrink={0}
backgroundColor={statuslineBackground()}
>
<Show when={visibleModeLabel()}>
<Show when={modeLabel()}>
{(label) => (
<box
paddingLeft={props.mono ? 0 : 1}
@@ -934,21 +896,12 @@ export function RunFooterView(props: RunFooterViewProps) {
gap={1}
flexGrow={1}
flexShrink={1}
minWidth={0}
paddingLeft={statuslineMainAvailable() >= 2 && !props.mono ? 1 : 0}
paddingRight={statuslineMainAvailable() >= (props.mono ? 1 : 2) ? 1 : 0}
minWidth={12}
paddingLeft={props.mono ? 0 : 1}
paddingRight={1}
backgroundColor="transparent"
overflow="hidden"
>
<Show
when={
footerDetails() &&
busy() &&
!exiting() &&
statuslineMainAvailable() >=
(props.mono ? 1 : 2) + stringWidth(spin().frames[0] ?? "") + 1 + stringWidth(statuslineText())
}
>
<Show when={footerDetails() && busy() && !exiting()}>
<box flexShrink={0}>
<spinner color={spin().color} frames={spin().frames} interval={40} />
</box>
@@ -964,36 +917,29 @@ export function RunFooterView(props: RunFooterViewProps) {
</text>
</box>
<Show when={statuslineLayout().showUsage && activityMeta()}>
{(usage) => (
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
<text fg={theme().muted} wrapMode="none">
{usage()}
</text>
</box>
)}
<Show when={activityMeta().length > 0}>
<box paddingRight={1} backgroundColor="transparent" flexShrink={1}>
<text fg={theme().muted} wrapMode="none" truncate>
{activityMeta()}
</text>
</box>
</Show>
<Show when={statuslineLayout().showAgent && agentStatus()}>
{(agent) => (
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
<text fg={theme().text} wrapMode="none">
<Show when={statuslineLayout().showUsage}>{sectionSeparator()}</Show>
{agent()}
</text>
</box>
)}
</Show>
<Show when={statuslineLayout().showModel && modelStatus()}>
<Show when={modelStatus()}>
{(info) => (
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
<text fg={theme().text} wrapMode="none">
<Show when={statuslineLayout().showUsage || statuslineLayout().showAgent}>
{sectionSeparator()}
<box
minWidth={8}
paddingRight={1}
backgroundColor="transparent"
flexShrink={1}
>
<text fg={theme().text} wrapMode="none" truncate>
<Show when={responsive().statusline.showAgent}>
{info().agent}
<span style={{ fg: theme().muted }}>{props.mono ? " - " : " · "}</span>
</Show>
{info().model}
<Show when={statuslineLayout().showVariant && info().variant}>
<Show when={info().variant}>
{(variant) => <span style={{ fg: theme().warning, bold: true }}> {variant()}</span>}
</Show>
</text>
@@ -1003,20 +949,25 @@ export function RunFooterView(props: RunFooterViewProps) {
<For each={contextHints()}>
{(hint, index) => (
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
<text fg={theme().text} wrapMode="none">
<Show when={index() > 0 || (hasStatuslineInfo() && index() === 0)}>{sectionSeparator()}</Show>
<box paddingRight={1} backgroundColor="transparent" flexShrink={0} maxWidth={24}>
<text fg={theme().text} wrapMode="none" truncate>
<Show when={index() > 0 || ((hasActivityMeta() || hasModelStatus()) && index() === 0)}>
{sectionSeparator()}
</Show>
<span style={{ fg: theme().text }}>{hint.key}</span>{" "}
<span style={{ fg: theme().muted }}>{hint.label}</span>
</text>
</box>
)}
</For>
<Show when={commandHint()}>
{(hint) => (
<box backgroundColor="transparent" flexShrink={0}>
<text fg={theme().text} wrapMode="none">
<Show when={hasStatuslineInfo() || contextHints().length > 0}>{sectionSeparator()}</Show>
<box paddingRight={1} backgroundColor="transparent" flexShrink={0} maxWidth={18}>
<text fg={theme().text} wrapMode="none" truncate>
<Show when={hasActivityMeta() || hasModelStatus() || hasContextHints()}>
{sectionSeparator()}
</Show>
<span style={{ fg: theme().text }}>{hint().key}</span>{" "}
<span style={{ fg: theme().muted }}>{hint().label}</span>
</text>
+25 -48
View File
@@ -1,54 +1,31 @@
// Shared responsive width policy
const FOOTER_WIDTH_BREAKPOINTS = {
commandHint: 24,
model: 32,
modelVariant: 40,
compact: 80,
context: 120,
spacious: 150,
} as const
export function footerWidthPolicy(width: number) {
const compact = width >= FOOTER_WIDTH_BREAKPOINTS.compact
const context = width >= FOOTER_WIDTH_BREAKPOINTS.context
const spacious = width >= FOOTER_WIDTH_BREAKPOINTS.spacious
return {
dialog: {
narrow: width < 80,
narrow: !compact,
},
statusline: {
showActivityMeta: compact,
showAgent: compact,
showCommandHint: width >= FOOTER_WIDTH_BREAKPOINTS.commandHint,
showModel: width >= FOOTER_WIDTH_BREAKPOINTS.model,
showModelVariant: width >= FOOTER_WIDTH_BREAKPOINTS.modelVariant,
showContextHints: compact,
contextHintLimit: !compact ? 0 : spacious ? undefined : context ? 2 : 1,
},
}
}
const USAGE_HEADROOM = 8
export function footerStatuslinePolicy(input: {
width: number
mainWidth: number
commandWidth?: number
agentWidth?: number
contextWidths: number[]
modelWidth?: number
variantWidth?: number
usageWidth?: number
}) {
let remaining = input.width - input.mainWidth - (input.commandWidth ?? 0)
let hasSection = input.commandWidth !== undefined
const include = (width: number | undefined, headroom = 0) => {
if (width === undefined) return false
const required = width + (hasSection ? 3 : 1)
if (remaining < required + headroom) return false
remaining -= required
hasSection = true
return true
}
const showModel = include(input.modelWidth)
const showAgent = include(input.agentWidth)
const hiddenContext = input.contextWidths.findIndex((width) => !include(width))
const contextCount = hiddenContext === -1 ? input.contextWidths.length : hiddenContext
const contextComplete = contextCount === input.contextWidths.length
const variantWidth = input.variantWidth
const showVariant = showModel && contextComplete && variantWidth !== undefined && remaining >= variantWidth
if (showVariant) remaining -= variantWidth
const showUsage =
(showModel || input.modelWidth === undefined) &&
(showAgent || input.agentWidth === undefined) &&
contextComplete &&
(showVariant || input.variantWidth === undefined) &&
include(input.usageWidth, USAGE_HEADROOM)
return {
showAgent,
contextCount,
showModel,
showVariant,
showUsage,
}
}
+13 -122
View File
@@ -1,17 +1,10 @@
import {
BoxRenderable,
CodeRenderable,
MarkdownRenderable,
RGBA,
Renderable,
StyledText,
TextRenderable,
TextTableRenderable,
isStyledText,
stringToStyledText,
type BorderCharacters,
type CliRendererExternalOutputEvent,
type TreeSitterClient,
type MarkdownOptions,
type Renderable,
} from "@opentui/core"
const prefixes: Record<number, string> = {
@@ -59,129 +52,27 @@ const asciiBorder: BorderCharacters = {
cross: "+",
}
const hooked = new WeakSet<Renderable>()
export const monoMarkdownTableOptions = {
style: "columns" as const,
widthMode: "content" as const,
borders: false,
}
export function monoMarkdownRenderable(renderable: MarkdownRenderable): void {
monoRenderable(renderable)
export const monoMarkdownRenderNode: NonNullable<MarkdownOptions["renderNode"]> = (token, context) => {
if (token.type !== "blockquote" && token.type !== "hr" && token.type !== "list") return
const renderable = context.defaultRender()
if (!renderable) return renderable
monoBorders(renderable)
return renderable
}
function monoRenderable(renderable: Renderable): void {
if (hooked.has(renderable)) return
hooked.add(renderable)
// Markdown reconciles nested lists and tables without calling renderNode.
// Hook the actual tree so future descendants are transformed before layout.
const add = renderable.add.bind(renderable)
renderable.add = (child, index) => {
if (child instanceof Renderable) monoRenderable(child)
return add(child, index)
}
function monoBorders(renderable: Renderable): void {
if (renderable instanceof BoxRenderable) renderable.customBorderChars = asciiBorder
if (renderable instanceof CodeRenderable) monoCode(renderable)
if (renderable instanceof TextRenderable) renderable.content = monoStyledText(renderable.content)
if (renderable instanceof TextTableRenderable) monoTable(renderable)
renderable.getChildren().forEach(monoRenderable)
renderable.getChildren().forEach(monoBorders)
}
function monoCode(renderable: CodeRenderable): void {
const onChunks = renderable.onChunks
const prose = renderable.filetype === "markdown" && onChunks !== undefined
renderable.onChunks = async (chunks, context) => monoChunks((await onChunks?.(chunks, context)) ?? chunks)
renderable.treeSitterClient = monoTreeSitter(renderable.treeSitterClient)
const initialDescriptor = Object.getOwnPropertyDescriptor(CodeRenderable.prototype, "initialStyledText")
const contentDescriptor = Object.getOwnPropertyDescriptor(CodeRenderable.prototype, "content")
if (!initialDescriptor?.set || !contentDescriptor?.get || !contentDescriptor.set) return
const initialSetter = initialDescriptor.set.bind(renderable)
const contentGetter = contentDescriptor.get.bind(renderable)
const contentSetter = contentDescriptor.set.bind(renderable)
const initial = Reflect.get(renderable, "_initialStyledText")
Object.defineProperty(renderable, "initialStyledText", {
configurable: true,
set(value: StyledText | undefined) {
initialSetter(value ? monoStyledText(value) : value)
},
})
Object.defineProperty(renderable, "content", {
configurable: true,
get: contentGetter,
set(value: string) {
if (!prose || !isStyledText(Reflect.get(renderable, "_initialStyledText"))) {
renderable.drawUnstyledText = true
renderable.initialStyledText = stringToStyledText(value)
}
contentSetter(value)
},
})
if (isStyledText(initial)) {
renderable.initialStyledText = initial
} else {
renderable.drawUnstyledText = true
renderable.initialStyledText = stringToStyledText(renderable.content)
}
if (!renderable.drawUnstyledText) return
// Refresh the eager buffer with the transformed initial text. Highlighted
// chunks continue through onChunks without changing the Markdown source.
const content = renderable.content
renderable.content = ""
renderable.content = content
}
function monoTreeSitter(client: TreeSitterClient): TreeSitterClient {
return new Proxy(client, {
get(target, property) {
if (property !== "highlightOnce") return Reflect.get(target, property, target)
// Keep parser failures on the chunk path instead of OpenTUI's raw-text fallback.
return (...args: Parameters<TreeSitterClient["highlightOnce"]>) =>
target.highlightOnce(...args).catch(() => ({ highlights: [] }))
},
})
}
function monoTable(renderable: TextTableRenderable): void {
const descriptor = Object.getOwnPropertyDescriptor(TextTableRenderable.prototype, "content")
if (!descriptor?.get || !descriptor.set) return
const cells = new WeakMap<StyledText["chunks"], StyledText["chunks"]>()
const content = renderable.content
Object.defineProperty(renderable, "content", {
configurable: true,
get: () => descriptor.get!.call(renderable),
set: (value: TextTableRenderable["content"]) => {
descriptor.set!.call(
renderable,
value.map((row) =>
row.map((cell) => {
if (!cell) return cell
const cached = cells.get(cell)
if (cached) return cached
const next = monoChunks(cell)
cells.set(cell, next)
return next
}),
),
)
},
})
renderable.content = content
}
function monoStyledText(value: StyledText): StyledText {
return new StyledText(monoChunks(value.chunks))
}
function monoChunks(value: StyledText["chunks"]): StyledText["chunks"] {
return value.map((chunk) => ({ ...chunk, text: monoText(chunk.text) }))
}
function monoText(value: string): string {
export function monoMarkdown(value: string, mono: boolean): string {
if (!mono) return value
return value.replace(/[^\t\n\x20-\x7e]/gu, (char) => markdown[char.codePointAt(0)!] ?? "?")
}
@@ -189,7 +80,7 @@ export function monoSnapshot(event: CliRendererExternalOutputEvent): void {
const buffers = event.snapshot.buffers
const chars = buffers.char
for (let index = 0; index < chars.length; index += 1) {
const point = chars[index]
const point = chars[index]!
if (point <= 0x7f) continue
const offset = index * 4
event.snapshot.setCell(
+4 -4
View File
@@ -14,7 +14,7 @@ import {
type ScrollbackSurface,
} from "@opentui/core"
import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
import { monoMarkdownRenderable, monoMarkdownTableOptions } from "./mono"
import { monoMarkdown, monoMarkdownRenderNode, monoMarkdownTableOptions } from "./mono"
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
import { turnSummaryCommit } from "./turn-summary"
import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer"
@@ -181,11 +181,11 @@ export class RunScrollbackStream {
streaming: true,
internalBlockMode: "top-level",
tableOptions: this.mono ? monoMarkdownTableOptions : { widthMode: "content" },
renderNode: this.mono ? monoMarkdownRenderNode : undefined,
fg: entryColor(commit, this.theme),
treeSitterClient,
})
if (this.mono && renderable instanceof MarkdownRenderable) monoMarkdownRenderable(renderable)
surface.root.add(renderable)
const rows = separatorRows(this.rendered, commit, body)
@@ -283,7 +283,7 @@ export class RunScrollbackStream {
}
const renderable = active.renderable
renderable.content = active.content
renderable.content = monoMarkdown(active.content, this.mono)
renderable.streaming = !done
await active.surface.settle()
this.releasePendingThemes()
@@ -378,7 +378,7 @@ export class RunScrollbackStream {
) {
await this.writeStreaming(commit, body)
if (entryDone(commit)) {
this.markRendered(await this.finishActive(entryFlags(commit).trailingNewline))
this.markRendered(await this.finishActive(false))
}
this.tail = commit
return
+4 -12
View File
@@ -1,14 +1,8 @@
import { createScrollbackWriter } from "@opentui/solid"
import {
MarkdownRenderable,
TextRenderable,
type ColorInput,
type ScrollbackRenderContext,
type ScrollbackWriter,
} from "@opentui/core"
import { TextRenderable, type ColorInput, type ScrollbackRenderContext, type ScrollbackWriter } from "@opentui/core"
import { Match, Switch, createMemo } from "solid-js"
import { entryBody, entryFlags } from "./entry.body"
import { monoMarkdownRenderable, monoMarkdownTableOptions } from "./mono"
import { monoMarkdown, monoMarkdownRenderNode, monoMarkdownTableOptions } from "./mono"
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
import { toolFiletype, toolStructuredFinal } from "./tool"
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
@@ -243,15 +237,13 @@ export function RunEntryContent(props: {
</Match>
<Match when={markdown()}>
<markdown
ref={(renderable: MarkdownRenderable) => {
if (props.opts?.mono) monoMarkdownRenderable(renderable)
}}
width="100%"
syntaxStyle={syntax()}
streaming={streaming()}
content={markdown()!.content}
content={monoMarkdown(markdown()!.content, props.opts?.mono === true)}
fg={color()}
tableOptions={props.opts?.mono ? monoMarkdownTableOptions : { widthMode: "content" }}
renderNode={props.opts?.mono ? monoMarkdownRenderNode : undefined}
/>
</Match>
</Switch>
+14 -20
View File
@@ -81,15 +81,7 @@ import { PluginSlot } from "../../plugin/context"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location"
import {
cacheReuseDrop,
createSessionRows,
messageBoundaryIDs,
resolvePart,
type CacheUsage,
type PartRef,
type SessionRow,
} from "./rows"
import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
import { switchLabel } from "../../util/model"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
import { stringWidth } from "../../util/string-width"
@@ -1087,7 +1079,7 @@ function SessionRowView(props: SessionRowViewProps) {
{(row) => (
<TurnTokenUsage
messageIDs={row().messageIDs}
previousCache={row().previousCache}
previousCacheRead={row().previousCacheRead}
message={props.message}
/>
)}
@@ -1099,13 +1091,13 @@ function SessionRowView(props: SessionRowViewProps) {
function TurnTokenUsage(props: {
messageIDs: string[]
previousCache?: CacheUsage
previousCacheRead?: number
message: (messageID: string) => SessionMessageInfo | undefined
}) {
const config = useConfig()
const { themeV2 } = useTheme()
const steps = createMemo(() => {
let previousCache = props.previousCache
let previousCacheRead = props.previousCacheRead
return props.messageIDs.flatMap((messageID) => {
const message = props.message(messageID)
if (message?.type !== "assistant" || !message.tokens) return []
@@ -1117,16 +1109,18 @@ function TurnTokenUsage(props: {
message.tokens.cache.write
if (total === 0) return []
const newTokens = total - message.tokens.cache.read
const currentCache = { read: message.tokens.cache.read, model: message.model }
const reuseDrop = cacheReuseDrop(previousCache, currentCache)
previousCache = currentCache
const cacheBust =
previousCacheRead !== undefined && message.tokens.cache.read < previousCacheRead
? previousCacheRead - message.tokens.cache.read
: undefined
previousCacheRead = message.tokens.cache.read
return [
{
finish: message.finish === "tool-calls" ? "tool-call" : (message.finish ?? "unknown"),
newTokens,
cached: message.tokens.cache.read,
total,
reuseDrop,
cacheBust,
},
]
})
@@ -1171,9 +1165,9 @@ function TurnTokenUsage(props: {
{" "}
{item.total.toLocaleString().padStart(columns().total)}
</text>
<Show when={item.reuseDrop !== undefined}>
<text fg={themeV2.text.feedback.warning.default}>
! Likely cache bust: {item.reuseDrop?.toLocaleString()} fewer cached tokens than the previous step
<Show when={item.cacheBust !== undefined}>
<text fg={themeV2.text.feedback.error.default}>
! Cache bust: {item.cacheBust?.toLocaleString()} fewer cached tokens than the previous step
</text>
</Show>
</box>
@@ -2872,7 +2866,7 @@ function Execute(props: ToolProps) {
const isLoading = createMemo(() => props.part.state.status === "streaming" || props.part.state.status === "running")
const calls = createMemo(() => executeCalls(props.metadata.toolCalls))
const output = createMemo(() => stripAnsi(props.output?.trim() ?? ""))
const hasRuntimeError = createMemo(() => props.metadata.error === true || props.part.state.status === "error")
const hasRuntimeError = createMemo(() => props.metadata.error === true)
const outputPreview = createMemo(() => collapseToolOutput(output(), 4, 4 * Math.max(20, ctx.width - 6)).output)
const showOutput = createMemo(() => output() && hasRuntimeError())
const content = createMemo(() => {
+6 -25
View File
@@ -10,11 +10,6 @@ export type PartRef = {
partID: string
}
export type CacheUsage = {
read: number
model: SessionMessageAssistant["model"]
}
export type SessionRow =
| { type: "message"; messageID: string }
| { type: "compaction-queued"; inputID: string }
@@ -33,7 +28,7 @@ export type SessionRow =
completed: boolean
}
| { type: "assistant-footer"; messageID: string }
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
| { type: "turn-usage"; messageIDs: string[]; previousCacheRead?: number }
export function createSessionRows(sessionID: Accessor<string>) {
const data = useData()
@@ -285,7 +280,7 @@ export function reduceSessionRows(
const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running")
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
const usage = turnTokens
? { steps: [] as SessionMessageAssistant[], previousTurnCache: undefined as CacheUsage | undefined }
? { steps: [] as SessionMessageAssistant[], previousTurnCacheRead: undefined as number | undefined }
: undefined
return [
...messages.filter((message) => !pending.has(message.id)),
@@ -294,8 +289,6 @@ export function reduceSessionRows(
].reduce<SessionRow[]>((rows, message) => {
if (message.type !== "assistant") {
if (message.type === "synthetic" && !message.description?.trim()) return rows
if (message.type === "compaction" && message.status === "completed" && usage)
usage.previousTurnCache = undefined
if (!pending.has(message.id)) completePrevious(rows)
rows.push({ type: "message", messageID: message.id })
return rows
@@ -319,9 +312,11 @@ export function reduceSessionRows(
rows.push({
type: "turn-usage",
messageIDs: stepsWithUsage.map((step) => step.id),
...(usage.previousTurnCache === undefined ? {} : { previousCache: usage.previousTurnCache }),
...(usage.previousTurnCacheRead === undefined
? {}
: { previousCacheRead: usage.previousTurnCacheRead }),
})
usage.previousTurnCache = { read: last.tokens.cache.read, model: last.model }
usage.previousTurnCacheRead = last.tokens.cache.read
}
usage.steps.length = 0
}
@@ -329,20 +324,6 @@ export function reduceSessionRows(
}, [])
}
export function cacheReuseDrop(previous: CacheUsage | undefined, current: CacheUsage) {
if (previous === undefined) return
if (
previous.model.providerID !== current.model.providerID ||
previous.model.id !== current.model.id ||
previous.model.variant !== current.model.variant
)
return
const drop = previous.read - current.read
// OpenAI cache reads can move between one and two 1,024-token buckets without a material loss of reuse.
if (current.model.providerID === "openai" && drop >= 1_024 && drop <= 2_048) return
return drop > 0 ? drop : undefined
}
function hasTokenUsage(
message: SessionMessageAssistant,
): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } {
+1 -87
View File
@@ -1,92 +1,6 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { cacheReuseDrop, messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
test("filters OpenAI cache quantization from cache reuse drops", () => {
const openai = { id: "gpt", providerID: "openai" }
expect(cacheReuseDrop(undefined, { read: 10_000, model: openai })).toBeUndefined()
expect(cacheReuseDrop({ read: 10_000, model: openai }, { read: 11_000, model: openai })).toBeUndefined()
expect(cacheReuseDrop({ read: 10_000, model: openai }, { read: 8_977, model: openai })).toBe(1_023)
expect(cacheReuseDrop({ read: 10_000, model: openai }, { read: 8_976, model: openai })).toBeUndefined()
expect(cacheReuseDrop({ read: 10_000, model: openai }, { read: 8_500, model: openai })).toBeUndefined()
expect(cacheReuseDrop({ read: 10_000, model: openai }, { read: 7_952, model: openai })).toBeUndefined()
expect(cacheReuseDrop({ read: 10_000, model: openai }, { read: 7_951, model: openai })).toBe(2_049)
})
test("compares cache reuse only for the same model", () => {
const previous = { read: 10_000, model: { id: "claude", providerID: "anthropic" } }
expect(cacheReuseDrop(previous, { read: 8_976, model: { id: "gpt", providerID: "openai" } })).toBeUndefined()
expect(cacheReuseDrop(previous, { read: 8_976, model: { id: "claude", providerID: "anthropic" } })).toBe(1_024)
expect(
cacheReuseDrop(
{ read: 10_000, model: { id: "gpt", providerID: "openai", variant: "low" } },
{ read: 8_976, model: { id: "gpt", providerID: "openai", variant: "high" } },
),
).toBeUndefined()
})
test("carries model identity with the cross-turn cache baseline", () => {
const first = assistant("assistant-1", [])
first.model = { id: "claude", providerID: "anthropic" }
first.finish = "stop"
first.tokens = { input: 1, output: 0, reasoning: 0, cache: { read: 10_000, write: 0 } }
const second = assistant("assistant-2", [])
second.model = { id: "gpt", providerID: "openai" }
second.finish = "stop"
second.tokens = { input: 1, output: 0, reasoning: 0, cache: { read: 8_976, write: 0 } }
const rows = reduceSessionRows(
[
{ type: "user", id: "user-1", text: "First", time: { created: 0 } },
first,
{ type: "user", id: "user-2", text: "Second", time: { created: 2 } },
second,
],
new Set(),
true,
).filter((row) => row.type === "turn-usage")
expect(rows).toEqual([
{ type: "turn-usage", messageIDs: ["assistant-1"] },
{
type: "turn-usage",
messageIDs: ["assistant-2"],
previousCache: { read: 10_000, model: { id: "claude", providerID: "anthropic" } },
},
])
})
test("resets the cross-turn cache baseline after compaction", () => {
const first = assistant("assistant-1", [])
first.finish = "stop"
first.tokens = { input: 1, output: 0, reasoning: 0, cache: { read: 370_176, write: 0 } }
const second = assistant("assistant-2", [])
second.finish = "stop"
second.tokens = { input: 1, output: 0, reasoning: 0, cache: { read: 13_824, write: 0 } }
const rows = reduceSessionRows(
[
first,
{
type: "compaction",
id: "compaction-1",
status: "completed",
reason: "auto",
summary: "Compacted context",
recent: "",
time: { created: 2 },
},
second,
],
new Set(),
true,
).filter((row) => row.type === "turn-usage")
expect(rows).toEqual([
{ type: "turn-usage", messageIDs: ["assistant-1"] },
{ type: "turn-usage", messageIDs: ["assistant-2"] },
])
})
import { messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
test("assigns assistant boundaries to the first rendered row instead of the first text row", () => {
const messages: SessionMessageInfo[] = [

Some files were not shown because too many files have changed in this diff Show More