Enable autonomous file handoffs
This commit is contained in:
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");
|
||||
|
||||
Reference in new issue
Block a user