-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. --- Dispatch job builder, validator, and context construction helpers. -- -- Implements `dispatch_job` construction per ARCHITECTURE §7 (Dispatch system). -- Fields: job_id, parent_turn_id, target_role, task, context, files, -- cancel_token. -- -- Validates target_role against the role matrix (§5 Roles and policy), task non-empty, -- and context minimum fields. Rejects raw transcript dump in context -- unless explicitly flagged. local agent_error = require("agent_smith.error") local role_matrix = require("agent_smith.role.matrix") local M = {} --- Required minimum context fields per ARCHITECTURE §7 (Dispatch system). -- Each key maps to a human-readable label for error messages. local CONTEXT_REQUIRED_FIELDS = { user_request_summary = "user request summary", role_instructions = "role-specific instructions", } --- Build a compact, deterministic, auditable context payload. -- -- Normalizes the raw context input into a canonical form with sorted keys. -- Rejects raw transcript unless `include_transcript` is explicitly true. -- -- @param ctx table — raw context input -- @return table normalized context, or nil + agent_error function M.build_context(ctx) if type(ctx) ~= "table" then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_context", message = "dispatch job context must be a table", origin = "dispatch:job", }) end -- Reject raw transcript unless explicitly flagged if ctx.raw_transcript and not ctx.include_transcript then return nil, agent_error.new({ kind = agent_error.kind.policy, code = "raw_transcript_rejected", message = "raw transcript in context requires explicit include_transcript flag", origin = "dispatch:job", }) end -- Validate required minimum fields for field, label in pairs(CONTEXT_REQUIRED_FIELDS) do if ctx[field] == nil or ctx[field] == "" then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "missing_context_field", message = "dispatch job context missing required field: " .. label, origin = "dispatch:job", }) end end -- Build normalized context with deterministic key order local normalized = {} -- Collect all keys except internal control flags local keys = {} for k in pairs(ctx) do if k ~= "include_transcript" and k ~= "raw_transcript" then table.insert(keys, k) end end table.sort(keys) for _, k in ipairs(keys) do normalized[k] = ctx[k] end -- Include raw_transcript only if explicitly flagged if ctx.include_transcript and ctx.raw_transcript then normalized.raw_transcript = ctx.raw_transcript end return normalized end --- Validate that target_role is a valid dispatch target for parent_role. -- -- Per ARCHITECTURE §5 (Roles and policy) role matrix: -- direct → worker, explorer -- orchestrator → worker, explorer -- worker → explorer -- explorer → none -- -- @param parent_role string -- @param target_role string -- @return boolean function M.validate_dispatch_target(parent_role, target_role) return role_matrix.check_dispatch(parent_role, target_role) end --- Construct and validate a dispatch_job. -- -- @param opts table with keys: -- job_id (string, required) -- parent_turn_id (string, required) -- parent_role (string, required) -- target_role (string, required) -- task (string, required, non-empty) -- context (table, required, must satisfy minimum fields) -- files (table, optional, array of file path strings) -- cancel_token (any, optional, child cancellation token) -- -- @return dispatch_job table on success, or nil + agent_error function M.new(opts) if type(opts) ~= "table" then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_job_opts", message = "dispatch job options must be a table", origin = "dispatch:job", }) end -- job_id: required non-empty string if type(opts.job_id) ~= "string" or opts.job_id == "" then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_job_id", message = "dispatch job_id must be a non-empty string", origin = "dispatch:job", }) end -- parent_turn_id: required non-empty string if type(opts.parent_turn_id) ~= "string" or opts.parent_turn_id == "" then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_parent_turn_id", message = "dispatch job parent_turn_id must be a non-empty string", origin = "dispatch:job", }) end -- parent_role: required non-empty string if type(opts.parent_role) ~= "string" or opts.parent_role == "" then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_parent_role", message = "dispatch job parent_role must be a non-empty string", origin = "dispatch:job", }) end -- target_role: required non-empty string if type(opts.target_role) ~= "string" or opts.target_role == "" then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_target_role", message = "dispatch job target_role must be a non-empty string", origin = "dispatch:job", }) end -- Validate target_role is a valid dispatch target for parent_role if not M.validate_dispatch_target(opts.parent_role, opts.target_role) then return nil, agent_error.new({ kind = agent_error.kind.policy, code = "invalid_dispatch_target", message = "role '" .. opts.parent_role .. "' may not dispatch to '" .. opts.target_role .. "'", origin = "dispatch:job", }) end -- task: required non-empty string if type(opts.task) ~= "string" or opts.task == "" then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_task", message = "dispatch job task must be a non-empty string", origin = "dispatch:job", }) end -- context: required, must validate local context, ctx_err = M.build_context(opts.context) if not context then return nil, ctx_err end -- files: optional, normalize to sorted array local files = {} if opts.files then if type(opts.files) ~= "table" then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_files", message = "dispatch job files must be an array of strings", origin = "dispatch:job", }) end for _, f in ipairs(opts.files) do if type(f) ~= "string" then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_file_path", message = "dispatch job file paths must be strings", origin = "dispatch:job", }) end table.insert(files, f) end table.sort(files) end -- Build the normalized dispatch_job local job = { job_id = opts.job_id, parent_turn_id = opts.parent_turn_id, target_role = opts.target_role, task = opts.task, context = context, files = files, cancel_token = opts.cancel_token, } return job end return M