Compel agents to keep acting until they explicitly finish

Forcing only the opening turn let a weak model take one token action and
stop, so a kickoff hired one agent (or none) and went quiet. Now every turn
is forced (tool_choice required) and the agent ends only by calling the new
`finish` tool — so it must keep hiring, delegating, and messaging until the
work is actually done. Finishing with zero actions, or returning no tool
call at all, fails the run loudly instead of faking success.

Prompts tell each agent it must call a tool every turn and finish only when
done. Tests drive weak-model stubs that act then finish, proving a full team
forms rather than a single gesture.

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 22:17:49 -04:00
1 parent ef9ee463c9
commit f7543e506d
7 files changed
+72 -50

No files matched your search

@@ -1,5 +1,5 @@
import type { ProviderService } from "./provider-service";
import { AGENT_TOOLS, dispatchAgentTool, type AgentToolContext, type AgentToolDeps } from "./agent-tools";
import { AGENT_TOOLS, dispatchAgentTool, FINISH_TOOL, type AgentToolContext, type AgentToolDeps } from "./agent-tools";
import type { AgentTurnMessage } from "./provider-tool-turn";
const MAX_ITERATIONS = 12;
@@ -46,30 +46,37 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn
instructions: input.instructions,
messages,
tools: AGENT_TOOLS,
// Compel a concrete action on the opening turn so even a weak model
// cannot answer with prose and quietly finish a no-op; once it has acted,
// let it choose freely (continue working, or stop when done).
toolChoice: iteration === 1 ? "required" : "auto",
// 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) {
if (iteration === 1) {
// The opening turn was forced to call a tool. None means the agent took
// no action at all — fail loudly instead of recording a deceptive
// "success" that hired no one, delegated nothing, and said nothing.
throw new Error("Agent took no action on its first turn — the model returned no tool calls.");
// 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.");
}
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.`);