Q3: record a structured handoff on every delegated task

create_task now pins upstream files by content hash and carries the
source task, source thread, verified facts, open questions and
acceptance criteria into the assignee's prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 02:35:12 -04:00
1 parent 9f2adce7b3
commit 6570492daa
22 files changed
+219 -28

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import type { DatabaseSync } from "node:sqlite";
import { optionalStringArray } from "./agent-tool-args";
import type { JsonRecord } from "./provider-http";
export interface TaskHandoff {
sourceTaskId: string | null;
sourceThreadId: string | null;
inputs: Array<{ fileId: string; name: string; sha256: string }>;
facts: string[];
openQuestions: string[];
acceptanceCriteria: string[];
}
export const HANDOFF_TOOL_PROPERTIES = {
acceptance_criteria: { type: "array", items: { type: "string" }, description: "Checks that prove the task is done." },
input_file_ids: { type: "array", items: { type: "string" }, description: "file_ids of upstream deliverables the assignee builds on; their current version is pinned." },
facts: { type: "array", items: { type: "string" }, description: "Facts already verified, so the assignee does not redo them." },
open_questions: { type: "array", items: { type: "string" }, description: "Questions still unresolved." },
} as const;
export function buildTaskHandoff(
db: DatabaseSync,
orgId: string,
source: { taskId: string | null; threadId: string | null },
args: JsonRecord,
): TaskHandoff {
const inputs = optionalStringArray(args.input_file_ids, "input_file_ids").map((fileId) => {
const file = db.prepare("select id, name from file_objects where id = ? and org_id = ?")
.get(fileId, orgId) as { id: string; name: string } | undefined;
if (!file) throw new Error(`No file with file_id "${fileId}" exists in this company.`);
const sha256 = currentSha256(db, orgId, file.id);
if (!sha256) throw new Error(`File ${file.name} (file_id "${fileId}") is missing on disk.`);
return { fileId: file.id, name: file.name, sha256 };
});
return {
sourceTaskId: source.taskId,
sourceThreadId: source.threadId,
inputs,
facts: optionalStringArray(args.facts, "facts"),
openQuestions: optionalStringArray(args.open_questions, "open_questions"),
acceptanceCriteria: optionalStringArray(args.acceptance_criteria, "acceptance_criteria"),
};
}
export function renderTaskHandoff(db: DatabaseSync, orgId: string, handoffJson: string | null): string[] {
if (!handoffJson) return [];
const handoff = JSON.parse(handoffJson) as TaskHandoff;
const lines = ["", "Handoff:"];
if (handoff.sourceTaskId) {
const source = db.prepare("select title from tasks where id = ? and org_id = ?")
.get(handoff.sourceTaskId, orgId) as { title: string } | undefined;
lines.push(`- Delegated from task "${source?.title ?? "deleted task"}" (task_id=${handoff.sourceTaskId}).`);
}
if (handoff.sourceThreadId) lines.push(`- Source conversation: thread_id="${handoff.sourceThreadId}".`);
for (const input of handoff.inputs) {
const current = currentSha256(db, orgId, input.fileId);
const state = !current ? "deleted since the handoff" : current === input.sha256 ? "unchanged since the handoff" : "CHANGED since the handoff — re-check it";
lines.push(`- Input ${input.name} (file_id=${input.fileId}, sha256=${input.sha256.slice(0, 12)}): ${state}.`);
}
for (const fact of handoff.facts) lines.push(`- Verified: ${fact}`);
for (const question of handoff.openQuestions) lines.push(`- Open question: ${question}`);
for (const check of handoff.acceptanceCriteria) lines.push(`- Done when: ${check}`);
return lines;
}
function currentSha256(db: DatabaseSync, orgId: string, fileId: string): string | null {
const row = db.prepare("select storage_path from file_objects where id = ? and org_id = ?")
.get(fileId, orgId) as { storage_path: string } | undefined;
if (!row || !existsSync(row.storage_path)) return null;
return createHash("sha256").update(readFileSync(row.storage_path)).digest("hex");
}
+8 -1
View File
@@ -2,6 +2,7 @@ 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 { buildTaskHandoff, HANDOFF_TOOL_PROPERTIES } from "./agent-task-handoff";
import type { ChatService } from "./chat-service";
import { canonicalMentionHandle } from "../../shared/mention-handles";
import { readMessagesTool } from "./agent-read-messages";
@@ -23,6 +24,9 @@ export interface AgentToolContext {
agent: { id: string; modelProviderId: string | null; modelId: string | null };
taskProjectId: string | null;
taskGoalId: string | null;
/** The task this run executes. */
taskId: string | null;
sourceThreadId: string | null;
/** writeAccess mirrors the task's approved filesystem grant (read_write mount) and gates run_command. */
workspace: { id: string; localPath: string; writeAccess: boolean } | null;
}
@@ -64,7 +68,7 @@ export const AGENT_TOOLS: ToolDefinition[] = [
},
{
name: "create_task",
description: "Create a task for the team and optionally assign it to one of your reports by name.",
description: "Create a task for the team and optionally assign it to one of your reports by name. Hand over what you know: acceptance criteria, upstream file_ids, verified facts, and open questions.",
parameters: {
type: "object",
additionalProperties: false,
@@ -77,6 +81,7 @@ export const AGENT_TOOLS: ToolDefinition[] = [
type: "boolean",
description: "Set true for engineering work that must change code or run commands (build, test) in the workspace. The assignee then gets run_command; the operator approves such tasks before they execute.",
},
...HANDOFF_TOOL_PROPERTIES,
},
required: ["title", "description"],
},
@@ -306,6 +311,7 @@ function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord
const description = requireString(args.description, "description");
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 create = () => {
const taskId = deps.organization.createTask({
orgId: ctx.orgId,
@@ -320,6 +326,7 @@ 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);
const assignmentThread = options.announceTaskAssignments !== false && assignee && assignee.id !== ctx.agent.id
? postAssignmentMessage(deps, ctx, { taskId, title, description, assignee })
: null;
+5 -1
View File
@@ -24,6 +24,7 @@ import {
type TaskRunIsolation,
type WorkspaceRow,
} from "./task-prompts";
import { renderTaskHandoff } from "./agent-task-handoff";
import { loadComposioTools } from "./composio/composio-tool-bridge";
import { connectorDisplayName } from "./composio/connector-brands";
import { ConnectorGrantsService } from "./composio/connector-grants";
@@ -133,14 +134,16 @@ export class TaskExecutionService {
let skillRuntimeSnapshot: SkillRuntimeSnapshotRow | null = null;
try {
skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId);
const chatContext = this.loadChatContext(orgId, isolation);
const toolContext: AgentToolContext = {
orgId,
agent: { id: agent.id, modelProviderId: agent.model_provider_id, modelId: agent.model_id },
taskProjectId: task.project_id,
taskGoalId: task.goal_id,
taskId: task.id,
sourceThreadId: chatContext?.threadId ?? null,
workspace: workspace ? { id: workspace.id, localPath: workspace.local_path, writeAccess: task.requires_filesystem === 1 } : null,
};
const chatContext = this.loadChatContext(orgId, isolation);
// The CEO is the c_suite role — the same predicate the grant system
// enforces — not "has no manager", which any board hire can match.
const isCeo = agent.role_level === "c_suite";
@@ -177,6 +180,7 @@ export class TaskExecutionService {
hasReports,
managerName: this.agentName(orgId, agent.manager_agent_id),
chatContext,
handoffLines: renderTaskHandoff(this.db, orgId, task.handoff_json),
priorCompletedRuns: this.completedRunCount(task.id, runId),
}),
toolContext,
+3
View File
@@ -13,6 +13,7 @@ export interface TaskExecutionTaskRow {
workspace_id: string | null;
priority: string;
requires_filesystem: number;
handoff_json: string | null;
}
export interface TaskExecutionAgentRow {
@@ -163,6 +164,7 @@ export function buildTaskPrompt(input: {
hasReports: boolean;
managerName: string | null;
chatContext: ChatContextBlock | null;
handoffLines: string[];
/** Completed runs this task already spent without reaching done. */
priorCompletedRuns: number;
}): string {
@@ -194,6 +196,7 @@ export function buildTaskPrompt(input: {
`Mounts: ${input.isolation.workspaceMountJson}`,
);
}
lines.push(...input.handoffLines);
lines.push("", `Secret scope: ${input.isolation.secretScopeJson}`);
if (input.skillRuntimeSnapshot) {
lines.push(