From 745c22ec77cb720893358f1fde978e4912012185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 26 Jun 2026 19:27:28 -0400 Subject: [PATCH] Fix agent assignment conversations --- .../src/main/services/agent-run-loop.ts | 24 +++- .../src/main/services/agent-tools.ts | 124 ++++++++++++++---- .../tests/integration/autonomous-run.test.ts | 2 +- .../tests/unit/agent-run-loop.test.ts | 64 ++++++++- 4 files changed, 184 insertions(+), 30 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..d21fcff7 100644 --- a/AgentCompany-New/src/main/services/agent-run-loop.ts +++ b/AgentCompany-New/src/main/services/agent-run-loop.ts @@ -1,5 +1,6 @@ import type { ProviderService } from "./provider-service"; import { AGENT_TOOLS, dispatchAgentTool, type AgentToolContext, type AgentToolDeps } from "./agent-tools"; +import { canonicalMentionHandle } from "./mention-handles"; import type { AgentTurnMessage } from "./provider-tool-turn"; const MAX_ITERATIONS = 12; @@ -62,7 +63,13 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn 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) }; + return { + toolCallId: call.id, + toolName: call.name, + output: dispatchAgentTool(deps.tools, input.toolContext, call, { + announceTaskAssignments: shouldAnnounceTaskAssignment(call, turn.toolCalls), + }), + }; }); messages.push({ role: "tool", results }); deps.onToolResults?.(); @@ -70,3 +77,18 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn throw new Error(`Agent run did not converge within ${MAX_ITERATIONS} iterations.`); } + +function shouldAnnounceTaskAssignment(call: { name: string; arguments: Record }, toolCalls: Array<{ name: string; arguments: Record }>): boolean { + if (call.name !== "create_task") return true; + const assigneeName = stringArg(call.arguments.assignee_agent_name); + if (!assigneeName) return true; + const handle = `@${canonicalMentionHandle(assigneeName)}`; + return !toolCalls.some((toolCall) => { + if (toolCall.name !== "send_message") return false; + return stringArg(toolCall.arguments.body).toLowerCase().includes(handle); + }); +} + +function stringArg(value: unknown): string { + return typeof value === "string" ? value : ""; +} diff --git a/AgentCompany-New/src/main/services/agent-tools.ts b/AgentCompany-New/src/main/services/agent-tools.ts index c5eba416..28c2f896 100644 --- a/AgentCompany-New/src/main/services/agent-tools.ts +++ b/AgentCompany-New/src/main/services/agent-tools.ts @@ -6,6 +6,7 @@ import type { ChatService } from "./chat-service"; import { writeDeliverable } from "./deliverable-writer"; import { DocumentService } from "./document-service"; import { recordWorkspaceFile } from "./files/file-object-store"; +import { canonicalMentionHandle } from "./mention-handles"; import type { JsonRecord } from "./provider-http"; import type { ToolCall, ToolDefinition } from "./provider-tool-turn"; @@ -27,6 +28,10 @@ export interface AgentToolDeps { chat: ChatService; } +export interface AgentToolDispatchOptions { + announceTaskAssignments?: boolean; +} + /** The tools the acting agent may call during a run. */ export const AGENT_TOOLS: ToolDefinition[] = [ { @@ -121,13 +126,18 @@ export const AGENT_TOOLS: ToolDefinition[] = [ }, ]; -export function dispatchAgentTool(deps: AgentToolDeps, ctx: AgentToolContext, call: ToolCall): string { +export function dispatchAgentTool( + deps: AgentToolDeps, + ctx: AgentToolContext, + call: ToolCall, + options: AgentToolDispatchOptions = {}, +): string { try { switch (call.name) { case "hire_agent": return hireAgent(deps, ctx, call.arguments); case "create_task": - return createTask(deps, ctx, call.arguments); + return createTask(deps, ctx, call.arguments, options); case "update_task_status": return updateTaskStatus(deps, ctx, call.arguments); case "write_deliverable": @@ -167,26 +177,32 @@ function hireAgent(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord) return `Hired ${name} as ${role} in ${department} (agent_id=${agentId}).`; } -function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { +function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord, options: AgentToolDispatchOptions): string { const title = requireString(args.title, "title"); const description = requireString(args.description, "description"); const assigneeName = optionalString(args.assignee_agent_name); - const assigneeId = assigneeName ? resolveAgentByName(deps.db, ctx.orgId, assigneeName) : null; - if (assigneeName && !assigneeId) throw new Error(`No agent named "${assigneeName}" — hire them first.`); - const taskId = deps.organization.createTask({ - orgId: ctx.orgId, - title, - description, - status: TASK_STATUS.BACKLOG, - assigneeAgentId: assigneeId, - risk: normalizeRisk(args.risk), - projectId: ctx.taskProjectId, - goalId: ctx.taskGoalId, - workspaceId: ctx.workspace?.id ?? null, - priority: TASK_PRIORITY.NORMAL, - requiresFilesystem: false, - }); - return `Created task "${title}" (task_id=${taskId})${assigneeId ? ` assigned to ${assigneeName}` : " (unassigned)"}.`; + const assignee = assigneeName ? requireRunnableAgentByName(deps.db, ctx.orgId, assigneeName) : null; + const create = () => { + const taskId = deps.organization.createTask({ + orgId: ctx.orgId, + title, + description, + status: TASK_STATUS.BACKLOG, + assigneeAgentId: assignee?.id ?? null, + risk: normalizeRisk(args.risk), + projectId: ctx.taskProjectId, + goalId: ctx.taskGoalId, + workspaceId: ctx.workspace?.id ?? null, + priority: TASK_PRIORITY.NORMAL, + requiresFilesystem: false, + }); + const assignmentThread = options.announceTaskAssignments !== false && assignee && assignee.id !== ctx.agent.id + ? postAssignmentMessage(deps, ctx, { taskId, title, description, assignee }) + : null; + return { taskId, assignmentThread }; + }; + const { taskId, assignmentThread } = assignee ? withSavepoint(deps.db, "agent_create_task_assignment", create) : create(); + return `Created task "${title}" (task_id=${taskId})${assignee ? ` assigned to ${assignee.name}` : " (unassigned)"}${assignmentThread ? ` in thread_id="${assignmentThread}"` : ""}.`; } function updateTaskStatus(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { @@ -303,10 +319,72 @@ function createDocumentTool(deps: AgentToolDeps, ctx: AgentToolContext, args: Js return `Created document "${title}" (doc_id=${docId}, type=${docType}, chars=${content.length}).`; } -function resolveAgentByName(db: DatabaseSync, orgId: string, name: string): string | null { - const row = db.prepare("select id from agents where org_id = ? and lower(name) = lower(?) and lifecycle = 'active' limit 1") - .get(orgId, name.trim()) as { id: string } | undefined; - return row?.id ?? null; +interface RunnableAssignmentAgent { + id: string; + name: string; + department: string; +} + +function requireRunnableAgentByName(db: DatabaseSync, orgId: string, name: string): RunnableAssignmentAgent { + const row = db.prepare(` + select a.id, a.name, a.department + from agents a + join providers p on p.id = a.model_provider_id and p.org_id = a.org_id + join service_identities si on si.agent_id = a.id and si.org_id = a.org_id + where a.org_id = ? + and lower(a.name) = lower(?) + and a.lifecycle = 'active' + and p.enabled = 1 + limit 1 + `).get(orgId, name.trim()) as RunnableAssignmentAgent | undefined; + if (!row) throw new Error(`No runnable agent named "${name}" with an enabled provider and service identity.`); + return row; +} + +function postAssignmentMessage( + deps: AgentToolDeps, + ctx: AgentToolContext, + input: { taskId: string; title: string; description: string; assignee: RunnableAssignmentAgent }, +): string { + const channel = assignmentChannel(input.assignee.department); + const body = [ + `@${canonicalMentionHandle(input.assignee.name)} take task_id=${input.taskId}: ${input.title}.`, + input.description, + ].join(" "); + 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; + if (!space) throw new Error(`No channel named "${channel}".`); + const messageId = deps.chat.sendMessage({ + orgId: ctx.orgId, + spaceId: space.id, + threadId: null, + senderType: "agent", + senderId: ctx.agent.id, + bodyMd: body, + attachmentFileIds: [], + }); + const row = deps.db.prepare("select thread_id from chat_messages where id = ?").get(messageId) as { thread_id: string }; + return row.thread_id; +} + +function assignmentChannel(department: string): string { + const normalized = department.trim().toLowerCase(); + if (normalized === "engineering" || normalized === "product") return normalized; + return "general"; +} + +function withSavepoint(db: DatabaseSync, name: string, operation: () => T): T { + db.exec(`savepoint ${name}`); + try { + const result = operation(); + db.exec(`release savepoint ${name}`); + return result; + } catch (error) { + db.exec(`rollback to savepoint ${name}`); + db.exec(`release savepoint ${name}`); + throw error; + } } function requireString(value: unknown, field: string): string { diff --git a/AgentCompany-New/tests/integration/autonomous-run.test.ts b/AgentCompany-New/tests/integration/autonomous-run.test.ts index 40dfc298..3e829aff 100644 --- a/AgentCompany-New/tests/integration/autonomous-run.test.ts +++ b/AgentCompany-New/tests/integration/autonomous-run.test.ts @@ -131,7 +131,7 @@ describe("autonomous bootstrap run", () => { // 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); + const kickoff = state.chatMessages.find((m) => m.orgId === orgId && m.spaceId === general?.id && m.bodyMd.includes("/handoff")); expect(kickoff).toMatchObject({ senderType: "agent", senderId: ceo?.id }); expect(kickoff?.bodyMd).toContain("/handoff @Mara-Quinn"); expect(state.chatAttachments).toContainEqual(expect.objectContaining({ messageId: kickoff?.id, fileId: planFile?.id })); diff --git a/AgentCompany-New/tests/unit/agent-run-loop.test.ts b/AgentCompany-New/tests/unit/agent-run-loop.test.ts index 44bcb361..a38d7d68 100644 --- a/AgentCompany-New/tests/unit/agent-run-loop.test.ts +++ b/AgentCompany-New/tests/unit/agent-run-loop.test.ts @@ -7,6 +7,7 @@ import { DatabaseConnection } from "../../src/main/database/connection"; import { OrganizationService } from "../../src/main/services/organization-service"; import { ChatService } from "../../src/main/services/chat-service"; import { runAgentLoop } from "../../src/main/services/agent-run-loop"; +import { canonicalMentionHandle } from "../../src/main/services/mention-handles"; import type { AgentToolContext } from "../../src/main/services/agent-tools"; import type { AgentTurnResult, ToolCall } from "../../src/main/services/provider-tool-turn"; @@ -46,6 +47,7 @@ describe("agent run loop (real tool dispatch)", () => { organizationName: "OrbitOps", employeeName: "Yang", mission: "Run an AI company.", goal: "Win customers.", }); providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; + connection.db.prepare("update providers set enabled = 1 where id = ?").run(providerId); connection.db.prepare(` insert into workspaces(id, org_id, name, local_path, repo_url, repo_ref, project_id, is_primary, created_at, updated_at) values (?, ?, 'Default Workspace', ?, '', '', null, 1, datetime('now'), datetime('now')) @@ -108,14 +110,13 @@ describe("agent run loop (real tool dispatch)", () => { expect(existsSync(deliverablePath)).toBe(true); expect(readFileSync(deliverablePath, "utf8")).toContain("Operating Plan"); - // The CEO's coordination message lands in #general so the channel is no longer blank. - const generalSpaceId = (connection.db.prepare("select id from chat_spaces where org_id = ? and name = 'general'").get(orgId) as { id: string }).id; + // The assignment message and the CEO's coordination message both land in chat. const messages = connection.db.prepare( "select sender_type, sender_id, body_md, space_id from chat_messages where org_id = ?", ).all(orgId) as Array<{ sender_type: string; sender_id: string; body_md: string; space_id: string }>; - expect(messages).toHaveLength(1); - expect(messages[0]).toMatchObject({ sender_type: "agent", sender_id: ceoId, space_id: generalSpaceId }); - expect(messages[0]?.body_md).toContain("operating plan is live"); + expect(messages).toHaveLength(2); + expect(messages).toContainEqual(expect.objectContaining({ sender_type: "agent", sender_id: ceoId, body_md: expect.stringContaining(`@${canonicalMentionHandle("Alex Rivera")}`) })); + expect(messages).toContainEqual(expect.objectContaining({ sender_type: "agent", sender_id: ceoId, body_md: expect.stringContaining("operating plan is live") })); connection.close(); }); @@ -151,6 +152,59 @@ describe("agent run loop (real tool dispatch)", () => { connection.close(); }); + test("delegating a task opens an assignment thread for the assignee", async () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-loop-assign-${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; + connection.db.prepare("update providers set enabled = 1 where id = ?").run(providerId); + 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 script: ScriptedTurn[] = [ + { text: "staff", toolCalls: [call("hire_agent", { name: "Alex Rivera", role: "Engineer", department: "Engineering" })] }, + { text: "delegate", toolCalls: [call("create_task", { title: "Build the booking API", description: "Ship the booking API.", assignee_agent_name: "Alex Rivera" })] }, + { text: "done", toolCalls: [] }, + ]; + + await runAgentLoop( + { providers: fakeProvider(script), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } }, + { orgId, providerId, workspaceId: null, instructions: "i", initialUserTurn: "u", toolContext: ctx }, + ); + + const alex = connection.db.prepare("select id from agents where org_id = ? and name = 'Alex Rivera'").get(orgId) as { id: string }; + const assignment = connection.db.prepare(` + select m.body_md, m.thread_id, s.name as space_name + from chat_messages m + join chat_spaces s on s.id = m.space_id + where m.org_id = ? + `).get(orgId) as { body_md: string; thread_id: string; space_name: string } | undefined; + expect(assignment).toMatchObject({ space_name: "engineering" }); + expect(assignment?.body_md).toContain(`@${canonicalMentionHandle("Alex Rivera")}`); + expect(assignment?.body_md).toContain("Build the booking API"); + + const wake = connection.db.prepare(` + select state, trigger, secret_scope_json + from task_runs + where org_id = ? and agent_id = ? and trigger = 'chat_mention' + `).get(orgId, alex.id) as { state: string; trigger: string; secret_scope_json: string } | undefined; + expect(wake).toMatchObject({ state: "queued", trigger: "chat_mention" }); + expect(JSON.parse(wake?.secret_scope_json ?? "{}")).toMatchObject({ sourceThreadId: assignment?.thread_id }); + connection.close(); + }); + test("a first turn with no text and no tool calls fails the run instead of completing a no-op", async () => { const connection = new DatabaseConnection(join(tmpdir(), `ac-loop3-${randomUUID()}.db`)); const organization = new OrganizationService(connection.db);