-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. --- Built-in tool: dispatch — parent-facing dispatch to child roles. -- Available to direct, orchestrator, and worker roles; denied to explorer. -- Non-terminal: parent turn loop continues after dispatch result so -- the parent LLM can synthesize a final answer. local TOOL_NAME = "dispatch" local std = require("std") local agent_error = require("agent_smith.error") local dispatch_service = require("agent_smith.dispatch") local role_matrix = require("agent_smith.role.matrix") -- Enum guard for the JSON schema; the actual parent→target policy is owned by -- agent_smith.role.matrix (check_dispatch). local VALID_TARGET_ROLES = { worker = true, explorer = true } --- Validate dispatch tool arguments. -- @param args table — tool call arguments -- @return nil on success, or string error message local function validate_dispatch_args(args) if type(args) ~= "table" then return "dispatch args must be a table" end if not VALID_TARGET_ROLES[args.role] then return "dispatch `role` must be one of: worker, explorer" end if type(args.task) ~= "string" or args.task == "" then return "dispatch `task` must be a non-empty string" end if args.context ~= nil and type(args.context) ~= "string" then return "dispatch `context` must be a string if provided" end if args.files ~= nil then if type(args.files) ~= "table" then return "dispatch `files` must be an array of strings if provided" end for i, f in ipairs(args.files) do if type(f) ~= "string" then return "dispatch `files` entries must be strings" end end end return nil end local M = {} function M.spec(options) options = options or {} return { name = TOOL_NAME, description = [[ Delegate a task to a worker or explorer role. Target role: worker or explorer. Worker only targets explorer; direct/orchestrator target worker or explorer. Returns structured dispatch_result as content. ]], schema = { type = "object", properties = { role = { type = "string", enum = { "worker", "explorer" }, description = "Target role. Must be worker or explorer.", }, task = { type = "string", description = "Task to delegate to the child role.", }, context = { type = "string", description = "Optional compact context for the child.", }, files = { type = "array", items = { type = "string" }, description = "Optional array of relevant file paths.", }, }, required = { "role", "task" }, }, role_allowlist = { "direct", "orchestrator", "worker" }, is_mutating = false, mutation_state_default = "none", -- is_terminal is intentionally NOT set (non-terminal tool) handler = function(context, args) -- ── Validate arguments ── local validation_err = validate_dispatch_args(args) if validation_err then return nil, { error = "validation_failed", detail = validation_err } end -- ── Resolve caller role and enforce role matrix ── local caller_role = (context and context.role) or (context and context.active_role) or "unknown" if not role_matrix.check_dispatch(caller_role, args.role) then return nil, { error = "invalid_dispatch_target", detail = "role '" .. caller_role .. "' may not dispatch to '" .. args.role .. "'", } end -- ── Resolve runtime ── local runtime = options.runtime or (context and context.runtime) if not runtime then return nil, { error = "no_runtime", detail = "dispatch tool has no runtime; cannot launch child", } end local turn_id = (context and context.turn_id) or "unknown-turn" -- Unique, collision-free job id (nanoid; ':'-free so it is a valid -- usage-key segment). The previous "job--" scheme -- collided when a turn dispatched more than once in the same second. local job_id = std.nanoid() local job_context = { user_request_summary = args.task, role_instructions = "You are a " .. args.role .. ". Complete the assigned task.", } if args.context then job_context.compact_context = args.context end local dispatch = options.dispatch_service or runtime:service_handles().dispatch or dispatch_service.new() local result, dispatch_err = dispatch:dispatch(runtime, { job_id = job_id, parent_turn_id = turn_id, parent_role = caller_role, target_role = args.role, task = args.task, context = job_context, files = args.files, turn_module = options.turn_module, on_event = context and context.on_event, }) if not result then local detail = "child launch failed" local code = "launch_failed" if dispatch_err and dispatch_err.message then detail = dispatch_err.message end if dispatch_err and dispatch_err.code == "invalid_dispatch_target" then code = "invalid_dispatch_target" elseif dispatch_err and dispatch_err.origin == "dispatch:job" then code = "job_construction_failed" end return nil, { error = code, detail = detail, } end -- ── Return structured dispatch_result as content ── -- Stamp the job id onto the result so the persistence layer can key -- child usage by the real job id rather than a synthetic counter, and -- the target role so usage attribution does not have to guess it from -- the result shape (cancelled results carry no role-specific fields). if type(result) == "table" then result.job_id = job_id result.target_role = args.role end return { content = result, } end, } end --- Expose validate_dispatch_args for testing. M.validate_dispatch_args = validate_dispatch_args return M