From bd1d35b9afb2bd31259f6a4098cb4896dda98ddf Mon Sep 17 00:00:00 2001 From: may Date: Mon, 3 Aug 2026 07:27:50 +0300 Subject: [PATCH 1/2] expose strict mode on tool definitions Tools can now declare strict: true, and the tool.definition plugin hook can set or override it. The flag is passed through to the AI SDK Tool, which providers that support structured outputs forward to the model. The existing hardcoded strict: false for @ai-sdk/openai, @ai-sdk/azure and @ai-sdk/amazon-bedrock/mantle is unchanged. --- packages/opencode/src/session/tools.ts | 1 + packages/opencode/src/tool/registry.ts | 3 ++ packages/opencode/src/tool/tool.ts | 1 + .../opencode/test/provider/transform.test.ts | 54 +++++++++++++++++++ packages/opencode/test/tool/registry.test.ts | 33 ++++++++++++ packages/plugin/src/index.ts | 5 +- packages/plugin/src/tool.ts | 1 + 7 files changed, 97 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 0f401c7562fa..c0756ea891b1 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -99,6 +99,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { tools[item.id] = tool({ description: item.description, inputSchema: jsonSchema(schema), + ...(item.strict !== undefined && { strict: item.strict }), execute(args, options) { return run.promise( Effect.gen(function* () { diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 15acc757f3d4..1aa1d2b25cf5 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -134,6 +134,7 @@ const layer = Layer.effect( id, parameters, jsonSchema, + strict: def.strict, description: def.description, execute: (args, toolCtx) => Effect.gen(function* () { @@ -309,6 +310,7 @@ const layer = Layer.effect( description: tool.description, parameters: tool.parameters, jsonSchema: tool.jsonSchema, + strict: tool.strict, } yield* plugin.trigger("tool.definition", { toolID: tool.id }, output) const jsonSchema = @@ -326,6 +328,7 @@ const layer = Layer.effect( .join("\n"), parameters: output.parameters, jsonSchema, + strict: output.strict, execute: tool.execute, formatValidationError: tool.formatValidationError, } diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index e5e7802858ca..fee05e00a65c 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -60,6 +60,7 @@ export interface Def< description: string parameters: Parameters jsonSchema?: JSONSchema7 + strict?: boolean execute(args: Schema.Schema.Type, ctx: Context): Effect.Effect> formatValidationError?(error: unknown): string } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 0b88fa59dff8..8d396220c280 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -586,6 +586,60 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { expect(result.tools.lookup.strict).toBe(false) }) + test("a tool's strict flag survives on bedrock and is still overridden on mantle", async () => { + const prepare = (npm: string) => + Effect.runPromise( + LLMRequestPrep.prepare({ + user: { + id: "msg_user-test", + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "test", + model: { providerID: "amazon-bedrock", modelID: "anthropic.claude-sonnet-4-6" }, + } as any, + sessionID, + model: { + ...createGpt5Model("anthropic.claude-sonnet-4-6"), + id: "amazon-bedrock/anthropic.claude-sonnet-4-6", + providerID: "amazon-bedrock", + api: { + id: "anthropic.claude-sonnet-4-6", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm, + }, + }, + agent: { + name: "test", + mode: "primary", + options: {}, + permission: [], + } as any, + system: [], + messages: [{ role: "user", content: "Hello" }], + tools: { + lookup: { + description: "Look up a value", + inputSchema: jsonSchema({ type: "object", properties: {} }), + strict: true, + }, + }, + provider: { id: "amazon-bedrock", options: {} } as any, + auth: undefined, + plugin: { + trigger: (_name: string, _input: unknown, output: unknown) => Effect.succeed(output), + list: () => Effect.succeed([]), + init: () => Effect.void, + } as any, + flags: { outputTokenMax: 32_000, client: "test" } as any, + isWorkflow: false, + }), + ) + + expect((await prepare("@ai-sdk/amazon-bedrock")).tools.lookup.strict).toBe(true) + expect((await prepare("@ai-sdk/amazon-bedrock/mantle")).tools.lookup.strict).toBe(false) + }) + test("gpt-5.1 should have textVerbosity set to low", () => { const model = createGpt5Model("gpt-5.1") const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index c8c5fac59559..6bf2b983880a 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -194,6 +194,39 @@ describe("tool.registry", () => { }), ) + it.instance("carries strict from a custom tool definition", () => + Effect.gen(function* () { + const test = yield* TestInstance + const tool = path.join(test.directory, ".opencode", "tool") + yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tool, "strict_tool.ts"), + [ + "export default {", + " description: 'strict tool',", + " args: {},", + " strict: true,", + " execute: async () => 'ok',", + "}", + "", + ].join("\n"), + ), + ) + + const registry = yield* ToolRegistry.Service + const agents = yield* Agent.Service + const tools = yield* registry.tools({ + providerID: ProviderV2.ID.opencode, + modelID: ModelV2.ID.make("test"), + agent: yield* agents.defaultInfo(), + }) + + expect(tools.find((item) => item.id === "strict_tool")?.strict).toBe(true) + expect(tools.filter((item) => item.id !== "strict_tool").every((item) => item.strict === undefined)).toBe(true) + }), + ) + it.instance("ignores non-tool exports in .opencode/tool files", () => Effect.gen(function* () { const test = yield* TestInstance diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index edfa0139dfca..efdcdfd42da6 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -331,5 +331,8 @@ export interface Hooks { /** * Modify tool definitions (description and parameters) sent to LLM */ - "tool.definition"?: (input: { toolID: string }, output: { description: string; parameters: any }) => Promise + "tool.definition"?: ( + input: { toolID: string }, + output: { description: string; parameters: any; strict?: boolean }, + ) => Promise } diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index 9c6daa34d04a..ca3349fab5ee 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -45,6 +45,7 @@ export type ToolResult = export function tool(input: { description: string args: Args + strict?: boolean execute(args: z.infer>, context: ToolContext): Promise }) { return input From 6323a78a7f08e185f5af45cf33e1aeee2ccf8980 Mon Sep 17 00:00:00 2001 From: may Date: Mon, 3 Aug 2026 09:29:16 +0300 Subject: [PATCH 2/2] test: use an openai model id for the mantle case --- .../opencode/test/provider/transform.test.ts | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 8d396220c280..5e45a528a750 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -587,7 +587,7 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { }) test("a tool's strict flag survives on bedrock and is still overridden on mantle", async () => { - const prepare = (npm: string) => + const prepare = (npm: string, apiId: string, url: string) => Effect.runPromise( LLMRequestPrep.prepare({ user: { @@ -596,18 +596,14 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { role: "user", time: { created: Date.now() }, agent: "test", - model: { providerID: "amazon-bedrock", modelID: "anthropic.claude-sonnet-4-6" }, + model: { providerID: "amazon-bedrock", modelID: apiId }, } as any, sessionID, model: { - ...createGpt5Model("anthropic.claude-sonnet-4-6"), - id: "amazon-bedrock/anthropic.claude-sonnet-4-6", + ...createGpt5Model(apiId), + id: `amazon-bedrock/${apiId}`, providerID: "amazon-bedrock", - api: { - id: "anthropic.claude-sonnet-4-6", - url: "https://bedrock-runtime.us-east-1.amazonaws.com", - npm, - }, + api: { id: apiId, url, npm }, }, agent: { name: "test", @@ -636,8 +632,19 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { }), ) - expect((await prepare("@ai-sdk/amazon-bedrock")).tools.lookup.strict).toBe(true) - expect((await prepare("@ai-sdk/amazon-bedrock/mantle")).tools.lookup.strict).toBe(false) + const converse = await prepare( + "@ai-sdk/amazon-bedrock", + "anthropic.claude-sonnet-4-6", + "https://bedrock-runtime.us-east-1.amazonaws.com", + ) + const mantle = await prepare( + "@ai-sdk/amazon-bedrock/mantle", + "openai.gpt-5.5", + "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + ) + + expect(converse.tools.lookup.strict).toBe(true) + expect(mantle.tools.lookup.strict).toBe(false) }) test("gpt-5.1 should have textVerbosity set to low", () => {