Files
AgentCompany/AgentCompany-New/src/main/services/agent-tools.ts
T
ZacharyZhang-NY cab7b01831 Give every 1:1 its own direct conversation instead of piling into #general
Agents could only post to named public channels - the messaging tool even
excluded direct spaces - so assignments, reports, and questions all crowded
into general. Now ChatService.ensureDirectSpace finds or creates the private
two-member conversation for any agent/employee pair; send_message takes
to="Name" for 1:1 traffic (exactly one of to/channel), read_messages reads a
conversation by teammate name, and task assignments land in the manager's DM
with the assignee. In a direct conversation the counterpart agent wakes on
every message without needing an @mention - same social contract as any
messenger - still bounded by the autonomous-turn budget, and wake task
briefings tell agents to reply with to= in kind. Covered by six new
direct-message contract tests; the assignment-thread test now encodes the
DM routing contract.
2026-07-06 22:49:31 -04:00

521 lines
21 KiB
TypeScript

import type { DatabaseSync } from "node:sqlite";
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, type RiskLevel } from "../../shared/domain";
import { ORGANIZATION_DEPARTMENTS } from "./org-governance-service";
import type { OrganizationService } from "./organization-service";
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 "../../shared/mention-handles";
import { readMessagesTool } from "./agent-read-messages";
import type { JsonRecord } from "./provider-http";
import type { ToolCall, ToolDefinition } from "./provider-tool-turn";
const HIRE_BUDGET_CENTS = 25000;
const HEARTBEAT_INTERVAL_SEC = 120;
export interface AgentToolContext {
orgId: string;
/** The acting agent (the CEO during team-building). */
agent: { id: string; modelProviderId: string | null; modelId: string | null };
taskProjectId: string | null;
taskGoalId: string | null;
workspace: { id: string; localPath: string } | null;
}
export interface AgentToolDeps {
db: DatabaseSync;
organization: OrganizationService;
chat: ChatService;
}
export interface AgentToolDispatchOptions {
announceTaskAssignments?: boolean;
}
/** The tools the acting agent may call during a run. */
export const AGENT_TOOLS: ToolDefinition[] = [
{
name: "hire_agent",
description:
"Add one direct report to your team. During team building, call this 3-5 times to staff the roles you need before delegating work.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
name: { type: "string", description: "Full name of the new agent." },
role: { type: "string", description: "Their role/title, e.g. 'Growth Marketer'." },
department: { type: "string", enum: [...ORGANIZATION_DEPARTMENTS], description: "The company department the hire belongs to." },
model: { type: "string", description: "Optional model id; defaults to your own model." },
},
required: ["name", "role", "department"],
},
},
{
name: "create_task",
description: "Create a task for the team and optionally assign it to one of your reports by name.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
title: { type: "string" },
description: { type: "string" },
assignee_agent_name: { type: "string", description: "Name of the agent to assign, if any." },
risk: { type: "string", enum: ["low", "medium", "high"] },
},
required: ["title", "description"],
},
},
{
name: "update_task_status",
description: "Move a task to a new status.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
task_id: { type: "string" },
status: { type: "string", enum: ["backlog", "in_progress", "review", "done"] },
},
required: ["task_id", "status"],
},
},
{
name: "write_deliverable",
description: "Write a deliverable file into the company workspace folder (relative path). Use this to produce real output.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
path: { type: "string", description: "Relative path inside the workspace, e.g. 'plan/business-plan.md'." },
content: { type: "string" },
},
required: ["path", "content"],
},
},
{
name: "send_message",
description: "Talk to your teammates the way colleagues do. For anything meant for one person — task updates, questions, reports to your manager, hand-offs — send a direct message with `to` so each conversation stays separate. Use a `channel` only for genuinely team-wide topics: 'engineering' for engineering, 'product' for product/design, 'general' for company-wide. Pass exactly one of `to` or `channel`. @mention people you address in channels, attach deliverable file ids with attachment_file_ids, and use /handoff @Name when passing a file. Reply inside a thread by passing its thread_id to keep one conversation together.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
to: { type: "string", description: "Teammate name for a direct 1:1 message, e.g. 'CEO' or 'Ava Patel'. Preferred for anything addressed to one person." },
channel: { type: "string", description: "Channel for team-wide topics: 'engineering', 'product', or 'general'." },
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: ["body"],
},
},
{
name: "read_messages",
description: "Read recent messages from a channel, a direct conversation, or your direct mentions. Check what your teammates have been saying before you respond or start work.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
channel: { type: "string", description: "Channel name: 'engineering', 'product', 'general'." },
with: { type: "string", description: "Teammate name to read your direct conversation with, e.g. 'CEO'." },
limit: { type: "number", description: "Number of recent messages (default 10, max 30)." },
},
required: [],
},
},
{
name: "create_document",
description: "Create a company document (PRD, spec, memo, report, runbook, proposal, policy, guide). Use this for structured deliverables that the company should keep versioned.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
title: { type: "string" },
doc_type: { type: "string", enum: ["prd", "spec", "memo", "report", "runbook", "proposal", "policy", "guide"] },
content: { type: "string", description: "Full document content in markdown." },
},
required: ["title", "doc_type", "content"],
},
},
];
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, options);
case "update_task_status":
return updateTaskStatus(deps, ctx, call.arguments);
case "write_deliverable":
return writeDeliverableTool(deps, ctx, call.arguments);
case "send_message":
return sendMessageTool(deps, ctx, call.arguments);
case "read_messages":
return readMessagesTool(deps, ctx, call.arguments);
case "create_document":
return createDocumentTool(deps, ctx, call.arguments);
default:
return `ERROR: unknown tool "${call.name}".`;
}
} catch (error) {
return `ERROR: ${error instanceof Error ? error.message : String(error)}`;
}
}
function hireAgent(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
if (!ctx.agent.modelProviderId) throw new Error("Acting agent has no model provider to assign hires.");
const name = requireString(args.name, "name");
const role = requireString(args.role, "role");
const department = requireString(args.department, "department");
const model = optionalString(args.model) ?? ctx.agent.modelId;
const agentId = deps.organization.createAgent({
orgId: ctx.orgId,
name,
role,
department,
modelProviderId: ctx.agent.modelProviderId,
modelId: model,
budgetCents: HIRE_BUDGET_CENTS,
managerAgentId: ctx.agent.id,
roleLevel: "staff",
workspaceId: ctx.workspace?.id ?? null,
heartbeatEnabled: true,
heartbeatIntervalSec: HEARTBEAT_INTERVAL_SEC,
});
return `Hired ${name} as ${role} in ${department} (agent_id=${agentId}).`;
}
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 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 {
const taskId = requireString(args.task_id, "task_id");
const status = normalizeStatus(args.status);
deps.organization.updateTaskStatus(ctx.orgId, taskId, status);
return `Task ${taskId} moved to ${status}.`;
}
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 = requireString(args.content, "content");
const written = writeDeliverable({ workspaceRoot: ctx.workspace.localPath, relativePath: path, content });
const fileId = recordWorkspaceFile(deps.db, {
orgId: ctx.orgId,
ownerType: "agent",
ownerId: ctx.agent.id,
workspaceRoot: ctx.workspace.localPath,
relativePath: written,
});
return `Wrote deliverable ${written} (file_id=${fileId}, chars=${content.length}).`;
}
function sendMessageTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
const channel = optionalString(args.channel);
const to = optionalString(args.to);
const body = requireString(args.body, "body");
const threadId = optionalString(args.thread_id);
const attachmentFileIds = optionalStringArray(args.attachment_file_ids, "attachment_file_ids");
if (Boolean(channel) === Boolean(to)) throw new Error('Pass exactly one of "to" (direct message) or "channel".');
const destination = to
? directSpaceWith(deps, ctx, to)
: channelSpace(deps.db, ctx.orgId, channel!);
const sent = withSavepoint(deps.db, "agent_send_message", () => {
const messageId = deps.chat.sendMessage({
orgId: ctx.orgId,
spaceId: destination.spaceId,
threadId,
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 { messageId, threadId: row.thread_id };
});
if (destination.kind === "direct") {
return [
`Your direct message to ${destination.counterpartName} is delivered; they can see it and reply in your conversation.`,
`To stay in this conversation, reply with to="${destination.counterpartName}" and thread_id="${sent.threadId}".`,
].join(" ");
}
return describeSend(deps.db, ctx.agent.id, destination.label, destination.spaceId, sent.messageId, sent.threadId);
}
interface ChannelDestination {
kind: "channel";
spaceId: string;
label: string;
}
interface DirectDestination {
kind: "direct";
spaceId: string;
counterpartName: string;
}
function channelSpace(db: DatabaseSync, orgId: string, channel: string): ChannelDestination {
const space = db.prepare(
"select id from chat_spaces where org_id = ? and lower(name) = lower(?) and space_type <> 'direct' limit 1",
).get(orgId, channel) as { id: string } | undefined;
if (!space) throw new Error(`No channel named "${channel}".`);
return { kind: "channel", spaceId: space.id, label: channel };
}
function directSpaceWith(deps: AgentToolDeps, ctx: AgentToolContext, teammateName: string): DirectDestination {
const counterpart = resolveTeammateByName(deps.db, ctx.orgId, teammateName);
if (counterpart.type === "agent" && counterpart.id === ctx.agent.id) {
throw new Error("You cannot open a direct conversation with yourself.");
}
const spaceId = deps.chat.ensureDirectSpace(
ctx.orgId,
{ type: "agent", id: ctx.agent.id },
{ type: counterpart.type, id: counterpart.id },
);
return { kind: "direct", spaceId, counterpartName: counterpart.name };
}
function resolveTeammateByName(
db: DatabaseSync,
orgId: string,
name: string,
): { type: "agent" | "employee"; id: string; name: string } {
const trimmed = name.trim();
const agent = db.prepare(
"select id, name from agents where org_id = ? and lower(name) = lower(?) and lifecycle = 'active' limit 1",
).get(orgId, trimmed) as { id: string; name: string } | undefined;
if (agent) return { type: "agent", id: agent.id, name: agent.name };
const employee = db.prepare(
"select id, display_name from employees where org_id = ? and lower(display_name) = lower(?) limit 1",
).get(orgId, trimmed) as { id: string; display_name: string } | undefined;
if (employee) return { type: "employee", id: employee.id, name: employee.display_name };
throw new Error(`No teammate named "${name}".`);
}
/**
* What the sending agent hears back: who is in the channel and who they just
* addressed — the presence of real colleagues, not a transaction id. Hearing
* the room is what lets an agent hold a conversation instead of posting into a
* void, while the thread_id keeps the next reply on the same thread.
*/
function describeSend(db: DatabaseSync, selfAgentId: string, channel: string, spaceId: string, messageId: string, threadId: string): string {
const addressed = mentionedTeammates(db, selfAgentId, messageId);
const present = channelTeammates(db, selfAgentId, spaceId, addressed);
const lines = [`Your message is up in #${channel}.`];
const replyReady = addressed.filter((teammate) => teammate.canReply).map((teammate) => teammate.name);
const mentionedOnly = addressed.filter((teammate) => !teammate.canReply).map((teammate) => teammate.name);
if (replyReady.length > 0) lines.push(`${joinNames(replyReady)} can see it and reply here.`);
if (mentionedOnly.length > 0) lines.push(`${joinNames(mentionedOnly)} was mentioned in this thread.`);
if (present.length > 0) lines.push(`Also in the room: ${joinNames(present)}.`);
lines.push(`To stay in this conversation, reply with thread_id="${threadId}".`);
return lines.join(" ");
}
interface MentionedTeammate {
name: string;
canReply: boolean;
}
function mentionedTeammates(db: DatabaseSync, selfAgentId: string, messageId: string): MentionedTeammate[] {
const wakePattern = `%"sourceMessageId":"${messageId}"%`;
const rows = db.prepare(`
select coalesce(a.name, e.display_name) as name,
case
when m.target_type = 'employee' then 1
when exists (
select 1
from task_runs r
where r.agent_id = m.target_id
and r.trigger = 'chat_mention'
and r.secret_scope_json like ?
) then 1
else 0
end as can_reply
from chat_mentions m
left join agents a on a.id = m.target_id and m.target_type = 'agent'
left join employees e on e.id = m.target_id and m.target_type = 'employee'
where m.message_id = ? and m.target_type in ('agent', 'employee') and m.target_id <> ?
`).all(wakePattern, messageId, selfAgentId) as Array<{ name: string | null; can_reply: number }>;
return dedupeTeammates(rows);
}
function channelTeammates(db: DatabaseSync, selfAgentId: string, spaceId: string, addressed: MentionedTeammate[]): string[] {
const alreadyNamed = new Set(addressed.map((teammate) => teammate.name));
const rows = db.prepare(`
select coalesce(a.name, e.display_name) as name
from chat_memberships cm
left join agents a on a.id = cm.principal_id and cm.principal_type = 'agent'
left join employees e on e.id = cm.principal_id and cm.principal_type = 'employee'
where cm.space_id = ? and cm.principal_id <> ?
`).all(spaceId, selfAgentId) as Array<{ name: string | null }>;
return dedupeNames(rows).filter((name) => !alreadyNamed.has(name)).slice(0, 6);
}
function dedupeNames(rows: Array<{ name: string | null }>): string[] {
const names = new Set<string>();
for (const row of rows) if (row.name) names.add(row.name);
return [...names];
}
function dedupeTeammates(rows: Array<{ name: string | null; can_reply: number }>): MentionedTeammate[] {
const teammates = new Map<string, MentionedTeammate>();
for (const row of rows) {
if (!row.name) continue;
const existing = teammates.get(row.name);
const canReply = row.can_reply === 1;
teammates.set(row.name, { name: row.name, canReply: canReply || existing?.canReply === true });
}
return [...teammates.values()];
}
function joinNames(names: string[]): string {
if (names.length === 1) return names[0];
if (names.length === 2) return `${names[0]} and ${names[1]}`;
return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`;
}
function createDocumentTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
const title = requireString(args.title, "title");
const docType = requireString(args.doc_type, "doc_type");
const content = requireString(args.content, "content");
const documentService = new DocumentService(deps.db);
const docId = documentService.create({
orgId: ctx.orgId,
title,
docType,
content,
authorAgentId: ctx.agent.id,
reviewerAgentId: null,
projectId: ctx.taskProjectId,
goalId: ctx.taskGoalId,
taskId: null,
});
return `Created document "${title}" (doc_id=${docId}, type=${docType}, chars=${content.length}).`;
}
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 spaceId = deps.chat.ensureDirectSpace(
ctx.orgId,
{ type: "agent", id: ctx.agent.id },
{ type: "agent", id: input.assignee.id },
);
const body = [
`@${canonicalMentionHandle(input.assignee.name)} take task_id=${input.taskId}: ${input.title}.`,
input.description,
].join(" ");
const messageId = deps.chat.sendMessage({
orgId: ctx.orgId,
spaceId,
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 withSavepoint<T>(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 {
if (typeof value !== "string" || !value.trim()) throw new Error(`"${field}" is required.`);
return value.trim();
}
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;
return RISK_LEVEL.LOW;
}
function normalizeStatus(value: unknown): string {
const allowed = [TASK_STATUS.BACKLOG, TASK_STATUS.IN_PROGRESS, TASK_STATUS.REVIEW, TASK_STATUS.DONE] as string[];
if (typeof value === "string" && allowed.includes(value)) return value;
throw new Error(`Invalid status. Use one of: ${allowed.join(", ")}.`);
}