From 5c7b09eb44e9e7e724b388b0e5790d539ebc16af 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 9167cb3ea6bc..ab72d2ac65f1 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -139,6 +139,7 @@ const layer = Layer.effect( id, parameters, jsonSchema, + strict: def.strict, description: def.description, execute: (args, toolCtx) => Effect.gen(function* () { @@ -314,6 +315,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 = @@ -331,6 +333,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 701658987402..c2e6115af107 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 efa5195ddb45225e77617a9918fa9de0686c30a5 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 c2e6115af107..4f1fba3ddc58 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", () => {