mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 16:26:14 -04:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e1bedda1b | |||
| c7871e14d4 | |||
| 9840f63b12 | |||
| 56a9c0150a | |||
| 203a0613b8 | |||
| 7d8f1bdab3 | |||
| 9eea5bc925 | |||
| f753103e82 | |||
| 1e35d33ecb | |||
| c5bf4edb10 | |||
| cce8bb0e1c | |||
| 02c66c5fc1 | |||
| 33390cc457 | |||
| b2afb35527 | |||
| 828148909d | |||
| 454145fe65 | |||
| 1291dc1f11 | |||
| c7d7f61146 | |||
| 13b6845e7e | |||
| d1d97014b4 | |||
| b31747124b | |||
| 49bec25ae5 | |||
| d66d0cb904 | |||
| 3193f3aa95 | |||
| ee5460a152 | |||
| b09a066fb5 | |||
| 423fad730c | |||
| 5ae2d6d3f6 | |||
| 993f046dd9 | |||
| 9b640cf97d | |||
| 2200d100d0 | |||
| 68ef893818 | |||
| 7ab3dd04ad |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/ai": patch
|
||||
---
|
||||
|
||||
Report OpenAI prompt cache write tokens in normalized usage.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/ai": patch
|
||||
---
|
||||
|
||||
Improve Anthropic and Bedrock prompt reuse with layered cache breakpoints that roll through long tool loops.
|
||||
@@ -0,0 +1,37 @@
|
||||
name: deploy-www
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- v2
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-www-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'v2')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build
|
||||
working-directory: packages/www
|
||||
run: bun run build
|
||||
env:
|
||||
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
|
||||
- name: Deploy
|
||||
working-directory: packages/www
|
||||
run: bun run deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
|
||||
- name: Check generated documentation
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/docs
|
||||
working-directory: packages/www
|
||||
run: bun run check:generated
|
||||
|
||||
e2e:
|
||||
|
||||
@@ -207,7 +207,9 @@ Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "aut
|
||||
|
||||
### Auto placement
|
||||
|
||||
`"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.
|
||||
`"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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -235,7 +237,7 @@ cache: {
|
||||
|
||||
### Manual hints
|
||||
|
||||
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints; it only fills gaps.
|
||||
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.
|
||||
|
||||
```ts
|
||||
LLM.request({
|
||||
@@ -251,8 +253,8 @@ LLM.request({
|
||||
|
||||
| Protocol | `cache: "auto"` |
|
||||
| ----------------------- | ------------------------------------------------------------------------- |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
|
||||
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
|
||||
|
||||
|
||||
@@ -2,32 +2,31 @@
|
||||
// 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 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.
|
||||
// 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.
|
||||
//
|
||||
// Manual `cache: CacheHint` placements on individual parts are preserved —
|
||||
// this function only fills gaps the caller left empty.
|
||||
// Manual `cache: CacheHint` placements on individual parts are preserved and
|
||||
// count against the four-breakpoint budget; auto only fills remaining slots.
|
||||
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: "latest-user-message",
|
||||
messages: { tail: 1 },
|
||||
}
|
||||
|
||||
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 + system + latest user msg.
|
||||
// - "auto" → tools + first/last system + final message boundary.
|
||||
// - "none" → no auto placement; manual `CacheHint`s still flow.
|
||||
// - object form → exactly what the caller asked for.
|
||||
const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||
@@ -44,18 +43,32 @@ 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" })
|
||||
|
||||
const markLastTool = (tools: ReadonlyArray<ToolDefinition>, hint: CacheHint): ReadonlyArray<ToolDefinition> => {
|
||||
interface Budget {
|
||||
remaining: number
|
||||
}
|
||||
|
||||
const markLastTool = (
|
||||
tools: ReadonlyArray<ToolDefinition>,
|
||||
hint: CacheHint,
|
||||
budget: Budget,
|
||||
): ReadonlyArray<ToolDefinition> => {
|
||||
if (tools.length === 0) return tools
|
||||
const last = tools.length - 1
|
||||
if (tools[last]!.cache) return tools
|
||||
if (tools[last]!.cache || budget.remaining === 0) return tools
|
||||
budget.remaining -= 1
|
||||
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
|
||||
}
|
||||
|
||||
const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMRequest["system"] => {
|
||||
const markSystemBoundaries = (system: LLMRequest["system"], hint: CacheHint, budget: Budget): LLMRequest["system"] => {
|
||||
if (system.length === 0) return system
|
||||
const last = system.length - 1
|
||||
if (system[last]!.cache) return system
|
||||
return system.map((part, i) => (i === last ? { ...part, cache: hint } : part))
|
||||
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 lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number =>
|
||||
@@ -64,14 +77,20 @@ 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): ReadonlyArray<Message> => {
|
||||
const markMessageAt = (
|
||||
messages: ReadonlyArray<Message>,
|
||||
index: number,
|
||||
hint: CacheHint,
|
||||
budget: Budget,
|
||||
): 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) return messages
|
||||
if (("cache" in existing && existing.cache) || budget.remaining === 0) return messages
|
||||
budget.remaining -= 1
|
||||
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
|
||||
@@ -86,25 +105,42 @@ 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)
|
||||
if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint)
|
||||
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)
|
||||
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)
|
||||
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint, budget)
|
||||
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 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
|
||||
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
|
||||
|
||||
if (tools === request.tools && system === request.system && messages === request.messages) return request
|
||||
return LLMRequest.update(request, { tools, system, messages })
|
||||
|
||||
@@ -7,8 +7,10 @@ import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
mergeJsonRecords,
|
||||
Usage,
|
||||
type CacheHint,
|
||||
type FinishReasonDetails,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
@@ -233,12 +235,27 @@ const AnthropicBodyFields = {
|
||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
|
||||
const AnthropicUsage = Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
cache_creation_input_tokens: optionalNull(Schema.Number),
|
||||
cache_read_input_tokens: optionalNull(Schema.Number),
|
||||
})
|
||||
const AnthropicUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
cache_creation_input_tokens: optionalNull(Schema.Number),
|
||||
cache_read_input_tokens: optionalNull(Schema.Number),
|
||||
server_tool_use: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({ web_search_requests: Schema.optional(Schema.Number) }),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
output_tokens_details: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({ thinking_tokens: Schema.optional(Schema.Number) }),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
|
||||
|
||||
const AnthropicStreamBlock = Schema.Struct({
|
||||
@@ -288,7 +305,12 @@ 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
|
||||
}
|
||||
|
||||
@@ -660,9 +682,8 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
// `input_tokens` is the *non-cached* count per the Messages API docs, with
|
||||
// cache reads and writes as separate fields. We sum them to derive the
|
||||
// inclusive `inputTokens` the rest of the contract expects. Extended
|
||||
// thinking tokens are *not* broken out by Anthropic — they're billed as
|
||||
// part of `output_tokens`, so `reasoningTokens` stays `undefined` and
|
||||
// `outputTokens` carries the combined total.
|
||||
// thinking tokens are included in `output_tokens`; newer responses also
|
||||
// expose that subset through `output_tokens_details.thinking_tokens`.
|
||||
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const nonCached = usage.input_tokens
|
||||
@@ -675,6 +696,7 @@ const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cacheRead,
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
|
||||
providerMetadata: { anthropic: usage },
|
||||
})
|
||||
@@ -693,18 +715,18 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
|
||||
const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens
|
||||
const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens)
|
||||
const outputTokens = right.outputTokens ?? left.outputTokens
|
||||
const reasoningTokens = right.reasoningTokens ?? left.reasoningTokens
|
||||
return new Usage({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
nonCachedInputTokens,
|
||||
cacheReadInputTokens,
|
||||
cacheWriteInputTokens,
|
||||
reasoningTokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
||||
providerMetadata: {
|
||||
anthropic: {
|
||||
...left.providerMetadata?.["anthropic"],
|
||||
...right.providerMetadata?.["anthropic"],
|
||||
},
|
||||
anthropic:
|
||||
mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -763,6 +785,10 @@ 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",
|
||||
}),
|
||||
},
|
||||
@@ -777,20 +803,31 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
]
|
||||
}
|
||||
|
||||
if (block.type === "text" && block.text) {
|
||||
if (block.type === "text" && block.text !== undefined) {
|
||||
const events: LLMEvent[] = []
|
||||
const id = `text-${event.index ?? 0}`
|
||||
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id)
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, block.text) },
|
||||
{ ...state, lifecycle: block.text ? Lifecycle.textDelta(lifecycle, events, id, block.text) : lifecycle },
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
if (block.type === "thinking" && block.thinking) {
|
||||
if (block.type === "thinking" && block.thinking !== undefined) {
|
||||
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: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, block.thinking),
|
||||
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 },
|
||||
},
|
||||
events,
|
||||
]
|
||||
@@ -799,7 +836,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) {
|
||||
if (block.type === "redacted_thinking" && block.data !== undefined) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
@@ -847,18 +884,13 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
}
|
||||
|
||||
if (delta?.type === "signature_delta" && delta.signature) {
|
||||
const events: LLMEvent[] = []
|
||||
const index = event.index ?? 0
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`reasoning-${event.index ?? 0}`,
|
||||
anthropicMetadata({ signature: delta.signature }),
|
||||
),
|
||||
reasoningSignatures: { ...state.reasoningSignatures, [index]: delta.signature },
|
||||
},
|
||||
events,
|
||||
NO_EVENTS,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
@@ -889,31 +921,53 @@ 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)
|
||||
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
|
||||
const reasoningSignatures = { ...state.reasoningSignatures }
|
||||
delete reasoningSignatures[event.index]
|
||||
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, 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: {
|
||||
normalized: mapFinishReason(event.delta?.stop_reason),
|
||||
raw: event.delta?.stop_reason ?? undefined,
|
||||
reason: state.pendingFinish?.reason ?? {
|
||||
normalized: "unknown",
|
||||
raw: undefined,
|
||||
},
|
||||
usage,
|
||||
providerMetadata: event.delta?.stop_sequence
|
||||
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
|
||||
: undefined,
|
||||
usage: state.usage,
|
||||
providerMetadata: state.pendingFinish?.providerMetadata,
|
||||
})
|
||||
return [{ ...state, lifecycle, usage }, events]
|
||||
return [{ ...state, lifecycle }, events]
|
||||
}
|
||||
|
||||
// Prefix `error.type` so overloads, rate limits, and quota errors are visible
|
||||
@@ -938,6 +992,7 @@ 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])
|
||||
}
|
||||
@@ -958,7 +1013,11 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(AnthropicEvent),
|
||||
initial: () => ({ tools: ToolStream.empty<number>(), lifecycle: Lifecycle.initial() }),
|
||||
initial: () => ({
|
||||
tools: ToolStream.empty<number>(),
|
||||
reasoningSignatures: {},
|
||||
lifecycle: Lifecycle.initial(),
|
||||
}),
|
||||
step,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -55,6 +55,9 @@ const OpenResponsesOutputText = Schema.Struct({
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
export const MessagePhase = Schema.Literals(["commentary", "final_answer"])
|
||||
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
|
||||
|
||||
const OpenResponsesReasoningSummaryText = Schema.Struct({
|
||||
type: Schema.tag("summary_text"),
|
||||
text: Schema.String,
|
||||
@@ -86,10 +89,14 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
|
||||
Schema.Array(OpenResponsesFunctionCallOutputContent),
|
||||
])
|
||||
|
||||
const OpenResponsesInputItem = Schema.Union([
|
||||
export const InputItem = 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) }),
|
||||
Schema.Struct({
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(OpenResponsesOutputText),
|
||||
phase: Schema.optionalKey(MessagePhase),
|
||||
}),
|
||||
OpenResponsesReasoningItem,
|
||||
OpenResponsesItemReference,
|
||||
Schema.Struct({
|
||||
@@ -104,7 +111,14 @@ const OpenResponsesInputItem = Schema.Union([
|
||||
output: OpenResponsesFunctionCallOutput,
|
||||
}),
|
||||
])
|
||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof OpenResponsesInputItem>
|
||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
|
||||
type LoweredInputItem =
|
||||
| OpenResponsesInputItem
|
||||
| {
|
||||
readonly role: "assistant"
|
||||
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
|
||||
readonly phase?: MessagePhase | null
|
||||
}
|
||||
|
||||
// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
|
||||
// multiple streamed summary parts into the same item before flushing.
|
||||
@@ -135,7 +149,7 @@ export const ToolChoice = Schema.Union([
|
||||
// transports in sync without a destructure-and-strip dance.
|
||||
export const coreFields = {
|
||||
model: Schema.String,
|
||||
input: Schema.Array(OpenResponsesInputItem),
|
||||
input: Schema.Array(InputItem),
|
||||
instructions: Schema.optional(Schema.String),
|
||||
tools: optionalArray(Tool),
|
||||
tool_choice: Schema.optional(ToolChoice),
|
||||
@@ -167,7 +181,12 @@ 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) })),
|
||||
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),
|
||||
@@ -201,6 +220,7 @@ export const Event = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
delta: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
item_id: Schema.optional(Schema.String),
|
||||
summary_index: Schema.optional(Schema.Number),
|
||||
item: Schema.optional(StreamItem),
|
||||
@@ -233,6 +253,7 @@ export interface Extension {
|
||||
readonly media: ProviderShared.ValidatedMedia
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | undefined
|
||||
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
|
||||
}
|
||||
|
||||
const BASE: Extension = { id: ADAPTER, name: NAME }
|
||||
@@ -244,6 +265,9 @@ export interface ParserState {
|
||||
readonly tools: ToolStream.State<string>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly messageItems: ReadonlySet<string>
|
||||
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
|
||||
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
readonly store: boolean | undefined
|
||||
}
|
||||
@@ -373,9 +397,9 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
|
||||
const system: OpenResponsesInputItem[] =
|
||||
const system: LoweredInputItem[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const input: OpenResponsesInputItem[] = [...system]
|
||||
const input: LoweredInputItem[] = [...system]
|
||||
const store = OpenResponsesOptions.resolve(request).store
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
@@ -407,7 +431,27 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
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 })) })
|
||||
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
|
||||
(groups, part) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const phase =
|
||||
ProviderShared.isRecord(metadata)
|
||||
? messagePhase(metadata.phase, extension)
|
||||
: undefined
|
||||
const group = groups.at(-1)
|
||||
if (group && group.phase === phase) group.parts.push(part)
|
||||
else groups.push({ phase, parts: [part] })
|
||||
return groups
|
||||
},
|
||||
[],
|
||||
)
|
||||
input.push(
|
||||
...groups.map((group) => ({
|
||||
role: "assistant" as const,
|
||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||
})),
|
||||
)
|
||||
content.splice(0, content.length)
|
||||
}
|
||||
for (const part of message.content) {
|
||||
@@ -508,9 +552,9 @@ const lowerOptions = (request: LLMRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (
|
||||
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
|
||||
request: LLMRequest,
|
||||
extension: Extension = BASE,
|
||||
extension: Extension,
|
||||
) {
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
@@ -536,23 +580,31 @@ export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (
|
||||
}
|
||||
})
|
||||
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
|
||||
|
||||
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Stream Parsing
|
||||
// =============================================================================
|
||||
// Responses APIs report `input_tokens` (inclusive total) with a
|
||||
// `cached_tokens` subset, and `output_tokens` (inclusive total) with a
|
||||
// `reasoning_tokens` subset. Pass the totals through and derive the
|
||||
// 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, cached)
|
||||
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 },
|
||||
@@ -588,24 +640,30 @@ const NO_EVENTS: StepResult["1"] = []
|
||||
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 => {
|
||||
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
const phase = state.messagePhases[id]
|
||||
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
|
||||
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
|
||||
{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) },
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const onOutputTextDone = (state: ParserState, event: Event): StepResult => {
|
||||
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
|
||||
if (state.messageItems.has(id)) {
|
||||
if (state.lifecycle.text.has(id) || event.text === undefined) return [state, NO_EVENTS]
|
||||
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
|
||||
}
|
||||
const events: LLMEvent[] = []
|
||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events]
|
||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
|
||||
}
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event): StepResult => {
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): 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 [
|
||||
@@ -636,6 +694,18 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
|
||||
// best-effort, not guaranteed.
|
||||
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
messageItems: new Set([...state.messageItems, item.id]),
|
||||
messagePhases: (() => {
|
||||
const phase = state.messagePhase(item.phase)
|
||||
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
|
||||
})(),
|
||||
},
|
||||
NO_EVENTS,
|
||||
]
|
||||
if (item && isReasoningItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
@@ -792,7 +862,28 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
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 === "message" && item.id) {
|
||||
const itemPhase = state.messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
|
||||
const events: LLMEvent[] = []
|
||||
const messageItems = new Set(state.messageItems)
|
||||
messageItems.delete(item.id)
|
||||
const { [item.id]: _phase, ...messagePhases } = state.messagePhases
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.textEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
item.id,
|
||||
phase === undefined ? undefined : providerMetadata(state, { phase }),
|
||||
),
|
||||
messageItems,
|
||||
messagePhases,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (item.type === "function_call") {
|
||||
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||
@@ -892,19 +983,41 @@ const providerError = (state: ParserState, event: Event, fallback: string) => {
|
||||
}
|
||||
|
||||
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")
|
||||
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(
|
||||
event.type === "response.output_text.delta"
|
||||
? onOutputTextDelta(state, event, event.item_id)
|
||||
: onOutputTextDone(state, event, event.item_id),
|
||||
)
|
||||
}
|
||||
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
|
||||
}
|
||||
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDone(state, event))
|
||||
}
|
||||
if (event.type === "response.reasoning_summary_part.added")
|
||||
return Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||
return event.item_id
|
||||
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
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))
|
||||
return event.item_id
|
||||
? Effect.succeed(onReasoningSummaryPartDone(state, event))
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.added") {
|
||||
if (event.item?.type === "message" && !event.item.id)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
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.output_item.done") {
|
||||
if (event.item?.type === "message" && !event.item.id)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
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`)
|
||||
@@ -926,10 +1039,18 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
messageItems: new Set<string>(),
|
||||
messagePhase: (value) => messagePhase(value, extension),
|
||||
messagePhases: {},
|
||||
reasoningItems: {},
|
||||
store: OpenResponsesOptions.resolve(request).store,
|
||||
})
|
||||
|
||||
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
|
||||
if (value === "commentary" || value === "final_answer") return value
|
||||
return extension.messagePhase?.(value)
|
||||
}
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: {
|
||||
|
||||
@@ -131,6 +131,7 @@ 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(
|
||||
@@ -453,20 +454,22 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
}
|
||||
|
||||
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
|
||||
// `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
|
||||
// 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
|
||||
// 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, cached)
|
||||
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, ProviderShared.sumTokens(cached, cacheWrite))
|
||||
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 },
|
||||
|
||||
@@ -35,8 +35,18 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("image_generation") }),
|
||||
])
|
||||
|
||||
const OpenAIResponsesInputItem = Schema.Union([
|
||||
Schema.Struct({
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
|
||||
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
|
||||
}),
|
||||
OpenResponses.InputItem,
|
||||
])
|
||||
|
||||
const OpenAIResponsesCoreFields = {
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(OpenAIResponsesInputItem),
|
||||
tools: optionalArray(OpenAIResponsesTools),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
}
|
||||
@@ -60,6 +70,7 @@ const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIRes
|
||||
const extension = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
messagePhase: (value: unknown) => (value === null ? null : undefined),
|
||||
lowerMedia: ({ part, media, request }) => {
|
||||
if (request.model.provider !== "xai" || media.mime !== "application/pdf") return undefined
|
||||
return {
|
||||
@@ -102,7 +113,7 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* OpenResponses.fromRequest(
|
||||
const body = yield* OpenResponses.fromRequestWithExtension(
|
||||
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
|
||||
extension,
|
||||
)
|
||||
@@ -208,9 +219,13 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
|
||||
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
|
||||
return Effect.succeed(OpenResponses.onReasoningDelta(state, event))
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
|
||||
return Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
|
||||
return onHostedToolDone(state, event.item)
|
||||
return OpenResponses.step(state, event)
|
||||
|
||||
@@ -14,16 +14,19 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
|
||||
return { ...state, stepStarted: true }
|
||||
}
|
||||
|
||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||
if (state.text.has(id)) return state
|
||||
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 }))
|
||||
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
|
||||
}
|
||||
|
||||
export const reasoningStart = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
|
||||
@@ -40,9 +40,9 @@ import { ProviderFailureClassification } from "./errors"
|
||||
* - Anthropic and Bedrock report the input breakdown natively: Anthropic's
|
||||
* `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their
|
||||
* mappers sum the breakdown to derive the inclusive `inputTokens`.
|
||||
* Anthropic does *not* break extended-thinking out of `output_tokens`, so
|
||||
* `reasoningTokens` is `undefined` and `outputTokens` carries the
|
||||
* combined total — a documented limitation of the Anthropic API.
|
||||
* Anthropic's `outputTokens` includes extended thinking. Newer responses
|
||||
* expose that subset as `output_tokens_details.thinking_tokens`, which maps
|
||||
* to `reasoningTokens`; older responses leave it undefined.
|
||||
*
|
||||
* `providerMetadata` always carries the provider's raw usage payload —
|
||||
* keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.)
|
||||
|
||||
@@ -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 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.
|
||||
// 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.
|
||||
//
|
||||
// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
|
||||
// object form to override individual choices.
|
||||
|
||||
@@ -39,8 +39,8 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
// No explicit cache field → auto policy fires → last system part + latest
|
||||
// user message both get cache_control markers.
|
||||
// A single system block is both the first and last boundary, so the auto
|
||||
// policy deduplicates it and still marks the conversation tail.
|
||||
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,12 +48,15 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' marks the last tool, last system part, and latest user message on Anthropic", () =>
|
||||
it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: "Sys A",
|
||||
system: [
|
||||
{ type: "text", text: "Base agent" },
|
||||
{ type: "text", text: "Project instructions" },
|
||||
],
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
messages: [
|
||||
Message.user("first user"),
|
||||
@@ -66,7 +69,10 @@ describe("applyCachePolicy", () => {
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
|
||||
system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }],
|
||||
system: [
|
||||
{ type: "text", text: "Base agent", cache_control: { type: "ephemeral" } },
|
||||
{ type: "text", text: "Project instructions", cache_control: { type: "ephemeral" } },
|
||||
],
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "first user" }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "assistant reply" }] },
|
||||
@@ -120,7 +126,10 @@ describe("applyCachePolicy", () => {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: bedrockModel,
|
||||
system: "Sys",
|
||||
system: [
|
||||
{ type: "text", text: "Base agent" },
|
||||
{ type: "text", text: "Project instructions" },
|
||||
],
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")],
|
||||
cache: "auto",
|
||||
@@ -131,7 +140,12 @@ describe("applyCachePolicy", () => {
|
||||
toolConfig: {
|
||||
tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }],
|
||||
},
|
||||
system: [{ text: "Sys" }, { cachePoint: { type: "default" } }],
|
||||
system: [
|
||||
{ text: "Base agent" },
|
||||
{ cachePoint: { type: "default" } },
|
||||
{ text: "Project instructions" },
|
||||
{ cachePoint: { type: "default" } },
|
||||
],
|
||||
messages: [
|
||||
{ role: "user", content: [{ text: "first user" }] },
|
||||
{ role: "assistant", content: [{ text: "reply" }] },
|
||||
@@ -193,9 +207,55 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> }
|
||||
const body = prepared.body as {
|
||||
system: Array<{ text: string; cache_control?: unknown }>
|
||||
messages: Array<{ content: Array<{ 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()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
+54
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM } from "../../src"
|
||||
import { CacheHint, LLM, LLMRequest, Message, ToolCallPart, ToolDefinition } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as Anthropic from "../../src/providers/anthropic"
|
||||
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
|
||||
@@ -24,6 +24,39 @@ 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",
|
||||
@@ -50,4 +83,28 @@ 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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -506,6 +506,7 @@ 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" } } },
|
||||
@@ -518,6 +519,199 @@ 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("maps thinking tokens and preserves unknown Anthropic usage fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: 5,
|
||||
cache_read_input_tokens: 2,
|
||||
service_tier: "standard",
|
||||
cache_creation: { ephemeral_5m_input_tokens: 1 },
|
||||
server_tool_use: { web_search_requests: 1, start_counter: 2 },
|
||||
output_tokens_details: { thinking_tokens: 3, start_detail: "preserved" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn" },
|
||||
usage: {
|
||||
output_tokens: 8,
|
||||
server_tool_use: { web_search_requests: 2, terminal_counter: 3 },
|
||||
output_tokens_details: { terminal_detail: "preserved" },
|
||||
future_terminal: { requests: 4 },
|
||||
},
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 7,
|
||||
outputTokens: 8,
|
||||
reasoningTokens: 3,
|
||||
totalTokens: 15,
|
||||
providerMetadata: {
|
||||
anthropic: {
|
||||
input_tokens: 5,
|
||||
cache_read_input_tokens: 2,
|
||||
service_tier: "standard",
|
||||
cache_creation: { ephemeral_5m_input_tokens: 1 },
|
||||
server_tool_use: { web_search_requests: 2, start_counter: 2, terminal_counter: 3 },
|
||||
output_tokens: 8,
|
||||
output_tokens_details: {
|
||||
thinking_tokens: 3,
|
||||
start_detail: "preserved",
|
||||
terminal_detail: "preserved",
|
||||
},
|
||||
future_terminal: { requests: 4 },
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
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(
|
||||
@@ -629,6 +823,7 @@ describe("Anthropic Messages route", () => {
|
||||
delta: { stop_reason: "model_context_window_exceeded" },
|
||||
usage: { output_tokens: 1 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -646,6 +841,7 @@ 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" },
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -664,6 +860,7 @@ 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, {
|
||||
@@ -849,6 +1046,7 @@ 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, {
|
||||
@@ -912,6 +1110,7 @@ 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, {
|
||||
|
||||
@@ -939,6 +939,7 @@ 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({
|
||||
|
||||
@@ -550,7 +550,7 @@ describe("OpenAI Chat route", () => {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 7,
|
||||
prompt_tokens_details: { cached_tokens: 1 },
|
||||
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
|
||||
completion_tokens_details: { reasoning_tokens: 0 },
|
||||
}),
|
||||
)
|
||||
@@ -558,8 +558,9 @@ describe("OpenAI Chat route", () => {
|
||||
const usage = new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
nonCachedInputTokens: 4,
|
||||
nonCachedInputTokens: 2,
|
||||
cacheReadInputTokens: 1,
|
||||
cacheWriteInputTokens: 2,
|
||||
reasoningTokens: 0,
|
||||
totalTokens: 7,
|
||||
providerMetadata: {
|
||||
@@ -567,7 +568,7 @@ describe("OpenAI Chat route", () => {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 7,
|
||||
prompt_tokens_details: { cached_tokens: 1 },
|
||||
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
|
||||
completion_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent } from "../../src"
|
||||
import { LLM, LLMEvent, Message } from "../../src"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses"
|
||||
import { OpenAI } from "../../src/providers"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses"
|
||||
@@ -70,6 +70,31 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits OpenAI-only nullable phases from the Open Responses baseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
}).model("example-model")
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "text",
|
||||
text: "Unclassified.",
|
||||
providerMetadata: { openresponses: { phase: null } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
input: [{ role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads standard options from the Open Responses namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
|
||||
@@ -832,7 +832,7 @@ describe("OpenAI Responses route", () => {
|
||||
input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
total_tokens: 7,
|
||||
input_tokens_details: { cached_tokens: 1 },
|
||||
input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
},
|
||||
@@ -842,8 +842,9 @@ describe("OpenAI Responses route", () => {
|
||||
const usage = new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
nonCachedInputTokens: 4,
|
||||
nonCachedInputTokens: 2,
|
||||
cacheReadInputTokens: 1,
|
||||
cacheWriteInputTokens: 2,
|
||||
reasoningTokens: 0,
|
||||
totalTokens: 7,
|
||||
providerMetadata: {
|
||||
@@ -851,7 +852,7 @@ describe("OpenAI Responses route", () => {
|
||||
input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
total_tokens: 7,
|
||||
input_tokens_details: { cached_tokens: 1 },
|
||||
input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
},
|
||||
@@ -881,6 +882,121 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves and replays assistant message phases", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_commentary" },
|
||||
},
|
||||
{ type: "response.output_text.delta", item_id: "msg_commentary", delta: "Checking." },
|
||||
{ type: "response.output_text.done", item_id: "msg_commentary" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_commentary", phase: "commentary" },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_final", phase: "final_answer" },
|
||||
},
|
||||
{ type: "response.output_text.done", item_id: "msg_final", text: "Finished." },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_final", phase: "final_answer" },
|
||||
},
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_null", phase: null } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_null", delta: "Unclassified." },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_null", phase: null } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking.",
|
||||
providerMetadata: { openai: { phase: "commentary" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Finished.",
|
||||
providerMetadata: { openai: { phase: "final_answer" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Unclassified.",
|
||||
providerMetadata: { openai: { phase: null } },
|
||||
},
|
||||
])
|
||||
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Checking." }],
|
||||
phase: "commentary",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Finished." }],
|
||||
phase: "final_answer",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Unclassified." }],
|
||||
phase: null,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects output text events without the spec-required item id", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_text.delta", delta: "orphaned" },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("response.output_text.delta is missing item_id")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects reasoning events without the spec-required item id", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = [
|
||||
{ type: "response.reasoning_summary_part.added", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_part.done", summary_index: 0 },
|
||||
{ type: "response.reasoning_text.done" },
|
||||
]
|
||||
|
||||
for (const event of events) {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } })),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain(`${event.type} is missing item_id`)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps incomplete response reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const generate = (incompleteDetails: object) =>
|
||||
|
||||
@@ -539,6 +539,7 @@ 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 } } },
|
||||
@@ -546,6 +547,7 @@ 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" } },
|
||||
)
|
||||
@@ -801,6 +803,7 @@ 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" } },
|
||||
)
|
||||
|
||||
@@ -98,8 +98,6 @@ export type SessionMessageShell = {
|
||||
output?: { output: string; cursor: number; size: number; truncated: boolean }
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantText = { type: "text"; text: string }
|
||||
|
||||
export type SessionMessageProviderState = { [x: string]: JsonValue }
|
||||
|
||||
export type SessionMessageToolStateStreaming = { status: "streaming"; input: string }
|
||||
@@ -157,8 +155,6 @@ export type ShellInfo = {
|
||||
time: { started: number; completed?: number }
|
||||
}
|
||||
|
||||
export type SessionMessageProviderState3 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageProviderState4 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageProviderState5 = { [x: string]: any }
|
||||
@@ -167,6 +163,10 @@ export type SessionMessageProviderState6 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageProviderState7 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageProviderState8 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageProviderState9 = { [x: string]: any }
|
||||
|
||||
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
|
||||
|
||||
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
|
||||
@@ -733,16 +733,6 @@ export type SessionTextStarted = {
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number }
|
||||
}
|
||||
|
||||
export type SessionTextEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.text.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; text: string }
|
||||
}
|
||||
|
||||
export type SessionToolInputStarted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1249,6 +1239,8 @@ export type SessionPendingSynthetic = {
|
||||
delivery: "steer" | "queue"
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
|
||||
|
||||
export type SessionMessageAssistantReasoning = {
|
||||
type: "reasoning"
|
||||
text: string
|
||||
@@ -1359,6 +1351,22 @@ export type ShellCreated = {
|
||||
data: { info: ShellInfo }
|
||||
}
|
||||
|
||||
export type SessionTextEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.text.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
ordinal: number
|
||||
text: string
|
||||
state?: SessionMessageProviderState4
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionReasoningStarted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1366,7 +1374,7 @@ export type SessionReasoningStarted = {
|
||||
type: "session.reasoning.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState3 }
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState5 }
|
||||
}
|
||||
|
||||
export type SessionReasoningEnded = {
|
||||
@@ -1381,7 +1389,7 @@ export type SessionReasoningEnded = {
|
||||
assistantMessageID: string
|
||||
ordinal: number
|
||||
text: string
|
||||
state?: SessionMessageProviderState4
|
||||
state?: SessionMessageProviderState6
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1398,7 +1406,7 @@ export type SessionToolCalled = {
|
||||
callID: string
|
||||
input: { [x: string]: any }
|
||||
executed: boolean
|
||||
state?: SessionMessageProviderState5
|
||||
state?: SessionMessageProviderState7
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1853,7 +1861,7 @@ export type SessionToolSuccess = {
|
||||
content: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState6
|
||||
resultState?: SessionMessageProviderState8
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1872,7 +1880,7 @@ export type SessionToolFailed = {
|
||||
content?: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState7
|
||||
resultState?: SessionMessageProviderState9
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,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 settles, with outcome and duration. */
|
||||
/** Observes each admitted tool call as it succeeds, fails, or is interrupted. */
|
||||
onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect<void, never, Services<Provided>>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Effect, Schema } from "effect"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { ToolError, toolError } from "./tool-error.js"
|
||||
import {
|
||||
decodeInput as decodeToolInput,
|
||||
@@ -52,7 +52,7 @@ export type ToolCallEnded = {
|
||||
readonly name: string
|
||||
readonly input: unknown
|
||||
readonly durationMs: number
|
||||
readonly outcome: "success" | "failure"
|
||||
readonly outcome: "success" | "failure" | "interrupted"
|
||||
readonly message?: string
|
||||
}
|
||||
|
||||
@@ -342,7 +342,6 @@ export type DiscoveryPlan = {
|
||||
|
||||
export type SearchEntry = {
|
||||
readonly description: ToolDescription
|
||||
readonly namespace: string
|
||||
readonly searchText: string
|
||||
}
|
||||
|
||||
@@ -373,7 +372,11 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
|
||||
const scoped =
|
||||
request.namespace === undefined
|
||||
? searchIndex
|
||||
: searchIndex.filter((entry) => entry.namespace === request.namespace)
|
||||
: searchIndex.filter(
|
||||
(entry) =>
|
||||
entry.description.path === request.namespace ||
|
||||
entry.description.path.startsWith(`${request.namespace}.`),
|
||||
)
|
||||
const trimmed = query.trim()
|
||||
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
|
||||
const exact =
|
||||
@@ -428,7 +431,6 @@ export const searchSignature = (() => {
|
||||
|
||||
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
|
||||
description,
|
||||
namespace: path.split(".", 1)[0]!,
|
||||
searchText: [
|
||||
path,
|
||||
tool.description,
|
||||
@@ -495,22 +497,19 @@ 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.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })),
|
||||
Effect.tapError((error) => {
|
||||
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)
|
||||
const message =
|
||||
error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed"
|
||||
return onEnd({
|
||||
...call,
|
||||
durationMs: Date.now() - startedAt,
|
||||
outcome: "failure",
|
||||
message,
|
||||
})
|
||||
return onEnd({ ...call, durationMs, outcome: "failure", message })
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -528,12 +527,6 @@ 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)
|
||||
@@ -547,9 +540,14 @@ export const make = <R>(
|
||||
name === "search" ? [] : ["The signature may have changed. Use search to get the current signature."],
|
||||
),
|
||||
})
|
||||
const index = yield* recordAndObserve(name, input)
|
||||
const index = yield* Effect.sync(() => {
|
||||
recordCall({ name })
|
||||
return calls.length - 1
|
||||
})
|
||||
const call = { index, 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),
|
||||
@@ -557,7 +555,7 @@ export const make = <R>(
|
||||
})
|
||||
return yield* decodeOutput(result, name)
|
||||
}),
|
||||
{ index, name, input },
|
||||
call,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -189,7 +189,12 @@ 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")) : Effect.succeed(query)),
|
||||
execute: ({ query }) =>
|
||||
query === "boom"
|
||||
? Effect.fail(toolError("Lookup refused"))
|
||||
: query === "defect"
|
||||
? Effect.die("broken")
|
||||
: Effect.succeed(query),
|
||||
})
|
||||
|
||||
const runtime = CodeMode.make({
|
||||
@@ -215,14 +220,98 @@ 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", () => {
|
||||
|
||||
@@ -54,6 +54,27 @@ describe("dotted tool names", () => {
|
||||
expect(flat.catalog()[0]?.path).toBe("issues.list")
|
||||
expect(await value(flat, `return await tools.issues.list({})`)).toBe("flat")
|
||||
})
|
||||
|
||||
test("search scopes to a nested namespace subtree", async () => {
|
||||
const nested = CodeMode.make({
|
||||
tools: {
|
||||
slack: {
|
||||
admin: echo("Admin", "admin"),
|
||||
"admin.invite": echo("Invite", "invite"),
|
||||
"admin.users.list": echo("List users", "users"),
|
||||
"administrator.list": echo("List administrators", "administrators"),
|
||||
read: echo("Read Slack", "read"),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = await value(nested, `return search({ query: "", namespace: "slack.admin" })`)
|
||||
expect((result as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
|
||||
"tools.slack.admin",
|
||||
"tools.slack.admin.invite",
|
||||
"tools.slack.admin.users.list",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("callable namespaces", () => {
|
||||
|
||||
@@ -63,7 +63,6 @@ const layer = Layer.effect(
|
||||
if (rule?.resource === "*" && rule.effect === "deny") continue
|
||||
registrations.set(name, registration)
|
||||
}
|
||||
if (registrations.size === 0) return {}
|
||||
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
|
||||
if (executeRule?.resource === "*" && executeRule.effect === "deny") return {}
|
||||
return {
|
||||
|
||||
@@ -6,22 +6,19 @@ import { Instructions } from "../instructions/index"
|
||||
import { CodeModeCatalog } from "./catalog"
|
||||
|
||||
// prettier-ignore
|
||||
const prompt = (hasMoreTools: boolean) => `Run JavaScript to orchestrate tool calls and compose their results. Imports, direct filesystem access, and timers are unavailable. Do not use \`fetch\`; all external access goes through \`tools\`.
|
||||
|
||||
Prefer an explicit \`return\`; if omitted, the final top-level expression becomes the result. Await tool calls before returning; any calls still pending when execution ends are interrupted. Run independent calls concurrently with \`Promise.all\`.
|
||||
|
||||
Do not infer or normalize tool names; use only the exact signatures shown below${hasMoreTools ? " or returned by `search`" : ""}, preserving bracket notation such as \`tools.<namespace>["tool-name"](input)\`.${hasMoreTools ? `
|
||||
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.${hasMoreTools ? `
|
||||
|
||||
## Search
|
||||
|
||||
Only some tool signatures are shown. Use \`search\` to discover exact paths and signatures for additional tools:
|
||||
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."
|
||||
if (catalog.total === 0)
|
||||
return "No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool."
|
||||
|
||||
const tools = catalog.namespaces.flatMap((namespace) => {
|
||||
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
|
||||
@@ -40,12 +37,13 @@ ${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.
|
||||
const replacement = `The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.
|
||||
|
||||
${render(current)}`
|
||||
if (current.total === 0) return replacement
|
||||
const previousComplete = previous.shown === previous.total
|
||||
const currentComplete = current.shown === current.total
|
||||
if (previousComplete !== currentComplete) return full
|
||||
if (previousComplete !== currentComplete) return replacement
|
||||
|
||||
const diff = Instructions.diffByKey(
|
||||
previous.namespaces.flatMap((namespace) => namespace.entries),
|
||||
@@ -56,7 +54,7 @@ ${render(current)}`
|
||||
const entriesChanged = diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0
|
||||
|
||||
if (!currentComplete) {
|
||||
if (entriesChanged) return full
|
||||
if (entriesChanged) return replacement
|
||||
const namespaces = Instructions.diffByKey(
|
||||
previous.namespaces,
|
||||
current.namespaces,
|
||||
@@ -64,7 +62,7 @@ ${render(current)}`
|
||||
(before, after) => before.count !== after.count,
|
||||
)
|
||||
const changed = namespaces.added.length > 0 || namespaces.removed.length > 0 || namespaces.changed.length > 0
|
||||
if (!changed) return full
|
||||
if (!changed) return replacement
|
||||
|
||||
const parts = ["The Code Mode tool catalog has changed."]
|
||||
if (namespaces.added.length > 0) {
|
||||
@@ -89,11 +87,11 @@ ${render(current)}`
|
||||
)
|
||||
}
|
||||
const delta = parts.join("\n\n")
|
||||
if (delta.length < full.length) return delta
|
||||
return full
|
||||
if (delta.length < replacement.length) return delta
|
||||
return replacement
|
||||
}
|
||||
|
||||
if (!entriesChanged) return full
|
||||
if (!entriesChanged) return replacement
|
||||
const parts = ["The Code Mode tool catalog has changed."]
|
||||
if (diff.added.length > 0) {
|
||||
parts.push(
|
||||
@@ -119,19 +117,19 @@ ${render(current)}`
|
||||
)
|
||||
}
|
||||
const delta = parts.join("\n\n")
|
||||
if (delta.length < full.length) return delta
|
||||
return full
|
||||
if (delta.length < replacement.length) return delta
|
||||
return replacement
|
||||
}
|
||||
|
||||
const key = Instructions.Key.make("core/codemode")
|
||||
const codec = Schema.toCodecJson(CodeModeCatalog.Summary)
|
||||
|
||||
export const make = (entries?: ReadonlyArray<CodeModeCatalog.Entry>): Instructions.Instructions => {
|
||||
const catalog = CodeModeCatalog.summarize(entries ?? [])
|
||||
const catalog = entries === undefined ? Instructions.removed : CodeModeCatalog.summarize(entries)
|
||||
return Instructions.make({
|
||||
key,
|
||||
codec,
|
||||
read: Effect.succeed(catalog.total === 0 ? Instructions.removed : catalog),
|
||||
read: Effect.succeed(catalog),
|
||||
render: {
|
||||
initial: render,
|
||||
changed: update,
|
||||
|
||||
@@ -11,16 +11,20 @@ import { Instructions } from "../instructions/index"
|
||||
const Summary = Schema.Struct({
|
||||
server: Schema.String,
|
||||
instructions: Schema.String,
|
||||
codemode: Schema.optionalKey(Schema.Literal(false)),
|
||||
})
|
||||
type Summary = typeof Summary.Type
|
||||
|
||||
const entries = (servers: ReadonlyArray<Summary>) =>
|
||||
servers.flatMap((server) => [
|
||||
` <server name="${server.server}">`,
|
||||
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.namespace(server.server))}]\`.`,
|
||||
...server.instructions.split("\n").map((line) => ` ${line}`),
|
||||
" </server>",
|
||||
])
|
||||
servers.flatMap((server) => {
|
||||
const result = [` <server name="${server.server}">`]
|
||||
if (server.codemode !== false)
|
||||
result.push(
|
||||
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.namespace(server.server))}]\`.`,
|
||||
)
|
||||
result.push(...server.instructions.split("\n").map((line) => ` ${line}`), " </server>")
|
||||
return result
|
||||
})
|
||||
|
||||
const render = (servers: ReadonlyArray<Summary>) =>
|
||||
["<mcp_instructions>", ...entries(servers), "</mcp_instructions>"].join("\n")
|
||||
@@ -30,7 +34,7 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
|
||||
previous,
|
||||
current,
|
||||
(server) => server.server,
|
||||
(before, after) => before.instructions !== after.instructions,
|
||||
(before, after) => before.instructions !== after.instructions || before.codemode !== after.codemode,
|
||||
)
|
||||
// Additions and removals render as small deltas; anything else restates the full list.
|
||||
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
|
||||
@@ -76,21 +80,29 @@ export const layer = Layer.effect(
|
||||
removed: () => "MCP server instructions are no longer available.",
|
||||
},
|
||||
})
|
||||
if (PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
|
||||
return source(Instructions.removed)
|
||||
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const canExecute = PermissionV2.evaluate("execute", "*", agent.permissions).effect !== "deny"
|
||||
// Instructions are useful only when this agent can reach at least one server tool.
|
||||
const visible = instructions
|
||||
.filter((item) => {
|
||||
.flatMap((item) => {
|
||||
const owned = tools.filter((tool) => tool.server === item.server)
|
||||
return owned.some(
|
||||
(tool) =>
|
||||
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
const codemode = owned[0]?.codemode !== false
|
||||
if (codemode && !canExecute) return []
|
||||
if (
|
||||
!owned.some(
|
||||
(tool) =>
|
||||
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
)
|
||||
)
|
||||
return []
|
||||
return [
|
||||
codemode
|
||||
? { server: item.server, instructions: item.instructions }
|
||||
: { server: item.server, instructions: item.instructions, codemode: false as const },
|
||||
]
|
||||
})
|
||||
.map((item) => ({ server: item.server, instructions: item.instructions }))
|
||||
.toSorted((a, b) => a.server.localeCompare(b.server))
|
||||
return source(visible.length === 0 ? Instructions.removed : visible)
|
||||
}),
|
||||
|
||||
@@ -218,6 +218,11 @@ 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(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@ Use this guide as the starting point for work involving OpenCode itself. It
|
||||
covers the core concepts needed to configure and customize OpenCode, extend it
|
||||
with plugins, and build integrations with the OpenCode SDK, clients, and API.
|
||||
|
||||
Full documentation is available at <https://v2.opencode.ai/>. This overview is
|
||||
Full documentation is available at <https://v2.opencode.ai/docs>. This overview is
|
||||
only an index of core concepts. Before answering a question about a topic below,
|
||||
fetch the URL named in that section and use the full page as the source of
|
||||
truth. Follow links from that page when the question needs more detail. Fetch
|
||||
@@ -16,7 +16,7 @@ documentation page.
|
||||
Always answer for OpenCode V2 unless the user explicitly asks about V1,
|
||||
legacy OpenCode, or migrating from V1.
|
||||
|
||||
Use only <https://v2.opencode.ai/> documentation as the source of truth for V2.
|
||||
Use only <https://v2.opencode.ai/docs> documentation as the source of truth for V2.
|
||||
Do not use <https://opencode.ai/docs/>, which documents V1, and do not use
|
||||
general web search to resolve a V2 documentation question when the V2 docs or
|
||||
their `llms.txt` index cover it. The schema served from
|
||||
@@ -29,7 +29,7 @@ V1 documentation and syntax may be consulted only when the user explicitly
|
||||
asks about V1 or when needed as migration input. Outputs and recommendations
|
||||
must still use V2 unless the user specifically requests a V1 result.
|
||||
|
||||
## [Configuration](https://v2.opencode.ai/config)
|
||||
## [Configuration](https://v2.opencode.ai/docs/config)
|
||||
|
||||
OpenCode configuration uses JSON or JSONC. Include the published schema so the
|
||||
user's editor can validate fields and provide autocomplete:
|
||||
@@ -60,15 +60,15 @@ linked topic guide as the source of truth, and preserve unrelated settings when
|
||||
editing an existing file. Keep the published `$schema` URL in configuration
|
||||
examples, but do not fetch it to determine the V2 configuration shape.
|
||||
|
||||
See the [full configuration guide](https://v2.opencode.ai/config) for
|
||||
See the [full configuration guide](https://v2.opencode.ai/docs/config) for
|
||||
every field, examples, config locations, and links to dedicated feature guides.
|
||||
|
||||
## [V1 to V2 migration](https://v2.opencode.ai/migrate-v1)
|
||||
## [V1 to V2 migration](https://v2.opencode.ai/docs/migrate-v1)
|
||||
|
||||
For any request to migrate OpenCode configuration, agents, commands, skills,
|
||||
plugins, integrations, or other behavior from V1 to V2, read the full
|
||||
[migration guide](https://v2.opencode.ai/migrate-v1) before acting. In
|
||||
the repository, its source is `packages/docs/migrate-v1.mdx`.
|
||||
[migration guide](https://v2.opencode.ai/docs/migrate-v1) before acting. In
|
||||
the repository, its source is `packages/www/content/docs/(Get started)/migrate-v1.mdx`.
|
||||
|
||||
V1 config files and `.opencode/` definitions are intended to remain compatible.
|
||||
The only intentional breaking changes are the server API and plugin API. Native
|
||||
@@ -76,18 +76,18 @@ V2 config uses more ergonomic shapes, but conversion is optional. When the user
|
||||
requests conversion, inspect the complete configuration, preserve behavior and
|
||||
unrelated settings, and apply only the relevant migrations from the guide. For
|
||||
plugin migrations, fetch and follow both the migration guide and the full
|
||||
[plugins guide](https://v2.opencode.ai/build/plugins). If non-API V1
|
||||
[plugins guide](https://v2.opencode.ai/docs/build/plugins). If non-API V1
|
||||
functionality fails in V2, use the `report` skill to file it as a compatibility
|
||||
bug.
|
||||
|
||||
## [Plugins](https://v2.opencode.ai/build/plugins)
|
||||
## [Plugins](https://v2.opencode.ai/docs/build/plugins)
|
||||
|
||||
For questions about creating, configuring, loading, publishing, or migrating
|
||||
plugins, fetch the full [plugins guide](https://v2.opencode.ai/build/plugins)
|
||||
plugins, fetch the full [plugins guide](https://v2.opencode.ai/docs/build/plugins)
|
||||
before answering. This includes questions about the Effect plugin API, hooks,
|
||||
transforms, tools, plugin context capabilities, and package entrypoints.
|
||||
|
||||
## [Service](https://v2.opencode.ai/troubleshooting#check-the-background-service)
|
||||
## [Service](https://v2.opencode.ai/docs/troubleshooting#check-the-background-service)
|
||||
|
||||
OpenCode uses a client-server architecture. Interfaces such as the TUI connect
|
||||
to a background OpenCode service, which owns sessions, configuration, plugins,
|
||||
@@ -106,7 +106,7 @@ Check its status after restarting:
|
||||
opencode2 service status
|
||||
```
|
||||
|
||||
## [API](https://v2.opencode.ai/api)
|
||||
## [API](https://v2.opencode.ai/docs/api)
|
||||
|
||||
OpenCode exposes an HTTP API from its server. The API is described by an
|
||||
OpenAPI document available from the running server at `/openapi.json`.
|
||||
@@ -135,15 +135,15 @@ connected to an explicit server instead of its managed background service, use
|
||||
the same configured server and authentication context rather than constructing
|
||||
an unauthenticated request separately.
|
||||
|
||||
See the [full API reference](https://v2.opencode.ai/api) for available
|
||||
See the [full API reference](https://v2.opencode.ai/docs/api) for available
|
||||
endpoints, parameters, request bodies, and response schemas. The
|
||||
raw [OpenAPI specification](https://v2.opencode.ai/openapi.json) is also
|
||||
available for code generation and other tooling.
|
||||
|
||||
## [Client](https://v2.opencode.ai/build/client)
|
||||
## [Client](https://v2.opencode.ai/docs/build/client)
|
||||
|
||||
For questions about connecting an application to OpenCode over the network,
|
||||
fetch the full [client guide](https://v2.opencode.ai/build/client) before
|
||||
fetch the full [client guide](https://v2.opencode.ai/docs/build/client) before
|
||||
answering.
|
||||
|
||||
`@opencode-ai/client` is the generated TypeScript client for the OpenCode HTTP
|
||||
@@ -154,7 +154,7 @@ exposes typed Effects, Streams, and decoded OpenCode schema values. Its
|
||||
`Service` API can discover, start, stop, and authenticate with the local
|
||||
background service from a Node application.
|
||||
|
||||
## [Troubleshooting](https://v2.opencode.ai/troubleshooting)
|
||||
## [Troubleshooting](https://v2.opencode.ai/docs/troubleshooting)
|
||||
|
||||
OpenCode runs a client and a background server. Start by determining whether a
|
||||
problem belongs to the client, the shared server, or one project.
|
||||
@@ -174,6 +174,6 @@ problem belongs to the client, the shared server, or one project.
|
||||
- Redact API keys, authorization headers, prompts, file contents, and other
|
||||
sensitive data before sharing diagnostics.
|
||||
|
||||
See the [full troubleshooting guide](https://v2.opencode.ai/troubleshooting)
|
||||
See the [full troubleshooting guide](https://v2.opencode.ai/docs/troubleshooting)
|
||||
for service lifecycle commands, API inspection, log locations, explicit server
|
||||
connections, issue-reporting details, and local development paths.
|
||||
|
||||
@@ -40,8 +40,7 @@ export const layer = Layer.effect(
|
||||
// Drain failures are already logged and durably recorded by the execution layer.
|
||||
yield* Effect.ignore(execution.resume(sessionID))
|
||||
}),
|
||||
// Each suspension is consumed atomically right before its drain; at most four drains run at once.
|
||||
{ concurrency: 4, discard: true },
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -319,7 +319,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
"session.text.ended": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestText(draft)
|
||||
if (match) match.text = event.data.text
|
||||
if (match) {
|
||||
match.text = event.data.text
|
||||
match.state = castDraft(event.data.state)
|
||||
}
|
||||
})
|
||||
},
|
||||
"session.tool.input.started": (event) => {
|
||||
|
||||
@@ -2,11 +2,14 @@ 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 { Context, Effect, Layer } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Result } 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"
|
||||
@@ -14,13 +17,32 @@ 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: ToolRegistry.ToolSet["execute"]
|
||||
readonly executeTool: (
|
||||
input: ToolRegistry.ExecuteInput,
|
||||
) => Effect.Effect<ToolRegistry.ToolOutcome, ExecuteError>
|
||||
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */
|
||||
readonly stepLimitReached: boolean
|
||||
}
|
||||
@@ -134,7 +156,7 @@ export const layer = Layer.effect(
|
||||
tools: hookedTools,
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
const executeTool: ToolRegistry.ToolSet["execute"] = (executeInput) => {
|
||||
const executeTool: Prepared["executeTool"] = (executeInput) => {
|
||||
if (stepLimitReached)
|
||||
return Effect.succeed({
|
||||
status: "error",
|
||||
@@ -145,7 +167,9 @@ 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)
|
||||
return toolSet
|
||||
.execute(executeInput)
|
||||
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
|
||||
}
|
||||
return {
|
||||
request,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SessionRunnerLLM from "./llm"
|
||||
|
||||
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } 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, type ToolCall } from "@opencode-ai/ai"
|
||||
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
|
||||
import { Database } from "../../database/database"
|
||||
import { EventV2 } from "../../event"
|
||||
import { PermissionV2 } from "../../permission"
|
||||
@@ -18,7 +18,7 @@ import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { SessionTitle } from "../title"
|
||||
import { Service } from "./index"
|
||||
import { createLLMEventPublisher } from "./publish-llm-event"
|
||||
import { createLLMEventPublisher, type StepRecord } from "./publish-llm-event"
|
||||
import { Snapshot } from "../../snapshot"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { llmClient } from "../../effect/app-node-platform"
|
||||
@@ -36,32 +36,53 @@ type CallOutcome = Data.TaggedEnum<{
|
||||
const CallOutcome = Data.taggedEnum<CallOutcome>()
|
||||
|
||||
// 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 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 and interactive declines abort
|
||||
* 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.
|
||||
* 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, ToolOutputStore.Error>>, never>) => {
|
||||
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]
|
||||
: settled.value.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
|
||||
const failure = causes.find((cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause))
|
||||
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)] : []
|
||||
}).at(0)
|
||||
return {
|
||||
interrupted: causes.some(Cause.hasInterrupts),
|
||||
declined: causes.some(isUserDeclined),
|
||||
declines,
|
||||
failure,
|
||||
infraError: failure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(failure)),
|
||||
}
|
||||
}
|
||||
|
||||
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
|
||||
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
|
||||
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -203,9 +224,12 @@ const layer = Layer.effect(
|
||||
context: loaded,
|
||||
step: currentStep,
|
||||
})
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
|
||||
let needsContinuation = false
|
||||
// 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 startSnapshot = yield* snapshots.capture()
|
||||
const publisher = createLLMEventPublisher(events, {
|
||||
sessionID: session.id,
|
||||
@@ -217,15 +241,9 @@ const layer = Layer.effect(
|
||||
snapshot: startSnapshot,
|
||||
assistantMessageID,
|
||||
})
|
||||
const publication = Semaphore.makeUnsafe(1)
|
||||
// Durable publishes are serialized so tool fibers and step settlement never interleave
|
||||
// mid-event.
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent) => serialized(publisher.publish(event))
|
||||
|
||||
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
|
||||
cost: SessionUsage.calculateCost(resolved.cost, settlement.tokens),
|
||||
tokens: settlement.tokens,
|
||||
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) => ({
|
||||
cost: SessionUsage.calculateCost(resolved.cost, finish.tokens),
|
||||
tokens: finish.tokens,
|
||||
})
|
||||
|
||||
const captureStepEnd = Effect.fnUntraced(function* () {
|
||||
@@ -239,20 +257,26 @@ const layer = Layer.effect(
|
||||
return { snapshot, files }
|
||||
})
|
||||
|
||||
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
|
||||
const publishStepEnd = (finish: NonNullable<StepRecord["finish"]>) =>
|
||||
Effect.gen(function* () {
|
||||
const end = yield* captureStepEnd()
|
||||
yield* serialized(
|
||||
events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: session.id,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: settlement.finish,
|
||||
...stepUsage(settlement),
|
||||
...end,
|
||||
}),
|
||||
)
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: session.id,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: finish.finish,
|
||||
...stepUsage(finish),
|
||||
...end,
|
||||
})
|
||||
})
|
||||
|
||||
// Concurrent writers, no lock: the provider loop and each tool fiber publish
|
||||
// durable events unserialized. This is safe because every publisher method commits
|
||||
// its state marks synchronously before its first await (see publish-llm-event.ts),
|
||||
// every required event order is per-source (each source is one sequential fiber),
|
||||
// and a fiber's events are causally after its own Tool.Called: the fork happens
|
||||
// below that publish. Cross-source order is unconstrained; either interleaving is
|
||||
// a truthful history of concurrent work.
|
||||
//
|
||||
// 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.
|
||||
@@ -262,43 +286,40 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
if (LLMEvent.is.providerError(event)) {
|
||||
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
|
||||
if (isContextOverflowFailure(event) && !publisher.record().outputStarted) {
|
||||
overflowFailure = event
|
||||
return
|
||||
}
|
||||
}
|
||||
yield* publish(event)
|
||||
if (LLMEvent.is.toolInputError(event)) {
|
||||
if (!prepared.stepLimitReached) needsContinuation = true
|
||||
return
|
||||
}
|
||||
yield* publisher.publish(event)
|
||||
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) =>
|
||||
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)),
|
||||
// Progress is ephemeral, not durable history: nothing to order.
|
||||
progress: (update) => publisher.progress(event.id, update),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatMap((execution) => serialized(publisher.toolExecution(event.id, event.name, execution))),
|
||||
// The fiber owns its call: it publishes its own completion, masked so a
|
||||
// finished execution always reaches its durable settlement.
|
||||
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
|
||||
),
|
||||
).pipe(FiberSet.run(toolFibers)),
|
||||
)
|
||||
).pipe(Effect.forkScoped),
|
||||
})
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(serialized(publisher.flush())),
|
||||
Effect.ensuring(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.
|
||||
// Settle: only the stream and the fiber joins are interruptible (restore); every
|
||||
// other line is protected so a started call always reaches one durable outcome.
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const stream = yield* restore(providerStream).pipe(Effect.exit)
|
||||
@@ -307,108 +328,103 @@ const layer = Layer.effect(
|
||||
// away non-interrupt failures, so both interrupt checks stay Cause-based.
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
|
||||
// Join every owned tool run first: await all exits, not just the first failure.
|
||||
// Afterwards no fiber is alive, settlement is the only writer, and the record
|
||||
// is final. A failed join means the waiting itself was interrupted, so the runs
|
||||
// we abandoned are interrupted before settlement closes them out.
|
||||
if (streamInterrupted) yield* interruptTools
|
||||
const joined = yield* restore(
|
||||
Effect.forEach(toolRuns, (run) => Fiber.await(run.fiber), { concurrency: "unbounded" }),
|
||||
).pipe(Effect.exit)
|
||||
if (joined._tag === "Failure") yield* interruptTools
|
||||
const tools = classifyToolExits(
|
||||
joined,
|
||||
toolRuns.map((run) => run.call),
|
||||
)
|
||||
|
||||
// A context overflow before any assistant output is recoverable: compact and
|
||||
// restart the step instead of surfacing the provider error.
|
||||
if (
|
||||
recoverOverflow &&
|
||||
!publisher.hasRetryEvidence() &&
|
||||
!publisher.record().outputStarted &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
(yield* restore(compaction.compact(compactionInput))).status === "completed"
|
||||
)
|
||||
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: true })
|
||||
|
||||
// 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
|
||||
// already recorded from the stream. Terminal publication waits for owned tools.
|
||||
if (overflowFailure) yield* publish(overflowFailure)
|
||||
// An unrecovered held-back overflow becomes the step's durable provider error.
|
||||
if (overflowFailure) yield* publisher.publish(overflowFailure)
|
||||
// A thrown LLM failure not already recorded as the provider error either
|
||||
// escapes as a scheduled retry or fails the assistant durably.
|
||||
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
|
||||
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,
|
||||
error,
|
||||
step: currentStep,
|
||||
})
|
||||
}
|
||||
yield* serialized(publisher.failAssistant(error))
|
||||
const llmError = llmFailure && !publisher.record().providerFailed ? toSessionError(llmFailure) : undefined
|
||||
if (llmFailure && llmError && SessionRunnerRetry.isRetryable(llmFailure) && !publisher.record().outputStarted) {
|
||||
// RetryScheduled and Step.Failed fold onto an existing assistant message, so
|
||||
// Step.Started must be durable before the failure escapes.
|
||||
yield* publisher.startAssistant()
|
||||
return yield* new SessionRunnerRetry.RetryableFailure({
|
||||
cause: llmFailure,
|
||||
error: llmError,
|
||||
step: currentStep,
|
||||
})
|
||||
}
|
||||
// Provider error events only arrive from the stream, so the flag is final here.
|
||||
const providerFailed = publisher.hasProviderError()
|
||||
if (llmError) yield* publisher.failAssistant(llmError)
|
||||
|
||||
// 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(ownedToolFibers, Fiber.await, { concurrency: "unbounded" }),
|
||||
).pipe(Effect.exit)
|
||||
if (settled._tag === "Failure") yield* FiberSet.clear(toolFibers)
|
||||
const tools = classifyToolExits(settled)
|
||||
|
||||
if (tools.declined || streamInterrupted || tools.interrupted) {
|
||||
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
|
||||
// Close every unsettled call with the reason it could not settle truthfully,
|
||||
// and fail the assistant when the step itself cannot complete. A declined call
|
||||
// settles with its own reason before the generic sweeps.
|
||||
for (const decline of tools.declines)
|
||||
yield* 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) {
|
||||
yield* publisher.failUnsettledTools(TOOLS_INTERRUPTED)
|
||||
yield* publisher.failAssistant(STEP_INTERRUPTED)
|
||||
}
|
||||
if (tools.failure !== undefined) {
|
||||
const error = toSessionError(tools.infraError ?? Cause.squash(tools.failure))
|
||||
yield* serialized(publisher.failUnsettledTools(error))
|
||||
if (tools.infraError !== undefined) yield* serialized(publisher.failAssistant(error))
|
||||
yield* publisher.failUnsettledTools(error)
|
||||
if (tools.infraError !== undefined) yield* publisher.failAssistant(error)
|
||||
}
|
||||
// Local calls have joined, so the remaining sweeps only close hosted calls the
|
||||
// provider promised but never resolved.
|
||||
if (publisher.record().providerFailed) yield* publisher.failUnsettledTools(TOOLS_INTERRUPTED)
|
||||
if (llmError) yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
|
||||
// A clean stream that still left hosted calls unresolved fails the step itself.
|
||||
if (stream._tag === "Success" && !publisher.record().providerFailed) {
|
||||
const hostedResultMissing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
|
||||
if (hostedResultMissing && !publisher.record().finish) yield* publisher.failAssistant(RESULT_MISSING)
|
||||
}
|
||||
|
||||
// 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" }))
|
||||
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()
|
||||
if (stepSettlement && !stepFailure) yield* publishStepEnd(stepSettlement)
|
||||
if (stepFailure) {
|
||||
// One terminal event: Step.Ended on a clean finish, Step.Failed otherwise.
|
||||
const record = publisher.record()
|
||||
if (record.finish && !record.failure) yield* publishStepEnd(record.finish)
|
||||
if (record.failure) {
|
||||
const end = yield* captureStepEnd()
|
||||
yield* serialized(
|
||||
publisher.publishStepFailure({
|
||||
...(stepSettlement ? stepUsage(stepSettlement) : {}),
|
||||
...end,
|
||||
}),
|
||||
)
|
||||
yield* publisher.publishStepFailure({
|
||||
...(record.finish ? stepUsage(record.finish) : {}),
|
||||
...end,
|
||||
})
|
||||
}
|
||||
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (tools.declined) return yield* Effect.interrupt
|
||||
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 (stepFailure) return yield* new StepFailedError({ error: stepFailure })
|
||||
return CallOutcome.Completed({ needsContinuation, step: currentStep })
|
||||
if (tools.interrupted && joined._tag === "Failure") return yield* Effect.failCause(joined.cause)
|
||||
if (record.failure) return yield* new StepFailedError({ error: record.failure })
|
||||
return CallOutcome.Completed({
|
||||
// A local call or malformed tool input requires another model step, unless
|
||||
// this step already exhausted the agent's allowance.
|
||||
needsContinuation:
|
||||
!prepared.stepLimitReached &&
|
||||
record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)),
|
||||
step: currentStep,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}, Effect.scoped)
|
||||
|
||||
@@ -23,9 +23,30 @@ type Input = {
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
}
|
||||
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
|
||||
|
||||
/** Immutable fold of the durable facts a step's writer has recorded so far. */
|
||||
export interface StepRecord {
|
||||
/** The model produced visible output this attempt, which bars transparent retries and overflow recovery. */
|
||||
readonly outputStarted: boolean
|
||||
readonly providerFailed: boolean
|
||||
/** The step's recorded assistant failure, if any. */
|
||||
readonly failure?: SessionError.Error
|
||||
/** Present once the provider finished the step normally. */
|
||||
readonly finish?: {
|
||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
|
||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
||||
}
|
||||
readonly calls: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly called: boolean
|
||||
readonly settled: boolean
|
||||
readonly providerExecuted: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
/** Derives canonical model content from a provider-hosted tool result. */
|
||||
const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => {
|
||||
if (result.type === "content") {
|
||||
@@ -35,7 +56,17 @@ const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => {
|
||||
return [{ type: "text", text: Tool.stringify(result.value) }]
|
||||
}
|
||||
|
||||
/** Persist one step without executing tools or starting a continuation step. */
|
||||
/**
|
||||
* Persist one step without executing tools or starting a continuation step.
|
||||
*
|
||||
* Concurrency invariant: the provider loop and each owned tool fiber call these methods
|
||||
* concurrently without a lock. Two rules keep that safe, and every method must preserve
|
||||
* them. (1) Commit state marks synchronously before the first await: never a yield
|
||||
* between a check (`tool.settled`, `stepStarted`, ...) and its mark, so check-and-mark
|
||||
* stays atomic under cooperative scheduling. (2) Never require a cross-source event
|
||||
* order: each publishing fiber is sequential, so per-source order holds by construction,
|
||||
* and consumers fold by callID/ordinal rather than global position.
|
||||
*/
|
||||
export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish">, input: Input) => {
|
||||
const tools = new Map<
|
||||
string,
|
||||
@@ -54,14 +85,9 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
let stepStarted = false
|
||||
let stepFailed = false
|
||||
let providerFailed = false
|
||||
let retryEvidence = false
|
||||
let outputStarted = false
|
||||
let stepFailure: SessionError.Error | undefined
|
||||
let stepSettlement:
|
||||
| {
|
||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
|
||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
||||
}
|
||||
| undefined
|
||||
let stepSettlement: StepRecord["finish"]
|
||||
|
||||
const startAssistant = Effect.fnUntraced(function* () {
|
||||
if (stepStarted) return assistantMessageID
|
||||
@@ -123,13 +149,14 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
|
||||
const text = fragments(
|
||||
"text",
|
||||
(_textID, value, ordinal) =>
|
||||
(_textID, value, ordinal, state) =>
|
||||
Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
ordinal,
|
||||
text: value,
|
||||
state,
|
||||
})
|
||||
}),
|
||||
true,
|
||||
@@ -232,21 +259,27 @@ 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
|
||||
tool.settled = true
|
||||
failed = true
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
error,
|
||||
...failureSnapshot(tool),
|
||||
executed: tool.providerExecuted,
|
||||
})
|
||||
failed = (yield* failTool(callID, error)) || failed
|
||||
}
|
||||
return failed
|
||||
})
|
||||
@@ -277,9 +310,9 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
|
||||
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
|
||||
error: SessionError.Error,
|
||||
hostedOnly = false,
|
||||
scope: "hosted" | "all" = "all",
|
||||
) {
|
||||
return yield* failTools(error, hostedOnly ? "hosted" : "all")
|
||||
return yield* failTools(error, scope)
|
||||
})
|
||||
|
||||
const assistantMessageIDForTool = (callID: string) => {
|
||||
@@ -293,8 +326,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
yield* startAssistant()
|
||||
return
|
||||
case "text-start":
|
||||
retryEvidence = true
|
||||
const startedTextOrdinal = yield* text.start(event.id)
|
||||
outputStarted = true
|
||||
const startedTextOrdinal = yield* text.start(event.id, providerState(event.providerMetadata))
|
||||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
@@ -302,7 +335,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
})
|
||||
return
|
||||
case "text-delta":
|
||||
const deltaTextOrdinal = yield* text.append(event.id, event.text)
|
||||
const deltaTextOrdinal = yield* text.append(event.id, event.text, providerState(event.providerMetadata))
|
||||
yield* events.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
@@ -311,10 +344,10 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
})
|
||||
return
|
||||
case "text-end":
|
||||
yield* text.end(event.id)
|
||||
yield* text.end(event.id, providerState(event.providerMetadata))
|
||||
return
|
||||
case "reasoning-start":
|
||||
retryEvidence = true
|
||||
outputStarted = true
|
||||
const startedReasoningOrdinal = yield* reasoning.start(event.id, providerState(event.providerMetadata))
|
||||
yield* events.publish(SessionEvent.Reasoning.Started, {
|
||||
sessionID: input.sessionID,
|
||||
@@ -340,7 +373,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
yield* reasoning.end(event.id, providerState(event.providerMetadata))
|
||||
return
|
||||
case "tool-input-start":
|
||||
retryEvidence = true
|
||||
outputStarted = true
|
||||
yield* startToolInput(event)
|
||||
return
|
||||
case "tool-input-delta": {
|
||||
@@ -362,11 +395,11 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
yield* endToolInput(event)
|
||||
return
|
||||
case "tool-input-error":
|
||||
retryEvidence = true
|
||||
outputStarted = true
|
||||
yield* failMalformedToolInput(event)
|
||||
return
|
||||
case "tool-call": {
|
||||
retryEvidence = true
|
||||
outputStarted = true
|
||||
if (!tools.has(event.id)) yield* startToolInput(event)
|
||||
const tool = tools.get(event.id)!
|
||||
if (toolInput.has(event.id)) yield* endToolInput(event)
|
||||
@@ -379,7 +412,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
input: record(event.input),
|
||||
input: asRecord(event.input),
|
||||
executed: tool.providerExecuted,
|
||||
state: providerState(event.providerMetadata),
|
||||
})
|
||||
@@ -525,12 +558,24 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
toolExecution,
|
||||
flush,
|
||||
failAssistant,
|
||||
failTool,
|
||||
publishStepFailure,
|
||||
failUnsettledTools,
|
||||
hasProviderError: () => providerFailed,
|
||||
hasRetryEvidence: () => retryEvidence,
|
||||
stepFailure: () => stepFailure,
|
||||
stepSettlement: () => stepSettlement,
|
||||
/** Immutable snapshot of everything recorded for this step so far. */
|
||||
record: (): StepRecord => ({
|
||||
outputStarted,
|
||||
providerFailed,
|
||||
failure: stepFailure,
|
||||
finish: stepSettlement,
|
||||
calls: Array.from(tools, ([id, tool]) => ({
|
||||
id,
|
||||
name: tool.name,
|
||||
called: tool.called,
|
||||
settled: tool.settled,
|
||||
providerExecuted: tool.providerExecuted,
|
||||
})),
|
||||
}),
|
||||
startAssistant,
|
||||
assistantMessageID: assistantMessageIDForTool,
|
||||
}
|
||||
|
||||
@@ -113,7 +113,14 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
|
||||
const sameModel = sameProvider && String(message.model.id) === String(model.id)
|
||||
const reuseProviderMetadata = sameModel && message.error === undefined
|
||||
const content = message.content.flatMap((item): ContentPart[] => {
|
||||
if (item.type === "text") return [{ type: "text", text: item.text }]
|
||||
if (item.type === "text")
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: item.text,
|
||||
providerMetadata: sameProvider ? providerMetadata(providerMetadataKey, item.state) : undefined,
|
||||
},
|
||||
]
|
||||
if (item.type === "reasoning")
|
||||
return reuseProviderMetadata
|
||||
? [
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
|
||||
## Registration
|
||||
|
||||
|
||||
@@ -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 } from "effect"
|
||||
import { Effect, Ref, Schema, Semaphore } from "effect"
|
||||
import { execute, make, toLLMDefinition, type Content, type Metadata, type Registration } from "./tool"
|
||||
|
||||
const ExecuteFile = Schema.Struct({
|
||||
@@ -34,10 +34,12 @@ type CollectedFiles = {
|
||||
|
||||
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
|
||||
const description = [
|
||||
"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.",
|
||||
"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`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. 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")
|
||||
|
||||
export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
@@ -50,12 +52,11 @@ 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>>([])
|
||||
// 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 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 }))),
|
||||
)
|
||||
const result = yield* runtime(
|
||||
registrations,
|
||||
(name, registration, input) =>
|
||||
@@ -66,7 +67,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
agent: context.agent,
|
||||
messageID: context.messageID,
|
||||
callID: context.callID,
|
||||
progress: context.progress,
|
||||
progress: () => Effect.void,
|
||||
}).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
|
||||
const outputFileParts = outputFiles(executed.content)
|
||||
if (outputFileParts.length > 0)
|
||||
@@ -74,26 +75,28 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
return executed.output
|
||||
}),
|
||||
{
|
||||
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
|
||||
onToolCallStart: ({ index, name, input }) => {
|
||||
const shown = displayInput(input)
|
||||
return updateCalls((items) => {
|
||||
const next = [...items]
|
||||
next[index] = { ...current, status: outcome === "success" ? "completed" : "error" }
|
||||
next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
|
||||
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* finalCalls
|
||||
const toolCalls = yield* Ref.get(calls)
|
||||
const collected = (yield* Ref.get(files))
|
||||
.toSorted((left, right) => left.index - right.index)
|
||||
.flatMap((item) => item.files)
|
||||
|
||||
@@ -7,6 +7,7 @@ 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"
|
||||
@@ -17,10 +18,10 @@ export const name = "glob"
|
||||
export const Input = Schema.Struct({
|
||||
pattern: FileSystem.GlobInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
|
||||
path: RelativePath.pipe(Schema.optional).annotate({
|
||||
description: "Relative directory to search. Defaults to the active Location.",
|
||||
description: "Directory to search. Defaults to the current working directory.",
|
||||
}),
|
||||
limit: FileSystem.GlobInput.fields.limit.annotate({
|
||||
description: `Maximum results to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`,
|
||||
description: `Maximum number of matching files to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -45,6 +46,7 @@ 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
|
||||
@@ -53,36 +55,51 @@ export const Plugin = {
|
||||
name,
|
||||
Tool.make({
|
||||
description:
|
||||
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
|
||||
'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
|
||||
const source = { type: "tool" as const, messageID: context.messageID, callID: context.callID }
|
||||
const target = yield* mutation.resolve({ path: searchPath ?? ".", 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],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: input.path ?? ".",
|
||||
path: input.path,
|
||||
root: searchPath ?? ".",
|
||||
path: searchPath,
|
||||
limit: input.limit,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, callID: context.callID },
|
||||
source,
|
||||
})
|
||||
const cwd = path.resolve(location.directory, input.path ?? ".")
|
||||
yield* fs
|
||||
.stat(cwd)
|
||||
const info = yield* fs
|
||||
.stat(target.canonical)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
||||
),
|
||||
)
|
||||
if (info.type !== "Directory")
|
||||
return yield* Effect.fail(
|
||||
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
|
||||
)
|
||||
const root = path.resolve(location.directory, searchPath ?? ".")
|
||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||
const entries = yield* ripgrep
|
||||
.glob({
|
||||
cwd,
|
||||
cwd: target.canonical,
|
||||
pattern: input.pattern,
|
||||
limit: limit + 1,
|
||||
})
|
||||
@@ -91,7 +108,7 @@ export const Plugin = {
|
||||
result.map((entry) =>
|
||||
FileSystem.Entry.make({
|
||||
...entry,
|
||||
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
|
||||
path: RelativePath.make(path.relative(location.directory, path.resolve(root, entry.path))),
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -91,6 +91,9 @@ 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 => {
|
||||
|
||||
@@ -22,9 +22,7 @@ export const Output = Schema.Struct({
|
||||
output: Schema.String,
|
||||
})
|
||||
export const description = [
|
||||
"Load a specialized skill when the task at hand matches one of the available skills in the instructions.",
|
||||
"",
|
||||
"Use this tool to inject the skill's instructions and resources into the current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc. in the same directory as the skill.",
|
||||
"Load a specialized skill's instructions and resources into the current conversation when the task at hand matches its description.",
|
||||
"",
|
||||
"The skill ID must match one of the available skills in the instructions.",
|
||||
].join("\n")
|
||||
|
||||
@@ -66,20 +66,8 @@ describe("CodeModeInstructions.render", () => {
|
||||
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("Do not infer or normalize tool names")
|
||||
expect(instructions).toContain('`tools.<namespace>["tool-name"](input)`')
|
||||
})
|
||||
|
||||
test("describes the runtime and execution lifecycle concisely", () => {
|
||||
const instructions = render([lookup])
|
||||
expect(instructions).toContain("Run JavaScript to orchestrate tool calls and compose their results.")
|
||||
expect(instructions).toContain("Imports, direct filesystem access, and timers are unavailable.")
|
||||
expect(instructions).toContain("Do not use `fetch`; all external access goes through `tools`.")
|
||||
expect(instructions).toContain(
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
)
|
||||
expect(instructions).toContain("any calls still pending when execution ends are interrupted")
|
||||
expect(instructions).toContain("Run independent calls concurrently with `Promise.all`.")
|
||||
expect(instructions).toContain("The Code Mode tool catalog below is complete.")
|
||||
expect(instructions).not.toContain("surrounding top-level agent tools")
|
||||
})
|
||||
|
||||
test("adds search guidance when the catalog exceeds the budget", () => {
|
||||
@@ -87,10 +75,10 @@ describe("CodeModeInstructions.render", () => {
|
||||
expect(partial).toContain("## Available tools")
|
||||
expect(partial).toContain("- orders (1 tool, none shown)")
|
||||
expect(partial).toContain("## Search")
|
||||
expect(partial).toContain("Only some tool signatures are shown.")
|
||||
expect(partial).toContain("The Code Mode tool catalog below is partial.")
|
||||
expect(partial).not.toContain("surrounding top-level agent tools")
|
||||
expect(partial).toContain("- search(input: {")
|
||||
expect(partial).toContain(" limit?: number,\n offset?: number,")
|
||||
expect(partial).toContain("or returned by `search`")
|
||||
expect(partial).not.toContain("tools.orders.lookup(input:")
|
||||
})
|
||||
|
||||
@@ -125,7 +113,9 @@ describe("CodeModeInstructions.render", () => {
|
||||
})
|
||||
|
||||
test("renders only the no-tools notice for an empty catalog", () => {
|
||||
expect(render([])).toBe("No tools are currently available.")
|
||||
expect(render([])).toBe(
|
||||
"No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -135,7 +125,8 @@ describe("CodeModeInstructions.update", () => {
|
||||
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])
|
||||
const unchanged = Array.from({ length: 5 }, (_, index) => entry(`stable.tool${index}`, `Stable ${index}`))
|
||||
const text = update([echo, lookup, ...unchanged], [changed, added, ...unchanged])
|
||||
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(
|
||||
@@ -169,7 +160,7 @@ describe("CodeModeInstructions.update", () => {
|
||||
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("must not be called")
|
||||
expect(text).not.toContain("The following tools are no longer available")
|
||||
})
|
||||
|
||||
test("renders namespace-only deltas without persisting hidden tool entries", () => {
|
||||
|
||||
@@ -21,6 +21,25 @@ const lookup: CodeModeCatalog.Entry = {
|
||||
}
|
||||
|
||||
describe("CodeModeInstructions", () => {
|
||||
it.effect("instructs the model not to call execute while the catalog is empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* readInitial(CodeModeInstructions.make([]))
|
||||
expect(initialized.text).toBe(
|
||||
"No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.",
|
||||
)
|
||||
|
||||
const added = yield* readUpdate(CodeModeInstructions.make([echo]), initialized)
|
||||
expect(added.text).toContain("New tools are available in addition to those previously listed:")
|
||||
expect(added.text).toContain(echo.signature)
|
||||
|
||||
expect(yield* readUpdate(CodeModeInstructions.make([]), { values: added.values })).toMatchObject({
|
||||
text:
|
||||
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\n" +
|
||||
"No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("renders the initial catalog, semantic deltas, and removal", () =>
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* readInitial(CodeModeInstructions.make([echo]))
|
||||
|
||||
@@ -32,6 +32,22 @@ export function waitForTool(
|
||||
})
|
||||
}
|
||||
|
||||
export function waitForCodeModeTool(
|
||||
registry: ToolRegistry.Interface,
|
||||
path: string,
|
||||
remaining = 1000,
|
||||
): Effect.Effect<ToolRegistry.ToolSet, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const toolSet = yield* registry.snapshot()
|
||||
if (toolSet.codeModeCatalog?.some((tool) => tool.path === path)) return toolSet
|
||||
if (remaining === 0) {
|
||||
return yield* Effect.fail(new Error(`Timed out waiting for Code Mode tool: ${path}`))
|
||||
}
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* waitForCodeModeTool(registry, path, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a core tool plugin's tools against the real registry without booting the
|
||||
* full plugin host. Only the tool domain is live; focused tool tests exercise
|
||||
|
||||
@@ -93,6 +93,60 @@ describe("McpInstructions", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("keeps MCP instructions when Code Mode is disabled and execute is denied", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* McpInstructions.Service
|
||||
const generation = yield* service
|
||||
.load(selection([{ action: "execute", resource: "*", effect: "deny" }]))
|
||||
.pipe(Effect.flatMap(readInitial))
|
||||
|
||||
expect(generation.text).toBe(
|
||||
[
|
||||
"<mcp_instructions>",
|
||||
' <server name="alpha">',
|
||||
" Alpha instructions",
|
||||
" </server>",
|
||||
"</mcp_instructions>",
|
||||
].join("\n"),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer(
|
||||
() => [instructions("alpha", "Alpha instructions")],
|
||||
() => [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("restates guidance when Code Mode is disabled for a server", () => {
|
||||
let tools = [tool("alpha")]
|
||||
return Effect.gen(function* () {
|
||||
const service = yield* McpInstructions.Service
|
||||
const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial))
|
||||
|
||||
tools = [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })]
|
||||
const changed = yield* readUpdate(yield* service.load(selection()), initialized)
|
||||
expect(changed.text).toBe(
|
||||
[
|
||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
||||
"<mcp_instructions>",
|
||||
' <server name="alpha">',
|
||||
" Alpha instructions",
|
||||
" </server>",
|
||||
"</mcp_instructions>",
|
||||
].join("\n"),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer(
|
||||
() => [instructions("alpha", "Alpha instructions")],
|
||||
() => tools,
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("renders additions, changes, and removal", () => {
|
||||
let catalog = [instructions("alpha", "Alpha instructions")]
|
||||
const tools = [tool("alpha"), tool("beta")]
|
||||
|
||||
@@ -33,7 +33,7 @@ import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { location } from "./fixture/location"
|
||||
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
import { executeTool, toolDefinitions, toolIdentity, waitForCodeModeTool, waitForTool } from "./lib/tool"
|
||||
|
||||
let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
|
||||
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
|
||||
@@ -802,10 +802,16 @@ test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "execute")
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
const execute = definitions.find((tool) => tool.name === "execute")
|
||||
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
|
||||
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
|
||||
|
||||
expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
|
||||
"direct_fail",
|
||||
"direct_lookup",
|
||||
"direct_media",
|
||||
"execute",
|
||||
])
|
||||
expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).toContain("ok: boolean")
|
||||
expect(execute?.description).not.toContain("tools.demo.search")
|
||||
}),
|
||||
)
|
||||
@@ -873,9 +879,9 @@ it.effect("waits for permission before calling an MCP tool", () =>
|
||||
const permission = yield* Deferred.make<void>()
|
||||
decision = Deferred.await(permission)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "execute")
|
||||
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
|
||||
|
||||
const fiber = yield* executeTool(registry, {
|
||||
const fiber = yield* toolSet.execute({
|
||||
sessionID: SessionV2.ID.make("ses_mcp_permission"),
|
||||
...toolIdentity,
|
||||
call: {
|
||||
@@ -912,9 +918,9 @@ it.effect("does not call MCP when permission is blocked", () =>
|
||||
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
|
||||
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "execute")
|
||||
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
|
||||
|
||||
const execution = yield* executeTool(registry, {
|
||||
const execution = yield* toolSet.execute({
|
||||
sessionID: SessionV2.ID.make("ses_mcp_blocked"),
|
||||
...toolIdentity,
|
||||
call: {
|
||||
|
||||
@@ -64,29 +64,3 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,6 +105,31 @@ 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
|
||||
@@ -115,9 +140,11 @@ 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 })
|
||||
|
||||
|
||||
@@ -767,4 +767,35 @@ Recent work
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves assistant text provider state across same-provider model changes and failures", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.Assistant.make({
|
||||
id: id("assistant-phase"),
|
||||
type: "assistant",
|
||||
agent: build,
|
||||
model: { id: ModelV2.ID.make("old"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
SessionMessage.AssistantText.make({
|
||||
type: "text",
|
||||
text: "Checking.",
|
||||
state: { phase: "commentary" },
|
||||
}),
|
||||
],
|
||||
error: { type: "provider.unknown", message: "Interrupted after commentary" },
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
ModelV2.Ref.make({ id: ModelV2.ID.make("new"), providerID: ProviderV2.ID.make("provider") }),
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking.",
|
||||
providerMetadata: { provider: { phase: "commentary" } },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -186,6 +186,7 @@ describe("SessionRunnerLLM recorded", () => {
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(AgentV2.ID.make("build"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.permissions.push({ action: "execute", resource: "*", effect: "deny" })
|
||||
}),
|
||||
)
|
||||
const pluginHost = host({
|
||||
|
||||
@@ -259,7 +259,7 @@ test("step finish records settlement without publishing step ended", async () =>
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })))
|
||||
|
||||
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
|
||||
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
|
||||
expect(publisher.record().finish).toMatchObject({ finish: "stop" })
|
||||
})
|
||||
|
||||
test("content-filter finish retains failure evidence until step closeout", async () => {
|
||||
@@ -280,7 +280,7 @@ test("content-filter finish retains failure evidence until step closeout", async
|
||||
)
|
||||
|
||||
expect(published.map((event) => event.type)).toEqual(["session.step.started.1"])
|
||||
const settlement = publisher.stepSettlement()
|
||||
const settlement = publisher.record().finish
|
||||
expect(settlement).toMatchObject({
|
||||
finish: "content-filter",
|
||||
tokens: { input: 8, output: 2, reasoning: 1 },
|
||||
|
||||
@@ -88,7 +88,7 @@ describe("ToolRegistry", () => {
|
||||
|
||||
expect(error).toBeInstanceOf(Tool.RegistrationError)
|
||||
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
|
||||
expect((yield* service.snapshot()).definitions).toEqual([])
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("ToolRegistry", () => {
|
||||
.register({ "echo.tool": make(), echo_tool: make() }, { codemode: false })
|
||||
.pipe(Effect.flip)
|
||||
expect(collision.message).toBe("Duplicate normalized tool name: echo_tool")
|
||||
expect((yield* service.snapshot()).definitions).toEqual([])
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -117,7 +117,7 @@ describe("ToolRegistry", () => {
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Tool.RegistrationError)
|
||||
expect((yield* service.snapshot()).definitions).toEqual([])
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -148,6 +148,20 @@ describe("ToolRegistry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps execute available without Code Mode tools unless explicitly denied", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
|
||||
const available = yield* service.snapshot()
|
||||
expect(available.definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect(available.codeModeCatalog).toEqual([])
|
||||
|
||||
const denied = yield* service.snapshot([{ action: "execute", resource: "*", effect: "deny" }])
|
||||
expect(denied.definitions).toEqual([])
|
||||
expect(denied.codeModeCatalog).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
@@ -156,7 +170,12 @@ describe("ToolRegistry", () => {
|
||||
const names = (permissions: PermissionV2.Ruleset) =>
|
||||
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
|
||||
|
||||
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual(["bash", "edit", "write"])
|
||||
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
|
||||
"bash",
|
||||
"edit",
|
||||
"write",
|
||||
"execute",
|
||||
])
|
||||
expect(
|
||||
yield* names([
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
@@ -169,7 +188,11 @@ describe("ToolRegistry", () => {
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
]),
|
||||
).toEqual([])
|
||||
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["bash", "question"])
|
||||
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual([
|
||||
"bash",
|
||||
"question",
|
||||
"execute",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -182,7 +205,7 @@ describe("ToolRegistry", () => {
|
||||
|
||||
expect(
|
||||
(yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map((tool) => tool.name),
|
||||
).toEqual(["first"])
|
||||
).toEqual(["first", "execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -191,9 +214,9 @@ describe("ToolRegistry", () => {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* service.register({ echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
|
||||
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
|
||||
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo", "execute"])
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* toolDefinitions(service)).toEqual([])
|
||||
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -213,9 +236,9 @@ describe("ToolRegistry", () => {
|
||||
yield* Deferred.await(registered)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
|
||||
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
|
||||
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo", "execute"])
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* toolDefinitions(service)).toEqual([])
|
||||
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -515,7 +538,7 @@ describe("ToolRegistry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("executes codemode tools advertised in a model request", () =>
|
||||
it.effect("executes and reports progress for codemode tools advertised in a model request", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const executed: string[] = []
|
||||
@@ -526,8 +549,11 @@ describe("ToolRegistry", () => {
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) =>
|
||||
Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ output: { text } })),
|
||||
execute: ({ text }, context) =>
|
||||
Effect.sync(() => executed.push(`old:${text}`)).pipe(
|
||||
Effect.andThen(context.progress({ stage: "old" })),
|
||||
Effect.as({ output: { text } }),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
@@ -546,6 +572,7 @@ describe("ToolRegistry", () => {
|
||||
}),
|
||||
})
|
||||
|
||||
const progress: ToolRegistry.Progress[] = []
|
||||
const execution = yield* toolSet.execute({
|
||||
...call("execute"),
|
||||
call: {
|
||||
@@ -554,10 +581,15 @@ 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" } }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -880,6 +880,46 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advertises execute and durable guidance for an empty Code Mode catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const empty = {
|
||||
catalog: [],
|
||||
tool: Tool.make({
|
||||
description: "Execute Code Mode",
|
||||
input: Schema.Struct({ code: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed({ output: "unused" }),
|
||||
}),
|
||||
}
|
||||
codeModeMaterializations = [empty, empty, {}]
|
||||
yield* admit(session, "Continue without Code Mode tools")
|
||||
response = reply.stop()
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* admit(session, "Still no Code Mode tools")
|
||||
yield* session.resume(sessionID)
|
||||
yield* admit(session, "Code Mode denied")
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail", "execute"])
|
||||
expect(requests[0]?.system.some((part) => part.text.includes("Do not call `execute`"))).toBe(true)
|
||||
expect(requests[1]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail", "execute"])
|
||||
expect(requests[1]?.messages.filter((message) => message.role === "system")).toEqual([])
|
||||
expect(requests[2]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail"])
|
||||
expect(
|
||||
requests[2]?.messages.some(
|
||||
(message) =>
|
||||
message.role === "system" &&
|
||||
message.content.some(
|
||||
(part) => part.type === "text" && part.text.includes("Code Mode tools are no longer available"),
|
||||
),
|
||||
),
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies session context hooks without exposing unavailable tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -2632,6 +2672,47 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("restores durable text provider metadata in the next request", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Check first")
|
||||
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "commentary", providerMetadata: { openai: { phase: "commentary" } } }),
|
||||
LLMEvent.textDelta({ id: "commentary", text: "Checking." }),
|
||||
LLMEvent.textEnd({
|
||||
id: "commentary",
|
||||
providerMetadata: { openai: { phase: "commentary" }, anthropic: { ignored: true } },
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
]
|
||||
yield* session.resume(sessionID)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Check first" },
|
||||
{
|
||||
type: "assistant",
|
||||
content: [{ type: "text", text: "Checking.", state: { phase: "commentary" } }],
|
||||
},
|
||||
])
|
||||
|
||||
yield* admit(session, "Continue")
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[1]?.messages[1]?.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking.",
|
||||
providerMetadata: { openai: { phase: "commentary" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays durable provider-executed tool results inline in the next request", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -3569,7 +3650,7 @@ describe("SessionRunnerLLM", () => {
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-declined",
|
||||
state: { status: "error", error: { message: "Tool execution interrupted" } },
|
||||
state: { status: "error", error: { type: "aborted", message: "The user declined this tool call" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -3721,7 +3802,7 @@ describe("SessionRunnerLLM", () => {
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-question",
|
||||
state: { status: "error", error: { type: "aborted", message: "Tool execution interrupted" } },
|
||||
state: { status: "error", error: { type: "aborted", message: "The user dismissed this question" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -137,10 +137,12 @@ describe("EditTool", () => {
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit"])
|
||||
expect(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).toEqual(
|
||||
[],
|
||||
)
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit", "execute"])
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).toEqual(["execute"])
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call({ path: "hello.txt", oldString: "before", newString: "after" }),
|
||||
|
||||
@@ -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 { Effect, Schema } from "effect"
|
||||
import { Deferred, Effect, Fiber, Schema } from "effect"
|
||||
|
||||
const context = {
|
||||
sessionID: Session.ID.make("ses_execute"),
|
||||
@@ -14,6 +14,19 @@ 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`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. 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",
|
||||
@@ -131,3 +144,46 @@ 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 } },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -181,7 +181,7 @@ describe("PatchTool", () => {
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch"])
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch", "execute"])
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
|
||||
@@ -97,7 +97,11 @@ describe("QuestionTool", () => {
|
||||
deny = true
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).toEqual(["execute"])
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
@@ -142,7 +146,7 @@ describe("QuestionTool", () => {
|
||||
},
|
||||
]
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question"])
|
||||
expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question", "execute"])
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
|
||||
@@ -197,8 +197,12 @@ describe("ReadTool", () => {
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(yield* toolDefinitions(registry)).toMatchObject([{ name: "read" }])
|
||||
expect(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["read", "execute"])
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).toEqual(["execute"])
|
||||
const execution = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
|
||||
@@ -8,6 +8,7 @@ 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"
|
||||
@@ -24,27 +25,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, PermissionV2.node],
|
||||
deps: [
|
||||
ToolRegistry.toolsNode,
|
||||
FSUtil.node,
|
||||
Ripgrep.node,
|
||||
Location.node,
|
||||
LocationMutation.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>) =>
|
||||
const withTools = <A, E, R>(
|
||||
directory: string,
|
||||
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
|
||||
assertions?: PermissionV2.AssertInput[],
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
return yield* body(yield* ToolRegistry.Service)
|
||||
}).pipe(
|
||||
@@ -54,7 +55,23 @@ const withTools = <A, E, R>(directory: string, body: (registry: ToolRegistry.Int
|
||||
Location.node,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
],
|
||||
[PermissionV2.node, permission],
|
||||
[
|
||||
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"),
|
||||
}),
|
||||
),
|
||||
],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
]),
|
||||
),
|
||||
@@ -125,4 +142,94 @@ describe("search tools", () => {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("reports a file used as the glob search path", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTools(tmp.path, (registry) =>
|
||||
executeTool(registry, call("glob", { path: "file.txt", pattern: "*" })),
|
||||
),
|
||||
),
|
||||
Effect.tap((result) =>
|
||||
Effect.sync(() => {
|
||||
expect(result).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "Search path is not a directory: file.txt" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("WebFetchTool registration", () => {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const url = "http://example.com/public"
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"])
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch", "execute"])
|
||||
expect(yield* executeTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
|
||||
status: "completed",
|
||||
output: { url, contentType: "text/plain", format: "text", output: "hello" },
|
||||
|
||||
@@ -154,7 +154,7 @@ describe("WebSearchTool registration", () => {
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch"])
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch", "execute"])
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
|
||||
@@ -118,7 +118,7 @@ describe("WriteTool", () => {
|
||||
reset()
|
||||
return withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write", "execute"])
|
||||
const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
|
||||
expect(settled).toEqual({
|
||||
status: "completed",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { isTrustedNavigationUrl } from "./navigation-policy"
|
||||
|
||||
describe("isTrustedNavigationUrl", () => {
|
||||
test("allows packaged renderer pages", () => {
|
||||
expect(isTrustedNavigationUrl("oc://renderer/index.html", undefined)).toBe(true)
|
||||
expect(isTrustedNavigationUrl("oc://renderer/assets/index.js", undefined)).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects other hosts and protocols", () => {
|
||||
expect(isTrustedNavigationUrl("oc://attacker/index.html", undefined)).toBe(false)
|
||||
expect(isTrustedNavigationUrl("https://example.com", undefined)).toBe(false)
|
||||
expect(isTrustedNavigationUrl("not a url", undefined)).toBe(false)
|
||||
})
|
||||
|
||||
test("allows only the configured development origin", () => {
|
||||
const devUrl = "http://localhost:5173"
|
||||
expect(isTrustedNavigationUrl("http://localhost:5173/index.html", devUrl)).toBe(true)
|
||||
expect(isTrustedNavigationUrl("http://localhost:5174/index.html", devUrl)).toBe(false)
|
||||
expect(isTrustedNavigationUrl("https://localhost:5173/index.html", devUrl)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
const rendererProtocol = "oc:"
|
||||
const rendererHost = "renderer"
|
||||
|
||||
export function isTrustedNavigationUrl(value: string, devUrl = process.env.ELECTRON_RENDERER_URL) {
|
||||
if (!URL.canParse(value)) return false
|
||||
const url = new URL(value)
|
||||
if (url.protocol === rendererProtocol && url.host === rendererHost) return true
|
||||
if (!devUrl || !URL.canParse(devUrl)) return false
|
||||
return url.origin === new URL(devUrl).origin
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { dirname, isAbsolute, join, relative, resolve } from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import type { TitlebarTheme } from "../preload/types"
|
||||
import { exportDebugLogs, write as writeLog } from "./logging"
|
||||
import { isTrustedNavigationUrl } from "./navigation-policy"
|
||||
import { getStore, removeStoreFile } from "./store"
|
||||
import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys"
|
||||
import { createUnresponsiveSampler } from "./unresponsive"
|
||||
@@ -203,6 +204,7 @@ export function createMainWindow(id: string = randomUUID()) {
|
||||
})
|
||||
|
||||
allowRendererPermissions(win)
|
||||
restrictNavigation(win)
|
||||
wireWindowRecovery(win, id)
|
||||
|
||||
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
@@ -229,6 +231,18 @@ export function createMainWindow(id: string = randomUUID()) {
|
||||
return win
|
||||
}
|
||||
|
||||
function restrictNavigation(win: BrowserWindow) {
|
||||
win.webContents.on("will-navigate", (event, url) => {
|
||||
if (isTrustedNavigationUrl(url)) return
|
||||
event.preventDefault()
|
||||
writeLog("window", "blocked renderer navigation", { url }, "warn")
|
||||
})
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
writeLog("window", "blocked renderer window", { url }, "warn")
|
||||
return { action: "deny" }
|
||||
})
|
||||
}
|
||||
|
||||
function registerWindow(win: BrowserWindow, id: string) {
|
||||
windowIDs.set(win, id)
|
||||
registry.register(id, win)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# V2 documentation guide
|
||||
|
||||
## Structure
|
||||
|
||||
- This directory is a standalone Mintlify site deployed from `packages/docs` on the `dev` branch.
|
||||
- Write documentation in MDX. Every page should have `title` and `description` frontmatter.
|
||||
- `docs.json` owns site configuration and navigation. Add, move, or remove its page entries whenever the corresponding MDX pages change.
|
||||
- Put static files in `assets/` and reference them with root-relative paths such as `/assets/example.svg`.
|
||||
- The API endpoint reference is generated by Mintlify from `openapi.json`; do not duplicate endpoint documentation as hand-written MDX.
|
||||
- Keep documentation aligned with the V2 packages. Do not use `packages/opencode` as the source of truth unless the task explicitly concerns V1.
|
||||
|
||||
## Local development
|
||||
|
||||
- At the start of documentation work, launch `bun dev` from `packages/docs` using the shell tool with `background: true`. Never run the dev server in a foreground shell call and do not poll the process; wait for the background completion notification.
|
||||
- Preview the site at `http://localhost:3333`. Mintlify does not expose a host option and binds the preview to all network interfaces. The server reloads changes to MDX and `docs.json` automatically.
|
||||
- Use the running preview to verify navigation, links, Mintlify components, code blocks, and desktop and mobile layout.
|
||||
|
||||
## Validation
|
||||
|
||||
- Run `bun validate` from `packages/docs` after making documentation or configuration changes.
|
||||
- Run `bun broken-links` from `packages/docs` when pages, navigation, headings, or links change.
|
||||
- Treat validation errors and broken internal links as blockers. Also verify external links relevant to the change when practical.
|
||||
@@ -1,33 +0,0 @@
|
||||
# OpenCode documentation
|
||||
|
||||
The V2 documentation is a Mintlify site deployed from `packages/docs` on the `dev` branch.
|
||||
|
||||
## Local preview
|
||||
|
||||
From this directory, run:
|
||||
|
||||
```bash
|
||||
bun dev
|
||||
```
|
||||
|
||||
The preview opens at `http://localhost:3333` and reloads when MDX or `docs.json` changes.
|
||||
|
||||
Validate changes before opening a pull request:
|
||||
|
||||
```bash
|
||||
bun validate
|
||||
bun broken-links
|
||||
```
|
||||
|
||||
The V2 theme token reference is generated from
|
||||
`packages/tui/src/theme/v2/schema.ts`. Regenerate it after schema changes:
|
||||
|
||||
```bash
|
||||
bun run generate
|
||||
```
|
||||
|
||||
`bun validate` checks that the committed snippet is current. The repository's
|
||||
generation workflow also refreshes it on pushes to `dev`, so Mintlify always
|
||||
receives the generated MDX as part of the published docs tree.
|
||||
|
||||
The hosted preview is available at [opencode.mintlify.site](https://opencode.mintlify.site).
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
title: "API Reference"
|
||||
description: "OpenCode HTTP API."
|
||||
---
|
||||
|
||||
The endpoint reference is generated from the current OpenCode V2 [OpenAPI specification](/openapi.json).
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 18H6V6H18V18Z" fill="#F5F5F5"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 3H21V21H3V3ZM8 8V16H16V8H8Z" fill="#3B7DD8"/>
|
||||
<path d="M16 8H20V12H16V8Z" fill="#FAB283"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 297 B |
@@ -1,10 +0,0 @@
|
||||
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/><path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
|
||||
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/><path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
|
||||
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/><path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
|
||||
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/><path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
|
||||
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/><path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
|
||||
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/><path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
|
||||
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/><path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
|
||||
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/><path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,10 +0,0 @@
|
||||
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 30H6V18H18V30Z" fill="#CFCECD"/><path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#656363"/>
|
||||
<path d="M48 30H36V18H48V30Z" fill="#CFCECD"/><path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#656363"/>
|
||||
<path d="M84 24V30H66V24H84Z" fill="#CFCECD"/><path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#656363"/>
|
||||
<path d="M108 36H96V18H108V36Z" fill="#CFCECD"/><path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#656363"/>
|
||||
<path d="M144 30H126V18H144V30Z" fill="#CFCECD"/><path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#211E1E"/>
|
||||
<path d="M168 30H156V18H168V30Z" fill="#CFCECD"/><path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#211E1E"/>
|
||||
<path d="M198 30H186V18H198V30Z" fill="#CFCECD"/><path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#211E1E"/>
|
||||
<path d="M234 24V30H216V24H234Z" fill="#CFCECD"/><path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#211E1E"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
Vendored
-183
@@ -1,183 +0,0 @@
|
||||
---
|
||||
title: "Client"
|
||||
description: "Connect an application to the OpenCode HTTP API."
|
||||
---
|
||||
|
||||
`@opencode-ai/client` is the generated TypeScript client for the OpenCode HTTP
|
||||
API. Use it when your application connects to an OpenCode server over the
|
||||
network. Its types and methods are generated from the same contract as the
|
||||
[API reference](/api).
|
||||
|
||||
<Warning>
|
||||
The V2 API and client are beta. Method names, inputs, and outputs may change
|
||||
before the stable release.
|
||||
</Warning>
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
bun add @opencode-ai/client@next
|
||||
```
|
||||
|
||||
## Create a client
|
||||
|
||||
Create a client with the server URL, then call methods grouped by API resource:
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:4096",
|
||||
})
|
||||
|
||||
const session = await client.session.create({
|
||||
location: { directory: "/workspace" },
|
||||
})
|
||||
|
||||
await client.session.prompt({
|
||||
sessionID: session.id,
|
||||
text: "Review the current changes",
|
||||
})
|
||||
```
|
||||
|
||||
## Headers and requests
|
||||
|
||||
Pass default authentication or application headers to `OpenCode.make` with
|
||||
`headers`. You can also supply a custom `fetch` implementation. Each operation
|
||||
accepts request options as its final argument for an `AbortSignal` or
|
||||
per-request headers.
|
||||
|
||||
```ts
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "https://opencode.example.com",
|
||||
headers: {
|
||||
authorization: `Bearer ${process.env.OPENCODE_TOKEN}`,
|
||||
},
|
||||
})
|
||||
|
||||
await client.session.list(undefined, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
```
|
||||
|
||||
## Stream events
|
||||
|
||||
Streaming endpoints return async iterables:
|
||||
|
||||
```ts
|
||||
for await (const event of client.event.subscribe()) {
|
||||
console.log(event.type)
|
||||
}
|
||||
```
|
||||
|
||||
## Local background service
|
||||
|
||||
The main client entrypoints are browser-compatible and do not include local
|
||||
process management. In a Node application, import the native Promise service
|
||||
API from `@opencode-ai/client/service`.
|
||||
|
||||
- `Service.discover()` returns a healthy registered endpoint without starting
|
||||
a process.
|
||||
- `Service.ensure()` returns a compatible service, starting one when needed.
|
||||
- `Service.stop()` stops the exact registered service instance.
|
||||
- `Service.headers(endpoint)` creates the authentication headers for a client.
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/service"
|
||||
|
||||
const endpoint = await Service.ensure()
|
||||
const client = OpenCode.make({
|
||||
baseUrl: endpoint.url,
|
||||
headers: Service.headers(endpoint),
|
||||
})
|
||||
|
||||
const health = await client.health.get()
|
||||
```
|
||||
|
||||
`Service.ensure()` accepts an optional registration file, required version,
|
||||
service command, and `onStart` callback:
|
||||
|
||||
```ts
|
||||
const endpoint = await Service.ensure({
|
||||
file: "/var/run/opencode/service.json",
|
||||
version: "2.0.0",
|
||||
command: ["opencode", "serve", "--service"],
|
||||
onStart(reason, previousVersion) {
|
||||
console.log(reason, previousVersion)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Omit these options to use the standard registration path and
|
||||
`opencode serve --service` command.
|
||||
|
||||
## Effect
|
||||
|
||||
OpenCode provides a first-class Effect client through the
|
||||
`@opencode-ai/client/effect` entrypoint. It returns typed Effects and Streams
|
||||
and decodes responses into OpenCode schema values.
|
||||
|
||||
```sh
|
||||
bun add @opencode-ai/client@next effect
|
||||
```
|
||||
|
||||
### Create a client
|
||||
|
||||
```ts
|
||||
import { AbsolutePath, Location, OpenCode } from "@opencode-ai/client/effect"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:4096" })
|
||||
const session = yield* client.session.create({
|
||||
location: Location.Ref.make({
|
||||
directory: AbsolutePath.make("/workspace"),
|
||||
}),
|
||||
})
|
||||
|
||||
return yield* client.session.get({ sessionID: session.id })
|
||||
})
|
||||
|
||||
const session = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(FetchHttpClient.layer)),
|
||||
)
|
||||
```
|
||||
|
||||
Streaming operations, including `client.event.subscribe()` and
|
||||
`client.session.log(...)`, return Effect `Stream` values.
|
||||
|
||||
### Local background service
|
||||
|
||||
The Node-only `@opencode-ai/client/effect/service` entrypoint exposes the same
|
||||
operations as Effect values. Add `@effect/platform-node` and provide its
|
||||
filesystem layer when running them.
|
||||
|
||||
```sh
|
||||
bun add @effect/platform-node
|
||||
```
|
||||
|
||||
```ts
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { OpenCode } from "@opencode-ai/client/effect"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const endpoint = yield* Service.ensure()
|
||||
const client = yield* OpenCode.make({
|
||||
baseUrl: endpoint.url,
|
||||
headers: Service.headers(endpoint),
|
||||
})
|
||||
return yield* client.health.get()
|
||||
})
|
||||
|
||||
const health = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
```
|
||||
Vendored
-24
@@ -1,24 +0,0 @@
|
||||
---
|
||||
title: "Build"
|
||||
description: "Build on the engine used by millions daily."
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
<CardGroup cols={1}>
|
||||
<Card title="Extend OpenCode" href="/build/plugins">
|
||||
Build plugins that add tools, integrations, commands, agents, and custom behavior while keeping the rest of OpenCode
|
||||
intact.
|
||||
</Card>
|
||||
<Card title="Run it as a server" href="/build/client">
|
||||
Connect to OpenCode with the same client used by the TUI and desktop app, then build any interface, workflow, or agent
|
||||
experience around it.
|
||||
</Card>
|
||||
<Card title="Embed it" href="/build/sdk">
|
||||
Embed OpenCode directly into your application and build a completely custom agent, interface, or developer product
|
||||
around it.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Warning>
|
||||
The plugin API, client, and SDK are still being finalized during beta and may change before OpenCode 2.0 is stable.
|
||||
</Warning>
|
||||
Vendored
-446
@@ -1,446 +0,0 @@
|
||||
---
|
||||
title: "Plugins"
|
||||
description: "Extend OpenCode with plugins."
|
||||
---
|
||||
|
||||
Plugins extend OpenCode in-process. They can transform agents, models, commands,
|
||||
integrations, references, skills, and tools; intercept model requests and tool
|
||||
execution; and call a subset of the V2 client.
|
||||
|
||||
<Warning>
|
||||
The V2 plugin API is beta. Entrypoints, hooks, draft shapes, and configuration may change before the stable release.
|
||||
Use the `/v2` exports described on this page.
|
||||
</Warning>
|
||||
|
||||
## Load plugins
|
||||
|
||||
Plugins can be loaded from npm packages, explicit local paths, or config
|
||||
directories. Each module must have one default export containing a unique
|
||||
plugin `id` and a `setup` function.
|
||||
|
||||
### Configuration
|
||||
|
||||
Add ordered entries to the `plugins` field in `opencode.json(c)`:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugins": [
|
||||
"opencode-acme-plugin@1.2.0",
|
||||
"@acme/opencode-plugin",
|
||||
"./plugins/local.ts",
|
||||
{
|
||||
"package": "./plugins/reviewer.ts",
|
||||
"options": {
|
||||
"agent": "reviewer",
|
||||
"strict": true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
A string is either a package specifier or a local path. Local paths must start
|
||||
with `./` or `../` and resolve relative to the configuration file containing
|
||||
the entry. Absolute paths and `file://` URLs are also supported. Both scoped
|
||||
packages and versioned package specifiers are supported.
|
||||
|
||||
Use the object form to pass JSON configuration to the plugin. OpenCode passes
|
||||
`options` unchanged as `ctx.options`; omitted options become an empty object.
|
||||
The plugin owns validation and defaults for its options.
|
||||
|
||||
See [Config](/config#locations) for configuration locations and precedence.
|
||||
Entries from all applicable files are processed from lowest to highest
|
||||
precedence rather than replacing the entire array.
|
||||
|
||||
### Local discovery
|
||||
|
||||
OpenCode automatically scans this directory in every discovered OpenCode config
|
||||
directory:
|
||||
|
||||
```text
|
||||
.opencode/plugins/
|
||||
```
|
||||
|
||||
The equivalent global directory is `~/.config/opencode/plugins/`. Direct `.ts`
|
||||
and `.js` children are loaded. An immediate child directory is also loaded as a
|
||||
package when OpenCode can resolve a string `exports`, `module`, or `main`
|
||||
entrypoint, or an `index.ts` or `index.js` file.
|
||||
|
||||
A `plugins/` directory beside a project-root `opencode.json` is not discovered
|
||||
automatically. Put it under `.opencode/`, or add its file explicitly with a
|
||||
relative config entry.
|
||||
|
||||
### Enable and disable
|
||||
|
||||
A string beginning with `-` disables plugins by their exported `id`. `*`
|
||||
matches every ID, and a suffix of `.*` matches an ID prefix. Directives are
|
||||
applied in order:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"plugins": ["./plugins/reviewer.ts", "-acme.reviewer", "-opencode.provider.*", "opencode.provider.openai"],
|
||||
}
|
||||
```
|
||||
|
||||
Package specifiers and local paths locate plugin modules; they are not disable
|
||||
selectors. Use the `id` from the plugin's default export to disable it. A later
|
||||
ID entry re-enables a loaded or built-in plugin. Explicit config directives run
|
||||
after local auto-discovery, so they can disable discovered plugins by ID.
|
||||
|
||||
User plugins are activated in configured order between OpenCode's internal
|
||||
plugin phases. Hooks run sequentially in registration order, and later hooks
|
||||
observe earlier mutations. Do not depend on the internal phase ordering while
|
||||
the API is beta.
|
||||
|
||||
### Installation and dependencies
|
||||
|
||||
OpenCode installs bare package entries and their production dependencies into
|
||||
an isolated cache. Package installation does not run lifecycle scripts.
|
||||
Published packages should expose their plugin entrypoint and include every
|
||||
runtime import in `dependencies`.
|
||||
|
||||
Local files and local package directories are imported directly. OpenCode does
|
||||
**not** install their dependencies. Install dependencies in a `package.json`
|
||||
visible from the plugin file, for example:
|
||||
|
||||
```sh
|
||||
cd .opencode
|
||||
bun add @opencode-ai/plugin@next
|
||||
```
|
||||
|
||||
Match the plugin package version to the OpenCode release you target.
|
||||
|
||||
Configuration and discovered plugin files under watched config directories are
|
||||
reloaded when they change. Reloading replaces the active plugin generation and
|
||||
releases its scoped registrations. Restart OpenCode after changing an npm
|
||||
package version or a local dependency when no watched file changed.
|
||||
|
||||
## Create a plugin
|
||||
|
||||
Export the result of `Plugin.define` as the module default:
|
||||
|
||||
```ts title=".opencode/plugins/reviewer.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.reviewer",
|
||||
setup: async (ctx) => {
|
||||
const description =
|
||||
typeof ctx.options.description === "string" ? ctx.options.description : "Reviews code for regressions"
|
||||
|
||||
await ctx.agent.transform((agents) => {
|
||||
agents.update("reviewer", (agent) => {
|
||||
agent.description = description
|
||||
agent.mode = "subagent"
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`setup` runs each time the plugin is activated. Register long-lived behavior
|
||||
during setup; do not wait there on an infinite event stream. It may return a
|
||||
synchronous or asynchronous cleanup function. OpenCode awaits that cleanup
|
||||
when the plugin is disabled, reloaded, or shut down:
|
||||
|
||||
```ts
|
||||
setup: async (ctx) => {
|
||||
const controller = new AbortController()
|
||||
const task = synchronize(ctx, controller.signal)
|
||||
|
||||
return async () => {
|
||||
controller.abort()
|
||||
await task
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Hook registrations are released automatically with the same plugin scope. Use
|
||||
the returned cleanup for resources the plugin owns, such as timers, watchers,
|
||||
connections, and background tasks.
|
||||
|
||||
### Context
|
||||
|
||||
The plugin context is essentially an [OpenCode server client](/build/client).
|
||||
Its read and action methods use the same inputs and responses as the client. It
|
||||
adds plugin-only methods for transforms, runtime hooks, reloads, registrations,
|
||||
and plugin options.
|
||||
|
||||
| Capability | Available operations |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `ctx.agent` | `list`, `get`, `transform`, `reload` |
|
||||
| `ctx.catalog.provider` | `list`, `get` |
|
||||
| `ctx.catalog.model` | `list`, `get`, `default` |
|
||||
| `ctx.catalog` | `transform`, `reload` |
|
||||
| `ctx.command` | `list`, `transform`, `reload` |
|
||||
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
|
||||
| `ctx.plugin` | `list` currently active plugin IDs |
|
||||
| `ctx.reference` | `list`, `transform`, `reload` |
|
||||
| `ctx.session` | `create`, `get`, `prompt`, `command`, `synthetic`, `interrupt`, and `hook` |
|
||||
| `ctx.skill` | `list`, `transform`, `reload` |
|
||||
| `ctx.tool` | `transform` and `hook` |
|
||||
| `ctx.aisdk` | `hook` |
|
||||
| `ctx.event` | `subscribe` to the current public server event stream |
|
||||
| `ctx.options` | Readonly options from the matching config object |
|
||||
|
||||
### Transform hooks
|
||||
|
||||
Transform hooks let a plugin modify how OpenCode is configured. Use them to add
|
||||
or remove definitions, override settings, choose defaults, and provide tools or
|
||||
other sources.
|
||||
|
||||
| Transform | Draft operations |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `agent.transform` | `list`, `get`, `default`, `update`, `remove` |
|
||||
| `catalog.transform` | Provider `list`, `get`, `update`, `remove`; model `get`, `update`, `remove`; default model `get`, `set` |
|
||||
| `command.transform` | `list`, `get`, `update`, `remove` |
|
||||
| `integration.transform` | Integration `list`, `get`, `update`, `remove`; method `list`, `update`, `remove` |
|
||||
| `reference.transform` | `add`, `remove`, `list` |
|
||||
| `skill.transform` | `source`, `list` |
|
||||
| `tool.transform` | `add` |
|
||||
|
||||
Here's an example that keeps models synced from a remote source:
|
||||
|
||||
```js title=".opencode/plugins/remote-models.js"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.remote-models",
|
||||
setup: async (ctx) => {
|
||||
let models = []
|
||||
|
||||
await ctx.catalog.transform((catalog) => {
|
||||
for (const model of models) {
|
||||
catalog.model.update(model.providerID, model.id, (draft) => Object.assign(draft, model))
|
||||
}
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
const response = await fetch("https://example.com/opencode/models.json", {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!response.ok) return
|
||||
models = await response.json()
|
||||
await ctx.catalog.reload()
|
||||
}
|
||||
|
||||
await refresh()
|
||||
const timer = setInterval(() => void refresh().catch(console.error), 60_000)
|
||||
return () => clearInterval(timer)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`ctx.catalog.reload()` replays every catalog transform to derive the new
|
||||
catalog. Each plugin's logic remains composed with the others, so a later
|
||||
plugin can still modify models added by an earlier one. The catalog updates
|
||||
without restarting OpenCode.
|
||||
|
||||
### Runtime hooks
|
||||
|
||||
Runtime hooks intercept live operations. Their event objects expose specific
|
||||
mutable fields:
|
||||
|
||||
| Hook | Mutable fields |
|
||||
| ------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
|
||||
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
|
||||
| `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
||||
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
|
||||
| `ctx.tool.hook("execute.after", callback)` | Terminal `content`, `metadata`, and `outputPaths`; `error` on failure |
|
||||
|
||||
For example, remove a tool from selected model requests and normalize another
|
||||
tool's input:
|
||||
|
||||
```ts title=".opencode/plugins/guards.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.guards",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("request", (event) => {
|
||||
delete event.tools.write
|
||||
})
|
||||
|
||||
await ctx.tool.hook("execute.before", (event) => {
|
||||
if (event.tool !== "lookup" || typeof event.input !== "object" || event.input === null) return
|
||||
event.input = { ...event.input, source: "plugin" }
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
A hook failure fails the operation it intercepts. Keep runtime hooks fast and
|
||||
handle expected errors inside the callback.
|
||||
|
||||
## Examples
|
||||
|
||||
### Add a tool
|
||||
|
||||
Create an executable tool with `Tool.make`, then register it with a name
|
||||
and registration options. Define its input with JSON Schema and use an async
|
||||
executor:
|
||||
|
||||
```js title=".opencode/plugins/greeting.js"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
import { Tool } from "@opencode-ai/plugin/v2/tool"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.greeting",
|
||||
setup: async (ctx) => {
|
||||
await ctx.tool.transform((tools) => {
|
||||
tools.add(
|
||||
"greeting",
|
||||
Tool.make({
|
||||
description: "Create a greeting",
|
||||
input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: {
|
||||
type: "object",
|
||||
properties: { greeting: { type: "string" } },
|
||||
required: ["greeting"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async ({ name }) => {
|
||||
const text = `Hello, ${name}!`
|
||||
return {
|
||||
output: { greeting: text },
|
||||
content: text,
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Unsupported characters in tool names are normalized to underscores. Namespace
|
||||
segments must begin with a letter, contain at most 64 letters, digits,
|
||||
underscores, or hyphens, and are joined with dots. Pass the optional third
|
||||
argument to `tools.add` to configure the registration with
|
||||
`{ namespace, codemode }`:
|
||||
|
||||
- `namespace` prefixes and groups the exposed tool name.
|
||||
- `codemode` defaults to `true` and makes the tool available through the
|
||||
`execute` CodeMode tool. Set `codemode: false` to expose it directly to the
|
||||
provider.
|
||||
|
||||
The executor receives a second context argument containing `sessionID`,
|
||||
`agent`, `messageID`, `callID`, and `progress`. A tool with `output`
|
||||
must return `output`; Effect and Standard Schema codecs validate it, while raw
|
||||
JSON Schema definitions enforce JSON compatibility only. A tool
|
||||
without `output` returns model-visible `content` instead.
|
||||
|
||||
### Add a command
|
||||
|
||||
```js title=".opencode/plugins/review-command.js"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.review-command",
|
||||
setup: async (ctx) => {
|
||||
await ctx.command.transform((commands) => {
|
||||
commands.update("review", (command) => {
|
||||
command.description = "Review the current changes"
|
||||
command.template = "Review the current changes for correctness and missing tests."
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Set the default model
|
||||
|
||||
```js title=".opencode/plugins/default-model.js"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.default-model",
|
||||
setup: async (ctx) => {
|
||||
await ctx.catalog.transform((catalog) => {
|
||||
catalog.model.default.set("anthropic", "claude-sonnet-4-5")
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Publish a package
|
||||
|
||||
A package plugin uses the same default export as a local plugin. A minimal
|
||||
manifest is:
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"name": "opencode-acme-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "next"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use versions compatible with the OpenCode release you target and test the
|
||||
installed package, not only a workspace-linked copy. Because the plugin API is
|
||||
beta, publish compatible plugin updates when V2 entrypoints or contracts
|
||||
change.
|
||||
|
||||
## Verify loading
|
||||
|
||||
List active plugin IDs through the V2 API:
|
||||
|
||||
```sh
|
||||
opencode2 api get /api/plugin
|
||||
```
|
||||
|
||||
If a plugin is absent, check the server log described in
|
||||
[Troubleshooting](/troubleshooting#read-logs). Invalid modules and setup failures are
|
||||
logged; one failing package does not prevent unrelated valid packages from
|
||||
being resolved.
|
||||
|
||||
## Effect
|
||||
|
||||
OpenCode provides a first-class Effect API for plugins through the
|
||||
`@opencode-ai/plugin/v2/effect` entrypoint. Install `effect` alongside the
|
||||
plugin package and export an `effect` function instead of `setup`:
|
||||
|
||||
```sh
|
||||
bun add @opencode-ai/plugin@next effect
|
||||
```
|
||||
|
||||
```ts title=".opencode/plugins/reviewer-effect.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.reviewer-effect",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.agent.transform((agents) => {
|
||||
agents.update("reviewer", (agent) => {
|
||||
agent.description = "Reviews code for regressions"
|
||||
agent.mode = "subagent"
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
Context operations return Effects. The plugin effect is scoped, so finalizers,
|
||||
fibers, and registrations are released when the plugin reloads or unloads.
|
||||
OpenCode does not expose its private Core services to the plugin; use the
|
||||
capabilities on `ctx`.
|
||||
|
||||
Typed tools can use `Schema` from `effect` and `Tool.make` from
|
||||
`@opencode-ai/plugin/v2/effect/tool`. Effect and Promise plugins use the same
|
||||
`tools.add(name, tool, options?)` registration shape. Effect executors
|
||||
return an Effect and may fail with the typed tool failure channel.
|
||||
Vendored
-84
@@ -1,84 +0,0 @@
|
||||
---
|
||||
title: "SDK"
|
||||
description: "Embed OpenCode directly in your application."
|
||||
---
|
||||
|
||||
We're working on a general-purpose SDK for embedding OpenCode directly inside
|
||||
your application. The regular SDK is coming soon.
|
||||
|
||||
An Effect-native version is available now for applications built with Effect.
|
||||
Its current documentation is below. For other applications, run OpenCode as a
|
||||
server and use the [TypeScript client](/build/client) in the meantime.
|
||||
|
||||
## Effect
|
||||
|
||||
`@opencode-ai/sdk-next` hosts OpenCode in-process. Unlike the
|
||||
[network client](/build/client), it assembles the OpenCode server and routes API
|
||||
calls through its HTTP router in memory. It opens no HTTP listener and adds no
|
||||
network hop between the client and server.
|
||||
|
||||
<Warning>
|
||||
The V2 SDK is beta and currently private to the OpenCode workspace. It is not
|
||||
published for external installation yet, and its package name and API may
|
||||
change before release.
|
||||
</Warning>
|
||||
|
||||
## Create a host
|
||||
|
||||
`OpenCode.create()` creates a scoped host. Closing its Effect Scope releases
|
||||
the router, location services, fibers, and scoped plugin registrations.
|
||||
|
||||
```ts
|
||||
import {
|
||||
AbsolutePath,
|
||||
Location,
|
||||
OpenCode,
|
||||
} from "@opencode-ai/sdk-next"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const opencode = yield* OpenCode.create()
|
||||
const session = yield* opencode.sessions.create({
|
||||
location: Location.Ref.make({
|
||||
directory: AbsolutePath.make("/workspace"),
|
||||
}),
|
||||
})
|
||||
|
||||
return yield* opencode.sessions.get({ sessionID: session.id })
|
||||
}),
|
||||
)
|
||||
|
||||
const session = await Effect.runPromise(program)
|
||||
```
|
||||
|
||||
The embedded host uses the same routes, middleware, codecs, errors, and schema
|
||||
values as `@opencode-ai/client/effect`. It exposes the full generated client and
|
||||
adds the convenience aliases `sessions` and `events` for the session and event
|
||||
groups.
|
||||
|
||||
## Use as a service
|
||||
|
||||
Use `OpenCode.layer` when the host should be provided through Effect dependency
|
||||
injection:
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode-ai/sdk-next"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const opencode = yield* OpenCode.Service
|
||||
return yield* opencode.sessions.active()
|
||||
})
|
||||
|
||||
const active = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(OpenCode.layer)),
|
||||
)
|
||||
```
|
||||
|
||||
## Register plugins
|
||||
|
||||
Call `opencode.plugin(...)` to register an embedded V2 plugin. Embedded plugins
|
||||
use the same discovery and location-scoped activation path as configured
|
||||
plugins. The SDK also exports `Tool` for plugin-defined tools. See the
|
||||
[Plugins guide](/build/plugins) for the plugin shape and available hooks.
|
||||
@@ -1,78 +0,0 @@
|
||||
{
|
||||
"$schema": "https://mintlify.com/docs.json",
|
||||
"theme": "mint",
|
||||
"name": "OpenCode",
|
||||
"description": "OpenCode documentation.",
|
||||
"colors": {
|
||||
"primary": "#3B7DD8",
|
||||
"light": "#3B7DD8",
|
||||
"dark": "#FAB283"
|
||||
},
|
||||
"favicon": "/assets/favicon.svg",
|
||||
"logo": {
|
||||
"light": "/assets/logo-light.svg",
|
||||
"dark": "/assets/logo-dark.svg"
|
||||
},
|
||||
"navigation": {
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Docs",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Get started",
|
||||
"pages": ["index", "migrate-v1", "config", "troubleshooting"]
|
||||
},
|
||||
{
|
||||
"group": "Configure",
|
||||
"pages": [
|
||||
"providers",
|
||||
"models",
|
||||
"agents",
|
||||
"permissions",
|
||||
"sharing",
|
||||
"snapshots",
|
||||
"commands",
|
||||
"skills",
|
||||
"instructions",
|
||||
"mcp-servers",
|
||||
"attachments",
|
||||
"compaction",
|
||||
"warming",
|
||||
"themes",
|
||||
"formatters",
|
||||
"lsp",
|
||||
"references"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Build",
|
||||
"pages": ["build/index", "build/plugins", "build/client", "build/sdk"]
|
||||
},
|
||||
{
|
||||
"tab": "API",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Overview",
|
||||
"pages": ["api/index"]
|
||||
},
|
||||
{
|
||||
"group": "Endpoints",
|
||||
"openapi": "openapi.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"global": {}
|
||||
},
|
||||
"contextual": {
|
||||
"options": ["copy", "view", "chatgpt", "claude", "mcp", "cursor", "vscode"]
|
||||
},
|
||||
"footer": {
|
||||
"socials": {
|
||||
"github": "https://github.com/anomalyco/opencode",
|
||||
"x": "https://x.com/opencode"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
---
|
||||
title: "LSP"
|
||||
description: ""
|
||||
---
|
||||
|
||||
Language Server Protocol (LSP) integrations can provide code diagnostics,
|
||||
symbols, definitions, references, and other language-aware context.
|
||||
|
||||
<Warning>
|
||||
OpenCode V2 does not yet have an LSP runtime or built-in language servers.
|
||||
The `lsp` configuration is accepted and preserved, but it does not currently
|
||||
start or download servers, expose an LSP tool, or add diagnostics to file tool
|
||||
results.
|
||||
</Warning>
|
||||
|
||||
## Built-in servers
|
||||
|
||||
There are no built-in LSP servers in the current V2 implementation. Setting
|
||||
`lsp` to `true` declares that built-ins should be enabled, but has no runtime
|
||||
effect until V2 provides a server registry and LSP runtime.
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"lsp": true
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The `lsp` field accepts a boolean or an object keyed by server name:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"lsp": {
|
||||
"custom-typescript": {
|
||||
"command": ["typescript-language-server", "--stdio"],
|
||||
"extensions": [".ts", ".tsx"],
|
||||
"env": {
|
||||
"TSS_LOG": "-level verbose"
|
||||
},
|
||||
"initialization": {
|
||||
"preferences": {
|
||||
"importModuleSpecifierPreference": "relative"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each enabled server entry has this shape:
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `command` | `string[]` | Yes | Executable followed by any arguments. |
|
||||
| `extensions` | `string[]` | No | File extensions associated with the server, including the leading dot. |
|
||||
| `disabled` | `boolean` | No | Disables the entry when `true`. |
|
||||
| `env` | `Record<string, string>` | No | Environment variables for the server process. The property is named `env`, not `environment`. |
|
||||
| `initialization` | `Record<string, unknown>` | No | Server-specific options for the LSP `initialize` request. |
|
||||
|
||||
The only entry that may omit `command` is the disable-only form:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"lsp": {
|
||||
"typescript": {
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Server names are arbitrary. The V2 schema permits `extensions` to be omitted,
|
||||
including for a custom server, although a future runtime will need a way to
|
||||
associate that server with files.
|
||||
|
||||
## Disable LSP
|
||||
|
||||
Omit `lsp` when no configuration is needed. Set it to `false` to explicitly
|
||||
disable the whole integration, including when a lower-priority configuration
|
||||
set it to `true` or supplied an object:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"lsp": false
|
||||
}
|
||||
```
|
||||
|
||||
Use `{ "disabled": true }` under a server name to disable one server while
|
||||
retaining the object form. `OPENCODE_DISABLE_LSP_DOWNLOAD` is not used by V2;
|
||||
V2 currently performs no automatic LSP downloads.
|
||||
|
||||
## Current usage
|
||||
|
||||
V2 loads and validates the configuration shape for compatibility and future
|
||||
integration. It does not currently use LSP when reading, writing, editing, or
|
||||
patching files, and those tools do not notify a language server or return LSP
|
||||
diagnostics.
|
||||
|
||||
For reliable feedback today, have the agent run the project's lint, typecheck,
|
||||
test, or compiler commands. Record those commands in an `AGENTS.md` file or a
|
||||
skill so the agent knows when and where to run them.
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/docs",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun run generate && bun --bun mint dev --no-open --port 3333",
|
||||
"generate": "bun script/generate-theme-tokens.ts",
|
||||
"check:generated": "bun script/generate-theme-tokens.ts --check",
|
||||
"validate": "bun run check:generated && bun --bun mint validate",
|
||||
"broken-links": "bun --bun mint broken-links"
|
||||
},
|
||||
"devDependencies": {
|
||||
"effect": "catalog:",
|
||||
"mint": "4.2.666",
|
||||
"prettier": "3.6.2"
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
---
|
||||
title: "Permissions"
|
||||
description: ""
|
||||
---
|
||||
|
||||
Permissions control whether an agent may perform an action on a resource. V2
|
||||
configuration uses the `permissions` field and an ordered array of rules.
|
||||
|
||||
<Warning>
|
||||
The V1 object syntax uses different field and action names. Do not use
|
||||
`permission`, `bash`, or `task` in V2 configuration; use `permissions`,
|
||||
`shell`, and `subagent`.
|
||||
</Warning>
|
||||
|
||||
## Rule schema
|
||||
|
||||
Each rule has three required string fields:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permissions": [
|
||||
{ "action": "*", "resource": "*", "effect": "ask" },
|
||||
{ "action": "read", "resource": "*", "effect": "allow" },
|
||||
{ "action": "read", "resource": "*.env", "effect": "deny" },
|
||||
{ "action": "shell", "resource": "git status *", "effect": "allow" },
|
||||
{ "action": "shell", "resource": "git push *", "effect": "deny" },
|
||||
{ "action": "edit", "resource": "packages/docs/*.mdx", "effect": "allow" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `action` matches a tool permission action.
|
||||
- `resource` matches the value the tool is trying to use, such as a path,
|
||||
command, URL, query, or agent ID.
|
||||
- `effect` is `"allow"`, `"deny"`, or `"ask"`.
|
||||
|
||||
`allow` proceeds without prompting, `deny` blocks the operation, and `ask`
|
||||
waits for a user decision. If no rule matches, the result is `ask`.
|
||||
|
||||
## Matching and order
|
||||
|
||||
Both `action` and `resource` support simple wildcards:
|
||||
|
||||
- `*` matches zero or more characters, including `/`.
|
||||
- `?` matches exactly one character.
|
||||
- All other characters are literal.
|
||||
|
||||
Matches cover the entire value. Slashes are normalized, and matching is
|
||||
case-insensitive on Windows. For shell convenience, a pattern ending in
|
||||
`" *"` also matches the command without arguments: `"git status *"` matches
|
||||
both `git status` and `git status --short`.
|
||||
|
||||
The **last matching rule wins**. Put broad rules first and exceptions later.
|
||||
Rules from lower-priority configuration files are loaded first. OpenCode then
|
||||
appends all global rules before agent-specific rules, so a matching agent rule
|
||||
overrides a global rule.
|
||||
|
||||
Some operations check several resources at once, such as a patch touching
|
||||
multiple files. OpenCode denies the operation if any resource resolves to
|
||||
`deny`; otherwise it asks if any resolves to `ask`; otherwise it allows it.
|
||||
|
||||
## Actions and resources
|
||||
|
||||
V2 action names are strings, so plugins may introduce additional actions. The
|
||||
current built-in actions use these resources:
|
||||
|
||||
| Action | Resource matched |
|
||||
| --- | --- |
|
||||
| `read` | Location-relative path for an internal file or directory; canonical absolute path for an external target |
|
||||
| `edit` | Target path for `edit`, `write`, and `patch`; all three tools share this action |
|
||||
| `glob` | The requested glob pattern |
|
||||
| `grep` | The requested regular expression, not the search path |
|
||||
| `shell` | The complete raw shell command string |
|
||||
| `subagent` | The target agent ID |
|
||||
| `skill` | The skill ID |
|
||||
| `question` | `*` |
|
||||
| `webfetch` | The requested URL |
|
||||
| `websearch` | The search query |
|
||||
| `external_directory` | A canonical external directory boundary, normally ending in `/*` |
|
||||
| `<server>_<tool>` | `*` for an MCP tool; unsupported characters in both names become `_` |
|
||||
| `execute` | `*`; controls availability of the Code Mode dispatcher, while each nested tool still enforces its own permission |
|
||||
|
||||
Built-in agent policy also reserves `plan_enter` and `plan_exit` for plan-mode
|
||||
transitions. `doom_loop` and `lsp` are not current V2 Core permission actions.
|
||||
|
||||
## External directories
|
||||
|
||||
An external path requires a separate `external_directory` decision before the
|
||||
tool's own `read` or `edit` decision. This applies to external paths used by
|
||||
`read`, `edit`, `write`, and `patch`, and to an external `shell` working
|
||||
directory.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permissions": [
|
||||
{
|
||||
"action": "external_directory",
|
||||
"resource": "~/projects/reference/*",
|
||||
"effect": "allow"
|
||||
},
|
||||
{
|
||||
"action": "read",
|
||||
"resource": "~/projects/reference/*",
|
||||
"effect": "allow"
|
||||
},
|
||||
{
|
||||
"action": "edit",
|
||||
"resource": "~/projects/reference/*",
|
||||
"effect": "deny"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
For `external_directory`, `read`, and `edit` resources, a leading `~`, `~/`,
|
||||
`$HOME`, or `$HOME/` is expanded when configuration loads. Shell resources are
|
||||
raw command text and are **not** home-expanded.
|
||||
|
||||
<Warning>
|
||||
`shell` runs with the host user's filesystem, process, and network authority.
|
||||
Its resource is raw text, not a parsed command. External command arguments
|
||||
produce only best-effort warnings; `external_directory` is enforced for the
|
||||
working directory, not every path embedded in a command. Prefer a narrow
|
||||
shell allowlist over patterns intended to identify every dangerous command.
|
||||
</Warning>
|
||||
|
||||
Relative mutation paths cannot escape the active Location, and symlink escapes
|
||||
from inside it are rejected. Explicit external paths are canonicalized before
|
||||
matching, so authorize only trusted directory boundaries.
|
||||
|
||||
## Defaults
|
||||
|
||||
The evaluator's fallback is `ask`, but shipped agents include ordered defaults:
|
||||
|
||||
| Agent | Effective default policy |
|
||||
| --- | --- |
|
||||
| `build` | Allows most actions; asks for external directories and `.env` reads; allows questions and entering plan mode; denies exiting plan mode |
|
||||
| `plan` | Uses the same base, allows questions and exiting plan mode, and denies edits except OpenCode plan files |
|
||||
| `general` | Uses the base policy but cannot launch another subagent; questions and plan transitions remain denied |
|
||||
| `explore` | Denies everything except `read`, `glob`, `grep`, `webfetch`, and `websearch`; cannot launch subagents and asks for external directories |
|
||||
| Hidden maintenance agents | Deny all actions |
|
||||
|
||||
The base read rules are ordered as follows:
|
||||
|
||||
```jsonc
|
||||
[
|
||||
{ "action": "read", "resource": "*", "effect": "allow" },
|
||||
{ "action": "read", "resource": "*.env", "effect": "ask" },
|
||||
{ "action": "read", "resource": "*.env.*", "effect": "ask" },
|
||||
{ "action": "read", "resource": "*.env.example", "effect": "allow" }
|
||||
]
|
||||
```
|
||||
|
||||
OpenCode also permits its managed tool-output and temporary directories where
|
||||
needed. These exceptions do not grant general external-directory access.
|
||||
|
||||
## Agent overrides
|
||||
|
||||
Configure shared policy at the top level and append narrower rules to a named
|
||||
agent under `agents.<id>.permissions`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permissions": [
|
||||
{ "action": "shell", "resource": "*", "effect": "ask" },
|
||||
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
|
||||
{ "action": "shell", "resource": "git status *", "effect": "allow" }
|
||||
],
|
||||
"agents": {
|
||||
"reviewer": {
|
||||
"description": "Review code without changing it",
|
||||
"mode": "subagent",
|
||||
"permissions": [
|
||||
{ "action": "edit", "resource": "*", "effect": "deny" },
|
||||
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
|
||||
{ "action": "shell", "resource": "git status *", "effect": "allow" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Agent rules do not replace the global array; they are appended after it. A
|
||||
custom subagent executes with its own permissions, not a permission subset
|
||||
derived from the parent agent.
|
||||
|
||||
## Approval choices
|
||||
|
||||
When an `ask` rule matches, clients can reply with:
|
||||
|
||||
- **Allow once** (`once`): approve only the pending request.
|
||||
- **Allow always** (`always`): approve this request and save the patterns
|
||||
proposed by the tool for the current project.
|
||||
- **Reject** (`reject`): reject the request. Rejecting also rejects other
|
||||
pending permission requests in the same session; clients may attach feedback.
|
||||
|
||||
Saved approvals are durable and project-scoped. They are additional `allow`
|
||||
rules, but they can never override a configured `deny`. The proposed saved
|
||||
pattern may be broader than the displayed resource: several tools propose `*`,
|
||||
shell proposes the exact command text, and skills and subagents propose their
|
||||
IDs. Review the confirmation carefully and remove saved approvals that are no
|
||||
longer needed.
|
||||
|
||||
For non-interactive runs, `opencode2 run --auto` replies `once` to permission
|
||||
requests. It does not save approvals, and explicit `deny` rules remain enforced.
|
||||
Without `--auto`, a non-interactive run rejects permission requests.
|
||||
@@ -1,177 +0,0 @@
|
||||
---
|
||||
title: "References"
|
||||
description: ""
|
||||
---
|
||||
|
||||
References give OpenCode named access to directories outside the current
|
||||
project. Use them for documentation, shared libraries, examples, or source from
|
||||
another repository.
|
||||
|
||||
Configure references by alias in `opencode.json` or `opencode.jsonc`:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"references": {
|
||||
"docs": {
|
||||
"path": "../product-docs",
|
||||
"description": "Use for product behavior and terminology"
|
||||
},
|
||||
"effect": {
|
||||
"repository": "Effect-TS/effect",
|
||||
"branch": "main",
|
||||
"description": "Use for Effect implementation details"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Local directories
|
||||
|
||||
Use `path` for a local directory:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"references": {
|
||||
"design-system": {
|
||||
"path": "../design-system",
|
||||
"description": "Use when working with components or design tokens"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Relative paths resolve from the directory containing the config file that
|
||||
defines them. Absolute paths and home-relative paths such as `~/docs` are also
|
||||
supported.
|
||||
|
||||
The string shorthand is useful when no other fields are needed:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"references": {
|
||||
"docs": "../docs",
|
||||
"shared": "~/work/shared"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Note>
|
||||
A shorthand string is treated as a local path only when it starts with `.`,
|
||||
`/`, or `~`. Use `./docs`, not `docs`; a bare `docs` value is interpreted as
|
||||
a Git repository.
|
||||
</Note>
|
||||
|
||||
## Git repositories
|
||||
|
||||
Use `repository` for a remote Git repository. GitHub `owner/repo` shorthand,
|
||||
Git URLs, host/path forms, and SCP-style remotes are supported.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"references": {
|
||||
"effect": {
|
||||
"repository": "Effect-TS/effect",
|
||||
"branch": "main"
|
||||
},
|
||||
"internal-sdk": {
|
||||
"repository": "git@gitlab.example.com:platform/sdk.git",
|
||||
"branch": "release/v2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Without `branch`, OpenCode checks out and refreshes the remote's default
|
||||
branch. Branch names may contain letters, numbers, `/`, `_`, `.`, and `-`, but
|
||||
cannot start with `-` or contain `..`. Local `file:` repositories are not
|
||||
supported.
|
||||
|
||||
Git references also support shorthand:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"references": {
|
||||
"effect": "Effect-TS/effect",
|
||||
"sdk": "gitlab.com/platform/sdk"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cloning and storage
|
||||
|
||||
OpenCode normalizes a remote and stores one checkout under its global data
|
||||
directory at `opencode/repos/<host>/<repository-path>`. On a typical Linux
|
||||
installation, for example, `Effect-TS/effect` is stored at:
|
||||
|
||||
```text
|
||||
~/.local/share/opencode/repos/github.com/Effect-TS/effect
|
||||
```
|
||||
|
||||
Missing repositories are cloned. Existing checkouts are fetched and reset to
|
||||
the requested branch, or to the remote default branch when `branch` is omitted.
|
||||
Materialization runs asynchronously when references load or reload, so a new
|
||||
reference can appear before its checkout is ready. Clone and refresh failures
|
||||
are logged and do not stop other references from loading.
|
||||
|
||||
<Warning>
|
||||
The cache has one checkout per normalized remote, not one per branch. Do not
|
||||
configure the same repository at multiple branches; only one branch can be
|
||||
exposed. Avoid editing cached checkouts because a refresh resets them.
|
||||
</Warning>
|
||||
|
||||
## Description and visibility
|
||||
|
||||
`description` tells agents when a reference is relevant. References with a
|
||||
description are included in agent instructions with their alias and resolved
|
||||
path. References without one remain available in `@` autocomplete but are not
|
||||
advertised automatically.
|
||||
|
||||
Set `hidden` to `true` to remove a reference from TUI `@` autocomplete:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"references": {
|
||||
"internal": {
|
||||
"path": "../internal",
|
||||
"description": "Use for internal service behavior",
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`hidden` controls only autocomplete visibility. It does not remove the
|
||||
reference from the reference API or agent instructions when a description is
|
||||
present.
|
||||
|
||||
## Use references
|
||||
|
||||
Type `@` in the TUI and select a reference alias to attach its root directory:
|
||||
|
||||
```text
|
||||
Compare the current implementation with @effect
|
||||
```
|
||||
|
||||
The attachment provides a non-recursive listing of the root's immediate files
|
||||
and directories. V2 currently attaches references by root alias;
|
||||
`@alias/path` is not a reference-specific file browser. Ask the agent to
|
||||
inspect a particular path when more detail is needed.
|
||||
|
||||
References do not grant extra tool permissions. Access outside the active
|
||||
Location remains subject to the agent's normal tool rules and the
|
||||
`external_directory` permission. Editing a reference additionally requires the
|
||||
applicable edit permission.
|
||||
|
||||
## Fields
|
||||
|
||||
| Field | Local | Git | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `path` | Required | No | Local directory path |
|
||||
| `repository` | No | Required | Remote Git repository |
|
||||
| `branch` | No | Optional | Branch to fetch and check out |
|
||||
| `description` | Optional | Optional | Guidance describing when agents should use it |
|
||||
| `hidden` | Optional | Optional | Hide it from TUI `@` autocomplete |
|
||||
|
||||
An alias cannot be empty or contain `/`, `\`, whitespace, a backtick, or a
|
||||
comma.
|
||||
@@ -314,6 +314,7 @@ export namespace Text {
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
ordinal: NonNegativeInt,
|
||||
text: Schema.String,
|
||||
state: SessionMessage.ProviderState.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type Ended = typeof Ended.Type
|
||||
|
||||
@@ -155,6 +155,7 @@ export interface AssistantText extends Schema.Schema.Type<typeof AssistantText>
|
||||
export const AssistantText = Schema.Struct({
|
||||
type: Schema.tag("text"),
|
||||
text: Schema.String,
|
||||
state: ProviderState.pipe(optional),
|
||||
}).annotate({ identifier: "Session.Message.Assistant.Text" })
|
||||
|
||||
export interface AssistantReasoning extends Schema.Schema.Type<typeof AssistantReasoning> {}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function Reconnecting() {
|
||||
const { themeV2 } = useTheme()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
|
||||
return (
|
||||
<box
|
||||
@@ -12,12 +13,23 @@ export function Reconnecting() {
|
||||
right={0}
|
||||
bottom={0}
|
||||
left={0}
|
||||
backgroundColor={themeV2.background.default}
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<box width={62} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
|
||||
<Spinner color={themeV2.text.subdued}>Waiting for background service...</Spinner>
|
||||
<box
|
||||
width={48}
|
||||
maxWidth="90%"
|
||||
flexDirection="column"
|
||||
backgroundColor={themeV2.background.default}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
gap={1}
|
||||
>
|
||||
<Spinner color={themeV2.text.default}>Restarting service...</Spinner>
|
||||
<text fg={themeV2.text.subdued}>Your session will resume automatically.</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -81,7 +81,15 @@ import { PluginSlot } from "../../plugin/context"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
|
||||
import {
|
||||
cacheReuseDrop,
|
||||
createSessionRows,
|
||||
messageBoundaryIDs,
|
||||
resolvePart,
|
||||
type CacheUsage,
|
||||
type PartRef,
|
||||
type SessionRow,
|
||||
} from "./rows"
|
||||
import { switchLabel } from "../../util/model"
|
||||
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
@@ -1079,7 +1087,7 @@ function SessionRowView(props: SessionRowViewProps) {
|
||||
{(row) => (
|
||||
<TurnTokenUsage
|
||||
messageIDs={row().messageIDs}
|
||||
previousCacheRead={row().previousCacheRead}
|
||||
previousCache={row().previousCache}
|
||||
message={props.message}
|
||||
/>
|
||||
)}
|
||||
@@ -1091,13 +1099,13 @@ function SessionRowView(props: SessionRowViewProps) {
|
||||
|
||||
function TurnTokenUsage(props: {
|
||||
messageIDs: string[]
|
||||
previousCacheRead?: number
|
||||
previousCache?: CacheUsage
|
||||
message: (messageID: string) => SessionMessageInfo | undefined
|
||||
}) {
|
||||
const config = useConfig()
|
||||
const { themeV2 } = useTheme()
|
||||
const steps = createMemo(() => {
|
||||
let previousCacheRead = props.previousCacheRead
|
||||
let previousCache = props.previousCache
|
||||
return props.messageIDs.flatMap((messageID) => {
|
||||
const message = props.message(messageID)
|
||||
if (message?.type !== "assistant" || !message.tokens) return []
|
||||
@@ -1109,18 +1117,16 @@ function TurnTokenUsage(props: {
|
||||
message.tokens.cache.write
|
||||
if (total === 0) return []
|
||||
const newTokens = total - message.tokens.cache.read
|
||||
const cacheBust =
|
||||
previousCacheRead !== undefined && message.tokens.cache.read < previousCacheRead
|
||||
? previousCacheRead - message.tokens.cache.read
|
||||
: undefined
|
||||
previousCacheRead = message.tokens.cache.read
|
||||
const currentCache = { read: message.tokens.cache.read, model: message.model }
|
||||
const reuseDrop = cacheReuseDrop(previousCache, currentCache)
|
||||
previousCache = currentCache
|
||||
return [
|
||||
{
|
||||
finish: message.finish === "tool-calls" ? "tool-call" : (message.finish ?? "unknown"),
|
||||
newTokens,
|
||||
cached: message.tokens.cache.read,
|
||||
total,
|
||||
cacheBust,
|
||||
reuseDrop,
|
||||
},
|
||||
]
|
||||
})
|
||||
@@ -1165,9 +1171,9 @@ function TurnTokenUsage(props: {
|
||||
{" "}
|
||||
{item.total.toLocaleString().padStart(columns().total)}
|
||||
</text>
|
||||
<Show when={item.cacheBust !== undefined}>
|
||||
<text fg={themeV2.text.feedback.error.default}>
|
||||
! Cache bust: {item.cacheBust?.toLocaleString()} fewer cached tokens than the previous step
|
||||
<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
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
@@ -2866,7 +2872,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)
|
||||
const hasRuntimeError = createMemo(() => props.metadata.error === true || props.part.state.status === "error")
|
||||
const outputPreview = createMemo(() => collapseToolOutput(output(), 4, 4 * Math.max(20, ctx.width - 6)).output)
|
||||
const showOutput = createMemo(() => output() && hasRuntimeError())
|
||||
const content = createMemo(() => {
|
||||
|
||||
@@ -10,6 +10,11 @@ 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 }
|
||||
@@ -28,7 +33,7 @@ export type SessionRow =
|
||||
completed: boolean
|
||||
}
|
||||
| { type: "assistant-footer"; messageID: string }
|
||||
| { type: "turn-usage"; messageIDs: string[]; previousCacheRead?: number }
|
||||
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>) {
|
||||
const data = useData()
|
||||
@@ -280,7 +285,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[], previousTurnCacheRead: undefined as number | undefined }
|
||||
? { steps: [] as SessionMessageAssistant[], previousTurnCache: undefined as CacheUsage | undefined }
|
||||
: undefined
|
||||
return [
|
||||
...messages.filter((message) => !pending.has(message.id)),
|
||||
@@ -289,6 +294,8 @@ 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
|
||||
@@ -312,11 +319,9 @@ export function reduceSessionRows(
|
||||
rows.push({
|
||||
type: "turn-usage",
|
||||
messageIDs: stepsWithUsage.map((step) => step.id),
|
||||
...(usage.previousTurnCacheRead === undefined
|
||||
? {}
|
||||
: { previousCacheRead: usage.previousTurnCacheRead }),
|
||||
...(usage.previousTurnCache === undefined ? {} : { previousCache: usage.previousTurnCache }),
|
||||
})
|
||||
usage.previousTurnCacheRead = last.tokens.cache.read
|
||||
usage.previousTurnCache = { read: last.tokens.cache.read, model: last.model }
|
||||
}
|
||||
usage.steps.length = 0
|
||||
}
|
||||
@@ -324,6 +329,20 @@ 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,6 +1,92 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
|
||||
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"] },
|
||||
])
|
||||
})
|
||||
|
||||
test("assigns assistant boundaries to the first rendered row instead of the first text row", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
.source
|
||||
.tanstack
|
||||
.wrangler
|
||||
dist
|
||||
node_modules
|
||||
worker-configuration.d.ts
|
||||
.blume/
|
||||
dist/
|
||||
node_modules/
|
||||
.blume-verify/
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Website and documentation guide
|
||||
|
||||
## Structure
|
||||
|
||||
- This package owns the `opencode.ai` website.
|
||||
- Add custom marketing routes as Astro files under `pages/`.
|
||||
- The whole project is mounted at `/v2/` by `deployment.base`, and documentation lives under `content/docs/` at `/v2/docs` through `basePath` in `blume.config.ts`.
|
||||
- Write documentation in MDX. Every page should have `title` and `description` frontmatter.
|
||||
- Use parenthesized content folders for sidebar groups that must not add a URL segment. Keep ungrouped top-level pages directly under `content/docs/`.
|
||||
- Put static files in `public/` and reference them with root-relative paths.
|
||||
- The API reference is generated from `openapi.json`; do not duplicate endpoint documentation as hand-written MDX.
|
||||
- Keep documentation aligned with the V2 packages. Do not use `packages/opencode` as the source of truth unless the task explicitly concerns V1.
|
||||
|
||||
## Local development
|
||||
|
||||
- Run `bun dev` from this package and preview the site at `http://localhost:3000/v2/`.
|
||||
- Verify both custom marketing routes and documentation routes after changing shared navigation or layout code.
|
||||
|
||||
## Validation
|
||||
|
||||
- Run `bun typecheck`, `bun validate`, and `bun run build` from this package after documentation or configuration changes.
|
||||
- Treat validation and build errors as blockers.
|
||||
@@ -1,6 +1,10 @@
|
||||
# OpenCode website
|
||||
|
||||
The server-rendered `opencode.ai` website. It uses TanStack Start on Cloudflare Workers and serves the V2 documentation with Fumadocs.
|
||||
The OpenCode V2 website, powered by Blume and deployed with Wrangler at `https://opencode.ai/v2/`. Blume mounts the documentation at `/v2/docs`, and `https://v2.opencode.ai` redirects to the same deployment.
|
||||
|
||||
Wrangler deploys the site through Blume's Cloudflare server adapter. Documentation pages are prerendered, while custom dynamic routes and endpoints can run in the Worker. Production uses `opencode-www` at `opencode.ai/v2/`; dev uses `opencode-www-dev` at `dev.opencode.ai/v2/`. The `v2.opencode.ai` alias is handled by a Cloudflare Redirect Rule outside this project.
|
||||
|
||||
The `deploy-www` GitHub workflow deploys the `dev` branch to the dev Worker and the `v2` branch to the production Worker.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -10,13 +14,12 @@ From this directory, run:
|
||||
bun dev
|
||||
```
|
||||
|
||||
The site opens at `http://localhost:3000`; documentation is available at `http://localhost:3000/docs`.
|
||||
The site opens at `http://localhost:3000/v2/`; documentation is available at `http://localhost:3000/v2/docs`.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
bun typecheck
|
||||
bun validate
|
||||
bun run build
|
||||
```
|
||||
|
||||
The existing `packages/web`, `packages/docs`, and `packages/stats/app` deployments remain in place until their routes have been migrated and verified.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defineConfig } from "blume"
|
||||
|
||||
export default defineConfig({
|
||||
title: "OpenCode",
|
||||
description: "The open source AI coding agent.",
|
||||
basePath: "/docs",
|
||||
logo: {
|
||||
image: {
|
||||
light: "/assets/logo-light.svg",
|
||||
dark: "/assets/logo-dark.svg",
|
||||
alt: "OpenCode",
|
||||
},
|
||||
text: "",
|
||||
href: "/",
|
||||
},
|
||||
content: {
|
||||
root: "content/docs",
|
||||
},
|
||||
github: {
|
||||
owner: "anomalyco",
|
||||
repo: "opencode",
|
||||
branch: "dev",
|
||||
dir: "packages/www",
|
||||
},
|
||||
navigation: {
|
||||
tabs: [
|
||||
{ label: "Docs", path: "/" },
|
||||
{ label: "Build", path: "/build" },
|
||||
{ label: "API", path: "/api" },
|
||||
],
|
||||
},
|
||||
openapi: {
|
||||
enabled: true,
|
||||
route: "/api",
|
||||
spec: "./openapi.json",
|
||||
},
|
||||
deployment: {
|
||||
adapter: "cloudflare",
|
||||
base: "/v2/",
|
||||
output: "server",
|
||||
site: process.env.BLUME_ENV === "dev" ? "https://dev.opencode.ai" : "https://opencode.ai",
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineComponents } from "blume"
|
||||
import ThemeTokens from "./snippets/generated/theme-tokens.mdx"
|
||||
|
||||
export default defineComponents({
|
||||
mdx: {
|
||||
ThemeTokens,
|
||||
},
|
||||
})
|
||||
@@ -227,10 +227,10 @@ shell commands, `edit` for all edit/write/patch tools, and `subagent` for child
|
||||
agents. Other tools generally use their tool name, such as `read`, `glob`,
|
||||
`grep`, `webfetch`, `websearch`, and `skill`.
|
||||
|
||||
<Tip>
|
||||
<Callout type="tip">
|
||||
Put broad wildcard rules first and exceptions afterward. For example, deny
|
||||
all subagents first, then allow `explore`.
|
||||
</Tip>
|
||||
</Callout>
|
||||
|
||||
`~` and `$HOME` are expanded in filesystem resources for `read`, `edit`, and
|
||||
`external_directory`. Shell resources are raw command text and are not
|
||||
@@ -274,10 +274,10 @@ The V2 schema accepts per-agent request `headers` and JSON `body` overlays:
|
||||
}
|
||||
```
|
||||
|
||||
<Warning>
|
||||
<Callout type="warning">
|
||||
The current V2 session runner preserves these overlays on the agent
|
||||
definition but does not yet apply them to model requests. Configure effective
|
||||
request settings on the provider, model, or model variant instead. Do not use
|
||||
legacy top-level agent fields such as `temperature`, `top_p`, `prompt`,
|
||||
`permission`, `tools`, `disable`, or `maxSteps` in new V2 configuration.
|
||||
</Warning>
|
||||
</Callout>
|
||||
+4
-4
@@ -17,12 +17,12 @@ and other binary prompt attachments are not currently included in the model
|
||||
request. Some clients may let you select a PDF, but V2 does not yet make that
|
||||
PDF visible to the model.
|
||||
|
||||
<Warning>
|
||||
<Callout type="warning">
|
||||
Use a model that supports image input before attaching an image. OpenCode
|
||||
passes supported image media to the selected provider, but the provider and
|
||||
model still enforce their own formats, dimensions, file counts, and size
|
||||
limits. A text-only model may reject the request.
|
||||
</Warning>
|
||||
</Callout>
|
||||
|
||||
## Add attachments
|
||||
|
||||
@@ -131,12 +131,12 @@ All fields are optional:
|
||||
| `max_height` | `2000` | Maximum height in pixels. Must be a positive integer. |
|
||||
| `max_base64_bytes` | `5242880` | Maximum byte length of the Base64-encoded image string. Must be a positive integer. |
|
||||
|
||||
<Note>
|
||||
<Callout type="note">
|
||||
In the current V2 runtime, these settings apply to image media produced by
|
||||
the built-in `read` tool. Images attached directly through the TUI, desktop,
|
||||
web, CLI, or API bypass this normalization. Resize direct attachments before
|
||||
adding them if the provider requires smaller media.
|
||||
</Note>
|
||||
</Callout>
|
||||
|
||||
The `read` tool recognizes PNG, JPEG, GIF, and WebP by their contents and will
|
||||
ingest at most 20 MiB of source image bytes. It decodes the image and compares
|
||||
@@ -137,10 +137,10 @@ project location and inserts its combined output into the template. Argument
|
||||
interpolation happens first, so avoid placing untrusted arguments inside shell
|
||||
interpolations.
|
||||
|
||||
<Warning>
|
||||
<Callout type="warning">
|
||||
Shell interpolations run when the command is evaluated, outside the agent's
|
||||
tool permission flow. Only use commands from sources you trust.
|
||||
</Warning>
|
||||
</Callout>
|
||||
|
||||
No other template interpolation is performed. In particular, an `@path`
|
||||
written into a stored template remains ordinary prompt text; V2 does not
|
||||
@@ -6,10 +6,10 @@ description: ""
|
||||
OpenCode V2 accepts formatter configuration, but it does not yet include a
|
||||
formatter runtime. File writes and edits are not automatically formatted.
|
||||
|
||||
<Warning>
|
||||
<Callout type="warning">
|
||||
V2 currently has no built-in formatters. The built-in formatter list and
|
||||
automatic post-edit formatting documented for V1 do not apply to V2.
|
||||
</Warning>
|
||||
</Callout>
|
||||
|
||||
## Configuration
|
||||
|
||||
+4
-4
@@ -43,10 +43,10 @@ If the Location is outside the project root, only the global file is loaded.
|
||||
Setting `OPENCODE_DISABLE_PROJECT_CONFIG=1` also skips project `AGENTS.md`
|
||||
discovery but does not disable the global file.
|
||||
|
||||
<Note>
|
||||
<Callout type="note">
|
||||
Current V2 discovery only recognizes `AGENTS.md`. The `CLAUDE.md` fallback
|
||||
and related precedence described by older OpenCode documentation do not apply.
|
||||
</Note>
|
||||
</Callout>
|
||||
|
||||
### Nested instructions
|
||||
|
||||
@@ -80,13 +80,13 @@ Configuration is loaded from global through project-local files. If more than
|
||||
one config defines `instructions`, the highest-precedence, closest config's
|
||||
entire array is selected; arrays are not merged.
|
||||
|
||||
<Warning>
|
||||
<Callout type="warning">
|
||||
V2 currently parses and retains this field but does not resolve its entries
|
||||
into instruction sources. Local files, glob patterns, and HTTP or HTTPS URLs
|
||||
in `instructions` therefore do not reach the model yet. Use `AGENTS.md` for
|
||||
active V2 instructions. URL fetching and timeout behavior documented for V1
|
||||
are not supported by the current V2 implementation.
|
||||
</Warning>
|
||||
</Callout>
|
||||
|
||||
See [Config](/config) for config locations and general precedence.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user