-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. --- Child runtime launch with isolated execution context. -- -- Constructs a child execution_context from a dispatch_job, launches -- a child turn via core/turn.lua, propagates cancellation, and -- normalizes the child turn result into a dispatch_result. local cancel = require("agent_smith.cancel") local agent_error = require("agent_smith.error") local dispatch_result = require("agent_smith.dispatch.result") local M = {} -- Child event types forwarded to the parent UI sink. Lifecycle/stream events -- are intentionally excluded (see build_child_event_sink). local FORWARD_CHILD_EVENTS = { llm_request_started = true, llm_tool_call_detected = true, tool_execution_started = true, tool_execution_finished = true, tool_execution_cancelled = true, llm_request_cancelled = true, } --- Build a wrapped on_event sink that forwards a child turn's render-relevant --- events to the parent UI sink, tagging each with the child role. --- --- @param parent_on_event function|nil — parent UI event sink --- @param source_role string — child role (e.g. "worker", "explorer") --- @return function|nil — wrapped sink, or nil when there is no parent sink local function build_child_event_sink(parent_on_event, source_role) if type(parent_on_event) ~= "function" then return nil end return function(ev) if type(ev) ~= "table" or not FORWARD_CHILD_EVENTS[ev.type] then return end -- Shallow copy + new top-level source_role tag; never mutate the child's -- stored event nor collide with data keys. parent_on_event({ type = ev.type, turn_id = ev.turn_id, ts = ev.ts, data = ev.data, source_role = source_role, }) end end --- Build a child execution_context from a dispatch_job and parent context. -- -- Per ARCHITECTURE §5 (Roles and policy) and §7 (Dispatch system): -- - new session_id (child-specific) -- - target_role as active_role -- - inherited conversation_id (scoped child conversation) -- - inherited service_handles -- -- @param job table — validated dispatch_job -- @param parent_ctx table — parent execution_context -- @return table child execution_context local function build_child_context(job, parent_ctx) local child_conversation_id = parent_ctx.conversation_id .. ":child:" .. job.job_id return { session_id = "session-child-" .. job.job_id, active_role = job.target_role, conversation_id = child_conversation_id, config_snapshot = parent_ctx.config_snapshot, service_handles = parent_ctx.service_handles, } end --- Build a child turn_request from a dispatch_job. -- -- Per ARCHITECTURE §5.2 turn_request shape: -- - turn_id derived from job_id -- - role set to target_role -- - user_message from job.task -- - conversation_state is fresh for the child -- - cancel_token is the derived child token -- -- @param job table — validated dispatch_job -- @param child_cancel_token table — derived child cancel token -- @return table turn_request local function build_child_request(job, child_cancel_token) -- conversation_state and tool_step_budget are intentionally omitted: the -- child starts with fresh conversation state and the default tool budget. return { turn_id = "turn-child-" .. job.job_id, role = job.target_role, user_message = job.task, stream = false, cancel_token = child_cancel_token, } end local function blocked_policy_result(job, usage, code, message, data) local blocked_result, blocked_err = dispatch_result.new({ status = "blocked", summary = "child turn completed without required terminal report payload", usage = usage, }, job.target_role) if not blocked_result then return nil, blocked_err end blocked_result.error = agent_error.new({ kind = agent_error.kind.policy, code = code, message = message, origin = "dispatch:worker", data = data, }) return blocked_result end -- Salvage a child that finished without calling `report`. The turn loop already -- re-prompts such a child (MAX_REPORT_NUDGES); if it still answers in prose, that -- prose is its real (unstructured) output — hand it back as a `partial` result with -- the text in `limitations` rather than discarding an expensive turn. Returns nil -- when there is no prose to salvage, so the caller falls back to a policy block. local function salvage_unreported_result(job, turn_result) local msg = turn_result.assistant_message local content = type(msg) == "table" and msg.content or nil if type(content) ~= "string" or content == "" then return nil end local lead = "Child did not submit a formal report; salvaged its final message." return dispatch_result.new({ status = "partial", summary = lead, limitations = lead .. "\n\n" .. content, usage = turn_result.usage_delta, }, job.target_role) end local function extract_report_args(terminal_result) if type(terminal_result) ~= "table" then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_terminal_result", message = "terminal report result must be a table", origin = "dispatch:worker", }) end local content = terminal_result.content if type(content) == "table" then return content end if type(content) == "string" then local ok, parsed = pcall(function() return require("cjson").decode(content) end) if not ok or type(parsed) ~= "table" then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_terminal_report_payload", message = "terminal report payload string must decode to a JSON object", origin = "dispatch:worker", }) end return parsed end return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_terminal_report_payload", message = "terminal report payload must be a table or JSON string", origin = "dispatch:worker", }) end --- Launch a child turn and collect its result. -- -- This is the main entry point for dispatch worker. It: -- 1. Derives a child cancel token from the parent's turn cancel token -- 2. Builds a child execution context with role isolation -- 3. Constructs a turn_request from the dispatch_job -- 4. Launches the child turn via the provided turn module -- 5. Normalizes the child turn result into a dispatch_result -- -- The runtime parameter must provide: -- - execution_context() → parent execution_context -- - current_cancel_token() → parent's turn cancel token -- - service_handles() → service handles -- -- @param runtime table — parent runtime instance -- @param job table — validated dispatch_job -- @param opts table|nil — optional overrides: -- turn_module (table, default core/turn) — turn module to use -- child_conversation_state (table, nil) — pre-built conversation state -- -- @return dispatch_result table, or nil + agent_error function M.launch(runtime, job, opts) opts = opts or {} local turn_mod = opts.turn_module or require("agent_smith.core.turn") -- ── Derive child cancel token from parent ── -- The dispatch service normally derives the child token in build_job and -- stamps it onto the job. Fall back to deriving here for callers that build -- a job without one — using the same cancel.derive_child path so the -- pre-cancel-on-already-cancelled-parent semantics stay identical. local child_token = job.cancel_token if not child_token then local parent_token = runtime:current_cancel_token() local token_err child_token, token_err = cancel.derive_child(parent_token) if parent_token and not child_token then return nil, agent_error.normalize(token_err, { kind = agent_error.kind.internal, origin = "dispatch:worker", }) end child_token = child_token or cancel.new_root() end -- ── Build child execution context ── local parent_ctx = runtime:execution_context() local child_ctx = build_child_context(job, parent_ctx) -- ── Build child runtime wrapper ── -- The child runtime must present the child execution_context and -- service_handles to the turn module. We construct a minimal wrapper -- that satisfies the runtime interface needed by turn.run. -- -- INVARIANT: every runtime method borrowed below must be purely -- self-dispatched — i.e. read/write `self.__state` and call `self:...`, -- never close over the module-local runtime instance. They are invoked as -- `runtime.begin_turn(child_runtime, ...)` so they operate on the child's -- private __state; a method that captured the parent would corrupt the -- parent's active turn. (Verified against core/runtime.lua.) local child_runtime = { cfg = runtime.cfg, __scope_resolver = runtime.__scope_resolver, __state = { current_turn = nil, next_turn_id = 0, service_handles = runtime:service_handles(), execution_context = child_ctx, session = { status = "running", started_at = nil, }, }, -- Runtime method stubs required by turn.run begin_turn = runtime.begin_turn, current_turn = runtime.current_turn, current_cancel_token = runtime.current_cancel_token, cancel_current_turn = runtime.cancel_current_turn, finish_turn = runtime.finish_turn, has_active_turn = runtime.has_active_turn, service_handles = runtime.service_handles, execution_context = function() return child_ctx end, with_turn = runtime.with_turn, } -- ── Build child turn request ── local child_request = build_child_request(job, child_token) if opts.child_conversation_state then child_request.conversation_state = opts.child_conversation_state end -- ── Build a child UI event sink ── -- Forward only render-relevant child events to the parent's UI sink, tagged -- with the child role so the mode can prefix lines / banner with its label. -- Lifecycle and stream events are skipped so the child does not clobber the -- parent prompt status, leak tokens into the parent markdown stream, or -- pollute parent usage counting (child events stay on the child event bus). local wrapped_on_event = build_child_event_sink(opts.on_event, job.target_role) -- ── Launch child turn ── local turn_result, turn_err = turn_mod.run(child_runtime, child_request, { llm_config = runtime.cfg and runtime.cfg.llm_config, on_event = wrapped_on_event, }) -- ── Normalize into dispatch_result ── if not turn_result then -- Turn failed to produce a result if agent_error.is_cancelled(turn_err) then return dispatch_result.cancelled_result({ usage = nil, }) end return nil, agent_error.normalize(turn_err, { kind = agent_error.kind.internal, origin = "dispatch:worker", }) end -- ── Handle cancelled turn ── if turn_result.cancelled then return dispatch_result.cancelled_result({ usage = turn_result.usage_delta, }) end -- ── Handle error turn ── if turn_result.stop_reason == "error" and turn_result.error then local err_result, err = dispatch_result.new({ status = "blocked", summary = turn_result.error.message or "child turn errored", error = turn_result.error, usage = turn_result.usage_delta, }, job.target_role) if not err_result then return nil, err end return err_result end -- ── Handle completed turn: extract report from terminal_result ── -- Per ARCHITECTURE §7: child must terminate through `report` (terminal tool). -- The terminal tool result is available on turn_result.terminal_result -- (set by core/turn.lua when a terminal tool executes successfully). if not turn_result.terminal_result then local salvaged = salvage_unreported_result(job, turn_result) if salvaged then return salvaged end return blocked_policy_result( job, turn_result.usage_delta, "missing_terminal_report", "child turn must terminate via report tool with a valid payload" ) end local report_args, extract_err = extract_report_args(turn_result.terminal_result) if not report_args then return blocked_policy_result( job, turn_result.usage_delta, "invalid_terminal_report", "child turn report payload is invalid", agent_error.to_table(extract_err) ) end local dr, dr_err = dispatch_result.normalize_from_report(report_args, job.target_role, turn_result.usage_delta) if dr then return dr end return blocked_policy_result( job, turn_result.usage_delta, "invalid_terminal_report", "child turn report payload failed dispatch_result schema validation", agent_error.to_table(dr_err) ) end return M