Q4: delegated tasks become subtasks of the work that created them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6570492daa
commit
fe76f0bc52
4 files changed
+150
-101
No files matched your search
@@ -30,3 +30,16 @@ export class DatabaseConnection {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
@@ -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}".`);
|
||||
}
|
||||
@@ -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<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 normalizeRisk(value: unknown): RiskLevel {
|
||||
if (value === RISK_LEVEL.MEDIUM) return RISK_LEVEL.MEDIUM;
|
||||
if (value === RISK_LEVEL.HIGH) return RISK_LEVEL.HIGH;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user