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

@@ -19,8 +19,7 @@ function fakeProvider(script: ScriptedTurn[]) {
let index = 0;
return {
runToolTurn: async (): Promise<AgentTurnResult & { requestId: string }> => {
// A run never ends on prose now; an exhausted script ends by calling finish.
const turn = script[index] ?? { text: "done", toolCalls: [call("finish", {})] };
const turn = script[index] ?? { text: "done", toolCalls: [] };
index += 1;
return { text: turn.text, toolCalls: turn.toolCalls, rawResponseJson: "{}", requestId: `req_${index}` };
},
@@ -31,25 +30,6 @@ function call(name: string, args: Record<string, unknown>): ToolCall {
return { id: `call_${randomUUID()}`, name, arguments: args };
}
/** Mimics a weak model: it rambles in prose and skips tools unless the backend
* compels a tool call. With forcing it posts a kickoff message, then stops. */
function weakModelProvider() {
let acted = false;
return {
runToolTurn: async (input: { toolChoice?: "auto" | "required" }): Promise<AgentTurnResult & { requestId: string }> => {
// Left to itself this model rambles (no tools); compelled, it posts, then ends.
if (input.toolChoice !== "required") {
return { text: "Let me think about this some more.", toolCalls: [], rawResponseJson: "{}", requestId: "req_prose" };
}
if (!acted) {
acted = true;
return { text: "Acting.", toolCalls: [call("send_message", { channel: "general", body: "Kicking off the company." })], rawResponseJson: "{}", requestId: "req_act" };
}
return { text: "Done.", toolCalls: [call("finish", {})], rawResponseJson: "{}", requestId: "req_finish" };
},
};
}
describe("agent run loop (real tool dispatch)", () => {
test("the CEO hires, creates a task, writes a deliverable, then converges", async () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-loop-${randomUUID()}.db`));
@@ -97,7 +77,7 @@ describe("agent run loop (real tool dispatch)", () => {
call("write_deliverable", { path: "plan/operating-plan.md", content: "# Operating Plan\n\n1. Validate demand.\n2. Launch booking site.\n" }),
call("send_message", { channel: "general", body: "Team staffed and the operating plan is live. Booking site is next." }),
] },
{ text: "Team staffed, work delegated, plan written.", toolCalls: [call("finish", {})] },
{ text: "Team staffed, work delegated, plan written.", toolCalls: [] },
];
let toolResultNotifications = 0;
@@ -140,33 +120,6 @@ describe("agent run loop (real tool dispatch)", () => {
connection.close();
});
test("forcing the opening turn makes a weak model act instead of completing a no-op", async () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-weak-${randomUUID()}.db`));
const organization = new OrganizationService(connection.db);
let orgId = "";
let ceoId = "";
let providerId = "";
connection.transaction(() => {
orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" });
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
ceoId = organization.createAgent({ orgId, name: "CEO", role: "CEO", department: "Executive", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null });
});
const ctx: AgentToolContext = {
orgId, agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" },
taskProjectId: null, taskGoalId: null, workspace: null,
};
const result = await runAgentLoop(
{ providers: weakModelProvider(), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } },
{ orgId, providerId, workspaceId: null, instructions: "You are the CEO.", initialUserTurn: "Start the company.", toolContext: ctx },
);
// The loop forced the opening turn, so the otherwise-rambling model acted.
expect(result.toolCallCount).toBe(1);
const messages = connection.db.prepare("select sender_id from chat_messages where org_id = ?").all(orgId) as Array<{ sender_id: string }>;
expect(messages).toHaveLength(1);
expect(messages[0]?.sender_id).toBe(ceoId);
connection.close();
});
test("rejects deliverable paths that escape the workspace", async () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-loop2-${randomUUID()}.db`));
const organization = new OrganizationService(connection.db);
@@ -186,7 +139,7 @@ describe("agent run loop (real tool dispatch)", () => {
};
const script: ScriptedTurn[] = [
{ text: "escape", toolCalls: [call("write_deliverable", { path: "../escape.txt", content: "x" })] },
{ text: "done", toolCalls: [call("finish", {})] },
{ text: "done", toolCalls: [] },
];
const result = await runAgentLoop(
{ providers: fakeProvider(script), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } },
@@ -198,7 +151,7 @@ describe("agent run loop (real tool dispatch)", () => {
connection.close();
});
test("a first turn that takes no action (prose only, no tool calls) fails the run", async () => {
test("a first turn with no text and no tool calls fails the run instead of completing a no-op", async () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-loop3-${randomUUID()}.db`));
const organization = new OrganizationService(connection.db);
let orgId = "";
@@ -213,12 +166,10 @@ describe("agent run loop (real tool dispatch)", () => {
orgId, agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" },
taskProjectId: null, taskGoalId: null, workspace: null,
};
// A weak model that answers the kickoff with prose and zero tool calls must
// not be recorded as a success — the forced first turn produced no action.
await expect(runAgentLoop(
{ providers: fakeProvider([{ text: "Here is my plan: first I will think about it.", toolCalls: [] }]), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } },
{ providers: fakeProvider([{ text: "", toolCalls: [] }]), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } },
{ orgId, providerId, workspaceId: null, instructions: "i", initialUserTurn: "u", toolContext: ctx },
)).rejects.toThrow(/no tool call on a forced turn/);
)).rejects.toThrow(/empty turn/);
connection.close();
});
});
@@ -210,22 +210,13 @@ describe("credential resolver", () => {
});
});
describe("tool definitions and forced tool choice", () => {
describe("responses tool definitions", () => {
const tools = [{ name: "hire_agent", description: "Add a report.", parameters: { type: "object", properties: {} } }];
test("Responses tool defs omit strict when null, keep it when boolean", () => {
test("omit strict when null (so the Codex endpoint keeps the tools), keep it when boolean", () => {
const codex = responsesToolDefs(tools, null)[0] as Record<string, unknown>;
expect("strict" in codex).toBe(false); // null would make the endpoint drop the tools
expect("strict" in codex).toBe(false); // strict:null is malformed and makes the endpoint drop the tools
expect(responsesToolDefs(tools)[0]).toMatchObject({ type: "function", name: "hire_agent", strict: false });
expect(responsesToolDefs(tools, true)[0]).toMatchObject({ strict: true });
});
test("Bedrock and Gemini encode a forced tool choice", () => {
expect(bedrockToolConfig(tools, "required").toolChoice).toEqual({ any: {} });
expect(bedrockToolConfig(tools).toolChoice).toEqual({ auto: {} });
const vertex = vertexRequestBody({ model: "m", instructions: null, messages: [], tools, toolChoice: "required" });
expect(vertex.toolConfig).toEqual({ functionCallingConfig: { mode: "ANY" } });
const anti = antigravityRequestBody({ model: "m", projectId: "p", instructions: null, messages: [], tools, requestId: "r" });
expect((anti.request as Record<string, unknown>).toolConfig).toEqual({ functionCallingConfig: { mode: "AUTO" } });
});
});
@@ -37,11 +37,7 @@ describe("task execution service", () => {
const fakeToolTurn = async (input: ProviderToolTurnInput): Promise<AgentTurnResult> => {
const first = input.messages[0];
if (first?.role === "user") capturedPrompt = first.text;
const acted = input.messages.some((message) => message.role === "tool");
const toolCall = acted
? { id: `call_${randomUUID()}`, name: "finish", arguments: {} }
: { id: `call_${randomUUID()}`, name: "create_document", arguments: { title: "Run note", doc_type: "memo", content: "Executed." } };
return { text: "executed:gpt-5.4", toolCalls: [toolCall], rawResponseJson: JSON.stringify({ id: "resp-task-1" }) };
return { text: "executed:gpt-5.4", toolCalls: [], rawResponseJson: JSON.stringify({ id: "resp-task-1" }) };
};
const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, fakeToolTurn);
const skills = new SkillService(connection.db, join(tmpdir(), `agentcompany-task-skills-${randomUUID()}`));
@@ -145,13 +141,11 @@ describe("task execution service", () => {
const organization = new OrganizationService(connection.db);
const approvals = new ApprovalService(connection.db);
const snapshot = new SnapshotService(connection.db);
const fakeToolTurn = async (input: ProviderToolTurnInput): Promise<AgentTurnResult> => {
const acted = input.messages.some((message) => message.role === "tool");
const toolCall = acted
? { id: `call_${randomUUID()}`, name: "finish", arguments: {} }
: { id: `call_${randomUUID()}`, name: "create_document", arguments: { title: "Run note", doc_type: "memo", content: "Executed." } };
return { text: "executed:gpt-5.4", toolCalls: [toolCall], rawResponseJson: JSON.stringify({ id: "resp-task-approved" }) };
};
const fakeToolTurn = async (): Promise<AgentTurnResult> => ({
text: "executed:gpt-5.4",
toolCalls: [],
rawResponseJson: JSON.stringify({ id: "resp-task-approved" }),
});
const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, fakeToolTurn);
const execution = new TaskExecutionService(connection.db, providers);
let orgId = "";