diff --git a/AgentCompany-New/src/main/services/agent-tools.ts b/AgentCompany-New/src/main/services/agent-tools.ts index a6fa9e0e..08b4e62f 100644 --- a/AgentCompany-New/src/main/services/agent-tools.ts +++ b/AgentCompany-New/src/main/services/agent-tools.ts @@ -86,13 +86,14 @@ export const AGENT_TOOLS: ToolDefinition[] = [ }, { name: "send_message", - description: "Post a short coordination message to a company channel (general, product, or engineering) so the team and the operator can follow the work.", + 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.", parameters: { type: "object", additionalProperties: false, properties: { - channel: { type: "string", description: "Channel name, e.g. 'general'." }, - body: { type: "string", description: "The message to post." }, + 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)." }, + 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." }, }, required: ["channel", "body"], }, @@ -190,6 +191,7 @@ function writeDeliverableTool(deps: AgentToolDeps, ctx: AgentToolContext, args: 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 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; @@ -197,13 +199,14 @@ function sendMessageTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonR const messageId = deps.chat.sendMessage({ orgId: ctx.orgId, spaceId: space.id, - threadId: null, + threadId, senderType: "agent", senderId: ctx.agent.id, bodyMd: body, attachmentFileIds: [], }); - return `Posted to #${channel} (message_id=${messageId}).`; + 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}).`; } function resolveAgentByName(db: DatabaseSync, orgId: string, name: string): string | null { diff --git a/AgentCompany-New/src/main/services/task-execution-service.ts b/AgentCompany-New/src/main/services/task-execution-service.ts index a2eb42b8..9562de83 100644 --- a/AgentCompany-New/src/main/services/task-execution-service.ts +++ b/AgentCompany-New/src/main/services/task-execution-service.ts @@ -187,6 +187,7 @@ export class TaskExecutionService { skillRuntimeSnapshot, isManager: !agent.manager_agent_id, hasReports: this.agentHasReports(orgId, agent.id), + managerName: this.agentName(orgId, agent.manager_agent_id), }), toolContext, }, @@ -385,6 +386,12 @@ export class TaskExecutionService { return Boolean(row); } + private agentName(orgId: string, agentId: string | null): string | null { + if (!agentId) return null; + const row = this.db.prepare("select name from agents where id = ? and org_id = ?").get(agentId, orgId) as { name: string } | undefined; + return row?.name ?? null; + } + private completeRun(orgId: string, runId: string, providerRequestId: string): void { this.db.prepare(` update task_runs @@ -474,8 +481,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 updates to a company channel (general, product, engineering).", - "Take concrete action with the tools instead of only describing a plan. Post a brief coordination message to #general with send_message so the team and the operator can follow your decisions. When your team is staffed, the work is delegated, the first operating deliverable is written, and you have posted your update, 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.", + "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.", ].join("\n"); } @@ -490,6 +498,7 @@ function buildTaskPrompt(input: { skillRuntimeSnapshot: SkillRuntimeSnapshotRow | null; isManager: boolean; hasReports: boolean; + managerName: string | null; }): string { const lines = [ "Execute this AgentCompany task.", @@ -501,6 +510,7 @@ function buildTaskPrompt(input: { `Risk: ${input.task.risk}`, `Requires filesystem: ${input.task.requires_filesystem === 1 ? "yes" : "no"}`, `Assignee: ${input.agent.name} (${input.agent.role})`, + ...(input.managerName ? [`Reports to: ${input.managerName}`] : []), `Provider: ${input.provider.label} ${input.provider.model ?? input.agent.model_id ?? ""}`.trim(), `Execution context: ${input.isolation.executionContextId}`, `Service identity: ${input.isolation.serviceIdentityId}`, @@ -531,10 +541,14 @@ 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), write the initial operating plan into the workspace (write_deliverable), and post a kickoff update to #general (send_message). 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, 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.", ); } else { - lines.push("", "Complete this task: do the work, write your output as real files with write_deliverable, post a brief progress update to #general with send_message, update the task status when done, and summarize."); + const manager = input.managerName ?? "your 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.`, + ); } return lines.join("\n"); } diff --git a/AgentCompany-New/tests/unit/agent-messaging.test.ts b/AgentCompany-New/tests/unit/agent-messaging.test.ts new file mode 100644 index 00000000..4c9cd7e9 --- /dev/null +++ b/AgentCompany-New/tests/unit/agent-messaging.test.ts @@ -0,0 +1,50 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { ChatService } from "../../src/main/services/chat-service"; +import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools"; + +function call(name: string, args: Record) { + return { id: `call_${randomUUID()}`, name, arguments: args }; +} + +describe("agent messaging", () => { + test("agents post to a department channel and reply in the same thread", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-msg-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + let ceoId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" }); + const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; + ceoId = organization.createAgent({ + orgId, name: "CEO", role: "CEO", department: "Executive", + modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null, + }); + }); + const deps = { db: connection.db, organization, chat: new ChatService(connection.db) }; + const ctx: AgentToolContext = { + orgId, agent: { id: ceoId, modelProviderId: null, modelId: null }, + taskProjectId: null, taskGoalId: null, workspace: null, + }; + + const first = dispatchAgentTool(deps, ctx, call("send_message", { channel: "engineering", body: "Assigning the booking API to Dana." })); + const threadId = /thread_id=([^),\s]+)/.exec(first)?.[1]; + expect(threadId).toBeTruthy(); + const second = dispatchAgentTool(deps, ctx, call("send_message", { channel: "engineering", body: "Update: API shipped.", thread_id: threadId })); + expect(second).toContain("Posted to #engineering"); + + const engineering = connection.db.prepare("select id from chat_spaces where org_id = ? and name = 'engineering'").get(orgId) as { id: string }; + const messages = connection.db.prepare("select space_id, thread_id, sender_type, sender_id from chat_messages where org_id = ?") + .all(orgId) as Array<{ space_id: string; thread_id: string; sender_type: string; sender_id: string }>; + expect(messages).toHaveLength(2); + expect(messages.every((m) => m.space_id === engineering.id)).toBe(true); + expect(messages.every((m) => m.sender_type === "agent" && m.sender_id === ceoId)).toBe(true); + // Both messages share the one thread — the reply joined the assignment thread. + expect(new Set(messages.map((m) => m.thread_id)).size).toBe(1); + connection.close(); + }); +});