/** * The plugin's single result protocol. * * Every result of every tool states its facts in one report (report-schema.ts). * Layers return structured facts and never append sentences: the commit layer * owns publication and freshness; each tool owns its outcome facts; * Action Fusion owns `then_run`. This module builds the * report of a success or a failure, derives its single `next`, and owns the * one model-facing serialization: one JSON object in schema order, without the * call's own tool and path. A success puts it after the content payload; * a failure is the report alone. The TUI receives the full report in details. * * Throw sites classify failures as `HashlineError` (core/errors.ts); a failure * beneath the failing step becomes structured `cause`, never part of `message`. * * @module pi-hashline-edit/pi */ import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { Value } from "typebox/value"; import { errorMessage, errnoCode, filesystemErrorCode, HashlineError, pathError, } from "../core/errors.ts"; import { boundedText, causeOf } from "./report-cause.ts"; import type { OutputBudgets } from "./budgets.ts"; import { cancellationError } from "./error-text.ts"; import { ERROR_DEFINITIONS, OUTCOME_NEXT, PUBLICATION_NEXT, REPORT_SCHEMA, WIRE_REPORT_SCHEMA, type ErrorCode, type FailureReport, type Report, type ReportTool, type SuccessReport, } from "./report-schema.ts"; /** Every result's details carry its report for the TUI; Pi does not send details to the model. */ export interface ReportDetails { readonly report: TReport; } type Content = AgentToolResult["content"]; /** What a report's text states: the originating call supplies its tool and path. */ export type ReportText = R extends unknown ? Omit : never; /** Fields the originating call supplies, which a result's text never repeats. */ const CALL_FIELDS = new Set(["tool", "path"]); /** The fields a report states, in schema order; both renderers list them in this order. */ export function reportEntries(report: ReportText): [string, unknown][] { return Object.keys(REPORT_SCHEMA.properties).flatMap((key) => { const value = (report as Record)[key]; return value === undefined ? [] : [[key, value] as [string, unknown]]; }); } /** Serialize facts in schema order; a result never repeats its call's tool and path. */ export function renderReport(report: ReportText): string { const entries = reportEntries(report).filter(([key]) => !CALL_FIELDS.has(key)); return JSON.stringify(Object.fromEntries(entries), null, 2); } /** Decode a text report; the text omits its tool, which the originating call supplies. */ export function decodeReport(text: string, contextTool: ReportTool): Report | undefined { if (!text.startsWith("{")) return undefined; try { let value: unknown = JSON.parse(text); if (typeof value === "object" && value !== null && !Array.isArray(value) && !("tool" in value)) value = { tool: contextTool, ...value }; return Value.Check(WIRE_REPORT_SCHEMA, value) ? (value as Report) : undefined; } catch (error) { if (error instanceof SyntaxError || error instanceof RangeError || error instanceof TypeError) return undefined; throw error; } } /** * The report a result's details carry, checked once against the wire schema: details also * come from saved sessions and other extensions' result hooks, so their shape is unknown. */ export function detailsReport(details: unknown): Report | undefined { const report: unknown = typeof details === "object" && details !== null ? Reflect.get(details, "report") : undefined; try { // Hooks can introduce cycles; reject non-JSON reports before recursive schema validation. JSON.stringify(report); return report !== undefined && Value.Check(WIRE_REPORT_SCHEMA, report) ? (report as Report) : undefined; } catch (error) { if (error instanceof TypeError || error instanceof RangeError) return undefined; throw error; } } /** Tool results carry their report in details; Pi argument failures carry it as their final text block. */ export function reportOf( result: { readonly content: Content; readonly details?: unknown }, tool: ReportTool, ): Report | undefined { const report = detailsReport(result.details); if (report) return report; const last = result.content.at(-1); return last?.type === "text" ? decodeReport(last.text, tool) : undefined; } /** The recovery a successful result's facts call for: the first in OUTCOME_NEXT order. */ function outcomeNext(report: Report): string | undefined { return OUTCOME_NEXT.find( ([fact, , except]) => report[fact] !== undefined && report[fact] !== except, )?.[1]; } /** * A successful result: the tool's payload, then its report when it states * anything beyond tool and path or when there is no payload. */ export function reportResult( report: SuccessReport, payload: Content = [], details?: TDetails, ): AgentToolResult> { const complete = Object.fromEntries( reportEntries({ ...report, next: outcomeNext(report) }), ) as SuccessReport; const stated = Object.keys(complete).some((key) => !CALL_FIELDS.has(key)); return { content: stated || payload.length === 0 ? [...payload, { type: "text", text: renderReport(complete) }] : payload, details: { ...details, report: complete } as TDetails & ReportDetails, }; } /** A failed result: the report is its entire content, flagged so Pi reports it as an error. */ export function failureResult( report: FailureReport, ): AgentToolResult> { const complete = Object.fromEntries(reportEntries(report)) as FailureReport; return { content: [{ type: "text", text: renderReport(complete) }], details: { report: complete }, isError: true, }; } /** Where a failure happened, as the caller knows it. */ export interface FailureContext { /** The path or paths the call names, as supplied. */ readonly path?: string | readonly string[]; readonly signal?: AbortSignal; readonly budget: OutputBudgets; } /** What a mutation tool states about its failure beside the classified error. */ type MutationFailureFacts = Pick; function classify(error: unknown, signal: AbortSignal | undefined): HashlineError { if (error instanceof HashlineError) return error; // Pi's native tools and Node report cancellation with their own errors. if (signal?.aborted) return cancellationError(); const path = pathError(error); if (path) return path; const code: ErrorCode = filesystemErrorCode(error) ?? "UNCLASSIFIED"; const cause = error instanceof Error ? error.cause : undefined; const diagnostic = errorMessage(error); const errno = errnoCode(error); const message = errno && !diagnostic.includes(errno) ? `${errno}: ${diagnostic}` : diagnostic; return new HashlineError(code, message, { cause }); } /** The recovery of a failure: publication first, then the throw site's alternative or the code's default. */ function failureNext( failure: HashlineError, publication: Report["publication"], ): string | undefined { if (publication === "PUBLISHED" || publication === "UNKNOWN") return PUBLICATION_NEXT[publication]; const next = (ERROR_DEFINITIONS[failure.errorCode] as { next?: Record }).next; return next?.[(failure.next as string | undefined) ?? "default"]; } /** Classify a caught failure as the report the model receives. */ export function describeFailure( tool: ReportTool, caught: unknown, context: FailureContext, mutation?: MutationFailureFacts, ): FailureReport { const failure = classify(caught, context.signal); const budget = context.budget; // HashlineError's constructor ties its facts to its code; the union cannot carry that link. return { error: failure.errorCode, tool, path: context.path as Report["path"], publication: mutation?.publication, message: boundedText(failure.message, budget.fieldBytes), cause: causeOf(failure.cause, budget.fieldBytes), ...failure.facts, then_run: mutation?.then_run, progressError: mutation?.progressError, next: failureNext(failure, mutation?.publication), } as FailureReport; } /** Run one tool execution, returning any failure as its report. */ export async function runTool( tool: ReportTool, context: FailureContext, run: () => Promise, describe: typeof describeFailure = describeFailure, ): Promise>> { try { return await run(); } catch (error) { return failureResult(describe(tool, error, context)); } }