Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/core/src/flag/tool-choice.ts
Original file line number Diff line number Diff line change
@@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve required tool choice for structured output

When a JSON-schema response runs on an agent's final allowed step (for example steps: 1, or after tool continuations consume the step budget), this returns none before checking structuredRequired. The live SessionPrompt path still adds the StructuredOutput tool and later treats a normal text finish as StructuredOutputError, so these requests can no longer succeed even though they previously forced toolChoice: "required" for all json_schema turns.

Useful? React with 👍 / 👎.

if (input.structuredRequired) return "required"
const flag = Flag.OPENCODE_TOOL_CHOICE
if (flag === "required" || flag === "auto" || flag === "none") return flag
return undefined
}
13 changes: 3 additions & 10 deletions packages/core/src/session/runner/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } })
},
}
}),
Expand Down
5 changes: 4 additions & 1 deletion packages/core/test/session-runner-tool-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
56 changes: 55 additions & 1 deletion packages/core/test/session-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\./) },
},
},
],
},
Expand Down Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions packages/core/test/tool-choice-resolve.test.ts
Original file line number Diff line number Diff line change
@@ -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
}
})
})
11 changes: 10 additions & 1 deletion packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
9 changes: 8 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/tool/invalid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
}),
}),
Expand Down
Loading