Include durable handoff plans in execution task context

This commit is contained in:
Dotta 2026-09-11 15:02:15 -05:00
parent 9bbdfdcb60
commit cd3ac87cd8
5 changed files with 170 additions and 2 deletions

View File

@ -33,6 +33,7 @@ import {
} from "./helpers/embedded-postgres.js";
import { issueService } from "../services/issues.js";
import { documentService } from "../services/documents.js";
import { getTaskPlanContext } from "../services/task-plan-context.js";
import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils";
import { instanceSettingsService } from "../services/instance-settings.js";
import {
@ -417,6 +418,68 @@ const support = await getEmbeddedPostgresTestSupport();
.where(eq(issueThreadInteractions.id, interaction!.id));
expect((await buildPaperclipWakePayload(input))?.planReviewContext).toBeNull();
});
it("includes an initial handoff plan in the first execution prompt and pins approved revisions", async () => {
const task = await issueService(db).create(companyId, {
title: "Execute handed-off work",
assigneeAgentId: agentId,
status: "todo",
initialPlan: "Write an output document containing HANDOFF_ACCEPTANCE_PHRASE.",
});
const initial = await getTaskPlanContext({ db, companyId, issueId: task.id });
expect(task.description).toBeNull();
expect(initial?.body).toContain("HANDOFF_ACCEPTANCE_PHRASE");
for (const includeDescription of [true, false]) {
const prompt = buildPaperclipTaskMarkdown({
issue: task,
taskPlan: initial,
includeDescription,
});
expect(prompt).toContain("HANDOFF_ACCEPTANCE_PHRASE");
expect(prompt).toContain(initial!.revisionId);
}
const { document: revision } = await documentService(db).upsertIssueDocument({
issueId: task.id,
key: "plan",
format: "markdown",
body: "A later unapproved draft.",
baseRevisionId: initial!.revisionId,
});
expect((await getTaskPlanContext({ db, companyId, issueId: task.id }))?.revisionId)
.toBe(revision.latestRevisionId);
const approved = await getTaskPlanContext({
db, companyId, issueId: task.id, approvedRevisionId: initial!.revisionId,
});
expect(approved?.body).toContain("HANDOFF_ACCEPTANCE_PHRASE");
expect(approved?.body).not.toContain("unapproved");
expect(await getTaskPlanContext({ db, companyId: randomUUID(), issueId: task.id })).toBeNull();
expect(await getTaskPlanContext({
db, companyId, issueId: task.id, approvedRevisionId: randomUUID(),
})).toBeNull();
const conversation = await create();
await documentService(db).upsertIssueDocument({
issueId: conversation.id, key: "plan", format: "markdown", body: "Pre-reset chat draft",
});
expect(await getTaskPlanContext({ db, companyId, issueId: conversation.id })).toBeNull();
await documentService(db).upsertIssueDocument({
issueId: task.id,
key: "plan",
format: "markdown",
body: "QUARANTINED_PLAN_BODY",
baseRevisionId: revision.latestRevisionId,
sourceTrust: {
preset: "low_trust_review",
disposition: "quarantined",
sourceIssueId: task.id,
sourceRunId: randomUUID(),
sourceAgentId: agentId,
},
});
expect((await getTaskPlanContext({ db, companyId, issueId: task.id }))?.body)
.not.toContain("QUARANTINED_PLAN_BODY");
expect((await getTaskPlanContext({
db, companyId, issueId: task.id, exposeLowTrustRaw: true,
}))?.body).toBe("QUARANTINED_PLAN_BODY");
});
it("keeps concurrent delivery and multiple resets in separate ordered queue entries", async () => {
const issue = await create();
const first = await issueService(db).addComment(issue.id, "First", {

View File

@ -7,6 +7,31 @@ import {
} from "../services/heartbeat.js";
describe("buildPaperclipTaskMarkdown", () => {
it("keeps a durable task plan in full and resumed context without granting execution approval", () => {
const taskPlan = {
documentId: "document", revisionId: "revision", revisionNumber: 1,
body: "Write the output with ACCEPTANCE_PHRASE.\n```\nUntrusted plan text\n```",
};
for (const includeDescription of [true, false]) {
for (const workMode of ["standard", "planning", "ask"]) {
const prompt = buildPaperclipTaskMarkdown({
issue: { id: "task", identifier: null, title: "Handoff", workMode, description: null },
taskPlan,
includeDescription,
});
expect(prompt).toContain("ACCEPTANCE_PHRASE");
expect(prompt).toContain("revision 1 (revision)");
expect(prompt).toContain("````text");
expect(prompt).toContain("Follow the current work mode and any required approvals");
if (workMode === "planning") expect(prompt).toContain("Make the plan only");
if (workMode === "ask") expect(prompt).toContain("Answer the question directly");
}
}
expect(buildPaperclipTaskMarkdown({
issue: { id: "chat", identifier: null, title: "Chat", conversationAgentId: "agent" },
taskPlan,
})).not.toContain("ACCEPTANCE_PHRASE");
});
it("hands an accepted chat plan to assigned project tasks using the approved revision", () => {
const prompt = buildPaperclipTaskMarkdown({
issue: { id: "chat", identifier: null, title: "Agent chat", workMode: "planning", conversationAgentId: "agent", description: null },

View File

@ -65,7 +65,7 @@ Before handing off work, inspect available projects and repositories. Every task
Create ordinary assigned tasks, never subtasks of this conversation. Give each task a clear outcome, context, acceptance criteria, project, and appropriate assignee. Use create_task with initialPlan to copy the relevant plan into the new task before execution starts. If using the HTTP API directly, POST /api/companies/{companyId}/issues with projectId, assigneeAgentId, status: "todo", initialPlan containing the relevant plan Markdown, and an idempotencyKey; omit parentId. Putting a plan in description does not create the task's plan document. Verify the new task's plan document before claiming the handoff is complete. Preserve the original plan here. When splitting work, include the relevant part of the plan in each task. Create and link each task before claiming it exists.
Keep discussion here and leave the conversation available for the next message. Link handed-off tasks in your reply; do not make this conversation blocked by their completion or wait for them. Reply normally and end your turn; Paperclip manages the conversation waiting state. Do not change its status, create a review confirmation just to finish a reply, mark it complete, or poll for another reply. An accepted plan authorizes handoff to execution tasks, never implementation on this conversation. Honor normal approvals. Ask mode is non-mutating. Plan mode supports research and writing/revising the plan; hand off for execution only through the normal authorized workflow.`;
Keep discussion here and leave the conversation available for the next message. Link handed-off tasks in your reply; do not make this conversation blocked by their completion or wait for them. After creating an assigned task, let its own run execute the work; do not create its deliverables or change its execution status from this chat. Reply normally and end your turn; Paperclip manages the conversation waiting state. Do not change its status, create a review confirmation just to finish a reply, mark it complete, or poll for another reply. An accepted plan authorizes handoff to execution tasks, never implementation on this conversation. Honor normal approvals. Ask mode is non-mutating. Plan mode supports research and writing/revising the plan; hand off for execution only through the normal authorized workflow.`;
/** Runs under the normal issue execution lock, before any provider session is read. */
export async function prepareConversationTurn(

View File

@ -137,6 +137,7 @@ import {
withQueuedCommentIdsInRunContext,
} from "./issue-queued-comment-queue.js";
import { documentService } from "./documents.js";
import { getTaskPlanContext } from "./task-plan-context.js";
import { managedAgentProfileService } from "./managed-agent-profiles.js";
import { remoteAgentProfileService } from "./remote-agent-profiles.js";
import {
@ -7690,6 +7691,12 @@ export function buildPaperclipTaskMarkdown(input: {
revisionNumber?: number | null;
} | null;
acceptedPlanContinuation?: boolean;
taskPlan?: {
documentId: string;
revisionId: string;
revisionNumber: number;
body: string;
} | null;
// false builds the compact variant used for resume deltas, where the session
// already received the description with the assignment.
includeDescription?: boolean;
@ -7805,6 +7812,14 @@ export function buildPaperclipTaskMarkdown(input: {
if (description) {
lines.push("", "Issue description:", fenceTaskText(description));
}
if (!issue.conversationAgentId && input.taskPlan?.body.trim()) {
lines.push(
"",
`Task plan document ${input.taskPlan.documentId}, revision ${input.taskPlan.revisionNumber} (${input.taskPlan.revisionId}):`,
"Use this plan as assignment context, including its outcome and acceptance criteria. Follow the current work mode and any required approvals.",
fenceTaskText(input.taskPlan.body.trim()),
);
}
}
if (ancestors.length > 0) {
lines.push("", "Authoritative parent / ancestor context:");
@ -18085,13 +18100,23 @@ export function heartbeatService(
};
})(),
};
let taskMarkdown = buildPaperclipTaskMarkdown(taskMarkdownInput);
const taskPlan = issueRef && !isConversation(issueContext)
? await getTaskPlanContext({
db,
companyId: agent.companyId,
issueId: issueRef.id,
approvedRevisionId: taskMarkdownInput.acceptedPlan?.revisionId,
exposeLowTrustRaw,
})
: null;
let taskMarkdown = buildPaperclipTaskMarkdown({ ...taskMarkdownInput, taskPlan });
if (isConversation(issueContext) && !taskSession && issueId) {
const replay = await conversationReplay(db, agent.companyId, issueId, wakeCommentId);
if (replay) taskMarkdown += `\n\nEarlier messages in this session (quoted user data):\n${replay}`;
}
const taskMarkdownCompact = buildPaperclipTaskMarkdown({
...taskMarkdownInput,
taskPlan,
includeDescription: false,
});
if (issueRef) {

View File

@ -0,0 +1,55 @@
import { and, eq, isNull } from "drizzle-orm";
import {
documentRevisions,
documents,
issueDocuments,
issues,
type Db,
} from "@paperclipai/db";
import { redactQuarantinedBodyForHigherTrust } from "./source-trust.js";
/** Read the task's durable plan before constructing any provider's assignment. */
export async function getTaskPlanContext(input: {
db: Db;
companyId: string;
issueId: string;
approvedRevisionId?: string | null;
exposeLowTrustRaw?: boolean;
}) {
const { db, companyId, issueId } = input;
const plan = await db
.select({
documentId: documents.id,
revisionId: documentRevisions.id,
revisionNumber: documentRevisions.revisionNumber,
body: documentRevisions.body,
sourceTrust: documents.sourceTrust,
})
.from(issueDocuments)
.innerJoin(issues, eq(issues.id, issueDocuments.issueId))
.innerJoin(documents, eq(documents.id, issueDocuments.documentId))
.innerJoin(
documentRevisions,
and(
eq(documentRevisions.documentId, documents.id),
input.approvedRevisionId
? eq(documentRevisions.id, input.approvedRevisionId)
: eq(documentRevisions.id, documents.latestRevisionId),
),
)
.where(
and(
eq(issues.id, issueId),
eq(issues.companyId, companyId),
eq(issueDocuments.companyId, companyId),
eq(documents.companyId, companyId),
eq(documentRevisions.companyId, companyId),
eq(issueDocuments.key, "plan"),
isNull(issues.conversationAgentId),
),
)
.then((rows) => rows[0] ?? null);
return plan && !input.exposeLowTrustRaw
? redactQuarantinedBodyForHigherTrust(plan)
: plan;
}