summaryrefslogtreecommitdiff
path: root/subagents/spawn.lua
diff options
context:
space:
mode:
Diffstat (limited to 'subagents/spawn.lua')
-rw-r--r--subagents/spawn.lua523
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