Files
PaperClipAI/server/src/services/native-runtime/native-deliverable-feedback.ts
T

266 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { and, eq } from "drizzle-orm";
import { assets, documents, documentRevisions, heartbeatRuns, issueDocuments, issueAttachments, issueWorkProducts, mcpAttachmentUploads, dotRunnerAssignments, type Db } from "@paperclipai/db";
import type { PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js";
function evidenceRefs(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.flatMap((entry) => {
if (typeof entry === "string") return [entry];
if (entry && typeof entry === "object" && typeof entry.ref === "string") return [entry.ref];
return [];
});
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> : {};
}
async function hasCurrentPublicationReceipt(db: Db, companyId: string, receipts: unknown, attachment: {
id: string; originatingRunId: string | null; filename: string | null; byteSize: number; sha256: string;
}): Promise<boolean> {
for (const value of Object.values(record(receipts))) {
const receipt = record(value);
const input = record(receipt.input);
const result = record(receipt.result);
if ((result.disposition !== "applied" && result.disposition !== "duplicate") ||
!Array.isArray(result.entityRefs) || result.entityRefs[0] !== attachment.id) continue;
if (receipt.operationId === "publish_external_deliverable" && result.source === "authenticated_upload"
&& result.commandId === `deliverable-uploaded:${attachment.id}` && typeof input.uploadId === "string"
&& typeof input.workSessionId === "string" && attachment.originatingRunId && attachment.filename
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(input.uploadId)
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(input.workSessionId)
&& input.filename === attachment.filename
&& input.byteSize === attachment.byteSize && input.sha256 === attachment.sha256) {
const [upload] = await db.select({ id: mcpAttachmentUploads.id }).from(mcpAttachmentUploads)
.innerJoin(dotRunnerAssignments, and(eq(dotRunnerAssignments.id, mcpAttachmentUploads.workSessionId),
eq(dotRunnerAssignments.companyId, companyId)))
.where(and(eq(mcpAttachmentUploads.id, input.uploadId), eq(mcpAttachmentUploads.companyId, companyId),
eq(mcpAttachmentUploads.workSessionId, input.workSessionId), eq(mcpAttachmentUploads.attachmentId, attachment.id),
eq(dotRunnerAssignments.runId, attachment.originatingRunId), eq(mcpAttachmentUploads.originalFilename, attachment.filename),
eq(mcpAttachmentUploads.byteSize, attachment.byteSize), eq(mcpAttachmentUploads.sha256, attachment.sha256))).limit(1);
if (upload) return true;
}
if (receipt.operationId === "register_deliverable" &&
result.commandId === `deliverable-prepared:${attachment.id}` &&
typeof input.filename === "string" && input.filename.trim() === attachment.filename &&
input.byteSize === attachment.byteSize &&
typeof input.sha256 === "string" && input.sha256.trim().toLowerCase() === attachment.sha256.toLowerCase()) return true;
if (receipt.operationId !== "reuse_chat_attachment" ||
result.commandId !== `chat-attachment-reused:${attachment.id}`) continue;
const prepared = record(result.prepared);
const source = record(result.source);
if (prepared.attachmentId !== attachment.id ||
source.attachmentId !== input.attachmentId || source.commentId !== input.sourceCommentId ||
typeof prepared.sha256 !== "string" || prepared.sha256.toLowerCase() !== attachment.sha256.toLowerCase() ||
typeof source.sha256 !== "string" || source.sha256.toLowerCase() !== attachment.sha256.toLowerCase()) continue;
if ("filename" in prepared || "byteSize" in prepared) {
if (prepared.filename === attachment.filename && prepared.byteSize === attachment.byteSize) return true;
continue;
}
// Older committed reuse receipts contain the authenticated source and hash,
// but not its filename/size. Only an intact, matching source can supply those
// missing facts; this does not authorize a new reuse or bypass its tool gate.
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
if (typeof source.attachmentId !== "string" || !uuid.test(source.attachmentId) ||
typeof source.commentId !== "string" || !uuid.test(source.commentId)) continue;
const [original] = await db.select({ filename: assets.originalFilename, byteSize: assets.byteSize, sha256: assets.sha256 })
.from(issueAttachments).innerJoin(assets, and(eq(assets.id, issueAttachments.assetId), eq(assets.companyId, companyId)))
.where(and(eq(issueAttachments.id, source.attachmentId), eq(issueAttachments.companyId, companyId),
eq(issueAttachments.issueCommentId, source.commentId))).limit(1);
if (original?.filename === attachment.filename && original.byteSize === attachment.byteSize &&
original.sha256.toLowerCase() === attachment.sha256.toLowerCase()) return true;
}
return false;
}
/** 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 {
const sentences = objective.replaceAll("\\_", "_").split(/(?:[.!?](?:\s|$)|\n)/iu);
let precedingFileOutput = false;
return sentences.some((sentence, index) => {
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 outputs = sentence.split(/(?:[;,]|\bbut\b)/iu).flatMap(clause => {
const creates = [...clause.matchAll(/\b(?:create|make|write|save|export|attach|send|generate|produce|prepare|provide|give|return|build)\b/giu)];
return creates.flatMap((create, createIndex) => {
const before = clause.slice(0, create.index);
if (/\b(?:do not|don['’]t|never|no need to)\s*$/iu.test(before)) return [];
const negation = /\b(?:do not|don['’]t|never|no need to)\b/iu.exec(before);
if (negation) {
const rest = before.slice(negation.index + negation[0].length);
if (!/\binstead\b/iu.test(rest) && /^(?:\s*|[\s\S]*\b(?:and|or|nor)\s*)$/iu.test(rest)) return [];
}
// Bind each object to its own verb. A denied write or "create no files"
// cannot suppress a separate requested report in this same clause.
const output = clause.slice(create.index + create[0].length, creates[createIndex + 1]?.index);
const objects = [...output.matchAll(file)].filter(match => {
const prefix = output.slice(0, match.index);
const suffix = output.slice(match.index + match[0].length);
// "Create no files" is a prohibition, even though it contains a creation
// verb. Negate this object only; another explicit output can still count.
if (/\b(?:no|zero|without(?:\s+any)?)\s+(?:(?:new|temporary|downloadable|attached|additional)\s+)*$/iu.test(prefix)) return false;
// "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+(?:tools?|permissions?|systems?|formats?|names?|paths?|types?|sizes?|descriptors?)\b/iu.test(suffix)) return false;
return true;
});
// An explicit attachment/export request can refer to the file by
// pronoun. It still requires publication when its name is omitted.
const referencesOutput = /^(?:attach|export|send|provide|give|return)$/iu.test(create[0])
&& /^\s+(?:me\s+)?(?:it|them|this|that)\b/iu.test(output);
const referencedAttachment = referencesOutput && /^(?:attach|export)$/iu.test(create[0]);
const inline = /\b(?:inline|(?:in|within|inside|as|into)\s+(?:(?:a|an|the|my|your|our|final|plain|markdown|chat|fenced)\s+)*(?:chat|response|reply|message|comment|text|code block)|(?:its|the) contents)\b/iu.test(output);
const downloadable = referencedAttachment || (!/\b(?:no|without)\s+(?:downloadable|attached)/iu.test(output)
&& /\b(?:downloadable|attached)\s+(?:file|report|document|checklist|draft)\b/iu.test(output));
const publication = /\b(?:downloadable|attached|attach|export|send|provide|return|give)\b/iu.test(create[0] + output);
const deniedAttempt = create[0].toLowerCase() === "write" && objects.length === 1
&& /\b(?:attempt|try)\s+(?:the\s+)?native\s*$/iu.test(before)
&& /\b(?:must be denied|denial is (?:the )?expected|(?:this|the) negative test)\b/iu.test(objective);
if (objects.length === 0 && !downloadable && /^\s+(?:no|zero|without)\b/iu.test(output)) return [];
return [{ objects: objects.length, downloadable, publication, deniedAttempt,
referencesOutput, publicationReference: referencesOutput && !inline }];
});
});
// "This ..." qualifies a single requested file in the preceding sentence,
// even when a later clause checks it. Multiple files cannot share this
// exception and separate report requests still require publication.
const internal = outputs.reduce((count, output) => count + output.objects + Number(output.downloadable && output.objects === 0), 0) === 1
&& /^\s*This is (?:an? )?(?:personal memory|internal (?:assertion|verification) file)\b[^.!?]*\bnot a (?:task )?deliverable\b/iu.test(sentences[index + 1] ?? "");
return outputs.some(output => {
const publicationReference = output.publicationReference && precedingFileOutput;
if (!output.referencesOutput) precedingFileOutput = output.objects > 0;
return (output.objects > 0 || output.downloadable || publicationReference)
&& (output.publication || (!internal && !output.deniedAttempt));
});
});
}
/** An explicitly requested document on the task must be published there. */
export function explicitlyRequestsTaskDocumentOutput(objective: string): boolean {
// Keep comma-separated conditions with their imperative. This is a narrow
// unconditional-output guard, not an interpreter of whether a condition held.
return objective.split(/(?:[.!?](?:\s|$)|\n|;)/iu).some(statement => {
// Check conditions before separating contrastive instructions: "create a
// document, but only if ..." must not become an unconditional requirement.
if (/\b(?:if|unless|when|once|otherwise|provided that|in case)\b/iu.test(statement)) return false;
return statement.split(/\bbut\b/iu).some(clause => {
if (/\boptionally\b/iu.test(clause)) return false;
const create = /\b(?:create|make|write|save|publish|prepare|provide|attach)\b/iu.exec(clause);
if (!create) return false;
const before = clause.slice(0, create.index);
if (/\b(?:do not|don['’]t|never|no need to|may|could|can)\b/iu.test(before)) return false;
if (/\b(?:explain|describe|discuss|review)\b/iu.test(before)) return false;
const output = clause.slice(create.index + create[0].length);
return [...output.matchAll(/\b(?:document|doc)\b/giu)].some(match => {
const prefix = output.slice(0, match.index);
if (/\b(?:of|about|from|using|for|with|without|no|zero)\b/iu.test(prefix)) return false;
return /^\s+(?:on|to|in|attached to)\s+(?:this|the|current)\s+(?:task|issue)\b/iu.test(output.slice(match.index + match[0].length));
});
});
});
}
/** Current attached revisions with server-owned publication proof. Joining the
* revision to its originating run preserves completed work across continuations
* without accepting stale, foreign-task, or provider-invented document refs.
*/
export async function publishedTaskDocuments(db: Db, binding: { companyId: string; issueId: string }) {
const saved = await db.select({ id: documents.id, revisionId: documents.latestRevisionId,
key: issueDocuments.key, resultJson: heartbeatRuns.resultJson })
.from(issueDocuments)
.innerJoin(documents, and(eq(documents.id, issueDocuments.documentId), eq(documents.companyId, binding.companyId)))
.innerJoin(documentRevisions, and(eq(documentRevisions.id, documents.latestRevisionId),
eq(documentRevisions.documentId, documents.id), eq(documentRevisions.companyId, binding.companyId)))
.innerJoin(heartbeatRuns, and(eq(heartbeatRuns.id, documentRevisions.createdByRunId),
eq(heartbeatRuns.companyId, binding.companyId), eq(heartbeatRuns.nativeIssueId, binding.issueId),
eq(heartbeatRuns.runtimeMode, "native")))
.where(and(eq(issueDocuments.companyId, binding.companyId), eq(issueDocuments.issueId, binding.issueId)));
return saved.filter(document => Object.values(record(document.resultJson?.semanticToolReceipts)).some(value => {
const receipt = record(value), result = record(receipt.result), published = record(result.document);
return receipt.operationId === "write_document" && ["applied", "duplicate"].includes(String(result.disposition))
&& published.id === document.id && published.latestRevisionId === document.revisionId;
})).map(({ id, revisionId, key }) => ({ id, revisionId, key }));
}
/** Files cited as completed output must be reachable outside the agent workspace. */
export async function validateNativeDeliverableEvidence(
db: Db,
binding: { companyId: string; issueId: string; runId: string; objective: string; semanticToolReceipts: unknown },
result: PrpStructuredRunResult,
): Promise<void> {
if (result.reportedWorkDisposition !== "done") return;
const fileRequested = explicitlyRequestsFileOutput(binding.objective);
const taskDocumentRequested = explicitlyRequestsTaskDocumentOutput(binding.objective);
const publishedTaskDocument = taskDocumentRequested && (await publishedTaskDocuments(db, binding)).length > 0;
const artifactRefs = new Set(evidenceRefs(result.artifacts));
const refs = new Set([
...evidenceRefs(result.evidence),
...artifactRefs,
...result.completionClaim.criteria.flatMap(({ evidenceRefs }) => evidenceRefs),
]);
let registeredAttachment = false;
for (const value of refs) {
if (typeof value !== "string") continue;
const ref = value.trim();
const attachmentPath = /^\/api\/attachments\/([^/?#]+)\/content(?:[?#].*)?$/u.exec(ref);
if (ref.startsWith("deliverable:") || attachmentPath) {
const id = attachmentPath?.[1] ?? ref.slice("deliverable:".length);
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
const [attachment] = uuid.test(id)
? await db.select({ id: issueAttachments.id, originatingRunId: issueAttachments.originatingRunId,
filename: assets.originalFilename, byteSize: assets.byteSize, sha256: assets.sha256 }).from(issueAttachments)
.innerJoin(assets, and(eq(assets.id, issueAttachments.assetId), eq(assets.companyId, binding.companyId)))
.where(and(eq(issueAttachments.id, id), eq(issueAttachments.companyId, binding.companyId), eq(issueAttachments.issueId, binding.issueId)))
.limit(1)
: [];
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.");
}
// 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
// controller restart of this run; a replacement can re-register preserved
// workspace bytes internally rather than asking the user to confirm them.
if ((fileRequested || taskDocumentRequested) && attachment.originatingRunId !== binding.runId) continue;
if ((fileRequested || taskDocumentRequested) && !await hasCurrentPublicationReceipt(db, binding.companyId, binding.semanticToolReceipts, attachment)) {
throw new Error("This attachment has no matching verified publication receipt for this run's requested output. Inspect any preserved file and use register_deliverable to verify its current filename, size, and SHA-256, then cite the new 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 && taskDocumentRequested && !publishedTaskDocument) {
throw new Error("The requested task document is only a workspace file. Publish it on this task with write_document, or attach the verified file with register_deliverable and cite deliverable:<attachmentId>. Reuse completed work; do not request a new completion approval.");
}
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 (taskDocumentRequested && !publishedTaskDocument && !registeredAttachment) {
throw new Error("The requested document has not been published on this task. Use write_document, or register_deliverable with its verified attachment receipt. A workspace path or final message alone is not the requested task document. Reuse completed work without requesting a new completion approval.");
}
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 (product.createdByRunId !== binding.runId) return false;
if (["failed", "cancelled", "archived"].includes(product.status)) return false;
// A workspace_file resource is only a locator: registration neither checks
// its current bytes nor keeps them alive after workspace cleanup. Requested
// files need a published URL or the verified attachment receipt above.
const accessible = typeof product.url === "string" && /^https?:\/\//iu.test(product.url);
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 an accessible work product registered by this run for this task. Empty evidence, prior-run output, and a verification result cannot substitute for the requested file. Continue publishing or report a concrete blocker; no human completion approval was created.");
}
}