fix(runner): require delivery evidence for explicit file outputs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
2582bf814d
commit
417a96011a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue