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.`);
@@ -119,8 +119,16 @@ 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) {
@@ -77,6 +77,7 @@ 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");
}
@@ -144,14 +145,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 summarize.",
"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.",
);
} 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, and summarize.`,
`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.`,
);
}
return lines.join("\n");
@@ -37,7 +37,7 @@ function scriptedTeam(): (input: ProviderToolTurnInput) => Promise<AgentTurnResu
call("create_task", { title: "Build the booking API", description: "Ship the booking API.", assignee_agent_name: "Dana Lee" }),
call("send_message", { channel: "engineering", body: `@${handle} you own the booking API — kicking off here.` }),
] },
{ text: "Kickoff complete.", toolCalls: [] },
{ text: "Kickoff complete.", toolCalls: [call("finish", {})] },
];
return async (input: ProviderToolTurnInput): Promise<AgentTurnResult> => {
if ((input.instructions ?? "").includes("Dana Lee")) {
@@ -46,11 +46,11 @@ function scriptedTeam(): (input: ProviderToolTurnInput) => Promise<AgentTurnResu
const threadId = firstUser ? /thread_id="([^"]+)"/.exec(firstUser.text)?.[1] : undefined;
const turn = dana === 0
? { text: "On it.", toolCalls: [call("send_message", { channel: "engineering", body: "On it — booking API design starting now.", ...(threadId ? { thread_id: threadId } : {}) })] }
: { text: "Replied.", toolCalls: [] };
: { text: "Replied.", toolCalls: [call("finish", {})] };
dana += 1;
return { text: turn.text, toolCalls: turn.toolCalls, rawResponseJson: "{}" };
}
const turn = ceoScript[ceo] ?? { text: "done", toolCalls: [] };
const turn = ceoScript[ceo] ?? { text: "done", toolCalls: [call("finish", {})] };
ceo += 1;
return { text: turn.text, toolCalls: turn.toolCalls, rawResponseJson: "{}" };
};
@@ -36,7 +36,7 @@ function scriptedCeo(): (input: ProviderToolTurnInput) => Promise<AgentTurnResul
call("write_deliverable", { path: "plan/operating-plan.md", content: "# OrbitOps Operating Plan\n\n- Validate demand\n- Launch booking site\n" }),
] },
{ text: "Handing off the plan.", toolCalls: [] },
{ text: "Team staffed, work delegated, plan written.", toolCalls: [] },
{ text: "Team staffed, work delegated, plan written.", toolCalls: [call("finish", {})] },
];
let index = 0;
return async (input) => {
@@ -55,7 +55,7 @@ function scriptedCeo(): (input: ProviderToolTurnInput) => Promise<AgentTurnResul
rawResponseJson: JSON.stringify({ id: `resp_${index}` }),
};
}
const turn = script[index] ?? { text: "done", toolCalls: [] };
const turn = script[index] ?? { text: "done", toolCalls: [call("finish", {})] };
index += 1;
return { text: turn.text, toolCalls: turn.toolCalls, rawResponseJson: JSON.stringify({ id: `resp_${index}` }) };
};
@@ -169,14 +169,13 @@ describe("autonomous bootstrap run", () => {
// The forced opening turn means each run must take a concrete action; it
// posts to the company channel, then stops on the next (free) turn.
const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, async (input) => {
if (input.toolChoice === "required") {
return {
text: "Reviewing the goal.",
toolCalls: [call("send_message", { channel: "general", body: "Reviewed the goal and posting the operating plan." })],
rawResponseJson: JSON.stringify({ id: "resp_goal_act" }),
};
}
return { text: "Done.", toolCalls: [], rawResponseJson: JSON.stringify({ id: "resp_goal_done" }) };
const acted = input.messages.some((message) => message.role === "tool");
if (acted) return { text: "Done.", toolCalls: [call("finish", {})], rawResponseJson: JSON.stringify({ id: "resp_goal_done" }) };
return {
text: "Reviewing the goal.",
toolCalls: [call("send_message", { channel: "general", body: "Reviewed the goal and posting the operating plan." })],
rawResponseJson: JSON.stringify({ id: "resp_goal_act" }),
};
});
const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
const organization = new OrganizationService(connection.db);
@@ -19,7 +19,8 @@ function fakeProvider(script: ScriptedTurn[]) {
let index = 0;
return {
runToolTurn: async (): Promise<AgentTurnResult & { requestId: string }> => {
const turn = script[index] ?? { text: "done", toolCalls: [] };
// A run never ends on prose now; an exhausted script ends by calling finish.
const turn = script[index] ?? { text: "done", toolCalls: [call("finish", {})] };
index += 1;
return { text: turn.text, toolCalls: turn.toolCalls, rawResponseJson: "{}", requestId: `req_${index}` };
},
@@ -33,14 +34,18 @@ function call(name: string, args: Record<string, unknown>): ToolCall {
/** 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 forced = false;
let acted = false;
return {
runToolTurn: async (input: { toolChoice?: "auto" | "required" }): Promise<AgentTurnResult & { requestId: string }> => {
if (input.toolChoice === "required" && !forced) {
forced = true;
return { text: "Acting.", toolCalls: [call("send_message", { channel: "general", body: "Kicking off the company." })], rawResponseJson: "{}", requestId: "req_forced" };
// 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" };
}
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" };
},
};
}
@@ -92,7 +97,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: [] },
{ text: "Team staffed, work delegated, plan written.", toolCalls: [call("finish", {})] },
];
let toolResultNotifications = 0;
@@ -181,7 +186,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: [] },
{ text: "done", toolCalls: [call("finish", {})] },
];
const result = await runAgentLoop(
{ providers: fakeProvider(script), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } },
@@ -213,7 +218,7 @@ describe("agent run loop (real tool dispatch)", () => {
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) } },
{ orgId, providerId, workspaceId: null, instructions: "i", initialUserTurn: "u", toolContext: ctx },
)).rejects.toThrow(/no action on its first turn/);
)).rejects.toThrow(/no tool call on a forced turn/);
connection.close();
});
});
@@ -37,10 +37,11 @@ describe("task execution service", () => {
const fakeToolTurn = async (input: ProviderToolTurnInput): Promise<AgentTurnResult> => {
const first = input.messages[0];
if (first?.role === "user") capturedPrompt = first.text;
if (input.toolChoice === "required") {
return { text: "executed:gpt-5.4", toolCalls: [{ id: `call_${randomUUID()}`, name: "create_document", arguments: { title: "Run note", doc_type: "memo", content: "Executed." } }], rawResponseJson: JSON.stringify({ id: "resp-task-1a" }) };
}
return { text: "executed:gpt-5.4", toolCalls: [], rawResponseJson: JSON.stringify({ id: "resp-task-1b" }) };
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" }) };
};
const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, fakeToolTurn);
const skills = new SkillService(connection.db, join(tmpdir(), `agentcompany-task-skills-${randomUUID()}`));
@@ -145,10 +146,11 @@ describe("task execution service", () => {
const approvals = new ApprovalService(connection.db);
const snapshot = new SnapshotService(connection.db);
const fakeToolTurn = async (input: ProviderToolTurnInput): Promise<AgentTurnResult> => {
if (input.toolChoice === "required") {
return { text: "executed:gpt-5.4", toolCalls: [{ id: `call_${randomUUID()}`, name: "create_document", arguments: { title: "Run note", doc_type: "memo", content: "Executed." } }], rawResponseJson: JSON.stringify({ id: "resp-task-approved-a" }) };
}
return { text: "executed:gpt-5.4", toolCalls: [], rawResponseJson: JSON.stringify({ id: "resp-task-approved-b" }) };
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 providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, fakeToolTurn);
const execution = new TaskExecutionService(connection.db, providers);