Enable autonomous file handoffs

This commit is contained in:
ZacharyZhang-NY committed 2026-06-07 13:25:28 -04:00
1 parent 87dfa9345b
commit ab546f822d
3 files changed
+73 -17

No files matched your search

@@ -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;
@@ -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)}`;
}
@@ -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<string, unknown>): 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<AgentTurnResult> {
const script: Array<{ text: string; toolCalls: ToolCall[] }> = [
{ text: "Staffing.", toolCalls: [
@@ -33,18 +33,43 @@ function scriptedCeo(): (input: ProviderToolTurnInput) => Promise<AgentTurnResul
] },
{ text: "Writing the plan.", toolCalls: [
call("write_deliverable", { path: "plan/operating-plan.md", content: "# OrbitOps Operating Plan\n\n- Validate demand\n- Launch booking site\n" }),
call("send_message", { channel: "general", body: "Kickoff: team staffed, plan written, booking site delegated to Mara." }),
] },
{ text: "Handing off the plan.", toolCalls: [] },
{ text: "Team staffed, work delegated, plan written.", toolCalls: [] },
];
let index = 0;
return async () => {
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");