diff options
Diffstat (limited to 'subagents/spawn.lua')
| -rw-r--r-- | subagents/spawn.lua | 523 |
1 files changed, 523 insertions, 0 deletions
diff --git a/subagents/spawn.lua b/subagents/spawn.lua new file mode 100644 index 0000000..a83e442 --- /dev/null +++ b/subagents/spawn.lua @@ -0,0 +1,523 @@ +-- Turn a delegation request into a child agent, and start its turn. +-- +-- Every path that starts a child — the subagents.run tool, `ctx:agent` in the +-- Lua workflow API, and the TOML workflow lowering — goes through here, so the +-- validation rules and the model/reasoning precedence exist exactly once: +-- +-- model = call.model or profile.model or (inherited) +-- reasoning = call.reasoning or profile.reasoning or (inherited) +-- +-- "Inherited" means the field is absent from the spec, and the primary's live +-- values from `session_info()` apply. A resumed child reads its own last +-- effective values from the stored conversation instead of a profile, so a +-- continuation without overrides keeps running on what it ran on before. The +-- two fields resolve independently: a call may override reasoning while +-- inheriting the model. +-- +-- A new child's conversation starts with the primary's effective system +-- context, then the fixed child-role instruction, then — when the profile has +-- a body — the profile prompt as a further system message. That profile +-- message carries the immutable manifest metadata (owning primary session id + +-- profile name), which is how a resumed child re-identifies itself. The parent +-- dialogue is never copied, which is why the child-role text tells the child +-- its final message is the whole of what the delegator sees. +-- +-- Edge cases: a profile with an empty body contributes no system message and +-- therefore no manifest, so a resumed child started from a body-less profile +-- reports no agent name. Resume opens the stored conversation as canonical, so +-- profile edits never reach an existing child. Errors are returned as plain +-- lowercase messages without an "Error: " prefix; the tool layer decides how to +-- present them. Nothing here raises: the binding reports its failures by +-- raising, and every such call goes through `try` so a caller sees one shape. + +local jobs = require("subagents.jobs") +local paths = require("subagents.paths") +local profiles_mod = require("subagents.profiles") +local progress = require("subagents.progress") + +-- A child never gets the delegation tools themselves: no recursion. +local TOOL_PREFIX = "subagents." + +local M = {} + +M.CHILD_ROLE = table.concat({ + "You are a subagent working inside another agent's session.", + "Complete the task you are given directly and end with a clear,", + "self-contained report; your final message is returned to the", + "delegating agent verbatim. You cannot ask the user questions.", +}, " ") + +local discovered = nil + +local function host() + return require("panto").ext +end + +local function binding() + return require("panto") +end + +-- The binding reports every failure by raising. Route those through one place +-- so a host error becomes the `nil, message` shape the callers already handle. +local function try(fn, ...) + local ok, value = pcall(fn, ...) + if not ok then + return false, tostring(value) + end + return true, value +end + +local function nonempty(value) + if type(value) == "string" and value ~= "" then + return value + end + return nil +end + +-- Discovery is cached: activation discovers once, and callers that omit the +-- profile set (a workflow calling build_spec with one argument) reuse it. +function M.profiles(given) + if given ~= nil then + return given + end + if discovered == nil then + discovered = profiles_mod.discover() + end + return discovered +end + +-- Comma-joined sorted profile names, for "unknown agent" messages. +function M.agent_names(profiles) + profiles = M.profiles(profiles) + local names = {} + for name in pairs(profiles.by_name or {}) do + names[#names + 1] = name + end + if #names == 0 then + return "(no agent profiles found)" + end + table.sort(names) + return table.concat(names, ", ") +end + +local function optional_string(value, field) + if value == nil then + return nil, nil + end + if type(value) ~= "string" or value == "" then + return nil, string.format("`%s` must be a non-empty string when given", field) + end + return value, nil +end + +local function build_output(output) + if type(output) ~= "table" then + return nil, "`output` must be a table" + end + if type(output.schema) ~= "table" then + return nil, "`output.schema` must be a JSON-Schema table" + end + return { + name = output.name or "emit_result", + description = output.description, + schema = output.schema, + }, nil +end + +-- build_spec(input, profiles) -> spec | nil, err +-- +-- input = { agent | id, prompt, model?, reasoning?, output? } +function M.build_spec(input, profiles) + if type(input) ~= "table" then + return nil, "expected a table of arguments" + end + if type(input.prompt) ~= "string" or input.prompt:match("^%s*$") then + return nil, "`prompt` must be a non-empty string" + end + + local agent, err = optional_string(input.agent, "agent") + if err then + return nil, err + end + local id + id, err = optional_string(input.id, "id") + if err then + return nil, err + end + if agent and id then + return nil, "pass exactly one of `agent` (start a new child) or `id` (continue one), not both" + end + if not agent and not id then + return nil, "pass exactly one of `agent` (start a new child) or `id` (continue one)" + end + + local model + model, err = optional_string(input.model, "model") + if err then + return nil, err + end + local reasoning + reasoning, err = optional_string(input.reasoning, "reasoning") + if err then + return nil, err + end + + local output + if input.output ~= nil then + output, err = build_output(input.output) + if err then + return nil, err + end + end + + -- The profile is resolved before the store is opened so an unknown agent + -- name reports itself instead of a session-directory failure. + local profile + if agent then + profile = (M.profiles(profiles).by_name or {})[agent] + if not profile then + return nil, string.format("unknown agent '%s'; known: %s", agent, M.agent_names(profiles)) + end + end + + -- child_store_dir returns the session info alongside the directory on + -- success, and the failure message in that same slot on failure. + local store_dir, info_or_err = paths.child_store_dir() + if not store_dir then + return nil, tostring(info_or_err) + end + + local spec = { + store_dir = store_dir, + prompt = input.prompt, + model = model, + reasoning = reasoning, + output = output, + } + + if id then + spec.session_id = id + return spec + end + + spec.label = profile.name + spec.model = model or profile.model + spec.reasoning = reasoning or profile.reasoning + + local system_messages = { { text = M.CHILD_ROLE } } + if profile.body and profile.body:match("%S") then + system_messages[#system_messages + 1] = { + text = profile.body, + metadata = { subagents = { owner = info_or_err.session_id, agent = profile.name } }, + } + end + spec.system_messages = system_messages + + return spec +end + +-- The stored conversation is the only record a resumed child has of itself: the +-- first system message carrying metadata holds the manifest, and the last user +-- message whose metadata names this extension holds the model and reasoning its +-- previous turn resolved to. A message whose metadata is malformed is skipped, +-- not treated as an error. +local function read_stored(conv) + local ok, messages = try(conv.messages, conv) + if not ok or type(messages) ~= "table" then + return {}, nil + end + + local manifest + for index = 1, #messages do + if messages[index].role == "system" then + local metadata = conv:message_metadata(index) + if type(metadata) == "table" then + manifest = metadata + break + end + end + end + + local defaults = {} + for index = #messages, 1, -1 do + if messages[index].role == "user" then + local metadata = conv:message_metadata(index) + local mine = type(metadata) == "table" and metadata.subagents or nil + if type(mine) == "table" then + defaults.model = nonempty(mine.model) + defaults.reasoning = nonempty(mine.reasoning) + break + end + end + end + return defaults, manifest +end + +-- The primary's effective system context, which every new child starts with. A +-- replace-mode system block supersedes everything before it, exactly as the +-- primary's own provider sees it. +local function primary_system_texts() + local primary = host().agent + if primary == nil then + return {} + end + local ok, conv = try(primary.conversation, primary) + if not ok or conv == nil then + return {} + end + local read, messages = try(conv.messages, conv) + if not read or type(messages) ~= "table" then + return {} + end + + local texts = {} + for _, message in ipairs(messages) do + if message.role == "system" then + local parts = {} + for _, block in ipairs(message.blocks or {}) do + if block.mode == "replace" then + texts, parts = {}, {} + end + if type(block.text) == "string" and (block.type == "system" or block.type == "text") then + parts[#parts + 1] = block.text + end + end + local text = table.concat(parts, "\n") + if text ~= "" then + texts[#texts + 1] = text + end + end + end + return texts +end + +-- Everything the primary can call except the delegation tools themselves. The +-- decls carry opaque source tags, so a child registering them reaches the same +-- handlers on the same runtime. +local function inherited_tools() + local primary = host().agent + if primary == nil then + return {} + end + local ok, decls = try(primary.tools, primary) + if not ok or type(decls) ~= "table" then + return {} + end + local kept = {} + for _, decl in ipairs(decls) do + if type(decl.name) ~= "string" or decl.name:sub(1, #TOOL_PREFIX) ~= TOOL_PREFIX then + kept[#kept + 1] = decl + end + end + return kept +end + +local function seed_conversation(agent, spec) + local conv = agent:conversation() + for _, text in ipairs(primary_system_texts()) do + conv:add_system_message(text) + end + for _, message in ipairs(spec.system_messages or {}) do + if message.metadata ~= nil then + conv:add_system_message(message.text, { metadata = message.metadata }) + else + conv:add_system_message(message.text) + end + end + return true +end + +-- spawn(spec) -> handle | nil, err +-- +-- Resolve the model, open the child's store, build the agent, seed or reopen +-- its conversation, hand it the primary's tools, and start one turn under the +-- session-wide bound. Everything that can fail before the turn starts (an +-- unknown id, an id already busy, an unknown model, a store that cannot be +-- opened) fails here, so a caller either has a running child or a message. +function M.spawn(spec) + if type(spec) ~= "table" then + return nil, "expected a spawn spec" + end + local one_shot = type(spec.output) == "table" + if one_shot and spec.session_id then + return nil, "a structured-output child cannot be resumed" + end + if spec.session_id and jobs.active(spec.session_id) then + return nil, string.format("subagent '%s' already has a turn in flight", spec.session_id) + end + + local ext = host() + local panto = binding() + local _, info = try(ext.session_info) + info = type(info) == "table" and info or {} + + -- A structured worker is ephemeral by contract: no durable file to resume, + -- so it never touches the child catalog. + local opened, store + if one_shot then + opened, store = try(panto.null_store) + else + local made, dir_err = paths.ensure_dir(spec.store_dir) + if not made then + return nil, dir_err + end + opened, store = try(panto.file_system_jsonl_store, { dir = spec.store_dir }) + end + if not opened then + return nil, tostring(store) + end + if store == nil then + return nil, "the child session store could not be opened" + end + + local conv, defaults, manifest + if spec.session_id then + -- The ownership boundary is the primary's own catalog directory: an id + -- from another session simply is not in this store. + local unknown = string.format("unknown subagent id '%s' for this session", spec.session_id) + local asked, found = try(store.resolve, store, spec.session_id) + if not asked then + return nil, tostring(found) + end + if found == nil then + return nil, unknown + end + local loaded + loaded, conv = try(store.load, store, spec.session_id) + if not loaded then + return nil, tostring(conv) + end + if conv == nil then + return nil, unknown + end + defaults, manifest = read_stored(conv) + end + defaults = defaults or {} + + local model = spec.model or defaults.model or nonempty(info.model) + local reasoning = spec.reasoning or defaults.reasoning or nonempty(info.reasoning) + + local asked, cfg, resolve_err = pcall(ext.resolve_model, { + model = model, + reasoning = reasoning, + tool_choice = one_shot and { name = spec.output.name } or nil, + }) + if not asked then + return nil, tostring(cfg) + end + if cfg == nil then + return nil, resolve_err and tostring(resolve_err) or "the child model could not be resolved" + end + -- The labels the host actually resolved to are what the turn records, so a + -- continuation reads back the same spelling. + local model_label = nonempty(cfg.model) or model + local reasoning_label = nonempty(cfg.reasoning) or reasoning + + local built, agent = try(panto.agent, { + config = cfg, + store = store, + session_id = spec.session_id, + conversation = conv, + }) + if not built or agent == nil then + return nil, built and "the subagent could not be created" or tostring(agent) + end + + if not spec.session_id then + local seeded, seed_err = try(seed_conversation, agent, spec) + if not seeded then + return nil, seed_err + end + end + + local decls = inherited_tools() + if one_shot then + decls = { { + name = spec.output.name, + description = spec.output.description or "", + schema = spec.output.schema, + } } + end + local armed, tools_err = try(agent.set_tools, agent, decls) + if not armed then + return nil, tools_err + end + + -- A one-shot worker reports no id: it has no durable session to name. + local named, session_id = try(agent.session_id, agent) + local id = (not one_shot) and named and nonempty(session_id) or nil + + local card = progress.card(spec.label or "subagent", id, model_label) + + -- The settled run_async result becomes the result table every caller + -- already reads: run.lua's block and the workflow API's shape_result. + local function shape(raw) + raw = type(raw) == "table" and raw or {} + local result = { + id = id, + status = raw.status or "failed", + output = raw.text, + error = raw.error, + resumable = false, + model = model_label, + reasoning = reasoning_label, + manifest = manifest, + } + if one_shot then + local wanted = spec.output.name + for _, call in ipairs(raw.tool_calls or {}) do + if call.name == wanted then + result.structured_json = call.input + break + end + end + if result.structured_json == nil and result.status == "completed" then + result.status = "failed" + result.output = nil + result.error = string.format("the child did not call the required '%s' output tool", wanted) + end + elseif id then + -- Durable resumability is a fact about the store, not about the + -- status: a turn that died before its first assistant message left + -- nothing to continue from. Ask again now that it has settled. + local ok, found = try(store.resolve, store, id) + result.resumable = ok and found ~= nil + end + card:done(result.status, result.error) + return result + end + + local handle, start_err = jobs.start { + label = spec.label, + id = id, + one_shot = one_shot, + build = function(wake_fd) + local job, err = agent:run_async { + prompt = spec.prompt, + metadata = { subagents = { model = model_label, reasoning = reasoning_label } }, + dispatch_tools = not one_shot, + wake_fd = wake_fd, + } + if not job then + return nil, err or "the host could not start the subagent" + end + return job + end, + on_event = function(event) + card:event(event) + end, + shape = shape, + } + if not handle then + card:done("failed", start_err) + return nil, start_err + end + + -- The job borrows the agent, which borrows the store; anchor both on the + -- handle so neither is collected while the pump is running. + handle.agent = agent + handle.store = store + return handle +end + +return M |
