import { createHash } from "node:crypto"; import { stat } from "node:fs/promises"; import { resolve } from "node:path"; import { assertWorkerPath } from "../security/worker-scope.js"; import { resultValueError, type JsonSchema } from "pi-agents/src/model/json-schema.js"; import { estimateTokens } from "./envelope.js"; import type { AgentFact, AgentResult, TaskEnvelope } from "../types.js"; export type AgentResultRole = "scout" | "writer" | "reducer"; export function factId(runId: string, nodeId: string, sequence: number): string { return `F:${runId}:${nodeId}:${sequence}`; } function regexLiteral(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } export function attributionManifestSchema(): JsonSchema { const string = (maxLength: number, minLength = 0) => ({ type: "string", minLength, maxLength }); const factIds = { type: "array", maxItems: 64, uniqueItems: true, items: string(512, 1) }; return { type: "object", additionalProperties: false, properties: { usedFactIds: factIds, rejectedFactIds: { type: "array", maxItems: 64, items: { type: "object", additionalProperties: false, properties: { id: string(512, 1), reason: { enum: ["duplicate", "unsupported", "irrelevant", "contradicted"] } }, required: ["id", "reason"], }, }, decisions: { type: "array", maxItems: 32, items: { type: "object", additionalProperties: false, properties: { decisionId: string(256, 1), description: string(2000, 1), supportingFactIds: factIds }, required: ["decisionId", "description", "supportingFactIds"], }, }, changes: { type: "array", maxItems: 64, items: { type: "object", additionalProperties: false, properties: { path: string(1024, 1), supportingDecisionIds: { type: "array", maxItems: 32, uniqueItems: true, items: string(256, 1) } }, required: ["path", "supportingDecisionIds"], }, }, }, required: ["usedFactIds", "rejectedFactIds", "decisions", "changes"], }; } export function agentResultSchema(envelope: TaskEnvelope, nodeId = envelope.trace.nodeId, allowNoMaterialFindings = false, role: AgentResultRole = "scout", requireProven = false): JsonSchema { const string = (maxLength: number, minLength = 0) => ({ type: "string", minLength, maxLength }); const evidence = { type: "object", additionalProperties: false, properties: { path: string(1024), lines: string(256), commandId: string(256), observation: string(2000, 1) }, required: ["observation"], }; const fingerprint = { type: "object", additionalProperties: false, properties: { hash: string(256, 1), class: string(256, 1), components: { type: "array", maxItems: 16, items: string(1000) } }, required: ["hash", "class", "components"], }; const attributionManifest = attributionManifestSchema(); const result: JsonSchema = { type: "object", additionalProperties: false, properties: { schemaVersion: { const: 1 }, status: requireProven ? { const: "complete" } : { enum: ["complete", "blocked", "partial"] }, summary: string(2000, 1), facts: { type: "array", minItems: requireProven ? 1 : 0, maxItems: envelope.outputContract.maxFacts, items: { type: "object", additionalProperties: false, properties: { id: { ...string(512, 1), pattern: `^F:${regexLiteral(envelope.trace.runId)}:${regexLiteral(nodeId)}:[1-9][0-9]*$` }, claim: string(2000, 1), evidence: { type: "array", minItems: requireProven ? 1 : 0, maxItems: 8, items: evidence }, confidence: { type: "number", minimum: 0, maximum: 1 }, }, required: ["id", "claim", "evidence", "confidence"], }, }, hypotheses: { type: "array", maxItems: envelope.outputContract.maxHypotheses, items: { type: "object", additionalProperties: false, properties: { id: string(512, 1), claim: string(2000, 1), confidence: { type: "number", minimum: 0, maximum: 1 }, status: { enum: ["open", "confirmed", "rejected"] } }, required: ["id", "claim", "confidence"], }, }, unknowns: { type: "array", maxItems: envelope.outputContract.maxUnknowns, items: string(2000, 1) }, dependencies: { type: "array", maxItems: 16, items: string(2000, 1) }, verification: { type: "array", maxItems: 8, items: { type: "object", additionalProperties: false, properties: { checkId: string(256, 1), passed: { type: "boolean" }, command: string(2000, 1), exitCode: { anyOf: [{ type: "integer" }, { type: "null" }] }, durationMs: { type: "number", minimum: 0 }, stdout: string(4000), stderr: string(4000), fingerprint, }, required: ["checkId", "passed", "command", "exitCode", "durationMs", "stdout", "stderr"], }, }, resourceRequest: { type: "object", additionalProperties: false, properties: { reason: string(2000, 1), requestedTopology: { enum: ["direct", "scout", "swarm", "deep", "warroom"] }, requestedModel: string(256, 1) }, required: ["reason"], }, attributionManifest, }, required: ["schemaVersion", "status", "summary", "facts", "hypotheses", "unknowns", "dependencies", ...(role === "scout" ? [] : ["attributionManifest"])], }; return allowNoMaterialFindings ? { oneOf: [result, { const: "NO_MATERIAL_FINDINGS" }] } : result; } export function parseAgentResult(value: unknown, envelope: TaskEnvelope, allowNoMaterialFindings = false, role: AgentResultRole = "scout"): AgentResult { const text = typeof value === "string" ? value.trim() : undefined; if (text === "NO_MATERIAL_FINDINGS") { if (!allowNoMaterialFindings) throw new Error("NO_MATERIAL_FINDINGS is only valid for reviewers"); return { schemaVersion: 1, status: "complete", summary: text, facts: [], hypotheses: [], unknowns: [], dependencies: [] }; } const result = text === undefined ? value : JSON.parse(text.replace(/^```(?:json)?\s*|\s*```$/g, "")) as unknown; const schemaError = resultValueError(result, agentResultSchema(envelope, envelope.trace.nodeId, false, role)); if (schemaError) throw new Error(`Worker result violates AgentResult schema: ${schemaError}`); const candidate = result as AgentResult; if (new Set(candidate.facts.map((fact) => fact.id)).size !== candidate.facts.length) throw new Error("Worker result contains duplicate fact IDs"); if (estimateTokens(candidate) > envelope.outputContract.maxOutputTokens) throw new Error("Worker result exceeds output contract"); return candidate; } /** * A result that satisfies the schema and still carries nothing. The contract cannot catch this: * an agent that answers with a well-formed empty envelope looks identical to one that ran and * found nothing, and the difference only shows up as a branch of the fleet that produced no * evidence while reporting success. NO_MATERIAL_FINDINGS is excluded because for a reviewer it * is the finding. */ export function isEmptyResult(result: AgentResult): boolean { return result.status === "complete" && result.summary !== "NO_MATERIAL_FINDINGS" && result.facts.length === 0 && result.hypotheses.length === 0 && result.unknowns.length === 0 && (result.verification?.length ?? 0) === 0 && !result.attributionManifest?.changes.length; } export async function assertDeclaredArtifacts(envelope: TaskEnvelope, root: string): Promise { for (const artifact of envelope.declaredArtifacts ?? []) { await assertWorkerPath(root, artifact, envelope.scope); try { await stat(resolve(root, artifact)); } catch { throw new Error(`Worker result contract failed: declared artifact was not created: ${artifact}`); } } } export interface MergedFacts { facts: AgentFact[]; duplicates: string[]; contradictions: string[]; graph: Record; groups: Record } export type MergeSource = AgentResult | { result: AgentResult; evidenceTarget: string; symbols?: string[] }; export function mergeFacts(results: MergeSource[]): MergedFacts { const facts: AgentFact[] = []; const duplicates: string[] = []; const contradictions: string[] = []; const claims = new Map(); const seenClaims = new Set(); const groups: Record = {}; for (const source of results) { const result = "result" in source ? source.result : source; const target = "result" in source ? source.evidenceTarget.trim().toLowerCase().replace(/\s+/g, " ") : ""; for (const fact of result.facts) { const claim = fact.claim.trim().toLowerCase().replace(/\s+/g, " "); const key = `${claim}\u0000${target}`; const existing = claims.get(key); if (existing) { duplicates.push(fact.id); continue; } const negated = claim.replace(/^not\s+/, ""); if (seenClaims.has(`not ${claim}`) || (claim !== negated && seenClaims.has(negated))) contradictions.push(fact.id); claims.set(key, fact); seenClaims.add(claim); facts.push(fact); for (const group of new Set([...fact.evidence.flatMap((entry) => entry.path ? [entry.path] : []), ...("result" in source ? source.symbols ?? [] : [])])) (groups[group] ??= []).push(fact.id); } } facts.sort((a, b) => b.confidence - a.confidence || a.id.localeCompare(b.id)); const graph: Record = {}; for (const fact of facts) graph[fact.id] = fact.evidence.map((evidence) => createHash("sha256").update(`${evidence.path ?? ""}:${evidence.lines ?? ""}:${evidence.observation}`).digest("hex").slice(0, 12)); return { facts, duplicates, contradictions, graph, groups }; }