From ab546f822dd268346f07880c69b40ed48527a4d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 7 Jun 2026 13:25:28 -0400 Subject: [PATCH] Enable autonomous file handoffs --- .../src/main/services/agent-tools.ts | 24 ++++++--- .../main/services/task-execution-service.ts | 16 ++++-- .../tests/integration/autonomous-run.test.ts | 50 ++++++++++++++++--- 3 files changed, 73 insertions(+), 17 deletions(-) diff --git a/AgentCompany-New/src/main/services/agent-tools.ts b/AgentCompany-New/src/main/services/agent-tools.ts index 08b4e62f..e9faaad9 100644 --- a/AgentCompany-New/src/main/services/agent-tools.ts +++ b/AgentCompany-New/src/main/services/agent-tools.ts @@ -86,14 +86,19 @@ export const AGENT_TOOLS: ToolDefinition[] = [ }, { name: "send_message", - description: "Post a coordination message to a company channel so teammates and the operator can follow the work. Use the channel that matches the work: 'engineering' for engineering, 'product' for product/design, otherwise 'general'. Reply inside a thread by passing its thread_id (start a new thread by omitting it). Name the teammate you are addressing so the conversation reads as a real handoff.", + description: "Post a coordination message to a company channel so teammates and the operator can follow the work. Use @mentions for teammates, attach deliverable file ids with attachment_file_ids, and use /handoff @Name when passing a file. Use the channel that matches the work: 'engineering' for engineering, 'product' for product/design, otherwise 'general'. Reply inside a thread by passing its thread_id.", parameters: { type: "object", additionalProperties: false, properties: { channel: { type: "string", description: "Channel: 'engineering', 'product', or 'general'." }, - body: { type: "string", description: "The message. Name the teammate you are addressing (assigning to, or reporting back to)." }, + body: { type: "string", description: "The message. Use @mentions for teammates and slash commands such as /handoff @Name when passing files." }, thread_id: { type: "string", description: "Reply inside an existing thread — pass the thread_id a prior send_message returned. Omit to start a new thread." }, + attachment_file_ids: { + type: "array", + items: { type: "string" }, + description: "Approved file object ids to attach to this chat message.", + }, }, required: ["channel", "body"], }, @@ -176,22 +181,23 @@ function updateTaskStatus(deps: AgentToolDeps, ctx: AgentToolContext, args: Json function writeDeliverableTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { if (!ctx.workspace) throw new Error("No workspace is bound to this task; cannot write deliverables."); const path = requireString(args.path, "path"); - const content = typeof args.content === "string" ? args.content : ""; + const content = requireString(args.content, "content"); const written = writeDeliverable({ workspaceRoot: ctx.workspace.localPath, relativePath: path, content }); - recordWorkspaceFile(deps.db, { + const fileId = recordWorkspaceFile(deps.db, { orgId: ctx.orgId, ownerType: "agent", ownerId: ctx.agent.id, workspaceRoot: ctx.workspace.localPath, relativePath: written, }); - return `Wrote deliverable ${written} (${content.length} chars).`; + return `Wrote deliverable ${written} (file_id=${fileId}, chars=${content.length}).`; } function sendMessageTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { const channel = requireString(args.channel, "channel"); const body = requireString(args.body, "body"); const threadId = optionalString(args.thread_id); + const attachmentFileIds = optionalStringArray(args.attachment_file_ids, "attachment_file_ids"); const space = deps.db.prepare( "select id from chat_spaces where org_id = ? and lower(name) = lower(?) and space_type <> 'direct' limit 1", ).get(ctx.orgId, channel) as { id: string } | undefined; @@ -203,7 +209,7 @@ function sendMessageTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonR senderType: "agent", senderId: ctx.agent.id, bodyMd: body, - attachmentFileIds: [], + attachmentFileIds, }); const row = deps.db.prepare("select thread_id from chat_messages where id = ?").get(messageId) as { thread_id: string }; return `Posted to #${channel} (message_id=${messageId}, thread_id=${row.thread_id}).`; @@ -224,6 +230,12 @@ function optionalString(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } +function optionalStringArray(value: unknown, field: string): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new Error(`"${field}" must be an array.`); + return value.map((item, index) => requireString(item, `${field}[${index}]`)); +} + function normalizeRisk(value: unknown): RiskLevel { if (value === RISK_LEVEL.MEDIUM) return RISK_LEVEL.MEDIUM; if (value === RISK_LEVEL.HIGH) return RISK_LEVEL.HIGH; diff --git a/AgentCompany-New/src/main/services/task-execution-service.ts b/AgentCompany-New/src/main/services/task-execution-service.ts index 9562de83..7e917aa1 100644 --- a/AgentCompany-New/src/main/services/task-execution-service.ts +++ b/AgentCompany-New/src/main/services/task-execution-service.ts @@ -9,6 +9,7 @@ import { ChatService } from "./chat-service"; import { SkillService } from "./skill-service"; import { runAgentLoop } from "./agent-run-loop"; import type { AgentToolContext } from "./agent-tools"; +import { canonicalMentionHandle } from "./mention-handles"; const TASK_RUN_STATE = { QUEUED: "queued", @@ -481,9 +482,9 @@ export class TaskExecutionService { function buildAgentInstructions(agent: TaskExecutionAgentRow): string { return [ `You are ${agent.name}, ${agent.title || agent.role} (${agent.department}, ${agent.role_level}) operating an autonomous AI company.`, - "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, and send_message posts coordination messages to a channel.", - "Communicate like a real team, not a status board. Post to the channel that matches the work — 'engineering' for engineering, 'product' for product or design, otherwise 'general' — instead of putting everything in #general. When you hand work to a teammate, post the assignment naming that teammate; when you finish work for someone, post your result naming the person who assigned it. 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 instead of only describing a plan. When the work is done and you have posted your coordination message, give a brief closing summary with no further tool calls.", + "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, 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.", ].join("\n"); } @@ -541,14 +542,19 @@ 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, for EACH report, send_message to that report's department channel (engineering, product, or general) naming the report 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 summarize.", ); } 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 and naming ${manager} so the hand-off is visible — 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, and summarize.`, ); } return lines.join("\n"); } + +function agentMention(name: string): string { + return `@${canonicalMentionHandle(name)}`; +} diff --git a/AgentCompany-New/tests/integration/autonomous-run.test.ts b/AgentCompany-New/tests/integration/autonomous-run.test.ts index 519b35d9..ff3f82e8 100644 --- a/AgentCompany-New/tests/integration/autonomous-run.test.ts +++ b/AgentCompany-New/tests/integration/autonomous-run.test.ts @@ -10,7 +10,7 @@ import { providerAdapters } from "../../src/main/services/provider-adapters"; import { TaskExecutionService } from "../../src/main/services/task-execution-service"; import { SnapshotService } from "../../src/main/services/snapshot-service"; import { claimNextQueuedRun } from "../../src/main/app/task-queue-worker"; -import type { AgentTurnResult, ProviderToolTurnInput, ToolCall } from "../../src/main/services/provider-tool-turn"; +import type { AgentTurnMessage, AgentTurnResult, ProviderToolTurnInput, ToolCall } from "../../src/main/services/provider-tool-turn"; const testVault = { encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), @@ -21,7 +21,7 @@ function call(name: string, args: Record): ToolCall { return { id: `call_${randomUUID()}`, name, arguments: args }; } -/** Scripts the CEO: staff two reports, delegate a task, write the plan, then stop. */ +/** Scripts the CEO: staff two reports, delegate work, hand off the file in chat, then stop. */ function scriptedCeo(): (input: ProviderToolTurnInput) => Promise { const script: Array<{ text: string; toolCalls: ToolCall[] }> = [ { text: "Staffing.", toolCalls: [ @@ -33,18 +33,43 @@ function scriptedCeo(): (input: ProviderToolTurnInput) => Promise { + return async (input) => { + const fileId = lastToolOutput(input.messages, "write_deliverable").match(/file_id=([^),\s]+)/)?.[1]; + if (fileId && index === 3) { + index += 1; + return { + text: "Handing off the plan.", + toolCalls: [ + call("send_message", { + channel: "general", + body: "/handoff @Mara-Quinn operating plan for booking-site execution. @Owen-Diaz coordinate launch operations in this thread.", + attachment_file_ids: [fileId], + }), + ], + rawResponseJson: JSON.stringify({ id: `resp_${index}` }), + }; + } const turn = script[index] ?? { text: "done", toolCalls: [] }; index += 1; return { text: turn.text, toolCalls: turn.toolCalls, rawResponseJson: JSON.stringify({ id: `resp_${index}` }) }; }; } +function lastToolOutput(messages: AgentTurnMessage[], toolName: string): string { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role !== "tool") continue; + const result = message.results.find((item) => item.toolName === toolName); + if (result) return result.output; + } + return ""; +} + describe("autonomous bootstrap run", () => { test("the queued kickoff run autonomously hires, delegates, and writes a deliverable", async () => { const connection = new DatabaseConnection(join(tmpdir(), `ac-auto-${randomUUID()}.db`)); @@ -102,11 +127,24 @@ describe("autonomous bootstrap run", () => { expect(planFile).toBeTruthy(); expect(planFile).toMatchObject({ ownerType: "agent", ownerId: ceo?.id, kind: "md", scanState: "approved" }); - // The CEO posts a coordination message, so the Messages channel is populated. + // The CEO posts a file handoff in the company channel, so chat carries the + // actual artifact and recipient mentions instead of a plain status note. const general = state.chatSpaces.find((s) => s.orgId === orgId && s.name === "general"); const kickoff = state.chatMessages.find((m) => m.orgId === orgId && m.spaceId === general?.id); expect(kickoff).toMatchObject({ senderType: "agent", senderId: ceo?.id }); - expect(kickoff?.bodyMd).toContain("team staffed"); + expect(kickoff?.bodyMd).toContain("/handoff @Mara-Quinn"); + expect(state.chatAttachments).toContainEqual(expect.objectContaining({ messageId: kickoff?.id, fileId: planFile?.id })); + expect(state.chatMentions).toEqual(expect.arrayContaining([ + expect.objectContaining({ messageId: kickoff?.id, targetType: "agent", targetId: mara?.id }), + expect.objectContaining({ messageId: kickoff?.id, targetType: "agent", targetId: state.agents.find((a) => a.orgId === orgId && a.name === "Owen Diaz")?.id }), + ])); + expect(state.fileHandoffRecords).toContainEqual(expect.objectContaining({ + messageId: kickoff?.id, + fileId: planFile?.id, + targetPrincipalType: "agent", + targetPrincipalId: mara?.id, + policyDecision: "approved_for_handoff", + })); const run = state.taskRuns.find((r) => r.id === claimed!.id); expect(run?.state).toBe("completed");