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.`);
@@ -119,16 +119,8 @@ export const AGENT_TOOLS: ToolDefinition[] = [
required: ["title", "doc_type", "content"],
},
},
{
name: "finish",
description: "End your turn. Call this ONLY after you have taken every action this task needs — for a kickoff that means hiring your team, delegating their first tasks, and posting a kickoff message to a channel. Do not call it just to stop early.",
parameters: { type: "object", additionalProperties: false, properties: {}, required: [] },
},
];
/** Control tool that ends an agent's turn; the run loop consumes it, never dispatched. */
export const FINISH_TOOL = "finish";
export function dispatchAgentTool(deps: AgentToolDeps, ctx: AgentToolContext, call: ToolCall): string {
try {
switch (call.name) {
@@ -12,7 +12,7 @@ import type { ProviderRateLimitRegistry } from "./provider-rate-limit-registry";
import type { ProviderRequestStore } from "./provider-request-store";
import type { ProviderResponseObserver } from "./provider-response-observer";
import { hashPrompt, readProviderResponseId, requireStreamText } from "./provider-response-utils";
import { type AgentTurnMessage, type AgentTurnResult, type ToolChoice, type ToolDefinition, type ToolTurnFn } from "./provider-tool-turn";
import { type AgentTurnMessage, type AgentTurnResult, type ToolDefinition, type ToolTurnFn } from "./provider-tool-turn";
import { selectToolTurn, supportsToolTurn } from "./provider-tool-turns/router";
import { resolveTurnCredential } from "./provider-tool-turns/credential-resolver";
import {
@@ -103,7 +103,6 @@ export class ProviderResponseRunner {
instructions: string | null;
messages: AgentTurnMessage[];
tools: ToolDefinition[];
toolChoice?: ToolChoice;
}): Promise<AgentTurnResult & { requestId: string }> {
const provider = loadConfiguredProvider(this.deps.db, input.orgId, input.providerId);
const workspaceId = this.normalizeWorkspaceId(input.orgId, input.workspaceId ?? null);
@@ -129,7 +128,7 @@ export class ProviderResponseRunner {
});
const startedAt = Date.now();
try {
const result = await turn({ credential, model, instructions: input.instructions, messages: input.messages, tools: input.tools, toolChoice: input.toolChoice });
const result = await turn({ credential, model, instructions: input.instructions, messages: input.messages, tools: input.tools });
this.deps.requestStore.markSucceeded({
orgId: input.orgId,
requestId,
@@ -51,18 +51,12 @@ export interface TurnCredential {
serviceAccountJson?: string;
}
/** How the backend should treat tool calls this turn. "required" compels the
* model to emit at least one tool call (no prose-only escape) — used to force a
* concrete action so a weak model cannot ramble its way to a silent no-op. */
export type ToolChoice = "auto" | "required";
export interface ProviderToolTurnInput {
credential: TurnCredential;
model: string;
instructions: string | null;
messages: AgentTurnMessage[];
tools: ToolDefinition[];
toolChoice?: ToolChoice;
}
export type ToolTurnFn = (input: ProviderToolTurnInput) => Promise<AgentTurnResult>;
@@ -1,11 +1,11 @@
/** AWS Bedrock Converse API encoding/decoding (ported from Pi's bedrock handler). */
import { isRecord, type JsonRecord } from "../provider-http";
import type { AgentTurnMessage, ToolCall, ToolChoice, ToolDefinition } from "../provider-tool-turn";
import type { AgentTurnMessage, ToolCall, ToolDefinition } from "../provider-tool-turn";
export function bedrockToolConfig(tools: ToolDefinition[], toolChoice?: ToolChoice): JsonRecord {
export function bedrockToolConfig(tools: ToolDefinition[]): JsonRecord {
return {
tools: tools.map((tool) => ({ toolSpec: { name: tool.name, description: tool.description, inputSchema: { json: tool.parameters } } })),
toolChoice: toolChoice === "required" ? { any: {} } : { auto: {} },
toolChoice: { auto: {} },
};
}
@@ -4,7 +4,7 @@
* Antigravity request wrapper in google-gemini-cli.
*/
import { isRecord, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http";
import { type AgentTurnMessage, type AgentTurnResult, type ToolCall, type ToolChoice, type ToolDefinition } from "../provider-tool-turn";
import { type AgentTurnMessage, type AgentTurnResult, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
const ANTIGRAVITY_SYSTEM_INSTRUCTION =
"You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding." +
@@ -64,7 +64,6 @@ export function antigravityRequestBody(input: {
instructions: string | null;
messages: AgentTurnMessage[];
tools: ToolDefinition[];
toolChoice?: ToolChoice;
requestId: string;
}): JsonRecord {
const request: JsonRecord = {
@@ -80,7 +79,7 @@ export function antigravityRequestBody(input: {
};
if (input.tools.length > 0) {
request.tools = geminiToolDefs(input.model, input.tools);
request.toolConfig = { functionCallingConfig: { mode: input.toolChoice === "required" ? "ANY" : "AUTO" } };
request.toolConfig = { functionCallingConfig: { mode: "AUTO" } };
}
return { project: input.projectId, model: input.model, request, requestType: "agent", userAgent: "antigravity", requestId: input.requestId };
}
@@ -91,13 +90,12 @@ export function vertexRequestBody(input: {
instructions: string | null;
messages: AgentTurnMessage[];
tools: ToolDefinition[];
toolChoice?: ToolChoice;
}): JsonRecord {
const body: JsonRecord = { contents: toGeminiContents(input.model, input.messages) };
if (input.instructions) body.systemInstruction = { parts: [{ text: input.instructions }] };
if (input.tools.length > 0) {
body.tools = geminiToolDefs(input.model, input.tools);
body.toolConfig = { functionCallingConfig: { mode: input.toolChoice === "required" ? "ANY" : "AUTO" } };
body.toolConfig = { functionCallingConfig: { mode: "AUTO" } };
}
return body;
}
@@ -41,7 +41,7 @@ export async function runOpenAiResponsesTurn(input: ProviderToolTurnInput): Prom
model: input.model,
input: toResponsesInput(input.messages),
tools: responsesToolDefs(input.tools),
tool_choice: input.toolChoice ?? "auto",
tool_choice: "auto",
store: false,
...(input.instructions ? { instructions: input.instructions } : {}),
};
@@ -58,7 +58,7 @@ export function makeChatCompletionsTurn(endpoint: string, label: string): (input
model: input.model,
messages: toChatMessages(input.instructions, input.messages),
tools: chatToolDefs(input.tools),
tool_choice: input.toolChoice ?? "auto",
tool_choice: "auto",
stream: false,
};
const response = await postJson(endpoint, { authorization: `Bearer ${requireApiKey(input.credential, label)}` }, body);
@@ -74,7 +74,6 @@ export async function runAnthropicTurn(input: ProviderToolTurnInput): Promise<Ag
model: input.model,
max_tokens: 4096,
tools: anthropicToolDefs(input.tools),
...(input.toolChoice === "required" ? { tool_choice: { type: "any" } } : {}),
messages: toAnthropicMessages(input.messages),
...(input.instructions ? { system: input.instructions } : {}),
};
@@ -94,7 +93,6 @@ export async function runClaudeProTurn(input: ProviderToolTurnInput): Promise<Ag
model: input.model,
max_tokens: 4096,
tools: anthropicToolDefs(input.tools),
...(input.toolChoice === "required" ? { tool_choice: { type: "any" } } : {}),
messages: toAnthropicMessages(input.messages),
system,
};
@@ -123,7 +121,7 @@ export async function runCodexTurn(input: ProviderToolTurnInput): Promise<AgentT
input: toResponsesInput(input.messages),
text: { verbosity: "medium" },
include: ["reasoning.encrypted_content"],
tool_choice: input.toolChoice ?? "auto",
tool_choice: "auto",
parallel_tool_calls: true,
tools: responsesToolDefs(input.tools, null),
};
@@ -146,7 +144,7 @@ export async function runCopilotTurn(input: ProviderToolTurnInput): Promise<Agen
model: input.model,
input: toResponsesInput(input.messages),
tools: responsesToolDefs(input.tools),
tool_choice: input.toolChoice ?? "auto",
tool_choice: "auto",
store: false,
};
const response = await postJson(`${baseUrl}/responses`, {
@@ -167,7 +165,7 @@ export async function runCustomOpenAiTurn(input: ProviderToolTurnInput): Promise
model: input.model,
messages: toChatMessages(input.instructions, input.messages),
tools: chatToolDefs(input.tools),
tool_choice: input.toolChoice ?? "auto",
tool_choice: "auto",
stream: false,
};
const response = await postJson(`${input.credential.baseUrl}/chat/completions`, { authorization: `Bearer ${requireApiKey(input.credential, "Custom OpenAI-compatible provider")}` }, body);
@@ -183,7 +181,6 @@ export async function runCustomAnthropicTurn(input: ProviderToolTurnInput): Prom
model: input.model,
max_tokens: 4096,
tools: anthropicToolDefs(input.tools),
...(input.toolChoice === "required" ? { tool_choice: { type: "any" } } : {}),
messages: toAnthropicMessages(input.messages),
...(input.instructions ? { system: input.instructions } : {}),
};
@@ -204,7 +201,6 @@ export async function runAntigravityTurn(input: ProviderToolTurnInput): Promise<
instructions: input.instructions,
messages: input.messages,
tools: input.tools,
toolChoice: input.toolChoice,
requestId,
});
const response = await postEventStream(`${ANTIGRAVITY_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`, {
@@ -224,7 +220,7 @@ export async function runAzureTurn(input: ProviderToolTurnInput): Promise<AgentT
model: input.credential.deployment,
messages: toChatMessages(input.instructions, input.messages),
tools: chatToolDefs(input.tools),
tool_choice: input.toolChoice ?? "auto",
tool_choice: "auto",
stream: false,
};
const url = `${input.credential.baseUrl}/openai/deployments/${input.credential.deployment}/chat/completions?api-version=2024-10-21`;
@@ -241,7 +237,7 @@ export async function runBedrockTurn(input: ProviderToolTurnInput): Promise<Agen
const converse: JsonRecord = {
messages: toBedrockMessages(input.messages),
inferenceConfig: { maxTokens: 4096 },
toolConfig: bedrockToolConfig(input.tools, input.toolChoice),
toolConfig: bedrockToolConfig(input.tools),
...(input.instructions ? { system: [{ text: input.instructions }] } : {}),
};
const bodyString = JSON.stringify(converse);
@@ -263,7 +259,7 @@ export async function runVertexTurn(input: ProviderToolTurnInput): Promise<Agent
const { projectId, region, serviceAccountJson } = input.credential;
if (!projectId || !region || !serviceAccountJson) throw new Error("Vertex AI credential is incomplete.");
const token = await getGcpAccessToken(serviceAccountJson);
const body = vertexRequestBody({ model: input.model, instructions: input.instructions, messages: input.messages, tools: input.tools, toolChoice: input.toolChoice });
const body = vertexRequestBody({ model: input.model, instructions: input.instructions, messages: input.messages, tools: input.tools });
const url = `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${input.model}:generateContent`;
const response = await postJson(url, { authorization: `Bearer ${token}` }, body);
const parsed = parseGeminiResponse(response.data);
@@ -77,7 +77,6 @@ export function buildAgentInstructions(agent: TaskExecutionAgentRow): string {
"You act by calling tools: hire_agent staffs your team, create_task delegates work and assigns it by agent name, update_task_status tracks progress, write_deliverable produces real files in the company workspace, create_document produces versioned company documents (PRDs, specs, memos, reports), and send_message posts coordination messages to a channel with optional file attachments.",
"Communicate like a real team. Post to the channel that matches the work — 'engineering' for engineering, 'product' for product or design, otherwise 'general'. Use @mentions when addressing a teammate. When you hand work or files to a teammate, use send_message with /handoff @Name and attachment_file_ids containing the file_id returned by write_deliverable. To keep a hand-off as one conversation, reply in the same thread by passing the thread_id a previous send_message returned.",
"Take concrete action with the tools and reserve narrative for brief summaries after coordination is posted.",
"Every turn you must call a tool — never reply with prose alone. When, and only when, you have completed everything the task needs, call finish to end your turn.",
].join("\n");
}
@@ -145,14 +144,14 @@ export function buildTaskPrompt(input: {
if (input.isManager && !input.hasReports) {
lines.push(
"",
"Your team is currently empty. Hire 3-5 direct reports for the roles this goal needs (hire_agent), create and assign their first tasks (create_task), and write the initial operating plan into the workspace (write_deliverable). Then use send_message with /handoff @Name and attachment_file_ids to pass the operating plan to the relevant report. For EACH report, send_message to that report's department channel (engineering, product, or general) using an @mention and the work you assigned them — start a new thread per report and note the thread_id it returns. Post the company-wide kickoff to #general. Then call finish.",
"Your team is currently empty. Hire 3-5 direct reports for the roles this goal needs (hire_agent), create and assign their first tasks (create_task), and write the initial operating plan into the workspace (write_deliverable). Then use send_message with /handoff @Name and attachment_file_ids to pass the operating plan to the relevant report. For EACH report, send_message to that report's department channel (engineering, product, or general) using an @mention and the work you assigned them — start a new thread per report and note the thread_id it returns. Post the company-wide kickoff to #general. Then summarize.",
);
} else {
const manager = input.managerName ?? "your manager";
const managerReference = input.managerName ? `${manager} (${agentMention(input.managerName)})` : manager;
lines.push(
"",
`Complete this task: do the work, write your output as real files with write_deliverable, then send_message to your department channel (engineering, product, or general) reporting your progress and completion to ${managerReference}. Use /handoff with attachment_file_ids when passing files. Reply in the assignment thread if you were given a thread_id. Update the task status when done, then call finish.`,
`Complete this task: do the work, write your output as real files with write_deliverable, then send_message to your department channel (engineering, product, or general) reporting your progress and completion to ${managerReference}. Use /handoff with attachment_file_ids when passing files. Reply in the assignment thread if you were given a thread_id. Update the task status when done, and summarize.`,
);
}
return lines.join("\n");