Let agents post coordination messages so company channels are populated

This commit is contained in:
ZacharyZhang-NY committed 2026-05-29 23:35:33 -04:00
1 parent 54ad9d1909
commit e14a4bfce8
4 files changed
+65 -8

No files matched your search

@@ -1,6 +1,7 @@
import type { DatabaseSync } from "node:sqlite";
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, type RiskLevel } from "../../shared/domain";
import type { OrganizationService } from "./organization-service";
import type { ChatService } from "./chat-service";
import { writeDeliverable } from "./deliverable-writer";
import { recordWorkspaceFile } from "./files/file-object-store";
import type { JsonRecord } from "./provider-http";
@@ -21,6 +22,7 @@ export interface AgentToolContext {
export interface AgentToolDeps {
db: DatabaseSync;
organization: OrganizationService;
chat: ChatService;
}
/** The tools the acting agent may call during a run. */
@@ -82,6 +84,19 @@ export const AGENT_TOOLS: ToolDefinition[] = [
required: ["path", "content"],
},
},
{
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.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
channel: { type: "string", description: "Channel name, e.g. 'general'." },
body: { type: "string", description: "The message to post." },
},
required: ["channel", "body"],
},
},
];
export function dispatchAgentTool(deps: AgentToolDeps, ctx: AgentToolContext, call: ToolCall): string {
@@ -95,6 +110,8 @@ export function dispatchAgentTool(deps: AgentToolDeps, ctx: AgentToolContext, ca
return updateTaskStatus(deps, ctx, call.arguments);
case "write_deliverable":
return writeDeliverableTool(deps, ctx, call.arguments);
case "send_message":
return sendMessageTool(deps, ctx, call.arguments);
default:
return `ERROR: unknown tool "${call.name}".`;
}
@@ -170,6 +187,25 @@ function writeDeliverableTool(deps: AgentToolDeps, ctx: AgentToolContext, args:
return `Wrote deliverable ${written} (${content.length} chars).`;
}
function sendMessageTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
const channel = requireString(args.channel, "channel");
const body = requireString(args.body, "body");
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: [],
});
return `Posted to #${channel} (message_id=${messageId}).`;
}
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;
@@ -5,6 +5,7 @@ import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
import { ProviderService } from "./provider-service";
import { OrganizationService } from "./organization-service";
import { ChatService } from "./chat-service";
import { SkillService } from "./skill-service";
import { runAgentLoop } from "./agent-run-loop";
import type { AgentToolContext } from "./agent-tools";
@@ -100,6 +101,7 @@ interface RunRow {
export class TaskExecutionService {
private readonly audit: AuditService;
private readonly organization: OrganizationService;
private readonly chat: ChatService;
constructor(
private readonly db: DatabaseSync,
@@ -108,6 +110,7 @@ export class TaskExecutionService {
) {
this.audit = new AuditService(db);
this.organization = new OrganizationService(db);
this.chat = new ChatService(db);
}
async runTask(input: RunTaskInput): Promise<string> {
@@ -167,7 +170,7 @@ export class TaskExecutionService {
workspace: workspace ? { id: workspace.id, localPath: workspace.local_path } : null,
};
const result = await runAgentLoop(
{ providers: this.providers, tools: { db: this.db, organization: this.organization } },
{ providers: this.providers, tools: { db: this.db, organization: this.organization, chat: this.chat } },
{
orgId,
providerId: provider.id,
@@ -471,8 +474,8 @@ 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, and write_deliverable produces real files in the company workspace.",
"Take concrete action with the tools instead of only describing a plan. When your team is staffed, the work is delegated, and the first operating deliverable is written, 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 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.",
].join("\n");
}
@@ -528,10 +531,10 @@ 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 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), write the initial operating plan into the workspace (write_deliverable), and post a kickoff update to #general (send_message). Then summarize.",
);
} else {
lines.push("", "Complete this task: do the work, write your output as real files with write_deliverable, update the task status when done, and summarize.");
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.");
}
return lines.join("\n");
}
@@ -33,6 +33,7 @@ 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: "Team staffed, work delegated, plan written.", toolCalls: [] },
];
@@ -101,6 +102,12 @@ 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.
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");
const run = state.taskRuns.find((r) => r.id === claimed!.id);
expect(run?.state).toBe("completed");
@@ -5,6 +5,7 @@ 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 { runAgentLoop } from "../../src/main/services/agent-run-loop";
import type { AgentToolContext } from "../../src/main/services/agent-tools";
import type { AgentTurnResult, ToolCall } from "../../src/main/services/provider-tool-turn";
@@ -74,17 +75,18 @@ describe("agent run loop (real tool dispatch)", () => {
] },
{ text: "Writing the plan.", toolCalls: [
call("write_deliverable", { path: "plan/operating-plan.md", content: "# Operating Plan\n\n1. Validate demand.\n2. Launch booking site.\n" }),
call("send_message", { channel: "general", body: "Team staffed and the operating plan is live. Booking site is next." }),
] },
{ text: "Team staffed, work delegated, plan written.", toolCalls: [] },
];
const result = await runAgentLoop(
{ providers: fakeProvider(script), tools: { db: connection.db, organization } },
{ providers: fakeProvider(script), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } },
{ orgId, providerId, workspaceId, instructions: "You are the CEO.", initialUserTurn: "Start the company.", toolContext },
);
expect(result.iterations).toBe(4);
expect(result.toolCallCount).toBe(4);
expect(result.toolCallCount).toBe(5);
expect(result.finalText).toContain("Team staffed");
const agentNames = (connection.db.prepare("select name from agents where org_id = ? order by name").all(orgId) as Array<{ name: string }>).map((r) => r.name);
@@ -99,6 +101,15 @@ 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;
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");
connection.close();
});
@@ -124,7 +135,7 @@ describe("agent run loop (real tool dispatch)", () => {
{ text: "done", toolCalls: [] },
];
const result = await runAgentLoop(
{ providers: fakeProvider(script), tools: { db: connection.db, organization } },
{ providers: fakeProvider(script), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } },
{ orgId, providerId, workspaceId: null, instructions: "i", initialUserTurn: "u", toolContext: ctx },
);
// The tool returns an error string to the model; nothing is written outside the root.