diff --git a/doc/AGENT-ARTIFACTS.md b/doc/AGENT-ARTIFACTS.md index f5c12fdaf9c..cb87f4d8bb9 100644 --- a/doc/AGENT-ARTIFACTS.md +++ b/doc/AGENT-ARTIFACTS.md @@ -27,6 +27,12 @@ remote workspace. Supply a workspace-relative `contentRef`, basename `filename`, The tool verifies the file, stores an attachment and artifact work product, and binds it to the response. Generic API tools and a legacy API key are unnecessary. +`register_deliverable` stores the file on its current task. A completion report +can also cite a file on a direct child task that is done, because the child's +own completion verified that file. A parent that delegated a file to a child +links the child's file and does not register a copy. The child's file does not +count as delivery of a file that the parent's own request names. + Named JSON file outputs and explicit publication requests require the same verified delivery evidence as other file outputs. A saved summary document does not replace a requested downloadable JSON file. Reading JSON inputs or discussing diff --git a/server/src/services/native-runtime/native-deliverable-feedback.ts b/server/src/services/native-runtime/native-deliverable-feedback.ts index bad131e5af9..ba36256ccd5 100644 --- a/server/src/services/native-runtime/native-deliverable-feedback.ts +++ b/server/src/services/native-runtime/native-deliverable-feedback.ts @@ -324,6 +324,20 @@ export async function publishedTaskDocuments(db: Db, binding: { companyId: strin href: `/${encodeURIComponent(issuePrefix)}/issues/${encodeURIComponent(issueIdentifier ?? binding.issueId)}#document-${encodeURIComponent(key)}` })); } +/** A parent that delegated a file to a child links the child's delivered file + * instead of publishing a copy, because native register_deliverable is bound to + * its current task. A completed direct child's attachment is a valid citation, + * but not this run's publication of a file that the parent itself requests. + */ +async function completedChildAttachment(db: Db, binding: { companyId: string; issueId: string; lockSources?: boolean }, id: string): Promise { + const query = db.select({ id: issueAttachments.id }).from(issueAttachments) + .innerJoin(issues, and(eq(issues.id, issueAttachments.issueId), eq(issues.companyId, binding.companyId), + eq(issues.parentId, binding.issueId), eq(issues.status, "done"))) + .where(and(eq(issueAttachments.id, id), eq(issueAttachments.companyId, binding.companyId))).limit(1); + const [attachment] = await (binding.lockSources ? query.for("share", { of: [issueAttachments, issues], noWait: true }) : query); + return attachment !== undefined; +} + /** Files cited as completed output must be reachable outside the agent workspace. */ export async function validateNativeDeliverableEvidence( db: Db, @@ -358,7 +372,8 @@ export async function validateNativeDeliverableEvidence( const [attachment] = uuid.test(id) ? await (binding.lockSources ? query.for("share", { of: [issueAttachments, assets], noWait: true }) : query) : []; if (!attachment) { - throw new NativeDeliverableEvidenceError("Completion cites no registered attachment on this task. Use register_deliverable for the requested file and cite deliverable: from its receipt. No human completion approval was created."); + if (uuid.test(id) && await completedChildAttachment(db, binding, id)) continue; + throw new NativeDeliverableEvidenceError("Completion cites no registered attachment on this task or its completed child tasks. Use register_deliverable for the requested file and cite deliverable: from its receipt. No human completion approval was created."); } // A prior output (or user input) can be useful context, but does not prove // this run published the newly requested output. The receipt survives a diff --git a/server/src/services/native-runtime/native-runner-file-handoff.test.ts b/server/src/services/native-runtime/native-runner-file-handoff.test.ts index a01a17d0dd7..e7c166fd71c 100644 --- a/server/src/services/native-runtime/native-runner-file-handoff.test.ts +++ b/server/src/services/native-runtime/native-runner-file-handoff.test.ts @@ -32,6 +32,7 @@ import { issueComments, issues, issueWorkProducts, + type Db, } from "@paperclipai/db"; import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; @@ -45,6 +46,7 @@ import { stageNativeRunnerAttachmentBytes, stageNativeRunnerWakeAttachments, } from "./native-runner-file-handoff.js"; +import { validateNativeDeliverableEvidence } from "./native-deliverable-feedback.js"; import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; describe("native runner file handoff", () => { @@ -461,7 +463,26 @@ describe("native runner file handoff", () => { try { await expect(nativeCompletionFeedback(db, runId, doneReport([`deliverable:${first.entityRefs[0]}`]))) .rejects.toThrow("registered attachment on this task"); + // A parent links the file its completed child delivered. It cannot + // register that file again, because register_deliverable is task-bound. + await db.update(issues).set({ parentId: issueId }).where(eq(issues.id, otherIssueId)); + await expect(nativeCompletionFeedback(db, runId, doneReport([`deliverable:${first.entityRefs[0]}`]))) + .rejects.toThrow("registered attachment on this task"); + await db.update(issues).set({ status: "done" }).where(eq(issues.id, otherIssueId)); + await db.update(issues).set({ title: "Review the delegated work" }).where(eq(issues.id, issueId)); + await expect(nativeCompletionFeedback(db, runId, doneReport([`/api/attachments/${first.entityRefs[0]}/content`]))) + .resolves.toContain("Completion report accepted"); + await expect(db.transaction(tx => validateNativeDeliverableEvidence(tx as unknown as Db, { + companyId, issueId, runId, objective: "Review the delegated work", semanticToolReceipts: {}, + lockSources: true, authoritativeDone: true, + }, doneReport([`deliverable:${first.entityRefs[0]}`])))).resolves.toBeUndefined(); + // The child's file is context, not this run's publication of a file the + // parent's own request names. + await db.update(issues).set({ title: "Prepare a requested file" }).where(eq(issues.id, issueId)); + await expect(nativeCompletionFeedback(db, runId, doneReport([`deliverable:${first.entityRefs[0]}`]))) + .rejects.toThrow("no accessible delivery evidence"); } finally { + await db.update(issues).set({ title: "Prepare a requested file" }).where(eq(issues.id, issueId)); await db.update(issueAttachments).set({ issueId }).where(eq(issueAttachments.id, first.entityRefs[0])); await db.delete(issues).where(eq(issues.id, otherIssueId)); }