diff --git a/AgentCompany-New/src/main/services/agent-run-loop.ts b/AgentCompany-New/src/main/services/agent-run-loop.ts index cadbd83c..cd961362 100644 --- a/AgentCompany-New/src/main/services/agent-run-loop.ts +++ b/AgentCompany-New/src/main/services/agent-run-loop.ts @@ -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.`); diff --git a/AgentCompany-New/src/main/services/agent-tools.ts b/AgentCompany-New/src/main/services/agent-tools.ts index 1708dd77..f364883e 100644 --- a/AgentCompany-New/src/main/services/agent-tools.ts +++ b/AgentCompany-New/src/main/services/agent-tools.ts @@ -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) { diff --git a/AgentCompany-New/src/main/services/task-prompts.ts b/AgentCompany-New/src/main/services/task-prompts.ts index 85398128..e8b0a653 100644 --- a/AgentCompany-New/src/main/services/task-prompts.ts +++ b/AgentCompany-New/src/main/services/task-prompts.ts @@ -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"); diff --git a/AgentCompany-New/tests/integration/agent-reply.test.ts b/AgentCompany-New/tests/integration/agent-reply.test.ts index 04e3cd22..4b3b0f57 100644 --- a/AgentCompany-New/tests/integration/agent-reply.test.ts +++ b/AgentCompany-New/tests/integration/agent-reply.test.ts @@ -37,7 +37,7 @@ function scriptedTeam(): (input: ProviderToolTurnInput) => Promise => { if ((input.instructions ?? "").includes("Dana Lee")) { @@ -46,11 +46,11 @@ function scriptedTeam(): (input: ProviderToolTurnInput) => Promise Promise { @@ -55,7 +55,7 @@ function scriptedCeo(): (input: ProviderToolTurnInput) => Promise { // 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); diff --git a/AgentCompany-New/tests/unit/agent-run-loop.test.ts b/AgentCompany-New/tests/unit/agent-run-loop.test.ts index 3aa7196f..ef11f1f4 100644 --- a/AgentCompany-New/tests/unit/agent-run-loop.test.ts +++ b/AgentCompany-New/tests/unit/agent-run-loop.test.ts @@ -19,7 +19,8 @@ function fakeProvider(script: ScriptedTurn[]) { let index = 0; return { runToolTurn: async (): Promise => { - 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): 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 => { - 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(); }); }); diff --git a/AgentCompany-New/tests/unit/task-execution-service.test.ts b/AgentCompany-New/tests/unit/task-execution-service.test.ts index 12caece7..dad7bb7b 100644 --- a/AgentCompany-New/tests/unit/task-execution-service.test.ts +++ b/AgentCompany-New/tests/unit/task-execution-service.test.ts @@ -37,10 +37,11 @@ describe("task execution service", () => { const fakeToolTurn = async (input: ProviderToolTurnInput): Promise => { 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 => { - 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);