From 9380c82ddacfa808d7202e8bc8f4cbbc1c35aec3 Mon Sep 17 00:00:00 2001 From: "timothy.vang" Date: Thu, 9 Jul 2026 18:23:04 +0000 Subject: [PATCH] feat: OPENCODE_TOOL_CHOICE=required forces tool calls on agent steps Local models often answer with prose/JSON instead of MCP tool invocations. When OPENCODE_TOOL_CHOICE=required (or VERDICT_FORCE_TOOL_CHOICE=1), non-final session runner steps send tool_choice: required. Final max-steps exit still uses none. --- packages/core/src/flag/flag.ts | 12 +++++++ packages/core/src/session/runner/llm.ts | 13 +++++++- packages/core/test/tool-choice-flag.test.ts | 36 +++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/tool-choice-flag.test.ts diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index a0eb78a..fc6d894 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -75,4 +75,16 @@ export const Flag = { get OPENCODE_CLIENT() { return process.env["OPENCODE_CLIENT"] ?? "cli" }, + /** + * When `OPENCODE_TOOL_CHOICE=required` (or `VERDICT_FORCE_TOOL_CHOICE=1`), + * non-final agent steps send OpenAI-compatible `tool_choice: "required"` + * so local models cannot answer with prose/JSON instead of tool calls. + * Final step still forces `none` (max-steps exit). + */ + get OPENCODE_TOOL_CHOICE() { + const raw = (process.env["OPENCODE_TOOL_CHOICE"] ?? "").toLowerCase() + if (raw === "required" || raw === "auto" || raw === "none") return raw + if (truthy("VERDICT_FORCE_TOOL_CHOICE")) return "required" + return undefined + }, } diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 94c77d1..2bbdd60 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -11,6 +11,7 @@ import { import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { AgentV2 } from "../../agent" import { Config } from "../../config" +import { Flag } from "../../flag/flag" import { Database } from "../../database/database" import { EventV2 } from "../../event" import { Location } from "../../location" @@ -205,7 +206,17 @@ const layer = Layer.effect( .map(SystemPart.make), messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])], tools: toolMaterialization?.definitions ?? [], - toolChoice: isLastStep ? "none" : undefined, + // Default: auto (undefined). OPENCODE_TOOL_CHOICE=required forces tool + // calls on non-final steps so weak local models cannot print JSON prose. + toolChoice: isLastStep + ? "none" + : Flag.OPENCODE_TOOL_CHOICE === "required" + ? "required" + : Flag.OPENCODE_TOOL_CHOICE === "none" + ? "none" + : Flag.OPENCODE_TOOL_CHOICE === "auto" + ? "auto" + : undefined, }) if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) return yield* Effect.die(continueAfterCompaction(currentStep)) diff --git a/packages/core/test/tool-choice-flag.test.ts b/packages/core/test/tool-choice-flag.test.ts new file mode 100644 index 0000000..9c94fc8 --- /dev/null +++ b/packages/core/test/tool-choice-flag.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test" + +describe("OPENCODE_TOOL_CHOICE / VERDICT_FORCE_TOOL_CHOICE", () => { + test("reads required from OPENCODE_TOOL_CHOICE", async () => { + const prev = process.env["OPENCODE_TOOL_CHOICE"] + const prevForce = process.env["VERDICT_FORCE_TOOL_CHOICE"] + try { + delete process.env["VERDICT_FORCE_TOOL_CHOICE"] + process.env["OPENCODE_TOOL_CHOICE"] = "required" + // Re-import would cache Flag getters — getters read process.env at access time. + const { Flag } = await import("../src/flag/flag") + expect(Flag.OPENCODE_TOOL_CHOICE).toBe("required") + } finally { + if (prev === undefined) delete process.env["OPENCODE_TOOL_CHOICE"] + else process.env["OPENCODE_TOOL_CHOICE"] = prev + if (prevForce === undefined) delete process.env["VERDICT_FORCE_TOOL_CHOICE"] + else process.env["VERDICT_FORCE_TOOL_CHOICE"] = prevForce + } + }) + + test("VERDICT_FORCE_TOOL_CHOICE=1 maps to required", async () => { + const prev = process.env["OPENCODE_TOOL_CHOICE"] + const prevForce = process.env["VERDICT_FORCE_TOOL_CHOICE"] + try { + delete process.env["OPENCODE_TOOL_CHOICE"] + process.env["VERDICT_FORCE_TOOL_CHOICE"] = "1" + const { Flag } = await import("../src/flag/flag") + expect(Flag.OPENCODE_TOOL_CHOICE).toBe("required") + } finally { + if (prev === undefined) delete process.env["OPENCODE_TOOL_CHOICE"] + else process.env["OPENCODE_TOOL_CHOICE"] = prev + if (prevForce === undefined) delete process.env["VERDICT_FORCE_TOOL_CHOICE"] + else process.env["VERDICT_FORCE_TOOL_CHOICE"] = prevForce + } + }) +})