fix(runner): require delivery evidence for explicit file outputs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-12 22:19:40 -05:00
parent 2582bf814d
commit 417a96011a
5 changed files with 128 additions and 7 deletions

View File

@ -1204,6 +1204,13 @@ accessible repository work products do not require an attachment. Publication
failure calls for continued work or a concrete blocker, not a human confirmation
that the task is complete.
For an explicit file output in the current request, an empty report, a
verification-only reference, or an unregistered URL cannot satisfy delivery.
The report must cite a task-scoped attachment or a registered accessible work
product. Reading or reviewing an existing file for an inline answer does not
require uploading that input. Ambiguous prose remains subject to the runner's
completion contract; the server's explicit-output check is deliberately narrow.
Local and remote runners use the same attachment publication contract. Remote
files are read through the bound environment runner, with workspace confinement,
no symlinks or hardlinks, stable file identity, a 10 MiB bound, and exact size and

View File

@ -62,7 +62,10 @@ export async function nativeCompletionFeedback(
if (issue.executionRunId && issue.executionRunId !== runId) {
return "Report accepted; a newer run owns the task. Do not claim this report changed its status.";
}
await validateNativeDeliverableEvidence(db, { companyId: run.companyId, issueId: issue.id }, result);
const continuation = run.contextSnapshot?.executionContinuation as { objective?: unknown } | undefined;
const objective = typeof continuation?.objective === "string"
? continuation.objective : [issue.title, issue.description].filter(Boolean).join("\n");
await validateNativeDeliverableEvidence(db, { companyId: run.companyId, issueId: issue.id, objective }, result);
const retiredCandidates = await findAutomaticCompletionReviews(db, issue.id);
const retiredIds = retiredCandidates.map(({ interaction }) => interaction.id);
const [interaction, approval] = await Promise.all([

View File

@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { explicitlyRequestsFileOutput } from "./native-deliverable-feedback.js";
describe("explicit file output requirements", () => {
it.each([
"Prepare a requested file",
"Make a Markdown file named checklist.md with three items.",
"Export the results as a CSV.",
"Give me a downloadable report.",
"Please create out/answer.pdf and attach it.",
"Do not use external services. Create a file with the results.",
"Make a file but do not send it to anyone else.",
"Export a summary of this PDF as CSV.",
])("recognizes an explicit output request: %s", objective => {
expect(explicitlyRequestsFileOutput(objective)).toBe(true);
});
it.each([
"Explain how a newsletter works",
"Read the file and explain what it does.",
"Review the PDF and answer in the chat.",
"Do not create a file; answer inline.",
"Don't attach a file. Reply with three bullets.",
"Fix a crash in parser.ts.",
"Read the file and write a short explanation inline.",
"No downloadable file is needed.",
"Write a summary of this PDF in chat.",
"Create a review of README.md; reply inline.",
"Give me advice on file permissions.",
])("does not require a file for a text or source-review request: %s", objective => {
expect(explicitlyRequestsFileOutput(objective)).toBe(false);
});
});

View File

@ -1,5 +1,5 @@
import { and, eq } from "drizzle-orm";
import { assets, issueAttachments, type Db } from "@paperclipai/db";
import { assets, issueAttachments, issueWorkProducts, type Db } from "@paperclipai/db";
import type { PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js";
function evidenceRefs(value: unknown): string[] {
@ -11,18 +11,46 @@ function evidenceRefs(value: unknown): string[] {
});
}
/** Recognize explicit output requests, not incidental mentions of source files.
* The current server-bound objective is authoritative; summaries cannot invent
* an output requirement or erase a user's request for a file.
*/
export function explicitlyRequestsFileOutput(objective: string): boolean {
return objective.split(/(?:[.!?](?:\s|$)|\n|[;,]|\bbut\b)/iu).some(clause => {
const file = /\b(?:files?|attachments?|downloads?|pdf|spreadsheets?|workbooks?|slide decks?|powerpoints?|docx|xlsx|csv)\b|\b[^\s/]+\.(?:md|txt|pdf|docx?|xlsx?|csv|pptx?|png|jpe?g|svg|zip)\b/giu;
const create = /\b(?:create|make|write|save|export|attach|send|generate|produce|prepare|provide|give|return|build)\b/iu.exec(clause);
if (create && /\b(?:do not|don't|never|no need to)\s*$/iu.test(clause.slice(0, create.index))) return false;
const output = create ? clause.slice(create.index + create[0].length) : "";
const fileObject = [...output.matchAll(file)].some(match => {
const prefix = output.slice(0, match.index);
const suffix = output.slice(match.index + match[0].length);
// "Write a summary of this PDF" names input, not a requested file.
// Explicit export destinations still count after such input references.
const destination = /\b(?:as|into|to)\s+(?:(?:a|an|the|new|separate|markdown|word|excel)\s+)*$/iu.test(prefix);
if (!destination && /\b(?:of|about|on|from|using|for|with)\b/iu.test(prefix)) return false;
if (/^files?$/iu.test(match[0]) && /^\s+(?:permissions?|systems?|formats?|names?|paths?|types?|sizes?|descriptors?)\b/iu.test(suffix)) return false;
return true;
});
return fileObject ||
(!/\b(?:no|without)\s+(?:downloadable|attached)/iu.test(clause) && /\b(?:downloadable|attached)\s+(?:file|report|document|checklist|draft)\b/iu.test(clause));
});
}
/** Files cited as completed output must be reachable outside the agent workspace. */
export async function validateNativeDeliverableEvidence(
db: Db,
binding: { companyId: string; issueId: string },
binding: { companyId: string; issueId: string; objective: string },
result: PrpStructuredRunResult,
): Promise<void> {
if (result.reportedWorkDisposition !== "done") return;
const fileRequested = explicitlyRequestsFileOutput(binding.objective);
const artifactRefs = new Set(evidenceRefs(result.artifacts));
const refs = new Set([
...evidenceRefs(result.evidence),
...evidenceRefs(result.artifacts),
...artifactRefs,
...result.completionClaim.criteria.flatMap(({ evidenceRefs }) => evidenceRefs),
]);
let registeredAttachment = false;
for (const value of refs) {
if (typeof value !== "string") continue;
const ref = value.trim();
@ -39,14 +67,29 @@ export async function validateNativeDeliverableEvidence(
if (!attachment) {
throw new Error("Completion cites no registered attachment on this task. Use register_deliverable for the requested file and cite deliverable:<attachmentId> from its receipt. No human completion approval was created.");
}
registeredAttachment = true;
continue;
}
// URLs and typed durable refs are not workspace paths. Verification commands
// belong in verification; do not scan prose or upload files named by a model.
const localFile = /^(?:file:|\.{0,2}\/|[a-z]:[\\/])/iu.test(ref)
|| (!/^[a-z][a-z0-9+.-]*:/iu.test(ref) && /^[^\r\n]+\.[a-z0-9]{1,16}(?::\d+(?::\d+)?)?$/iu.test(ref));
if (localFile) {
if (localFile && (fileRequested || artifactRefs.has(value))) {
throw new Error("Completion cites a workspace-only file that the user cannot download. Before finishing, use register_deliverable for requested file outputs and cite deliverable:<attachmentId> from the receipt, with /api/attachments/<attachmentId>/content as the download link. For repository changes, cite an accessible PR or registered work product instead. No human completion approval was created.");
}
}
if (fileRequested && !registeredAttachment) {
const products = refs.size ? await db.select().from(issueWorkProducts).where(and(
eq(issueWorkProducts.companyId, binding.companyId), eq(issueWorkProducts.issueId, binding.issueId),
)) : [];
const accessibleProduct = products.some(product => {
if (["failed", "cancelled", "archived"].includes(product.status)) return false;
const resource = product.metadata?.resourceRef as { kind?: unknown; path?: unknown } | undefined;
const accessible = (typeof product.url === "string" && /^https?:\/\//iu.test(product.url)) ||
(resource?.kind === "workspace_file" && typeof resource.path === "string" && resource.path.length > 0);
return accessible && [product.url, `work_product:${product.id}`, `work-product:${product.id}`, `artifact:${product.id}`]
.some(ref => typeof ref === "string" && refs.has(ref));
});
if (!accessibleProduct) throw new Error("The requested file has no accessible delivery evidence. Use register_deliverable and cite deliverable:<attachmentId>, or cite a registered accessible work product for this task. Empty evidence and a verification result cannot substitute for the requested file. Continue publishing or report a concrete blocker; no human completion approval was created.");
}
}

View File

@ -200,9 +200,45 @@ describe("native runner file handoff", () => {
.rejects.toThrow(/register_deliverable|registered attachment/);
}
await expect(nativeCompletionFeedback(db, runId, doneReport([])))
.resolves.toContain("Completion report accepted");
.rejects.toThrow(/requested file.*accessible|register_deliverable/);
await expect(nativeCompletionFeedback(db, runId, doneReport(["verification:passed"])))
.rejects.toThrow(/requested file.*accessible|register_deliverable/);
await expect(nativeCompletionFeedback(db, runId, doneReport(["https://example.com/report.pdf"])))
.resolves.toContain("Completion report accepted");
.rejects.toThrow(/requested file.*accessible|register_deliverable/);
await db.update(issues).set({ title: "Explain how a newsletter works" }).where(eq(issues.id, issueId));
try {
await expect(nativeCompletionFeedback(db, runId, doneReport([])))
.resolves.toContain("Completion report accepted");
await expect(nativeCompletionFeedback(db, runId, doneReport(["README.md"])))
.resolves.toContain("Completion report accepted");
await expect(nativeCompletionFeedback(db, runId, { ...doneReport([]), artifacts: [{ ref: "unpublished.pdf" }] }))
.rejects.toThrow("workspace-only file");
} finally {
await db.update(issues).set({ title: "Prepare a requested file" }).where(eq(issues.id, issueId));
}
});
it("accepts a cited registered work product and respects the current request", async () => {
const [product] = await db.insert(issueWorkProducts).values({ companyId, issueId, type: "artifact", provider: "external",
title: "Requested report", status: "ready_for_review", url: "https://example.com/report.pdf", createdByRunId: runId }).returning();
try {
for (const ref of [product.url!, `work_product:${product.id}`]) {
await expect(nativeCompletionFeedback(db, runId, doneReport([ref])))
.resolves.toContain("Completion report accepted");
}
await db.update(issueWorkProducts).set({ url: null, metadata: { resourceRef: { kind: "workspace_file", path: "report.pdf" } } }).where(eq(issueWorkProducts.id, product.id));
await expect(nativeCompletionFeedback(db, runId, doneReport([`work_product:${product.id}`])))
.resolves.toContain("Completion report accepted");
await db.update(issueWorkProducts).set({ status: "failed" }).where(eq(issueWorkProducts.id, product.id));
await expect(nativeCompletionFeedback(db, runId, doneReport([`work_product:${product.id}`])))
.rejects.toThrow("requested file has no accessible delivery evidence");
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, executionContinuation: { objective: "Do not create a file. Explain the result inline." } } }).where(eq(heartbeatRuns.id, runId));
await expect(nativeCompletionFeedback(db, runId, doneReport([])))
.resolves.toContain("Completion report accepted");
} finally {
await db.delete(issueWorkProducts).where(eq(issueWorkProducts.id, product.id));
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId } }).where(eq(heartbeatRuns.id, runId));
}
});
it("prepares one verified same-run attachment and replays without duplicates", async () => {