-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. --- Dispatch result builder, validator, and normalization. -- -- Fields: status, summary, changes (worker), verification (worker), -- findings (explorer), evidence (explorer), limitations, -- cancelled, error, usage. -- -- Normalizes child turn's terminal tool result (from `report`) into -- the `dispatch_result` shape. Handles cancellation: child turn -- cancelled -> cancelled=true and appropriate error. local agent_error = require("agent_smith.error") local report_schema = require("agent_smith.dispatch.report_schema") local M = {} local VALID_STATUSES = report_schema.VALID_STATUSES local validate_report_payload = report_schema.validate --- Validate a dispatch_result for a given role. -- -- @param result table — dispatch_result to validate -- @param role string — "worker" or "explorer" -- @return nil on success, or agent_error function M.validate(result, role) if type(result) ~= "table" then return agent_error.new({ kind = agent_error.kind.schema, code = "invalid_result", message = "dispatch_result must be a table", origin = "dispatch:result", }) end if not VALID_STATUSES[result.status] then return agent_error.new({ kind = agent_error.kind.schema, code = "invalid_status", message = "dispatch_result `status` must be one of: ok, partial, blocked", origin = "dispatch:result", }) end if type(result.summary) ~= "string" or result.summary == "" then return agent_error.new({ kind = agent_error.kind.schema, code = "invalid_summary", message = "dispatch_result `summary` must be a non-empty string", origin = "dispatch:result", }) end -- Worker-specific fields if role == "worker" then if result.changes ~= nil and type(result.changes) ~= "table" then return agent_error.new({ kind = agent_error.kind.schema, code = "invalid_changes", message = "worker dispatch_result `changes` must be an array", origin = "dispatch:result", }) end if result.verification ~= nil then if type(result.verification) ~= "table" then return agent_error.new({ kind = agent_error.kind.schema, code = "invalid_verification", message = "worker dispatch_result `verification` must be a table", origin = "dispatch:result", }) end if result.verification.commands ~= nil and type(result.verification.commands) ~= "table" then return agent_error.new({ kind = agent_error.kind.schema, code = "invalid_verification_commands", message = "worker dispatch_result `verification.commands` must be an array if provided", origin = "dispatch:result", }) end end end -- Explorer-specific fields if role == "explorer" then if result.findings ~= nil and type(result.findings) ~= "table" then return agent_error.new({ kind = agent_error.kind.schema, code = "invalid_findings", message = "explorer dispatch_result `findings` must be an array", origin = "dispatch:result", }) end if result.evidence ~= nil and type(result.evidence) ~= "table" then return agent_error.new({ kind = agent_error.kind.schema, code = "invalid_evidence", message = "explorer dispatch_result `evidence` must be an array", origin = "dispatch:result", }) end end -- Common optional fields if result.limitations ~= nil and type(result.limitations) ~= "string" then return agent_error.new({ kind = agent_error.kind.schema, code = "invalid_limitations", message = "dispatch_result `limitations` must be a string if provided", origin = "dispatch:result", }) end if result.cancelled ~= nil and type(result.cancelled) ~= "boolean" then return agent_error.new({ kind = agent_error.kind.schema, code = "invalid_cancelled", message = "dispatch_result `cancelled` must be a boolean if provided", origin = "dispatch:result", }) end if result.usage ~= nil and type(result.usage) ~= "table" then return agent_error.new({ kind = agent_error.kind.schema, code = "invalid_usage", message = "dispatch_result `usage` must be a table if provided", origin = "dispatch:result", }) end return nil end --- Construct and validate a dispatch_result. -- -- @param opts table with keys: -- status (string, required: ok/partial/blocked) -- summary (string, required, non-empty) -- changes (table, worker-only, optional) -- verification (table, worker-only, optional) -- findings (table, explorer-only, optional) -- evidence (table, explorer-only, optional) -- limitations (string, optional) -- cancelled (boolean, optional, default false) -- error (table, optional canonical agent_error) -- usage (table, optional token counts) -- @param role string — "worker" or "explorer" -- -- @return dispatch_result table on success, or nil + agent_error function M.new(opts, role) if type(opts) ~= "table" then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_result_opts", message = "dispatch_result options must be a table", origin = "dispatch:result", }) end -- Validate input local err = M.validate(opts, role) if err then return nil, err end -- Build normalized dispatch_result local result = { status = opts.status, summary = opts.summary, cancelled = opts.cancelled or false, } -- Role-specific fields if role == "worker" then result.changes = opts.changes or {} if opts.verification ~= nil then -- Copy into a fresh table: opts.verification is often the child's raw -- report args, and writing `commands` back onto it would mutate the -- caller's structure. local verification = {} for k, v in pairs(opts.verification) do verification[k] = v end verification.commands = verification.commands or {} result.verification = verification else result.verification = { commands = {} } end end if role == "explorer" then result.findings = opts.findings or {} result.evidence = opts.evidence or {} end -- Common optional fields if opts.limitations ~= nil then result.limitations = opts.limitations end if opts.error ~= nil then result.error = opts.error end if opts.usage ~= nil then result.usage = opts.usage end return result end --- Normalize child turn's `report` tool args into a dispatch_result. -- -- Maps report input fields directly onto dispatch_result fields per -- ARCHITECTURE §7: "report input must validate as rigorously as -- dispatch_result." This is the primary normalization path: the -- dispatch worker captures the child's report args and produces a -- structured dispatch_result for the parent. -- -- @param report_args table — the args the child passed to `report` -- @param role string — "worker" or "explorer" -- @param usage table|nil — token counts for the child turn -- -- @return dispatch_result table on success, or nil + agent_error function M.normalize_from_report(report_args, role, usage) if type(report_args) ~= "table" then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_report_args", message = "report args must be a table for dispatch_result normalization", origin = "dispatch:result", }) end report_args = report_schema.coerce(report_args, role) local validation_err = validate_report_payload(report_args, role) if validation_err then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_report_payload", message = validation_err, origin = "dispatch:result", }) end -- Build opts from report args, mapping fields 1:1 local opts = { status = report_args.status, summary = report_args.summary, limitations = report_args.limitations, cancelled = report_args.cancelled, error = report_args.error, usage = usage, } -- Worker-specific fields from report args if role == "worker" then opts.changes = report_args.changes opts.verification = report_args.verification end -- Explorer-specific fields from report args if role == "explorer" then opts.findings = report_args.findings opts.evidence = report_args.evidence end return M.new(opts, role) end --- Create a dispatch_result for a cancelled child turn. -- -- @param opts table|nil with optional keys: -- summary (string, default "child turn was cancelled") -- error (table, optional canonical agent_error; defaults to -- agent_error.cancelled("dispatch")) -- usage (table, optional token counts) -- -- @return dispatch_result table (always succeeds) function M.cancelled_result(opts) opts = opts or {} local err = opts.error or agent_error.cancelled("dispatch") return { status = "blocked", summary = opts.summary or "child turn was cancelled", cancelled = true, error = err, usage = opts.usage, } end return M