diff --git a/src/main/database/connection.ts b/src/main/database/connection.ts index ef22da96..9d1fee57 100644 --- a/src/main/database/connection.ts +++ b/src/main/database/connection.ts @@ -30,3 +30,16 @@ export class DatabaseConnection { this.db.close(); } } + +export 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; + } +} diff --git a/src/main/services/agent-send-message.ts b/src/main/services/agent-send-message.ts new file mode 100644 index 00000000..af4a339e --- /dev/null +++ b/src/main/services/agent-send-message.ts @@ -0,0 +1,90 @@ +import type { DatabaseSync } from "node:sqlite"; +import { withSavepoint } from "../database/connection"; +import { describeSend } from "./agent-message-presence"; +import { optionalString, optionalStringArray, requireString } from "./agent-tool-args"; +import type { AgentToolContext, AgentToolDeps } from "./agent-tools"; +import type { JsonRecord } from "./provider-http"; + +export 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}".`); +} diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index ed1dc628..58dc407d 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -10,7 +10,8 @@ import { grantConnectorTool, listConnectorsTool } from "./agent-connector-tools" import { bindSkillSlugs, grantSkillTool, listSkillsTool } from "./agent-skill-tools"; import { createDocumentTool, writeDeliverableTool } from "./agent-deliverable-tools"; import { companyStatusTool, GOAL_TOOLS, updateGoalTool } from "./agent-goal-tools"; -import { describeSend } from "./agent-message-presence"; +import { sendMessageTool } from "./agent-send-message"; +import { withSavepoint } from "../database/connection"; import { optionalString, optionalStringArray, requireString } from "./agent-tool-args"; import type { JsonRecord } from "./provider-http"; import type { ToolCall, ToolDefinition } from "./provider-tool-turn"; @@ -312,6 +313,7 @@ function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord const assigneeName = optionalString(args.assignee_agent_name); const assignee = assigneeName ? requireRunnableAgentByName(deps.db, ctx.orgId, assigneeName) : null; const handoff = buildTaskHandoff(deps.db, ctx.orgId, { taskId: ctx.taskId, threadId: ctx.sourceThreadId }, args); + const parentTaskId = delegationParent(deps.db, ctx); const create = () => { const taskId = deps.organization.createTask({ orgId: ctx.orgId, @@ -326,16 +328,26 @@ function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord priority: TASK_PRIORITY.NORMAL, requiresFilesystem: args.requires_filesystem === true, }); - deps.db.prepare("update tasks set handoff_json = ? where id = ?").run(JSON.stringify(handoff), taskId); + deps.db.prepare("update tasks set handoff_json = ?, parent_task_id = ? where id = ?") + .run(JSON.stringify(handoff), parentTaskId, taskId); 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(); + const { taskId, assignmentThread } = withSavepoint(deps.db, "agent_create_task", create); return `Created task "${title}" (task_id=${taskId})${assignee ? ` assigned to ${assignee.name}` : " (unassigned)"}${assignmentThread ? ` in thread_id="${assignmentThread}"` : ""}.`; } +/** Only real work parents a subtask; chat and review vehicles do not. */ +function delegationParent(db: DatabaseSync, ctx: AgentToolContext): string | null { + if (!ctx.taskId) return null; + const source = db.prepare("select origin from tasks where id = ? and org_id = ?") + .get(ctx.taskId, ctx.orgId) as { origin: string | null } | undefined; + if (!source) throw new Error(`The task this run executes (${ctx.taskId}) no longer exists.`); + return source.origin === null ? ctx.taskId : null; +} + function updateTaskStatus(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { const taskId = requireString(args.task_id, "task_id"); const status = normalizeStatus(args.status); @@ -344,91 +356,6 @@ function updateTaskStatus(deps: AgentToolDeps, ctx: AgentToolContext, args: Json } -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}".`); -} - - interface RunnableAssignmentAgent { id: string; name: string; @@ -478,19 +405,6 @@ function postAssignmentMessage( return row.thread_id; } -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 normalizeRisk(value: unknown): RiskLevel { if (value === RISK_LEVEL.MEDIUM) return RISK_LEVEL.MEDIUM; if (value === RISK_LEVEL.HIGH) return RISK_LEVEL.HIGH; diff --git a/tests/unit/agent-task-handoff.test.ts b/tests/unit/agent-task-handoff.test.ts index 584d36d9..11e6b31b 100644 --- a/tests/unit/agent-task-handoff.test.ts +++ b/tests/unit/agent-task-handoff.test.ts @@ -94,3 +94,35 @@ describe("structured task handoff", () => { h.connection.close(); }); }); + +describe("delegation parentage", () => { + test("a task created inside real work becomes its subtask", () => { + const h = harness(); + const result = dispatchAgentTool(h.deps, h.ctx, call("create_task", { title: "Child", description: "d" })); + const childId = /task_id=([^)\s]+)\)/.exec(result)![1]; + expect(h.connection.db.prepare("select parent_task_id from tasks where id = ?").get(childId)).toEqual({ parent_task_id: h.sourceTaskId }); + h.connection.close(); + }); + + test("tasks created from a chat-wake vehicle keep no parent but record their source", () => { + const h = harness(); + h.connection.db.prepare("update tasks set origin = 'chat_wake' where id = ?").run(h.sourceTaskId); + const result = dispatchAgentTool(h.deps, h.ctx, call("create_task", { title: "From chat", description: "d" })); + const childId = /task_id=([^)\s]+)\)/.exec(result)![1]; + const row = h.connection.db.prepare("select parent_task_id, handoff_json from tasks where id = ?").get(childId) as { parent_task_id: string | null; handoff_json: string }; + expect(row.parent_task_id).toBeNull(); + expect(JSON.parse(row.handoff_json).sourceTaskId).toBe(h.sourceTaskId); + h.connection.close(); + }); +}); + +describe("delegation from a vanished task", () => { + test("fails before creating anything", () => { + const h = harness(); + h.connection.db.prepare("delete from tasks where id = ?").run(h.sourceTaskId); + expect(dispatchAgentTool(h.deps, h.ctx, call("create_task", { title: "Orphan", description: "d" }))) + .toBe(`ERROR: The task this run executes (${h.sourceTaskId}) no longer exists.`); + expect(h.connection.db.prepare("select count(*) as n from tasks where title = 'Orphan'").get()).toEqual({ n: 0 }); + h.connection.close(); + }); +});