import { HANDOFF_CONTEXT_FIELD, HANDOFF_ENVELOPE_FIELDS, HANDOFF_LEGACY_NON_CANONICAL_ALIASES, HANDOFF_SATISFIED_FIELD, HANDOFF_TEXT_FIELD, HANDOFF_WORK_ITEMS_FIELD, } from "./handoffContract.js"; import { formatHandoffWorkItemShape, parseHandoffWorkItem } from "../domain/handoff.js"; import { deriveSocketOutputRequirements, socketConsumesSatisfied, type SocketOutputFieldType, type SocketOutputRequirements } from "./socketOutputRequirements.js"; import type { MateriaPipelineSocketConfig } from "../types.js"; export interface HandoffValidationOptions { socketId?: string; socket?: MateriaPipelineSocketConfig; requirements?: SocketOutputRequirements; /** True when this JSON output was authored by an agent/model rather than a utility/script. */ agentOutput?: boolean; /** True when normalized graph semantics identify this socket as a workItems-producing generator/planner. */ workItemsProducer?: boolean; } export interface HandoffValidationIssue { path: string; message: string; expected?: SocketOutputFieldType | "json_object" | "present"; reason?: string; } export class HandoffJsonValidationError extends Error { readonly socketId: string; readonly issues: HandoffValidationIssue[]; constructor(socketId: string, issues: HandoffValidationIssue[]) { super(formatHandoffValidationErrorMessage(socketId, issues)); this.name = "HandoffJsonValidationError"; this.socketId = socketId; this.issues = issues; } } export function validateHandoffJsonOutput(value: unknown, options: HandoffValidationOptions): Record { const socketId = options.socketId ?? "unknown"; const socketLabel = `socket "${socketId}"`; const requirements = options.requirements ?? (options.socket ? deriveSocketOutputRequirements({ socket: options.socket, socketId, workItemsProducer: options.workItemsProducer }) : undefined); const issues: HandoffValidationIssue[] = []; if (!isPlainJsonObject(value)) { throw new HandoffJsonValidationError(socketId, [{ path: "$", expected: "json_object", message: `Invalid handoff JSON output for ${socketLabel}: expected a JSON object at the top level.`, reason: requirements?.jsonObjectReason ?? "Socket parse mode is json.", }]); } if (options.agentOutput) { validateAgentTopLevelFields(value, issues, requirements, options.socket); addMisplacedTextRepairHint(value, issues, requirements); } if (!requirements) { if (issues.length > 0) throw new HandoffJsonValidationError(socketId, issues); return value; } const consumedPayloadPathSet = new Set(requirements.consumedPayloadPaths.map((path) => path.payloadPath)); for (const rule of requirements.reservedFieldTypeRules) { const required = rule.required || consumedPayloadPathSet.has(rule.path); const present = Object.prototype.hasOwnProperty.call(value, rule.field); if (!present && !required) continue; if (!present) { issues.push({ path: rule.path, expected: rule.type, message: `Missing required reserved field "${rule.field}" at ${rule.path}; expected ${articleForType(rule.type)} ${rule.type}.`, reason: rule.reason, }); continue; } if (!matchesType(value[rule.field], rule.type)) { issues.push({ path: rule.path, expected: rule.type, message: `Reserved field "${rule.field}" at ${rule.path} must be ${articleForType(rule.type)} ${rule.type}.`, reason: rule.reason, }); } } const checkedRequiredPaths = new Set(requirements.reservedFieldTypeRules.map((rule) => rule.path)); for (const requirement of requirements.requiredFields) { if (checkedRequiredPaths.has(requirement.path)) continue; const current = getJsonPath(value, requirement.path); if (!current.exists) { issues.push({ path: requirement.path, expected: requirement.type, message: `Missing required field "${requirement.field}" at ${requirement.path}; expected ${articleForType(requirement.type)} ${requirement.type}.`, reason: requirement.reason, }); continue; } if (!matchesType(current.value, requirement.type)) { issues.push({ path: requirement.path, expected: requirement.type, message: `Field "${requirement.field}" at ${requirement.path} must be ${articleForType(requirement.type)} ${requirement.type}.`, reason: requirement.reason, }); } } if ((options.agentOutput || options.workItemsProducer) && Array.isArray(value.workItems)) { for (const [index, workItem] of value.workItems.entries()) { const result = parseHandoffWorkItem(workItem, `$.workItems.${index}`); if (!result.ok) { for (const issue of result.issues) { issues.push({ path: issue.path, expected: expectedWorkItemFieldType(issue.path), message: `${issue.path}: ${issue.message}; expected canonical work item shape ${formatHandoffWorkItemShape()}.`, reason: "Generator and workItems-assignment sockets must emit canonical workItems. Agent-produced workItems contain only title:string and context:string." }); } } } } const requiredPaths = new Set(requirements.requiredFields.map((requirement) => requirement.path)); for (const consumed of requirements.consumedPayloadPaths) { if (consumed.payloadPath === "$" || requiredPaths.has(consumed.payloadPath) || checkedRequiredPaths.has(consumed.payloadPath)) continue; const current = getJsonPath(value, consumed.payloadPath); if (!current.exists) { issues.push({ path: consumed.payloadPath, expected: "present", message: `Missing payload path ${consumed.payloadPath} consumed by assignment to ${consumed.targetPath}.`, reason: consumed.reason, }); } } if (issues.length > 0) { addLegacySatisfiedHint(value, issues); addArchitectureAliasHints(value, issues, options.agentOutput === true); throw new HandoffJsonValidationError(socketId, issues); } return value; } export function requiresSatisfiedControl(socket: MateriaPipelineSocketConfig): boolean { return socketConsumesSatisfied(socket); } export function handoffValidationIssues(error: unknown): HandoffValidationIssue[] | undefined { return error instanceof HandoffJsonValidationError ? error.issues : undefined; } function formatHandoffValidationErrorMessage(socketId: string, issues: HandoffValidationIssue[]): string { const detail = issues.map((issue) => issue.message).join(" "); return `Invalid handoff JSON output for socket "${socketId}": ${detail}`; } function validateAgentTopLevelFields(value: Record, issues: HandoffValidationIssue[], requirements: SocketOutputRequirements | undefined, socket: MateriaPipelineSocketConfig | undefined): void { const allowed = new Set(HANDOFF_ENVELOPE_FIELDS); for (const consumed of requirements?.consumedPayloadPaths ?? []) { if (consumed.topLevelField) allowed.add(consumed.topLevelField); } const allowsCustomPayload = (requirements?.consumedPayloadPaths.length ?? 0) > 0 || socketUsesCustomJsonPayloadCondition(socket); for (const field of Object.keys(value)) { if (!allowed.has(field) && !allowsCustomPayload) { issues.push({ path: `$.${field}`, expected: "present", message: `Unexpected top-level agent handoff field ${JSON.stringify(field)}. Agent JSON handoffs may only contain ${HANDOFF_ENVELOPE_FIELDS.map((name) => JSON.stringify(name)).join(", ")}.`, reason: "Utility/script structured data belongs under utility state patches, not agent handoff JSON.", }); } } if (Object.prototype.hasOwnProperty.call(value, HANDOFF_CONTEXT_FIELD) && typeof value[HANDOFF_CONTEXT_FIELD] !== "string") { issues.push({ path: `$.${HANDOFF_CONTEXT_FIELD}`, expected: "string", message: `Agent handoff field ${JSON.stringify(HANDOFF_CONTEXT_FIELD)} must be a string when present.`, reason: "Agent context is accumulated as plain explanatory text for downstream prompts.", }); } if (Object.prototype.hasOwnProperty.call(value, HANDOFF_TEXT_FIELD) && typeof value[HANDOFF_TEXT_FIELD] !== "string") { issues.push({ path: `$.${HANDOFF_TEXT_FIELD}`, expected: "string", message: `Agent handoff field ${JSON.stringify(HANDOFF_TEXT_FIELD)} must be a string when present.`, reason: "The text field is the materia's authoritative renderable prose payload; the TUI renders it as a one-way presentation layer.", }); } if (Object.prototype.hasOwnProperty.call(value, HANDOFF_SATISFIED_FIELD) && typeof value[HANDOFF_SATISFIED_FIELD] !== "boolean") { issues.push({ path: `$.${HANDOFF_SATISFIED_FIELD}`, expected: "boolean", message: `Agent handoff field ${JSON.stringify(HANDOFF_SATISFIED_FIELD)} must be a boolean when present.`, reason: "The satisfied field controls satisfied/not_satisfied routing.", }); } if (Object.prototype.hasOwnProperty.call(value, HANDOFF_WORK_ITEMS_FIELD) && !Array.isArray(value[HANDOFF_WORK_ITEMS_FIELD])) { issues.push({ path: `$.${HANDOFF_WORK_ITEMS_FIELD}`, expected: "array", message: `Agent handoff field ${JSON.stringify(HANDOFF_WORK_ITEMS_FIELD)} must be an array when present.`, reason: "Generated work belongs in workItems as title/context objects.", }); } } /** * Flags a top-level renderable `text` payload on a non-text-enabled JSON agent * socket as a structured repair issue. Renderable text is opt-in (only sockets * that consume `$.text` via assignment or carry explicit renderable-text intent * may emit it), so ordinary Auto-Plan/Auto-Eval/Maintain/Chain-Context sockets * must keep explanatory prose in `context`. * * Repair guidance is context-aware: when `context` is already present and * non-empty the model is told to drop the duplicate `text`; otherwise it is * told to move the prose into `context`. Non-string `text` is left to the type * check so the two issue categories never overlap. */ function addMisplacedTextRepairHint(value: Record, issues: HandoffValidationIssue[], requirements: SocketOutputRequirements | undefined): void { if (requirements?.renderableTextIntent !== false) return; if (!Object.prototype.hasOwnProperty.call(value, HANDOFF_TEXT_FIELD)) return; if (typeof value[HANDOFF_TEXT_FIELD] !== "string") return; const contextValue = value[HANDOFF_CONTEXT_FIELD]; const hasContext = typeof contextValue === "string" && contextValue.trim().length > 0; const textAssignmentExample = JSON.stringify("$.text"); const reservation = `${JSON.stringify(HANDOFF_TEXT_FIELD)} is reserved for renderable-prose sockets that opt in via ${textAssignmentExample} assignment or explicit intent.`; issues.push({ path: `$.${HANDOFF_TEXT_FIELD}`, expected: "present", message: hasContext ? `Duplicate top-level renderable text field ${JSON.stringify(HANDOFF_TEXT_FIELD)}: this socket already carries ${JSON.stringify(HANDOFF_CONTEXT_FIELD)} and is not configured for renderable text output.` : `Unexpected top-level renderable text field ${JSON.stringify(HANDOFF_TEXT_FIELD)}: this socket is not configured for renderable text output.`, reason: hasContext ? `Drop ${JSON.stringify(HANDOFF_TEXT_FIELD)} and keep your explanation in ${JSON.stringify(HANDOFF_CONTEXT_FIELD)}. ${reservation}` : `Move your explanatory prose into ${JSON.stringify(HANDOFF_CONTEXT_FIELD)} (the default handoff notes field). ${reservation}`, }); } function socketUsesCustomJsonPayloadCondition(socket: MateriaPipelineSocketConfig | undefined): boolean { if (!socket) return false; const conditions = [socket.advance?.when, ...(socket.edges ?? []).map((edge) => edge.when)]; return conditions.some((condition) => typeof condition === "string" && condition.includes("$.")); } function addLegacySatisfiedHint(value: Record, issues: HandoffValidationIssue[]): void { if (!issues.some((issue) => issue.path === "$.satisfied" && issue.expected === "boolean")) return; const legacyFields = HANDOFF_LEGACY_NON_CANONICAL_ALIASES.filter((field) => Object.prototype.hasOwnProperty.call(value, field)); if (legacyFields.length === 0) return; issues.push({ path: legacyFields.map((field) => `$.${field}`).join(", "), expected: "boolean", message: `Legacy field ${legacyFields.map((field) => JSON.stringify(field)).join(", ")} is not canonical and is not used for routing.`, reason: "Use the canonical satisfied field for satisfied/not_satisfied control flow.", }); } function addArchitectureAliasHints(value: Record, issues: HandoffValidationIssue[], validateWorkItemsShape: boolean): void { if (!validateWorkItemsShape) return; const aliases: string[] = []; for (const field of ["architectureGuidance", "architecture"] as const) { if (Object.prototype.hasOwnProperty.call(value, field)) aliases.push(`$.${field}`); } if (Array.isArray(value.workItems)) { value.workItems.forEach((item, index) => { if (!isPlainJsonObject(item)) return; for (const field of ["architectureGuidance", "architecture"] as const) { if (Object.prototype.hasOwnProperty.call(item, field)) aliases.push(`$.workItems.${index}.${field}`); } const context = item.context; if (isPlainJsonObject(context) && Object.prototype.hasOwnProperty.call(context, "architectureGuidance")) aliases.push(`$.workItems.${index}.context.architectureGuidance`); }); } if (aliases.length === 0) return; issues.push({ path: aliases.join(", "), expected: "string", message: `Architecture alias field ${aliases.join(", ")} is not canonical for workItems payloads. Put item-specific guidance in the workItems[].context string instead.`, reason: "Generated units belong in top-level workItems, and agent-produced workItems contain only title:string and context:string." }); } function requiresWorkItemsShapeValidation(requirements: SocketOutputRequirements): boolean { return requirements.requiredFields.some((requirement) => requirement.path === "$.workItems"); } function expectedWorkItemFieldType(path: string): SocketOutputFieldType | "present" { if (path.endsWith(".title") || path.endsWith(".context")) return "string"; return "present"; } function matchesType(value: unknown, type: SocketOutputFieldType): boolean { switch (type) { case "array": return Array.isArray(value); case "boolean": return typeof value === "boolean"; case "object": return isPlainJsonObject(value); case "string": return typeof value === "string"; case "unknown": return value !== undefined; } } function getJsonPath(root: Record, path: string): { exists: boolean; value?: unknown } { if (path === "$") return { exists: true, value: root }; if (!path.startsWith("$.")) return { exists: false }; let current: unknown = root; for (const part of path.slice(2).split(".")) { if (current === undefined || current === null) return { exists: false }; if (Array.isArray(current) && /^\d+$/.test(part)) { const index = Number(part); if (index >= current.length) return { exists: false }; current = current[index]; continue; } if (typeof current !== "object" || !Object.prototype.hasOwnProperty.call(current, part)) return { exists: false }; current = (current as Record)[part]; } return { exists: true, value: current }; } function isPlainJsonObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function articleForType(type: string): string { return /^[aeiou]/i.test(type) ? "an" : "a"; }