From 4351bc9439933f9063e948b7f4bee47132a181d0 Mon Sep 17 00:00:00 2001 From: 0xDevNinja Date: Tue, 16 Jun 2026 12:12:44 +0530 Subject: [PATCH] fix(agent): apply configured tool_choice to model requests The agent tool_choice option was accepted in config but silently dropped: the v1 agent schema pushed any unknown key into options during normalize, so it never reached the agent builder, and the request always sent the default (effectively tool_choice: auto). Models that don't reliably emit tool calls under auto could never be forced into a tool call. Add tool_choice to the v1 agent schema (and KNOWN_KEYS so it survives normalize), carry it onto the runtime agent as toolChoice, and pass it into the chat request (json_schema structured output still forces required). Closes #32465 --- packages/core/src/v1/config/agent.ts | 4 ++++ packages/opencode/src/agent/agent.ts | 2 ++ packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/test/agent/agent.test.ts | 19 +++++++++++++++++++ packages/web/src/content/docs/agents.mdx | 22 ++++++++++++++++++++++ 5 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/core/src/v1/config/agent.ts b/packages/core/src/v1/config/agent.ts index b220bd7ef87d..e8be7bed9fa8 100644 --- a/packages/core/src/v1/config/agent.ts +++ b/packages/core/src/v1/config/agent.ts @@ -17,6 +17,9 @@ const AgentSchema = Schema.StructWithRest( }), temperature: Schema.optional(Schema.Finite), top_p: Schema.optional(Schema.Finite), + tool_choice: Schema.optional(Schema.Literals(["auto", "required", "none"])).annotate({ + description: "How the model should select tools: auto, required, or none", + }), prompt: Schema.optional(Schema.String), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({ description: "@deprecated Use 'permission' field instead", @@ -48,6 +51,7 @@ const KNOWN_KEYS = new Set([ "description", "temperature", "top_p", + "tool_choice", "mode", "hidden", "color", diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b1430314fffe..bb0917786675 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -40,6 +40,7 @@ export const Info = Schema.Struct({ hidden: Schema.optional(Schema.Boolean), topP: Schema.optional(Schema.Finite), temperature: Schema.optional(Schema.Finite), + toolChoice: Schema.optional(Schema.Literals(["auto", "required", "none"])), color: Schema.optional(Schema.String), permission: PermissionV1.Ruleset, model: Schema.optional( @@ -282,6 +283,7 @@ export const layer = Layer.effect( item.description = value.description ?? item.description item.temperature = value.temperature ?? item.temperature item.topP = value.top_p ?? item.topP + item.toolChoice = value.tool_choice ?? item.toolChoice item.mode = value.mode ?? item.mode item.color = value.color ?? item.color item.hidden = value.hidden ?? item.hidden diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b3f85c813f20..42a8a7d178ef 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1343,7 +1343,7 @@ export const layer = Layer.effect( messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], tools, model, - toolChoice: format.type === "json_schema" ? "required" : undefined, + toolChoice: format.type === "json_schema" ? "required" : agent.toolChoice, }) if (structured !== undefined) { diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 1df95b5c0f87..7ad9fc74a479 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -74,6 +74,25 @@ it.instance("build agent has correct default properties", () => }), ) +it.instance( + "agent tool_choice config is applied to the agent", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build).toBeDefined() + expect(build?.toolChoice).toBe("required") + }), + { + config: { + agent: { + build: { + tool_choice: "required", + }, + }, + }, + }, +) + it.instance("plan agent denies edits except .opencode/plans/*", () => Effect.gen(function* () { const plan = yield* load((svc) => svc.get("plan")) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 53048b7927b9..b7e6345e5a06 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -288,6 +288,28 @@ If no temperature is specified, OpenCode uses model-specific defaults; typically --- +### Tool choice + +Control how the model decides whether to call a tool with the `tool_choice` config. + +```json title="opencode.json" +{ + "agent": { + "build": { + "tool_choice": "required" + } + } +} +``` + +- **`auto`**: The model decides whether to call a tool. This is the default. +- **`required`**: The model must call a tool. Useful for models that don't reliably emit tool calls on their own. +- **`none`**: The model is not allowed to call tools. + +If not set, OpenCode lets the provider use its default behavior (`auto`). + +--- + ### Max steps Control the maximum number of agentic iterations an agent can perform before being forced to respond with text only. This allows users who wish to control costs to set a limit on agentic actions.