diff --git a/packages/core/src/flag/tool-choice.ts b/packages/core/src/flag/tool-choice.ts new file mode 100644 index 0000000..01e31c0 --- /dev/null +++ b/packages/core/src/flag/tool-choice.ts @@ -0,0 +1,20 @@ +import { Flag } from "./flag" + +/** + * Resolve OpenAI-compatible tool_choice for one agent step. + * + * - Final max-steps turn always forces `none` so the model must answer in text. + * - Structured-output turns always force `required` (schema tool). + * - Otherwise honor OPENCODE_TOOL_CHOICE / VERDICT_FORCE_TOOL_CHOICE when set. + * - Default remains undefined (provider auto). + */ +export function resolveAgentToolChoice(input: { + readonly isLastStep: boolean + readonly structuredRequired?: boolean +}): "auto" | "required" | "none" | undefined { + if (input.isLastStep) return "none" + if (input.structuredRequired) return "required" + const flag = Flag.OPENCODE_TOOL_CHOICE + if (flag === "required" || flag === "auto" || flag === "none") return flag + return undefined +} diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 2bbdd60..9560d2c 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -11,7 +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 { resolveAgentToolChoice } from "../../flag/tool-choice" import { Database } from "../../database/database" import { EventV2 } from "../../event" import { Location } from "../../location" @@ -208,15 +208,8 @@ const layer = Layer.effect( tools: toolMaterialization?.definitions ?? [], // 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, + // Final max-steps exit still forces none via resolveAgentToolChoice. + toolChoice: resolveAgentToolChoice({ isLastStep }), }) if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) return yield* Effect.die(continueAfterCompaction(currentStep)) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 1c2dfe7..00dc815 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -116,7 +116,12 @@ const registryLayer = Layer.effect( settle: (input) => { const registration = registrations.get(input.call.name) if (registration) return settleWith(input, registration.identity) - return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } }) + const available = Array.from(registrations.keys()).slice(0, 48) + const value = + available.length > 0 + ? `Unknown tool: ${input.call.name}. Available tools (${available.length}): ${available.join(", ")}` + : `Unknown tool: ${input.call.name}` + return Effect.succeed({ result: { type: "error", value } }) }, } }), diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 82cd015..d73dde5 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -178,7 +178,10 @@ describe("ToolRegistry", () => { ...identity, call: { type: "tool-call", id: "missing", name: "missing", input: {} }, }), - ).toEqual({ type: "error", value: "Unknown tool: missing" }) + ).toMatchObject({ + type: "error", + value: expect.stringMatching(/^Unknown tool: missing\. Available tools \(\d+\): /), + }) yield* service.register({ defect: Tool.make({ diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 7fdf4df..487b826 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -2552,7 +2552,10 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "call-missing", - state: { status: "error", error: { message: "Unknown tool: missing" } }, + state: { + status: "error", + error: { message: expect.stringMatching(/^Unknown tool: missing\./) }, + }, }, ], }, @@ -2809,6 +2812,57 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("forces tool_choice required on non-final steps when OPENCODE_TOOL_CHOICE=required", () => + Effect.gen(function* () { + const prev = process.env["OPENCODE_TOOL_CHOICE"] + const prevForce = process.env["VERDICT_FORCE_TOOL_CHOICE"] + process.env["OPENCODE_TOOL_CHOICE"] = "required" + delete process.env["VERDICT_FORCE_TOOL_CHOICE"] + try { + yield* setup + const agents = yield* AgentV2.Service + yield* agents.transform((editor) => + editor.update(AgentV2.ID.make("build"), (agent) => { + agent.steps = 2 + }), + ) + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Require tools" }), resume: false }) + + requests.length = 0 + executions.length = 0 + responses = [ + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-required", name: "echo", input: { text: "done" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text-final" }), + LLMEvent.textDelta({ id: "text-final", text: "Done" }), + LLMEvent.textEnd({ id: "text-final" }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ], + ] + + yield* session.resume(sessionID) + + expect(requests).toHaveLength(2) + expect(requests[0]?.toolChoice).toMatchObject({ type: "required" }) + expect(requests[1]?.toolChoice).toMatchObject({ type: "none" }) + expect(executions).toEqual(["done"]) + } 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 + } + }), + ) + it.effect("forces a text response on an agent's configured final step", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/tool-choice-resolve.test.ts b/packages/core/test/tool-choice-resolve.test.ts new file mode 100644 index 0000000..edf6246 --- /dev/null +++ b/packages/core/test/tool-choice-resolve.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test" +import { resolveAgentToolChoice } from "../src/flag/tool-choice" + +describe("resolveAgentToolChoice", () => { + test("last step always forces none (max-steps early stop)", async () => { + const prev = process.env["OPENCODE_TOOL_CHOICE"] + const prevForce = process.env["VERDICT_FORCE_TOOL_CHOICE"] + try { + process.env["OPENCODE_TOOL_CHOICE"] = "required" + delete process.env["VERDICT_FORCE_TOOL_CHOICE"] + expect(resolveAgentToolChoice({ isLastStep: true })).toBe("none") + expect(resolveAgentToolChoice({ isLastStep: true, structuredRequired: true })).toBe("none") + } 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("structured output requires tool choice on non-final steps", async () => { + const prev = process.env["OPENCODE_TOOL_CHOICE"] + const prevForce = process.env["VERDICT_FORCE_TOOL_CHOICE"] + try { + delete process.env["OPENCODE_TOOL_CHOICE"] + delete process.env["VERDICT_FORCE_TOOL_CHOICE"] + expect(resolveAgentToolChoice({ isLastStep: false, structuredRequired: true })).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("OPENCODE_TOOL_CHOICE=required forces tool calls on non-final steps", 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" + expect(resolveAgentToolChoice({ isLastStep: false })).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 on non-final steps", 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" + expect(resolveAgentToolChoice({ isLastStep: false })).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("default remains undefined (provider auto)", async () => { + const prev = process.env["OPENCODE_TOOL_CHOICE"] + const prevForce = process.env["VERDICT_FORCE_TOOL_CHOICE"] + try { + delete process.env["OPENCODE_TOOL_CHOICE"] + delete process.env["VERDICT_FORCE_TOOL_CHOICE"] + expect(resolveAgentToolChoice({ isLastStep: false })).toBeUndefined() + } 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 + } + }) +}) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a99f8ac..aaccbfb 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -301,11 +301,20 @@ const live: Layer.Layer< toolName: lower, } } + // Surface a short available-tool list so weak local models can + // self-correct instead of looping on invented names until timeout. + const available = Object.keys(prepared.tools) + .filter((name) => name !== "invalid") + .slice(0, 48) + const hint = + available.length > 0 + ? ` Available tools (${available.length}): ${available.join(", ")}` + : "" return { ...failed.toolCall, input: JSON.stringify({ tool: failed.toolCall.toolName, - error: failed.error.message, + error: `${failed.error.message}.${hint}`, }), toolName: "invalid", } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6734a1f..03de3ba 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -17,6 +17,7 @@ import { SystemPrompt } from "./system" import { Instruction } from "./instruction" import { Plugin } from "../plugin" import { MAX_STEPS_PROMPT } from "@opencode-ai/core/session/runner/max-steps" +import { resolveAgentToolChoice } from "@opencode-ai/core/flag/tool-choice" import { ToolRegistry } from "@/tool/registry" import { MCP } from "../mcp" import { LSP } from "@/lsp/lsp" @@ -1281,7 +1282,13 @@ const layer = Layer.effect( ], tools, model, - toolChoice: format.type === "json_schema" ? "required" : undefined, + // Live `verdict run` path (caseforge investigate). m19 only wired + // OPENCODE_TOOL_CHOICE into the V2 runner; this SessionPrompt loop + // is what the binary actually uses for agent turns. + toolChoice: resolveAgentToolChoice({ + isLastStep, + structuredRequired: format.type === "json_schema", + }), }) if (structured !== undefined) { diff --git a/packages/opencode/src/tool/invalid.ts b/packages/opencode/src/tool/invalid.ts index b8d145d..2748d5f 100644 --- a/packages/opencode/src/tool/invalid.ts +++ b/packages/opencode/src/tool/invalid.ts @@ -14,7 +14,9 @@ export const InvalidTool = Tool.define( execute: (params: { tool: string; error: string }) => Effect.succeed({ title: "Invalid Tool", - output: `The arguments provided to the tool are invalid: ${params.error}`, + // Used for both unknown tool names (via experimental_repairToolCall) and + // schema failures — tell the model to retry with an exact advertised name. + output: `Tool call rejected for "${params.tool}": ${params.error}. Use an exact available tool name with valid arguments; do not invent tool names.`, metadata: {}, }), }),