Compare commits

..
Author SHA1 Message Date
Aiden Cline dd641b298a fix(ai): preserve top-level prompt cache key defaults 2026-08-26 16:14:36 -05:00
33 changed files with 487 additions and 607 deletions
+2
View File
@@ -376,6 +376,8 @@ Request options in order of stability:
Route/provider defaults are overridden by request-level values for each axis.
`promptCacheKey` belongs at the top level, not inside `providerOptions`. It can also be set in provider configuration (for example, `OpenAI.configure({ promptCacheKey: "shared-prefix" })`) or `LanguageModel.defaults`. The effective key is resolved in request > model defaults > route defaults order. For Chat and Responses, `cache: "none"` suppresses the wire cache key even when a default is configured.
The selected model supplies the provider-specific option type, so per-request overrides stay flat while the canonical runtime request remains provider-neutral:
```ts
@@ -78,7 +78,7 @@ export const configure = (input: Config = {}) => {
return {
id,
model: responses,
model: chat,
chat,
responses,
configure,
@@ -112,4 +112,4 @@ export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProvider
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const model = responsesModel
export const model = chatModel
@@ -1,2 +1,2 @@
export { responsesModel as model } from "../amazon-bedrock-mantle.js"
export { chatModel as model } from "../amazon-bedrock-mantle.js"
export type { Settings } from "../amazon-bedrock-mantle.js"
+4
View File
@@ -73,6 +73,7 @@ export type RouteLanguageModelInput = Omit<LanguageModel.Input, "provider" | "ro
export type RouteRoutedLanguageModelInput = Omit<LanguageModel.Input, "route">
export interface RouteDefaults {
readonly promptCacheKey?: string
readonly headers?: Record<string, string>
readonly generation?: GenerationOptions
readonly providerOptions?: ProviderOptions
@@ -80,6 +81,7 @@ export interface RouteDefaults {
}
export interface RouteDefaultsInput {
readonly promptCacheKey?: string
readonly headers?: Record<string, string>
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions
@@ -117,6 +119,7 @@ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefault
return {
...base,
...patch,
promptCacheKey: patch.promptCacheKey ?? base?.promptCacheKey,
headers,
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
@@ -172,6 +175,7 @@ const resolveRequestOptions = (request: LLMRequest) => {
const modelDefaults = request.model.defaults
const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation)
return LLMRequest.update(request, {
promptCacheKey: request.promptCacheKey ?? modelDefaults?.promptCacheKey ?? routeDefaults.promptCacheKey,
generation: generation ?? new GenerationOptions({}),
providerOptions: mergeProviderOptions(
routeDefaults.providerOptions,
+3
View File
@@ -115,6 +115,7 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
}
export class LanguageModelDefaults extends Schema.Class<LanguageModelDefaults>("LLM.LanguageModelDefaults")({
promptCacheKey: Schema.optional(Schema.String),
generation: Schema.optional(GenerationOptions),
providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions),
@@ -124,6 +125,7 @@ export namespace LanguageModelDefaults {
export type Input =
| LanguageModelDefaults
| {
readonly promptCacheKey?: string
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input
@@ -133,6 +135,7 @@ export namespace LanguageModelDefaults {
export const make = (input: Input) => {
if (input instanceof LanguageModelDefaults) return input
return new LanguageModelDefaults({
promptCacheKey: input.promptCacheKey,
generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
providerOptions: input.providerOptions,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
+33 -2
View File
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Effect, Ref, Schema } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, Message, ToolCallPart, mergeProviderOptions } from "../src/index.js"
import { AnthropicMessages, OpenAIChat } from "../src/protocols.js"
import { LLM, LLMRequest, Message, ToolCallPart, mergeProviderOptions } from "../src/index.js"
import { AnthropicMessages, OpenAIChat, OpenAIResponses } from "../src/protocols.js"
import { Auth, LLMClient } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { it } from "./lib/effect.js"
@@ -14,6 +14,37 @@ const TargetJson = Schema.fromJsonString(Schema.Unknown)
const decodeJson = Schema.decodeUnknownSync(TargetJson)
describe("request option precedence", () => {
it.effect("resolves top-level prompt cache keys from request, model, and route defaults", () =>
Effect.gen(function* () {
for (const route of [OpenAIChat.route, OpenAIResponses.route]) {
for (const [routeKey, modelKey, requestKey, expected] of [
[undefined, undefined, undefined, undefined],
["route", undefined, undefined, "route"],
["route", "model", undefined, "model"],
["route", "model", "request", "request"],
["route", "", undefined, ""],
["route", "model", "", ""],
]) {
const model = route
.with({ auth: Auth.bearer("test"), promptCacheKey: routeKey })
.with({ headers: { "x-test": "value" }, promptCacheKey: undefined })
.model({ id: "gpt-4o-mini", defaults: { promptCacheKey: modelKey } })
const request = LLM.request({ model, prompt: "Hi", promptCacheKey: requestKey })
const prepared = yield* compileRequest(request)
const disabled = yield* compileRequest(LLMRequest.update(request, { cache: "none" }))
expect(model.route.defaults.promptCacheKey).toBe(routeKey)
expect(model.defaults?.promptCacheKey).toBe(modelKey)
if (expected) expect(prepared.body).toMatchObject({ prompt_cache_key: expected })
if (!expected) expect(prepared.body).not.toHaveProperty("prompt_cache_key")
expect(disabled.body).not.toHaveProperty("prompt_cache_key")
expect(request.promptCacheKey).toBe(requestKey)
expect(request.providerOptions).toBeUndefined()
}
}
}),
)
test("deep-merges provider option records and replaces arrays, primitives, and null", () => {
const merged = mergeProviderOptions(
{
+3
View File
@@ -121,6 +121,7 @@ describe("llm constructors", () => {
const model = chatRoute.model({
id: "kimi-k2",
defaults: {
promptCacheKey: "model-cache",
generation: { maxTokens: 1_024, stop: ["END"] },
providerOptions: { parallelToolCalls: false },
http: { body: { extra_body: true } },
@@ -129,6 +130,8 @@ describe("llm constructors", () => {
})
const request = LLM.request({ model, prompt: "Say hello." })
expect(request.model.defaults?.promptCacheKey).toBe("model-cache")
expect(request.promptCacheKey).toBeUndefined()
expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] })
expect(request.model.defaults?.providerOptions).toEqual({ parallelToolCalls: false })
expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } })
@@ -3,7 +3,6 @@ import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message } from "../../src/index.js"
import { AmazonBedrockMantle } from "../../src/providers.js"
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
@@ -18,16 +17,13 @@ const credentials = {
}
describe("Amazon Bedrock Mantle provider", () => {
it.effect("uses Responses by default and exposes Chat explicitly", () =>
it.effect("uses Chat by default and exposes Responses", () =>
Effect.gen(function* () {
const provider = AmazonBedrockMantle.configure({ credentials })
expect(provider.model).toBe(provider.responses)
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
expect(model).toBe(AmazonBedrockMantle.responsesModel)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
expect(provider.responses("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }))
const responses = yield* compileRequest(
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
LLM.request({ model: provider.responses("openai.gpt-oss-120b"), prompt: "Hi" }),
)
expect(chat).toMatchObject({
@@ -41,7 +37,7 @@ describe("Amazon Bedrock Mantle provider", () => {
body: { model: "openai.gpt-oss-120b", store: false },
})
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
expect(provider.chat("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
expect(provider.responses("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
}),
)
+4 -2
View File
@@ -148,7 +148,9 @@ export function map(input: MapInput): Mapping | undefined {
}
function mapProviderOptions(settings: Readonly<Record<string, unknown>>, excluded: ReadonlyArray<string>) {
const options = Object.fromEntries(Object.entries(settings).filter(([name]) => !excluded.includes(name)))
const options = Object.fromEntries(
Object.entries(settings).filter(([name]) => name !== "promptCacheKey" && !excluded.includes(name)),
)
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
}
@@ -278,7 +280,6 @@ function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
...(typeof settings.reasoningSummary === "string" ? { reasoningSummary: settings.reasoningSummary } : {}),
...(Array.isArray(settings.include) ? { include: settings.include } : {}),
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
...(typeof settings.textVerbosity === "string" ? { textVerbosity: settings.textVerbosity } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
}
@@ -289,6 +290,7 @@ function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
return {
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}),
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
}
}
+4
View File
@@ -170,6 +170,10 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return LanguageModel.update(runtime, {
provider: resolved.providerID,
defaults:
typeof mapped.promptCacheKey === "string"
? { ...runtime.defaults, promptCacheKey: mapped.promptCacheKey }
: runtime.defaults,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
+8 -2
View File
@@ -377,8 +377,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
hook: (name, callback) => hooks.register("shell", name, callback),
},
tool: {
transform: tools.transform,
reload: tools.reload,
transform: (callback) =>
tools
.transform((draft) =>
callback({
add: (tool) => draft.add(tool),
}),
)
.pipe(Effect.as({ dispose: Effect.void })),
hook: (name, callback) => hooks.register("tool", name, callback),
},
vcs: {
+4 -1
View File
@@ -328,7 +328,10 @@ export const layer = Layer.effect(
headers: sessionHeaders(session, app),
},
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
promptCacheKey: promptCacheKey(session.fork?.sessionID ?? session.id),
promptCacheKey:
model.defaults?.promptCacheKey ??
model.route.defaults.promptCacheKey ??
promptCacheKey(session.fork?.sessionID ?? session.id),
system: context.system,
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
+136 -132
View File
@@ -4,8 +4,7 @@ export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/too
import { ToolDefinition, type ToolCall } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
import type { ToolDraft } from "@opencode-ai/plugin/effect/tool"
import { Context, Effect, Layer, Result, Schema, SchemaIssue, Types } from "effect"
import { Context, Effect, Layer, Schema, SchemaIssue, Scope, Semaphore } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type { Agent } from "./agent.js"
import { CodeModeCatalog } from "./codemode/catalog.js"
@@ -15,7 +14,6 @@ import { Permission } from "./permission.js"
import { PluginHooks } from "./plugin/hooks.js"
import { SessionMessage } from "./session/message.js"
import { SessionSchema } from "./session/schema.js"
import { State } from "./state.js"
import { definition, execute, normalizeContent } from "./tool/runtime.js"
import { Wildcard } from "./util/wildcard.js"
@@ -24,7 +22,10 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
message: Schema.String,
}) {}
export interface Interface extends State.Transformable<ToolDraft> {
export interface Interface {
readonly transform: (
callback: (draft: { readonly add: (tool: Tool.Info) => void }) => void,
) => Effect.Effect<void, never, Scope.Scope>
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
}
@@ -78,6 +79,9 @@ const layer = Layer.effect(
]
})
const local = new Map<string, Array<{ readonly token: object; readonly tool: Tool.Info }>>()
const lock = Semaphore.makeUnsafe(1)
const executeTool = Effect.fn("Tool.execute")(function* (
tool: Tool.Info,
name: string,
@@ -133,123 +137,112 @@ const layer = Layer.effect(
}
})
const state = State.create({
name: "tool",
initial: () => ({
tools: new Map<string, Types.Mutable<Tool.Info>>(),
errors: new Array<{ tool: Tool.Info; error: RegistrationError }>(),
}),
draft: (data) => data,
finalize: (draft) =>
Effect.forEach(
draft.errors,
(entry) =>
Effect.logError("Skipping invalid tool registration", {
name: entry.tool.name,
namespace: entry.tool.options?.namespace,
error: entry.error.message,
}),
{ discard: true },
const transform: Interface["transform"] = Effect.fn("Tool.transform")(function* (callback) {
const tools: Array<Tool.Info> = []
yield* Effect.sync(() => callback({ add: (tool) => tools.push(tool) }))
const valid = yield* Effect.filter(normalizedEntries(tools), (entry) =>
Effect.gen(function* () {
if (entry.tool.options?.namespace !== undefined) yield* validateNamespace(entry.tool.options.namespace)
yield* validateName(normalizedName(entry.tool))
if (entry.tool.options?.codemode === false && entry.key === "execute")
return yield* new RegistrationError({
name: entry.key,
message: 'Tool name "execute" is reserved for CodeMode',
})
yield* Effect.try({
try: () => ToolDefinition.make(definition(entry.tool)),
catch: (error) =>
new RegistrationError({
name: entry.key,
message: `Invalid tool definition ${entry.key}: ${schemaMakeError(error)}`,
}),
})
return true
}).pipe(Effect.catchTag("Tool.RegistrationError", (error) => skipRegistration(entry.tool, error))),
)
// Reject every ambiguous entry rather than choosing a winner.
const entries = yield* Effect.filter(valid, (entry) => {
if (!valid.some((candidate) => candidate !== entry && candidate.key === entry.key)) return Effect.succeed(true)
return skipRegistration(
entry.tool,
new RegistrationError({ name: entry.key, message: `Duplicate normalized tool name: ${entry.key}` }),
)
})
if (entries.length === 0) return
yield* Effect.uninterruptible(
lock.withPermit(
Effect.gen(function* () {
const token = {}
for (const entry of entries)
local.set(entry.key, [...(local.get(entry.key) ?? []), { token, tool: entry.tool }])
yield* Effect.addFinalizer(() =>
lock.withPermit(
Effect.sync(() => {
for (const entry of entries) {
const remaining = local.get(entry.key)?.filter((item) => item.token !== token) ?? []
if (remaining.length > 0) local.set(entry.key, remaining)
else local.delete(entry.key)
}
}),
),
)
}),
),
)
})
return Service.of({
transform: (callback) =>
state.transform((draft) => {
// Preserve rejection of ambiguous adds within one transform, without rejecting later overrides.
const added = new Map<string, Tool.Info | undefined>()
callback({
add: (tool) => {
const error = registrationError(tool)
if (error) {
draft.errors.push({ tool, error })
return
}
const id = effectiveName(tool)
if (added.has(id)) {
draft.errors.push({
tool,
error: new RegistrationError({ name: id, message: `Duplicate normalized tool name: ${id}` }),
})
const previous = added.get(id)
if (previous) {
draft.tools.set(id, previous)
return
}
draft.tools.delete(id)
return
}
added.set(id, draft.tools.get(id))
draft.tools.set(id, { ...tool })
},
update: (id, update) => {
const current = draft.tools.get(id)
if (!current) return
const tool = { ...current }
update(tool)
tool.name = current.name
if (tool.options?.namespace !== current.options?.namespace)
tool.options = { ...tool.options, namespace: current.options?.namespace }
const error = registrationError(tool)
if (error) {
draft.errors.push({ tool, error })
return
}
draft.tools.set(id, tool)
},
remove: (id) => {
draft.tools.delete(id)
added.delete(id)
},
})
}),
reload: state.reload,
transform,
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
Effect.sync(() => {
const active = new Map<string, Tool.Info>()
const rules = permissions ?? []
for (const [name, tool] of state.get().tools) {
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
active.set(name, tool)
}
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
const codemodeTool = codemodeEnabled
? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context))
: undefined
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
return {
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
definitions: [
...Array.from(direct)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([, tool]) => definition(tool)),
...(codemodeTool ? [definition(codemodeTool)] : []),
],
execute: (input: {
readonly sessionID: SessionSchema.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
}) => {
const context: Tool.Context = {
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
id: Tool.CallID.make(input.call.id),
progress: input.progress ?? (() => Effect.void),
}
if (input.call.name === "execute" && codemodeTool)
return executeTool(codemodeTool, input.call.name, input.call.input, context)
const tool = direct.get(input.call.name)
if (tool) return executeTool(tool, input.call.name, input.call.input, context)
return new Tool.Error({ message: `Unknown tool: ${input.call.name}` })
},
}
}),
lock.withPermit(
Effect.gen(function* () {
const active = new Map<string, Tool.Info>()
const rules = permissions ?? []
for (const [name, entries] of local) {
const tool = entries.at(-1)?.tool
if (!tool) continue
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
active.set(name, tool)
}
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
const codemodeTool = codemodeEnabled
? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context))
: undefined
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
return {
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
definitions: [
...Array.from(direct)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([, tool]) => definition(tool)),
...(codemodeTool ? [definition(codemodeTool)] : []),
],
execute: (input: {
readonly sessionID: SessionSchema.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
}) => {
const context: Tool.Context = {
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
id: Tool.CallID.make(input.call.id),
progress: input.progress ?? (() => Effect.void),
}
if (input.call.name === "execute" && codemodeTool)
return executeTool(codemodeTool, input.call.name, input.call.input, context)
const tool = direct.get(input.call.name)
if (tool) return executeTool(tool, input.call.name, input.call.input, context)
return new Tool.Error({ message: `Unknown tool: ${input.call.name}` })
},
}
}),
),
),
})
}),
@@ -267,22 +260,27 @@ function schemaMakeError(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
function registrationError(tool: Tool.Info) {
const namespace = tool.options?.namespace
if (namespace !== undefined && !namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment)))
return new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` })
const name = normalizedName(tool)
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) return new RegistrationError({ name, message: `Invalid tool name: ${name}` })
const id = effectiveName(tool)
if (tool.options?.codemode === false && id === "execute")
return new RegistrationError({ name: id, message: 'Tool name "execute" is reserved for CodeMode' })
const result = Result.try({
try: () => ToolDefinition.make(definition(tool)),
catch: (error) =>
new RegistrationError({ name: id, message: `Invalid tool definition ${id}: ${schemaMakeError(error)}` }),
})
return Result.isFailure(result) ? result.failure : undefined
}
const skipRegistration = (tool: Tool.Info, error: RegistrationError) =>
Effect.logError("Skipping invalid tool registration", {
name: tool.name,
namespace: tool.options?.namespace,
error: error.message,
}).pipe(Effect.as(false))
const validateName = (name: string) =>
/^[A-Za-z0-9_-]{1,64}$/.test(name)
? Effect.void
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
const validateNamespace = (namespace: string) =>
namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))
? Effect.void
: Effect.fail(
new RegistrationError({
name: namespace,
message: `Invalid tool namespace: ${JSON.stringify(namespace)}`,
}),
)
const normalizedName = (tool: Tool.Info) => tool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
@@ -291,6 +289,12 @@ const effectiveName = (tool: Tool.Info) =>
? normalizedName(tool)
: `${tool.options.namespace.replaceAll(".", "_")}_${normalizedName(tool)}`
const normalizedEntries = (tools: ReadonlyArray<Tool.Info>) =>
tools.map((tool) => ({
key: effectiveName(tool),
tool,
}))
export const node = makeLocationNode({
service: Service,
layer,
+5 -6
View File
@@ -30,18 +30,17 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
## Registration
Built-ins, plugins, and MCP install tools through `Tool.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
Built-ins, plugins, and MCP install tools through `ToolRegistry.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
Registrations are scoped:
- Tool transforms use the shared `State.create` lifecycle, like agents and skills: `add`, `update`, and `remove` replay in registration order when state is rebuilt.
- `update` and `remove` do nothing for missing tools. `add` requires a complete tool definition.
- Disposing a registration or closing its scope removes its transform and rebuilds the remaining state. `reload` replays transforms after their external inputs change.
- The latest active same-placement registration wins.
- Closing any registration removes only that registration and reveals the next active one.
- Each model request captures the effective tools it advertises; later registration changes affect later requests.
Type safety ends at registration. The registry validates model input and declared output at runtime and should not carry producer schema generics through storage or execution.
`Tool.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
`ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
## Permissions
@@ -57,4 +56,4 @@ Producer capture limits remain local to producers. For example, Bash keeps `AppP
## Current Gaps
- Future Session-scoped registrations still need an explicit canonical registration design.
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
+85 -79
View File
@@ -2,7 +2,7 @@ export * as McpTool from "./mcp.js"
import { ToolFailure } from "@opencode-ai/ai"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Context, Effect, Fiber, type JsonSchema, Layer, Semaphore, Stream } from "effect"
import { Context, Effect, Exit, Fiber, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
@@ -30,88 +30,94 @@ export const layer = Layer.effect(
const tools = yield* Tool.Service
const bus = yield* Bus.Service
const permission = yield* Permission.Service
const scope = yield* Scope.Scope
const lock = Semaphore.makeUnsafe(1)
let discovered: MCP.Tool[] = []
let current: Scope.Closeable | undefined
// Keep the source's position so later plugin transforms also apply after MCP refreshes.
yield* tools.transform((draft) => {
for (const tool of discovered) {
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
draft.add({
name: tool.name,
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
description: tool.description ?? "",
input: {
...schema,
type: "object",
properties: schema.properties ?? {},
additionalProperties: false,
},
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name(tool.server, tool.name),
resources: ["*"],
save: ["*"],
metadata: {},
sessionID: context.sessionID,
agent: context.agent,
source: {
type: "tool",
messageID: context.messageID,
id: context.id,
},
})
const result = yield* mcp
.callTool({
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
})
.pipe(
Effect.catchTags({
"MCP.NotFoundError": (error) =>
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
if (result.isError)
return yield* new ToolFailure({
message:
result.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
.trim() || "MCP tool returned an error",
})
const content = result.content.map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
type: "file" as const,
uri: `data:${part.mimeType};base64,${part.data}`,
mime: part.mimeType,
},
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return {
output: result.structured ?? (text === "" ? null : text),
...(content.length === 0 ? {} : { content }),
}
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
),
),
})
}
})
// Register the current tool set under a fresh child scope, then close the previous one so the
// registry never has a gap where MCP tools disappear mid-swap.
const reconcile = lock.withPermit(
Effect.gen(function* () {
discovered = yield* mcp.tools()
yield* tools.reload()
const discovered = yield* mcp.tools()
const next = yield* Scope.fork(scope)
yield* tools
.transform((draft) => {
for (const tool of discovered) {
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
draft.add({
name: tool.name,
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
description: tool.description ?? "",
input: {
...schema,
type: "object",
properties: schema.properties ?? {},
additionalProperties: false,
},
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name(tool.server, tool.name),
resources: ["*"],
save: ["*"],
metadata: {},
sessionID: context.sessionID,
agent: context.agent,
source: {
type: "tool",
messageID: context.messageID,
id: context.id,
},
})
const result = yield* mcp
.callTool({
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
})
.pipe(
Effect.catchTags({
"MCP.NotFoundError": (error) =>
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
if (result.isError)
return yield* new ToolFailure({
message:
result.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
.trim() || "MCP tool returned an error",
})
const content = result.content.map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
type: "file" as const,
uri: `data:${part.mimeType};base64,${part.data}`,
mime: part.mimeType,
},
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return {
output: result.structured ?? (text === "" ? null : text),
...(content.length === 0 ? {} : { content }),
}
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
),
),
})
}
})
.pipe(Scope.provide(next))
if (current) yield* Scope.close(current, Exit.void)
current = next
}),
)
+23 -5
View File
@@ -5,6 +5,26 @@ const map = (packageName: string, settings: Readonly<Record<string, unknown>>, m
AISDKNative.map({ packageName, settings, modelID, providerID: "test-provider" })
describe("AISDKNative", () => {
test("keeps configured prompt cache keys in common settings rather than provider options", () => {
for (const name of [
"@ai-sdk/openai",
"@ai-sdk/openai-compatible",
"@ai-sdk/azure",
"@ai-sdk/amazon-bedrock/mantle",
"@ai-sdk/anthropic",
"@ai-sdk/google",
"@ai-sdk/google-vertex",
"@ai-sdk/xai",
"@openrouter/ai-sdk-provider",
]) {
for (const promptCacheKey of ["configured", "", undefined, 123]) {
const mapped = map(name, { baseURL: "https://provider.test/v1", promptCacheKey })
expect(mapped?.settings.promptCacheKey).toBe(typeof promptCacheKey === "string" ? promptCacheKey : undefined)
expect(mapped?.settings.providerOptions ?? {}).not.toHaveProperty("promptCacheKey")
}
}
})
test("maps OpenAI-family packages and request options to native providers", () => {
expect(
map("@ai-sdk/openai", {
@@ -222,11 +242,9 @@ describe("AISDKNative", () => {
},
headers: { "x-test": "value" },
})
for (const modelID of ["openai.gpt-oss-safeguard-20b", "openai.gpt-oss-safeguard-120b"]) {
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, modelID)?.package).toBe(
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
)
}
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-safeguard-20b")?.package).toBe(
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
)
expect(
map(
"@ai-sdk/amazon-bedrock/mantle",
+4 -2
View File
@@ -64,8 +64,10 @@ export const registerToolPlugin = <R>(
hook: () => Effect.succeed({ dispose: Effect.void }),
},
tool: {
transform: tools.transform,
reload: tools.reload,
transform: (callback) =>
tools
.transform((draft) => callback({ add: (tool) => draft.add(tool) }))
.pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
})
+21 -36
View File
@@ -1229,7 +1229,7 @@ test("serializes concurrent MCP lifecycle operations", async () => {
)
})
testEffect(Layer.empty).live("isolates invalid MCP tools and reapplies plugin mutations on catalog updates", () =>
testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updates alive", () =>
Effect.gen(function* () {
const tool = (server: string, name: string) =>
new MCP.Tool({
@@ -1246,14 +1246,12 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and reapplies plugin mu
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
const bus = yield* Bus.Service
const policy = yield* registry.transform((draft) => {
draft.update("demo_search", (tool) => {
tool.description = "Updated search"
})
draft.remove("other_lookup")
})
yield* registration.flush
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_search", "execute"])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_search",
"other_lookup",
"execute",
])
yield* Ref.set(catalog, [tool("demo", "y".repeat(65)), ...healthy, tool("demo", "added"), namespace])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
@@ -1261,36 +1259,23 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and reapplies plugin mu
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_added",
"demo_search",
"other_lookup",
"execute",
])
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
"Updated search",
)
expect(
yield* executeTool(registry, {
yield* Effect.forEach(["demo_search", "other_lookup"], (name) =>
executeTool(registry, {
sessionID: Session.ID.make("ses_mcp_invalid_catalog"),
...toolIdentity,
call: { type: "tool-call", id: "call_demo_search", name: "demo_search", input: {} },
}),
).toMatchObject({ status: "completed" })
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
}).pipe(Effect.tap((result) => Effect.sync(() => expect(result).toMatchObject({ status: "completed" })))),
)
yield* Ref.set(catalog, [
tool("demo", "status"),
tool("other", "lookup"),
tool("demo", "added"),
tool("repaired", "lookup"),
])
yield* Ref.set(catalog, [tool("demo", "status"), ...healthy, tool("demo", "added"), tool("repaired", "lookup")])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
yield* waitForTool(registry, "demo_status")
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_added",
"demo_status",
"repaired_lookup",
"execute",
])
yield* policy.dispose
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_added",
"demo_search",
"demo_status",
"other_lookup",
"repaired_lookup",
@@ -1324,7 +1309,7 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and reapplies plugin mu
}),
)
it.live("advertises MCP output schemas to Code Mode", () =>
it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
@@ -1341,7 +1326,7 @@ it.live("advertises MCP output schemas to Code Mode", () =>
}),
)
it.live("returns content-only MCP results through Code Mode", () =>
it.effect("returns content-only MCP results through Code Mode", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
@@ -1366,7 +1351,7 @@ it.live("returns content-only MCP results through Code Mode", () =>
}),
)
it.live("advertises MCP tools directly when Code Mode is disabled for the server", () =>
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
yield* waitForTool(registry, "direct_lookup")
@@ -1380,7 +1365,7 @@ it.live("advertises MCP tools directly when Code Mode is disabled for the server
// Baseline (PLAN.md step 1): MCP isError must become one failed tool call, not a
// success whose text happens to describe an error.
it.live("fails the call when MCP reports isError", () =>
it.effect("fails the call when MCP reports isError", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
@@ -1398,7 +1383,7 @@ it.live("fails the call when MCP reports isError", () =>
)
// Baseline (PLAN.md step 1): mixed MCP text and media content must reach the model intact.
it.live("preserves MCP text and media content for the model", () =>
it.effect("preserves MCP text and media content for the model", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
@@ -1419,7 +1404,7 @@ it.live("preserves MCP text and media content for the model", () =>
}),
)
it.live("waits for permission before calling an MCP tool", () =>
it.effect("waits for permission before calling an MCP tool", () =>
Effect.gen(function* () {
calls = 0
assertion = yield* Deferred.make<Permission.AssertInput>()
@@ -1461,7 +1446,7 @@ it.live("waits for permission before calling an MCP tool", () =>
}),
)
it.live("does not call MCP when permission is blocked", () =>
it.effect("does not call MCP when permission is blocked", () =>
Effect.gen(function* () {
calls = 0
assertion = yield* Deferred.make<Permission.AssertInput>()
+40 -1
View File
@@ -385,6 +385,43 @@ describe("ModelResolver", () => {
),
)
it.effect("carries configured prompt cache keys into native request defaults", () =>
Effect.gen(function* () {
for (const [packageName, modelID, useCompletionUrls] of [
[Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), "openai.gpt-oss-120b", false],
[Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), "openai.gpt-oss-safeguard-20b", false],
[Provider.aisdk("@ai-sdk/openai"), "gpt-4o-mini", false],
[Provider.aisdk("@ai-sdk/openai-compatible"), "model", false],
[Provider.aisdk("@ai-sdk/azure"), "deployment", false],
[Provider.aisdk("@ai-sdk/azure"), "deployment", true],
["@opencode-ai/ai/providers/openai", "gpt-4o-mini", false],
] as const) {
const resolved = yield* ModelResolver.fromCatalogModel(
model(packageName, {
modelID,
settings: {
apiKey: "test",
baseURL: "https://provider.test/v1",
promptCacheKey: "configured-cache",
useCompletionUrls,
},
}),
)
expect(resolved.defaults?.promptCacheKey).toBe("configured-cache")
expect(resolved.route.defaults.providerOptions ?? {}).not.toHaveProperty("promptCacheKey")
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hi" }))
const overridden = yield* compileRequest(
LLM.request({ model: resolved, prompt: "Hi", promptCacheKey: "request-cache" }),
)
const disabled = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hi", cache: "none" }))
expect(prepared.body).toMatchObject({ prompt_cache_key: "configured-cache" })
expect(overridden.body).toMatchObject({ prompt_cache_key: "request-cache" })
expect(disabled.body).not.toHaveProperty("prompt_cache_key")
}
}),
)
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
@@ -468,12 +505,13 @@ describe("ModelResolver", () => {
it.effect("overlays selected OpenAI variant settings and bodies", () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
settings: { baseURL: "https://openai.example/v1", promptCacheKey: "base-cache" },
variants: [
{
id: VariantID.make("xhigh"),
settings: {
reasoningEffort: "xhigh",
promptCacheKey: "variant-cache",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
@@ -507,6 +545,7 @@ describe("ModelResolver", () => {
})
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body).toMatchObject({
prompt_cache_key: "variant-cache",
include: ["reasoning.encrypted_content"],
reasoning: { effort: "xhigh", summary: "auto" },
})
-1
View File
@@ -114,7 +114,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
},
tool: overrides.tool ?? {
transform: () => Effect.die("unused tool.transform"),
reload: () => Effect.die("unused tool.reload"),
hook: () => Effect.die("unused tool.hook"),
},
vcs: overrides.vcs ?? {
-1
View File
@@ -72,7 +72,6 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
},
tool: {
transform: () => Effect.die("unused tool.transform"),
reload: () => Effect.die("unused tool.reload"),
hook: (name, callback) => {
if (name === "execute.after") {
// Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API.
-73
View File
@@ -636,77 +636,4 @@ describe("fromPromise", () => {
})
}),
)
it.live("adapts tool mutation, replay, and disposal through the Promise API", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const progress: Tool.Metadata[] = []
let greeting = "Hello"
let registration: { dispose(): Promise<void> } | undefined
yield* host.tool.transform((draft) => {
const text = greeting
draft.add({
name: "hello",
description: "Hello",
options: { namespace: "acme", codemode: false },
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: ({ name }, context) =>
context.progress({ phase: "original" }).pipe(Effect.as({ output: `${text}, ${name}!` })),
})
draft.add({
name: "temporary",
description: "Temporary",
input: Schema.Struct({}),
options: { codemode: false },
execute: () => Effect.succeed({ content: "temporary" }),
})
})
yield* PluginPromise.fromPromise(
define({
id: "promise-update",
setup: async (ctx) => {
registration = await ctx.tool.transform((draft) => {
draft.update("missing", () => {
throw new Error("must not create a tool")
})
draft.update("acme_hello", (tool) => {
const execute = tool.execute
tool.description = "Wrapped"
tool.execute = async (input, context) => {
const result = await execute(input, context)
return { ...result, output: `${result.output} Wrapped.` }
}
})
draft.remove("temporary")
})
greeting = "Hi"
await ctx.tool.reload()
},
}),
).effect(host)
const snapshot = yield* registry.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"])
expect(snapshot.definitions[0]?.description).toBe("Wrapped")
expect(
yield* snapshot.execute({
sessionID: Session.ID.make("ses_promise_update"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_promise_update"),
progress: (value) => Effect.sync(() => progress.push(value)),
call: { type: "tool-call", id: "call_promise_update", name: "acme_hello", input: { name: "world" } },
}),
).toMatchObject({ output: "Hi, world! Wrapped." })
expect(progress).toEqual([{ phase: "original" }])
const registered = registration
if (!registered) throw new Error("Expected registration")
yield* Effect.promise(() => registered.dispose())
yield* Effect.promise(() => registered.dispose())
const restored = yield* registry.snapshot()
expect(restored.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "temporary", "execute"])
expect(restored.definitions[0]?.description).toBe("Hello")
}),
)
})
@@ -7,7 +7,6 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool"
import { State } from "@opencode-ai/core/state"
import type { Info } from "@opencode-ai/schema/tool"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { executeTool, toolDefinitions } from "./lib/tool"
@@ -72,138 +71,6 @@ const transform = (service: Tool.Interface, tools: Readonly<Record<string, Info>
)
describe("Tool", () => {
it.live("replays updates and removals on reload and restores definitions on disposal", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
let text = "original"
const source = yield* service.transform((draft) =>
draft.add({
...constant(text),
name: "echo",
description: text,
options: { namespace: "acme", codemode: false },
}),
)
yield* transform(service, { echo: make() }, { namespace: "other", codemode: false })
const before = yield* service.snapshot()
const update = yield* service.transform((draft) => {
draft.update("missing", () => {
throw new Error("must not create a tool")
})
draft.update("acme_echo", (tool) => {
tool.description += " updated"
const execute = tool.execute
tool.execute = (input, context) =>
execute(input, context).pipe(
Effect.map((result) => ({ ...result, output: { text: `${result.output.text} updated` } })),
)
})
})
const removal = yield* service.transform((draft) => {
draft.remove("missing")
draft.remove("other_echo")
})
const updated = yield* service.snapshot()
expect(updated.definitions.map((tool) => tool.name)).toEqual(["acme_echo", "execute"])
expect(updated.definitions[0]?.description).toBe("original updated")
expect((yield* updated.execute(call("acme_echo"))).output).toEqual({ text: "original updated" })
text = "refreshed"
yield* service.reload()
const reloaded = yield* service.snapshot()
expect(reloaded.definitions.map((tool) => tool.name)).toEqual(["acme_echo", "execute"])
expect(reloaded.definitions[0]?.description).toBe("refreshed updated")
expect((yield* reloaded.execute(call("acme_echo"))).output).toEqual({ text: "refreshed updated" })
expect((yield* before.execute(call("acme_echo"))).output).toEqual({ text: "original" })
yield* removal.dispose
yield* removal.dispose
yield* update.dispose
const restored = yield* service.snapshot()
expect(restored.definitions.map((tool) => tool.name)).toEqual(["acme_echo", "other_echo", "execute"])
expect((yield* restored.execute(call("acme_echo"))).output).toEqual({ text: "refreshed" })
yield* source.dispose
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["other_echo", "execute"])
}),
)
it.effect("does not retain an updated tool after its source scope closes", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const scope = yield* Scope.make()
yield* transform(service, { echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
yield* service.transform((draft) =>
draft.update("echo", (tool) => {
tool.description = "Updated"
}),
)
yield* Scope.close(scope, Exit.void)
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
it.effect("batches tool transforms with the shared state lifecycle", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
let runs = 0
yield* State.batch(
Effect.gen(function* () {
yield* service.transform((draft) => {
runs++
draft.add({ ...make(), options: { codemode: false } })
})
yield* service.transform((draft) =>
draft.update("echo", (tool) => {
tool.description = "Batched"
}),
)
expect(runs).toBe(0)
}),
)
expect(runs).toBe(1)
expect((yield* service.snapshot()).definitions[0]?.description).toBe("Batched")
}),
)
it.effect("skips invalid updates without dropping the existing definition", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { echo: make() }, { codemode: false })
yield* service.transform((draft) =>
draft.update("echo", (tool) => {
Object.assign(tool, { description: undefined })
}),
)
expect((yield* service.snapshot()).definitions[0]?.description).toBe("Echo text")
}),
)
it.effect("updates newly added tools and applies removals in order", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.add({ ...make(), options: { codemode: false } })
draft.update("echo", (tool) => {
tool.description = "Updated"
tool.input = Schema.Struct({ value: Schema.Number })
tool.output = Schema.Number
tool.execute = ({ value }) => Effect.succeed({ output: value * 2 })
})
draft.add({ ...make(), name: "removed" })
draft.remove("removed")
draft.add({ ...make(), name: "removed" })
draft.remove("removed")
})
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["echo", "execute"])
expect(snapshot.definitions[0]?.description).toBe("Updated")
expect(
(yield* snapshot.execute({
...call("echo"),
call: { type: "tool-call", id: "updated", name: "echo", input: { value: 3 } },
})).output,
).toBe(6)
}),
)
it.effect("logs and skips invalid dotted namespaces", () => {
const output: unknown[] = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
+38
View File
@@ -4142,6 +4142,44 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("prefers configured prompt cache keys over the automatic session key", () =>
Effect.gen(function* () {
yield* setup
const context = yield* SessionContext.Service
const modelRequests = yield* SessionModelRequest.Service
const selected = yield* context.select(sessionID)
const database = yield* Database.Service
const bus = yield* Bus.Service
yield* InstructionState.prepare(database.db, bus, selected.instructions, sessionID)
const loaded = yield* context.load(selected)
for (const [routeKey, modelKey, expected] of [
[undefined, undefined, sessionID],
["route-cache", undefined, "route-cache"],
["route-cache", "model-cache", "model-cache"],
["route-cache", "", ""],
]) {
const prepared = yield* modelRequests.prepare({
scope: {
session: loaded.session,
agentID: loaded.agent.id,
model: {
...loaded.model,
model: LanguageModel.update(loaded.model.model, {
route: loaded.model.model.route.with({ promptCacheKey: routeKey }),
defaults: { promptCacheKey: modelKey },
}),
},
tools: loaded.tools,
},
transcript: { system: [], messages: [] },
})
expect(prepared.request.promptCacheKey).toBe(expected)
expect(prepared.request.providerOptions ?? {}).not.toHaveProperty("promptCacheKey")
}
}),
)
it.effect("bounds 64-character session prompt cache keys", () =>
Effect.gen(function* () {
const session = yield* setup
+1 -5
View File
@@ -2,16 +2,13 @@ import { Tool } from "@opencode-ai/schema/tool"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { Effect, JsonSchema, Types } from "effect"
import type { JsonSchema } from "effect"
import type { Hooks, Transform } from "./registration.js"
export interface ToolDraft {
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Tool.Info<Input, Output>,
): void
/** Updates an existing tool; missing IDs are ignored. */
update(id: string, update: (tool: Types.Mutable<Tool.Info>) => void): void
remove(id: string): void
}
export interface ToolHooks {
@@ -51,6 +48,5 @@ export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly reload: () => Effect.Effect<void>
readonly hook: Hooks<ToolHooks, ToolFailures>
}
-21
View File
@@ -294,31 +294,10 @@ export function fromPromise(plugin: Plugin) {
scan: (options) => run(host.storage.scan(options)),
},
tool: {
reload: () => run(host.tool.reload()),
transform: (callback) =>
register(
host.tool.transform((draft) =>
callback({
update: (id, update) =>
draft.update(id, (tool) => {
const execute = tool.execute
const value: Info = {
...tool,
execute: (input, context) =>
run(
execute(input, {
...context,
progress: (update) => Effect.promise(() => context.progress(update)),
}),
),
}
update(value)
Object.assign(tool, {
...value,
execute: (input: unknown, context: Tool.Context) => executePromiseTool(value, input, context),
})
}),
remove: (id) => draft.remove(id),
add: (tool: Info) =>
draft.add({
...tool,
+1 -5
View File
@@ -5,7 +5,7 @@ import { Tool } from "@opencode-ai/schema/tool"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { JsonSchema, Types } from "effect"
import type { JsonSchema } from "effect"
import type { Hooks, Transform } from "./registration.js"
export interface ToolContext extends Omit<Tool.Context, "progress"> {
@@ -26,9 +26,6 @@ interface ToolDraft {
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Info<Input, Output>,
): void
/** Updates an existing tool; missing IDs are ignored. */
update(id: string, update: (tool: Types.Mutable<Info>) => void): void
remove(id: string): void
}
interface ToolHooks {
@@ -62,6 +59,5 @@ interface ToolHooks {
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly reload: () => Promise<void>
readonly hook: Hooks<ToolHooks>
}
+11 -9
View File
@@ -29,10 +29,12 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
(sessionID) => sessions.refresh(sessionID).catch(() => undefined),
)
const session = () => sessions.get(props.sessionID)
const terminals = () => session()?.terminals ?? []
const selectedTerminal = () => {
if (!config.data.session.terminal) return
const value = session()
return value.terminals.find((terminal) => terminal.id === value.selectedTerminalID)
if (value?.hidden) return
return value?.terminals.find((terminal) => terminal.id === value.selectedTerminalID) ?? value?.terminals.at(-1)
}
createEffect(
on(
@@ -52,7 +54,7 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
const rightPane = createMemo(() => {
if (sidebarOpen() && sidebarVisible()) return "sidebar"
if (selectedTerminal()) return "terminal"
if (sidebarVisible()) return "sidebar"
if (sidebarVisible() && !session()?.hidden) return "sidebar"
})
const toggleSidebar = () => {
batch(() => {
@@ -63,11 +65,11 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
})
.catch(toast.error)
setSidebarOpen(!visible)
if (!visible && selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
if (!visible && selectedTerminal()) void sessions.hideTerminal(props.sessionID).catch(toast.error)
})
}
createEffect(() => {
if (!restoreTerminalFocus() || selectedTerminal()) return
if (!restoreTerminalFocus() || terminals().length > 0) return
setRestoreTerminalFocus(false)
prompt.current?.focus()
})
@@ -137,13 +139,13 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
<Show
when={rightPane() === "sidebar"}
fallback={
<Show keyed when={selectedTerminal()?.id}>
{(ptyID) => (
<Show keyed when={selectedTerminal()}>
{(terminal) => (
<TerminalPane
ptyID={ptyID}
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
ptyID={terminal.id}
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(terminal.id)}
onAutoFocus={() => {
sessions.clearFocus(ptyID)
sessions.clearFocus(terminal.id)
setRestoreTerminalFocus(false)
}}
onFocusChange={setTerminalFocused}
+2 -2
View File
@@ -330,8 +330,8 @@ function sameSize(first: TerminalSize | undefined, second: TerminalSize | undefi
}
function terminalPalette(theme: ResolvedThemeTokens, mode: "dark" | "light", background: RGBA) {
const base = mode === "dark" ? 200 : 800
const bright = mode === "dark" ? 100 : 900
const base = mode === "dark" ? 500 : 700
const bright = mode === "dark" ? 300 : 500
const colors = [
background,
theme.text.feedback.error.default,
+39 -39
View File
@@ -7,8 +7,14 @@ import { useData } from "./data"
import { useEvent } from "./event"
import { useStorage } from "./storage"
type SessionTerminals = {
terminals: PersistentPtyInfo[]
selectedTerminalID?: string
hidden?: boolean
}
type SessionTerminalsState = {
sessions: Record<string, string | null>
sessions: Record<string, SessionTerminals>
}
export const { use: useSessionTerminals, provider: SessionTerminalsProvider } = createSimpleContext({
@@ -19,62 +25,56 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } =
const data = useData()
const event = useEvent()
const [focus, setFocus] = createSignal<string>()
const storage = useStorage()
const [store, update] = storage.store<SessionTerminalsState>("session-terminal-selection", {
const [store, update] = useStorage().store<SessionTerminalsState>("session-terminals-v1", {
initial: { sessions: {} },
})
const [terminals, updateTerminals] = storage.memory<Record<string, PersistentPtyInfo[]>>("session-terminals", {
initial: {},
})
const save = (sessionID: string, terminals: PersistentPtyInfo[], selectedTerminalID?: string) =>
update((draft) => {
const current = draft.sessions[sessionID]?.selectedTerminalID
const selected = selectedTerminalID ?? current
draft.sessions[sessionID] = {
terminals,
selectedTerminalID: terminals.some((terminal) => terminal.id === selected) ? selected : terminals.at(-1)?.id,
...(selectedTerminalID === undefined && draft.sessions[sessionID]?.hidden ? { hidden: true } : {}),
}
})
const refresh = async (sessionID: string) => {
if (!terminals[sessionID]) updateTerminals((draft) => (draft[sessionID] = []))
const result = await client.api.experimental.persistentPty.list({ sessionID })
updateTerminals((draft) => (draft[sessionID] = result))
const selected = store.sessions[sessionID]
if (!selected || result.some((terminal) => terminal.id === selected)) return
await update((draft) => {
if (draft.sessions[sessionID] !== selected) return
draft.sessions[sessionID] = null
})
}
const selectTerminal = async (sessionID: string, ptyID: string | null) => {
if (ptyID !== null && !terminals[sessionID]?.some((terminal) => terminal.id === ptyID)) return
setFocus(ptyID ?? undefined)
await update((draft) => {
draft.sessions[sessionID] = ptyID
})
await save(sessionID, await client.api.experimental.persistentPty.list({ sessionID }))
}
for (const type of ["persistent-pty.added", "persistent-pty.removed"] as const) {
onCleanup(
event.on(type, (evt) => {
if (!config.session.terminal || !terminals[evt.data.sessionID]) return
if (!config.session.terminal || !store.sessions[evt.data.sessionID]) return
void refresh(evt.data.sessionID).catch((error) =>
console.error("Failed to refresh persistent terminal panes", error),
)
}),
)
}
onCleanup(
event.on("server.connected", () => {
if (!config.session.terminal) return
Object.keys(terminals).forEach((sessionID) => {
void refresh(sessionID).catch((error) => console.error("Failed to refresh persistent terminal panes", error))
})
}),
)
return {
get(sessionID: string) {
return {
terminals: terminals[sessionID] ?? [],
selectedTerminalID: store.sessions[sessionID] ?? null,
}
return store.sessions[sessionID]
},
refresh,
selectTerminal,
selectTerminal(sessionID: string, ptyID: string) {
setFocus(ptyID)
return update((draft) => {
const session = draft.sessions[sessionID]
if (!session?.terminals.some((terminal) => terminal.id === ptyID)) return
session.selectedTerminalID = ptyID
delete session.hidden
})
},
hideTerminal(sessionID: string) {
return update((draft) => {
const session = draft.sessions[sessionID]
if (session) session.hidden = true
})
},
async newTerminal(sessionID: string): Promise<PersistentPtyInfo> {
const session = data.session.get(sessionID)
const terminal = await client.api.experimental.persistentPty.create({
@@ -85,8 +85,8 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } =
title: "Terminal",
env: {},
})
await refresh(sessionID)
await selectTerminal(sessionID, terminal.id)
setFocus(terminal.id)
await save(sessionID, await client.api.experimental.persistentPty.list({ sessionID }), terminal.id)
return terminal
},
shouldFocus(ptyID: string) {
+7 -11
View File
@@ -903,19 +903,15 @@ export function Session(props: {
id: "terminal.toggle",
group: "Session",
run: () => {
const sessionID = route.sessionID
if (props.visibleTerminalID) {
promptRef.current?.focus()
void terminals.selectTerminal(sessionID, null).catch(toast.error)
void terminals.hideTerminal(route.sessionID).catch(toast.error)
} else {
void terminals
.refresh(sessionID)
.then(async () => {
const terminal = terminals.get(sessionID).terminals.at(-1)
if (terminal) return terminals.selectTerminal(sessionID, terminal.id)
await terminals.newTerminal(sessionID)
})
.catch(terminalError)
const state = terminals.get(route.sessionID)
const terminal =
state?.terminals.find((item) => item.id === state.selectedTerminalID) ?? state?.terminals.at(-1)
if (terminal) void terminals.selectTerminal(route.sessionID, terminal.id).catch(terminalError)
else void terminals.newTerminal(route.sessionID).catch(terminalError)
}
dialog.clear()
},
@@ -938,7 +934,7 @@ export function Session(props: {
enabled: props.visibleTerminalID !== undefined,
run: () => {
promptRef.current?.focus()
void terminals.selectTerminal(route.sessionID, null).catch(toast.error)
void terminals.hideTerminal(route.sessionID).catch(toast.error)
dialog.clear()
},
},
@@ -847,23 +847,13 @@ interface ToolDraft {
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Tool.Info<Input, Output>,
): void
update(id: string, update: (tool: Types.Mutable<Tool.Info>) => void): void
remove(id: string): void
}
interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly reload: () => Effect.Effect<void>
}
```
`update` and `remove` use the effective registry name, such as `acme_greeting` above, and do nothing for missing tools.
Creating a tool requires `add`, not an agent-style upsert. Updates preserve the name and namespace; assign new schemas
and options to replace them.
As with agents and skills, transforms replay in registration order. Use `yield* ctx.tool.reload()` after external state
changes. `yield* registration.dispose` removes that transform and rebuilds the tools. Scope cleanup does the same.
### VCS
Read repository information, working-copy status, or file diffs.
@@ -783,7 +783,7 @@ interface StorageScanResult {
### Tools
Register, update, and remove tools with a transform.
Register tools with a transform.
```ts
await ctx.tool.transform((draft) => {
@@ -802,22 +802,9 @@ await ctx.tool.transform((draft) => {
return { content: `Hello ${(input as { name: string }).name}!` }
},
})
draft.update("acme_greeting", (tool) => {
tool.description = "Greet someone by name"
})
draft.remove("legacy")
})
```
`update` and `remove` use effective registry names, including the namespace: `acme_greeting` in the example above.
Dots in namespaces and unsupported characters in tool names become `_`.
`update` does nothing when the ID is missing. Unlike agent upserts, creating a tool requires `add` with a complete
definition. Updates preserve the tool's name and namespace; replace its schemas and options by assigning new values.
As with agents and skills, transforms replay in registration order when registered, reloaded, or disposed. Later
transforms see earlier changes. Call `await ctx.tool.reload()` after external state used by a transform changes.
Calling `await registration.dispose()` removes that transform and rebuilds the tools; plugin unload does the same.
#### Reference
Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#schema-Tool.TextContent),
@@ -826,13 +813,10 @@ Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#s
```ts
interface ToolContext {
transform(callback: (draft: ToolDraft) => void): Promise<Registration>
reload(): Promise<void>
}
interface ToolDraft {
add(tool: ToolInfo): void
update(id: string, update: (tool: Types.Mutable<ToolInfo>) => void): void
remove(id: string): void
}
```