diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 0baec44cba..58248674d5 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -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 diff --git a/server/src/services/native-runtime/native-completion-feedback.ts b/server/src/services/native-runtime/native-completion-feedback.ts index 2b562d9d2b..d57a9128c7 100644 --- a/server/src/services/native-runtime/native-completion-feedback.ts +++ b/server/src/services/native-runtime/native-completion-feedback.ts @@ -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([ diff --git a/server/src/services/native-runtime/native-deliverable-feedback.test.ts b/server/src/services/native-runtime/native-deliverable-feedback.test.ts new file mode 100644 index 0000000000..f962d04454 --- /dev/null +++ b/server/src/services/native-runtime/native-deliverable-feedback.test.ts @@ -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); + }); +}); diff --git a/server/src/services/native-runtime/native-deliverable-feedback.ts b/server/src/services/native-runtime/native-deliverable-feedback.ts index e79a7493aa..998bdb605e 100644 --- a/server/src/services/native-runtime/native-deliverable-feedback.ts +++ b/server/src/services/native-runtime/native-deliverable-feedback.ts @@ -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 { 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: 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: from the receipt, with /api/attachments//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:, 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."); + } } 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 3197134d11..8c2d7c02f6 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 @@ -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 () => {