238 lines
9.7 KiB
TypeScript
238 lines
9.7 KiB
TypeScript
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";
|
|
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;
|
|
}
|
|
|
|
/** 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", description: "Department, e.g. 'Marketing' or 'Operations'." },
|
|
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: "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: '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"],
|
|
},
|
|
},
|
|
];
|
|
|
|
export function dispatchAgentTool(deps: AgentToolDeps, ctx: AgentToolContext, call: ToolCall): string {
|
|
try {
|
|
switch (call.name) {
|
|
case "hire_agent":
|
|
return hireAgent(deps, ctx, call.arguments);
|
|
case "create_task":
|
|
return createTask(deps, ctx, call.arguments);
|
|
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);
|
|
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): 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)"}.`;
|
|
}
|
|
|
|
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 = typeof args.content === "string" ? args.content : "";
|
|
const written = writeDeliverable({ workspaceRoot: ctx.workspace.localPath, relativePath: path, content });
|
|
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).`;
|
|
}
|
|
|
|
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;
|
|
if (!space) throw new Error(`No channel named "${channel}".`);
|
|
const messageId = deps.chat.sendMessage({
|
|
orgId: ctx.orgId,
|
|
spaceId: space.id,
|
|
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 `Posted to #${channel} (message_id=${messageId}, thread_id=${row.thread_id}).`;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 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(", ")}.`);
|
|
}
|