From ef9ee463c9074c8d70bce899f57611fc1c1d56e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 10 Jun 2026 21:43:49 -0400 Subject: [PATCH] Force agents to act so weak models can't complete a no-op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole premise is cheap models + skills + a team — so the control plane, not the model, must guarantee action. Every adapter hardcoded tool_choice:"auto", letting a weak model answer the kickoff with prose and "succeed" having hired no one, delegated nothing, and said nothing. Now the agent loop compels a tool call on the opening turn (tool_choice plumbed through every backend: OpenAI/Responses/Chat, Anthropic, Codex, Copilot, Azure, Custom, Gemini ANY mode, Bedrock any). If the forced turn still yields no tool call, the run fails loudly instead of recording a deceptive success — surfacing the problem (let it crash) rather than a silent dead company. After it has acted, the model chooses freely. Tested: a stub that rambles on "auto" but acts when forced now posts a kickoff message; a prose-only first turn fails the run. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/main/services/agent-run-loop.ts | 14 ++++-- .../main/services/provider-response-runner.ts | 5 +- .../src/main/services/provider-tool-turn.ts | 6 +++ .../provider-tool-turns/bedrock-format.ts | 6 +-- .../provider-tool-turns/gemini-format.ts | 8 +-- .../services/provider-tool-turns/turns.ts | 20 +++++--- .../tests/integration/autonomous-run.test.ts | 17 +++++-- .../tests/unit/agent-run-loop.test.ts | 50 +++++++++++++++++-- .../tests/unit/task-execution-service.test.ts | 16 +++--- 9 files changed, 107 insertions(+), 35 deletions(-) diff --git a/AgentCompany-New/src/main/services/agent-run-loop.ts b/AgentCompany-New/src/main/services/agent-run-loop.ts index 953ec254..cadbd83c 100644 --- a/AgentCompany-New/src/main/services/agent-run-loop.ts +++ b/AgentCompany-New/src/main/services/agent-run-loop.ts @@ -46,15 +46,19 @@ 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", }); lastRequestId = turn.requestId; if (turn.toolCalls.length === 0) { - 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."); + 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."); } return { finalText: turn.text, iterations: iteration, toolCallCount, lastRequestId }; } diff --git a/AgentCompany-New/src/main/services/provider-response-runner.ts b/AgentCompany-New/src/main/services/provider-response-runner.ts index 22e81771..57121c4c 100644 --- a/AgentCompany-New/src/main/services/provider-response-runner.ts +++ b/AgentCompany-New/src/main/services/provider-response-runner.ts @@ -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 ToolDefinition, type ToolTurnFn } from "./provider-tool-turn"; +import { type AgentTurnMessage, type AgentTurnResult, type ToolChoice, 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,6 +103,7 @@ export class ProviderResponseRunner { instructions: string | null; messages: AgentTurnMessage[]; tools: ToolDefinition[]; + toolChoice?: ToolChoice; }): Promise { const provider = loadConfiguredProvider(this.deps.db, input.orgId, input.providerId); const workspaceId = this.normalizeWorkspaceId(input.orgId, input.workspaceId ?? null); @@ -128,7 +129,7 @@ export class ProviderResponseRunner { }); const startedAt = Date.now(); try { - const result = await turn({ credential, model, instructions: input.instructions, messages: input.messages, tools: input.tools }); + const result = await turn({ credential, model, instructions: input.instructions, messages: input.messages, tools: input.tools, toolChoice: input.toolChoice }); this.deps.requestStore.markSucceeded({ orgId: input.orgId, requestId, diff --git a/AgentCompany-New/src/main/services/provider-tool-turn.ts b/AgentCompany-New/src/main/services/provider-tool-turn.ts index 867db307..90f3541d 100644 --- a/AgentCompany-New/src/main/services/provider-tool-turn.ts +++ b/AgentCompany-New/src/main/services/provider-tool-turn.ts @@ -51,12 +51,18 @@ 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; diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/bedrock-format.ts b/AgentCompany-New/src/main/services/provider-tool-turns/bedrock-format.ts index 3a14319e..b17d22c3 100644 --- a/AgentCompany-New/src/main/services/provider-tool-turns/bedrock-format.ts +++ b/AgentCompany-New/src/main/services/provider-tool-turns/bedrock-format.ts @@ -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, ToolDefinition } from "../provider-tool-turn"; +import type { AgentTurnMessage, ToolCall, ToolChoice, ToolDefinition } from "../provider-tool-turn"; -export function bedrockToolConfig(tools: ToolDefinition[]): JsonRecord { +export function bedrockToolConfig(tools: ToolDefinition[], toolChoice?: ToolChoice): JsonRecord { return { tools: tools.map((tool) => ({ toolSpec: { name: tool.name, description: tool.description, inputSchema: { json: tool.parameters } } })), - toolChoice: { auto: {} }, + toolChoice: toolChoice === "required" ? { any: {} } : { auto: {} }, }; } diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/gemini-format.ts b/AgentCompany-New/src/main/services/provider-tool-turns/gemini-format.ts index 1bc7424e..94603e7a 100644 --- a/AgentCompany-New/src/main/services/provider-tool-turns/gemini-format.ts +++ b/AgentCompany-New/src/main/services/provider-tool-turns/gemini-format.ts @@ -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 ToolDefinition } from "../provider-tool-turn"; +import { type AgentTurnMessage, type AgentTurnResult, type ToolCall, type ToolChoice, 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,6 +64,7 @@ export function antigravityRequestBody(input: { instructions: string | null; messages: AgentTurnMessage[]; tools: ToolDefinition[]; + toolChoice?: ToolChoice; requestId: string; }): JsonRecord { const request: JsonRecord = { @@ -79,7 +80,7 @@ export function antigravityRequestBody(input: { }; if (input.tools.length > 0) { request.tools = geminiToolDefs(input.model, input.tools); - request.toolConfig = { functionCallingConfig: { mode: "AUTO" } }; + request.toolConfig = { functionCallingConfig: { mode: input.toolChoice === "required" ? "ANY" : "AUTO" } }; } return { project: input.projectId, model: input.model, request, requestType: "agent", userAgent: "antigravity", requestId: input.requestId }; } @@ -90,12 +91,13 @@ 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: "AUTO" } }; + body.toolConfig = { functionCallingConfig: { mode: input.toolChoice === "required" ? "ANY" : "AUTO" } }; } return body; } diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/turns.ts b/AgentCompany-New/src/main/services/provider-tool-turns/turns.ts index f3b342a8..d8ee93f7 100644 --- a/AgentCompany-New/src/main/services/provider-tool-turns/turns.ts +++ b/AgentCompany-New/src/main/services/provider-tool-turns/turns.ts @@ -41,7 +41,7 @@ export async function runOpenAiResponsesTurn(input: ProviderToolTurnInput): Prom model: input.model, input: toResponsesInput(input.messages), tools: responsesToolDefs(input.tools), - tool_choice: "auto", + tool_choice: input.toolChoice ?? "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: "auto", + tool_choice: input.toolChoice ?? "auto", stream: false, }; const response = await postJson(endpoint, { authorization: `Bearer ${requireApiKey(input.credential, label)}` }, body); @@ -74,6 +74,7 @@ export async function runAnthropicTurn(input: ProviderToolTurnInput): Promise { test("a goal created after onboarding reaches the CEO and runs autonomously", async () => { const connection = new DatabaseConnection(join(tmpdir(), `ac-goal-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, async () => ({ - text: "Reviewed the goal and posted the operating plan.", - toolCalls: [], - rawResponseJson: JSON.stringify({ id: "resp_goal" }), - })); + // 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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const organization = new OrganizationService(connection.db); const taskExecution = new TaskExecutionService(connection.db, providers); diff --git a/AgentCompany-New/tests/unit/agent-run-loop.test.ts b/AgentCompany-New/tests/unit/agent-run-loop.test.ts index 44bcb361..3aa7196f 100644 --- a/AgentCompany-New/tests/unit/agent-run-loop.test.ts +++ b/AgentCompany-New/tests/unit/agent-run-loop.test.ts @@ -30,6 +30,21 @@ function call(name: string, args: Record): 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 forced = false; + return { + runToolTurn: async (input: { toolChoice?: "auto" | "required" }): Promise => { + 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" }; + } + return { text: "Let me think about this some more.", toolCalls: [], rawResponseJson: "{}", requestId: "req_prose" }; + }, + }; +} + 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`)); @@ -120,6 +135,33 @@ 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); @@ -151,7 +193,7 @@ describe("agent run loop (real tool dispatch)", () => { connection.close(); }); - test("a first turn with no text and no tool calls fails the run instead of completing a no-op", async () => { + test("a first turn that takes no action (prose only, no tool calls) fails the run", async () => { const connection = new DatabaseConnection(join(tmpdir(), `ac-loop3-${randomUUID()}.db`)); const organization = new OrganizationService(connection.db); let orgId = ""; @@ -166,10 +208,12 @@ 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: "", toolCalls: [] }]), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } }, + { 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(/empty turn/); + )).rejects.toThrow(/no action on its first turn/); connection.close(); }); }); diff --git a/AgentCompany-New/tests/unit/task-execution-service.test.ts b/AgentCompany-New/tests/unit/task-execution-service.test.ts index dd9c9982..12caece7 100644 --- a/AgentCompany-New/tests/unit/task-execution-service.test.ts +++ b/AgentCompany-New/tests/unit/task-execution-service.test.ts @@ -37,7 +37,10 @@ describe("task execution service", () => { const fakeToolTurn = async (input: ProviderToolTurnInput): Promise => { const first = input.messages[0]; if (first?.role === "user") capturedPrompt = first.text; - return { text: "executed:gpt-5.4", toolCalls: [], rawResponseJson: JSON.stringify({ id: "resp-task-1" }) }; + 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 providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, fakeToolTurn); const skills = new SkillService(connection.db, join(tmpdir(), `agentcompany-task-skills-${randomUUID()}`)); @@ -141,11 +144,12 @@ 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 (): Promise => ({ - text: "executed:gpt-5.4", - toolCalls: [], - rawResponseJson: JSON.stringify({ id: "resp-task-approved" }), - }); + const fakeToolTurn = async (input: ProviderToolTurnInput): Promise => { + 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 providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, fakeToolTurn); const execution = new TaskExecutionService(connection.db, providers); let orgId = "";