Revert tool-choice forcing — the Codex regression was strict:null

The GPT-account (Codex) path supports tool calls and worked before; my
forced tool_choice broke it and was the wrong fix. The real regression was
strict:null in the rebuild's tool definitions, which made the endpoint drop
the tool list so the model had nothing to call — a "Done" run that hired no
one. Forcing then turned that silent failure into a red one.

Restore the original "auto" loop (no forced tool_choice, no finish tool) and
keep only the one-line strict:null omission. Codex now receives valid tools
on the working "auto" choice, as in the version that worked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.8 committed 2026-06-10 23:17:42 -04:00
1 parent 74422da4f8
commit 97242ae684
13 files changed
+57 -160

No files matched your search

@@ -1,5 +1,5 @@
import type { ProviderService } from "./provider-service";
import { AGENT_TOOLS, dispatchAgentTool, FINISH_TOOL, type AgentToolContext, type AgentToolDeps } from "./agent-tools";
import { AGENT_TOOLS, dispatchAgentTool, type AgentToolContext, type AgentToolDeps } from "./agent-tools";
import type { AgentTurnMessage } from "./provider-tool-turn";
const MAX_ITERATIONS = 12;
@@ -46,37 +46,26 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn
instructions: input.instructions,
messages,
tools: AGENT_TOOLS,
// Compel a tool call on every turn so a weak model cannot answer with
// prose and stop after a token gesture. The agent ends deliberately by
// calling `finish` — only after it has actually done the work.
toolChoice: "required",
});
lastRequestId = turn.requestId;
if (turn.toolCalls.length === 0) {
// The provider ignored the forced tool choice; treat it as no action.
throw new Error("Agent returned no tool call on a forced turn.");
}
const wantsFinish = turn.toolCalls.some((call) => call.name === FINISH_TOOL);
const actionable = turn.toolCalls.filter((call) => call.name !== FINISH_TOOL);
if (actionable.length > 0) {
messages.push({ role: "assistant", text: turn.text, toolCalls: actionable });
const results = actionable.map((call) => {
toolCallCount += 1;
return { toolCallId: call.id, toolName: call.name, output: dispatchAgentTool(deps.tools, input.toolContext, call) };
});
messages.push({ role: "tool", results });
deps.onToolResults?.();
}
if (wantsFinish) {
if (toolCallCount === 0) {
// Finishing before doing anything is the silent no-op we must reject.
throw new Error("Agent finished without taking any action.");
if (toolCallCount === 0 && !turn.text.trim()) {
// A first turn with neither text nor tool calls means the provider
// produced nothing actionable (e.g. output truncated mid-reasoning);
// completing the run would silently mark a no-op as done.
throw new Error("Provider returned an empty turn: no text and no tool calls.");
}
return { finalText: turn.text, iterations: iteration, toolCallCount, lastRequestId };
}
messages.push({ role: "assistant", text: turn.text, toolCalls: turn.toolCalls });
const results = turn.toolCalls.map((call) => {
toolCallCount += 1;
return { toolCallId: call.id, toolName: call.name, output: dispatchAgentTool(deps.tools, input.toolContext, call) };
});
messages.push({ role: "tool", results });
deps.onToolResults?.();
}
throw new Error(`Agent run did not converge within ${MAX_ITERATIONS} iterations.`);