/** * ToolResultContract – structured outcome of every tool invocation. * * Every tool call that passes through ToolPipeline MUST produce a contract. * The contract is the single source of truth for: * - What happened (status, summary) * - Where it came from (sourceRefs, evidencePath) * - What's next (nextAction) * - Trust boundary (untrusted) * - Gate correlation (gateTraceId) */ import type { GateDecision } from "../gates/findings.ts"; // ── ToolResultContract ────────────────────────────────────────────────────── export type ToolResultStatus = "success" | "blocked" | "failed"; export type ToolResultKind = | "read" | "search" | "list" | "shell" | "knowledge" | "evidence" | "write" | "other"; /** * Structured contract produced by every tool invocation. * * REQUIRED fields: toolName, status, kind, summary, untrusted * OPTIONAL fields: sourceRefs, evidencePath, nextAction, gateTraceId */ export interface ToolResultContract { /** Name of the tool that was invoked. */ toolName: string; /** Outcome of the invocation. */ status: ToolResultStatus; /** Semantic category of the tool. */ kind: ToolResultKind; /** Human-readable summary of what happened (max 220 chars). */ summary: string; /** File paths, line references, or URLs relevant to this result. */ sourceRefs: string[]; /** Path to an evidence file, if the tool produced one. */ evidencePath?: string; /** * Suggested next action for the agent. * REQUIRED when status is "failed" – never let the LLM guess the path. */ nextAction?: string; /** Trace id from the gate decision that allowed (or blocked) this call. */ gateTraceId?: string; /** * Whether the tool output contains untrusted content. * Document/web/external content tools MUST set this to true. * When true, the output text is wrapped with an untrusted prefix. */ untrusted: boolean; } // ── Untrusted content tools ───────────────────────────────────────────────── /** * Tools whose output is considered untrusted external content. * Matches: kd_doc_read, web fetch, external file read, MCP external content. */ const UNTRUSTED_CONTENT_TOOLS: ReadonlySet = new Set([ "kd_doc_read", "web_fetch", "web_search", "external_file_read", "mcp_external_content", ]); /** * Check if a tool name produces untrusted content. * * Uses prefix matching for extensibility: * - Exact match in UNTRUSTED_CONTENT_TOOLS * - Starts with "web_" or "external_" * - Contains "mcp" and "external" */ export function isUntrustedContentTool(toolName: string): boolean { const normalized = toolName.trim().toLowerCase(); if (UNTRUSTED_CONTENT_TOOLS.has(normalized)) return true; if (normalized.startsWith("web_") || normalized.startsWith("external_")) return true; if (normalized.includes("mcp") && normalized.includes("external")) return true; return false; } // ── Factory ───────────────────────────────────────────────────────────────── export interface CreateToolResultContractInput { toolName: string; status: ToolResultStatus; kind?: ToolResultKind; summary: string; sourceRefs?: string[]; evidencePath?: string; nextAction?: string; gateTraceId?: string; untrusted?: boolean; } /** * Create a validated ToolResultContract. * * Rules: * - toolName, summary are trimmed and truncated. * - kind is inferred from toolName if not provided. * - untrusted defaults based on toolName if not explicitly set. * - sourceRefs is deduplicated and normalized. * - failed status MUST have nextAction (enforced by validateToolResultContract). */ export function createToolResultContract(input: CreateToolResultContractInput): ToolResultContract { const toolName = input.toolName.trim(); const kind = input.kind ?? inferToolKind(toolName); const untrusted = input.untrusted ?? isUntrustedContentTool(toolName); return { toolName, status: input.status, kind, summary: trimSummary(input.summary), sourceRefs: normalizeStringArray(input.sourceRefs) ?? [], evidencePath: input.evidencePath?.trim() || undefined, nextAction: input.nextAction?.trim() || undefined, gateTraceId: input.gateTraceId?.trim() || undefined, untrusted, }; } // ── Validation ────────────────────────────────────────────────────────────── export interface ValidationResult { valid: boolean; issues: string[]; } /** * Validate a ToolResultContract against business rules. * * Checks: * - All required fields are present and non-empty. * - status is a valid ToolResultStatus. * - kind is a valid ToolResultKind. * - failed status has nextAction. * - summary is within length limits. */ export function validateToolResultContract(contract: ToolResultContract): ValidationResult { const issues: string[] = []; if (!contract.toolName || !contract.toolName.trim()) { issues.push("toolName is required and must be non-empty"); } if (!contract.status || !["success", "blocked", "failed"].includes(contract.status)) { issues.push("status must be 'success', 'blocked', or 'failed'"); } if (!contract.kind || !["read", "search", "list", "shell", "knowledge", "evidence", "write", "other"].includes(contract.kind)) { issues.push("kind must be a valid ToolResultKind"); } if (!contract.summary || !contract.summary.trim()) { issues.push("summary is required and must be non-empty"); } if (contract.summary && contract.summary.length > 220) { issues.push("summary must be 220 characters or less"); } if (contract.status === "failed" && (!contract.nextAction || !contract.nextAction.trim())) { issues.push("failed status requires nextAction – never let the LLM guess the path"); } if (!Array.isArray(contract.sourceRefs)) { issues.push("sourceRefs must be an array"); } return { valid: issues.length === 0, issues, }; } // ── Untrusted content wrapping ────────────────────────────────────────────── const UNTRUSTED_PREFIX = "[UNTRUSTED EXTERNAL CONTENT – verify before use]\n\n"; /** * Wrap tool output text with an untrusted prefix if the contract indicates * untrusted content. */ export function wrapUntrustedOutput(contract: ToolResultContract, output: string): string { if (!contract.untrusted) return output; return `${UNTRUSTED_PREFIX}${output}`; } // ── Private helpers ───────────────────────────────────────────────────────── function inferToolKind(toolName: string): ToolResultKind { const normalized = toolName.trim().toLowerCase(); if (/find|search/.test(normalized)) return "search"; if (/list|dir/.test(normalized)) return "list"; if (/read|doc/.test(normalized)) return "read"; if (/write|edit|patch/.test(normalized)) return "write"; if (/build|verify|signature|metadata|ksql|config/.test(normalized)) return "evidence"; if (/bash|shell|powershell/.test(normalized)) return "shell"; if (/table|knowledge|api/.test(normalized)) return "knowledge"; return "other"; } function normalizeStringArray(value: unknown): string[] | undefined { if (!Array.isArray(value)) return undefined; const items = [ ...new Set( value .filter((item): item is string => typeof item === "string" && Boolean(item.trim())) .map((item) => item.trim()), ), ]; return items.length > 0 ? items : undefined; } function trimSummary(value: string, maxLength = 220): string { const normalized = value.trim().replace(/\s+/g, " "); return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength)}...`; }