summaryrefslogtreecommitdiff
path: root/subagents/spawn.lua
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-08-19 19:02:30 -0600
committert <t@tjp.lol>2026-08-19 19:03:52 -0600
commitd1306506aa7f504b0e91c9c6ed7314afbf99978e (patch)
treee5a7295dbc566a3679e99e14dae13376b0469e84 /subagents/spawn.lua
parent94e3fd8358bbdb5d6ed81aed475fab7fc73e2097 (diff)
Add background Lua workflows, inline child prompts, and layered config
subagents.lua now starts a workflow on its own coroutine and returns a session-scoped id immediately, so fan-out continues while the primary keeps working; completion wakes the primary, and later calls read immutable records from subagents.workflows. Every ctx:agent takes a workflow-unique name so those records are addressable. A session_start guidance message tells the primary when to reach for run vs lua. Children no longer inherit the primary's system context: a child starts from the fixed child-role instruction plus its profile, and subagents.run/ctx:agent accept an inline system_prompt instead of a profile. The now-redundant `agent` form of subagents.models is gone. Concurrency defaults to five and is configurable through [subagents] max_concurrent in any layered config.toml; turn boundaries reap only settled jobs so background workflows survive, while interrupt and session end cancel. Config roots come from panto.ext.dirs.layers rather than a hand-rolled XDG lookup, which picks up the base and git-ignored local layers for both agents/ and workflows/. TOML workflows tighten up: the subagents.workflow tool takes a discovered name only (inline `steps` duplicated subagents.lua at less power), an optional top-level `output` array chooses the reported steps and their order instead of the terminal set, a step with no workflow input gets no empty input heading, and a workflow naming an undiscovered agent is rejected at discovery rather than part-way through a run.
Diffstat (limited to 'subagents/spawn.lua')
-rw-r--r--subagents/spawn.lua82
1 files changed, 28 insertions, 54 deletions
diff --git a/subagents/spawn.lua b/subagents/spawn.lua
index caf65aa..9117fdd 100644
--- a/subagents/spawn.lua
+++ b/subagents/spawn.lua
@@ -14,12 +14,14 @@
-- 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); workflow-local profiles also carry an inline marker so the
--- progress replay path can expose only prompts the caller explicitly supplied.
+-- A new child's conversation starts with the fixed child-role instruction,
+-- then — when the profile has a body — the profile prompt as a further system
+-- message. The primary's system messages and dialogue are never copied: the
+-- profile defines the child's system context rather than augmenting the
+-- primary agent's prompt. That profile message carries the immutable manifest
+-- metadata (owning primary session id + profile name); workflow-local profiles
+-- also carry an inline marker so the progress replay path can expose only
+-- prompts the caller explicitly supplied.
-- The per-turn user metadata records the effective model/reasoning, the
-- per-outer-call card sequence, terminal presentation status, and — when the
-- spawn happened inside a bound extension tool — that outer tool call id.
@@ -130,7 +132,7 @@ end
-- build_spec(input, profiles) -> spec | nil, err
--
--- input = { agent | id, prompt, model?, reasoning?, output? }
+-- input = { agent | system_prompt | id, prompt, model?, reasoning?, output? }
function M.build_spec(input, profiles)
if type(input) ~= "table" then
return nil, "expected a table of arguments"
@@ -148,11 +150,14 @@ function M.build_spec(input, profiles)
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"
+ local system_prompt
+ system_prompt, err = optional_string(input.system_prompt, "system_prompt")
+ if err then
+ return nil, err
end
- if not agent and not id then
- return nil, "pass exactly one of `agent` (start a new child) or `id` (continue one)"
+ local selectors = (agent and 1 or 0) + (system_prompt and 1 or 0) + (id and 1 or 0)
+ if selectors ~= 1 then
+ return nil, "pass exactly one of `agent` (start from a profile), `system_prompt` (start without a profile), or `id` (continue one)"
end
local model
@@ -182,6 +187,12 @@ function M.build_spec(input, profiles)
if not profile then
return nil, string.format("unknown agent '%s'; known: %s", agent, M.agent_names(profiles))
end
+ elseif system_prompt then
+ profile = {
+ name = "subagent",
+ body = system_prompt,
+ inline = true,
+ }
end
-- child_store_dir returns the session info alongside the directory on
@@ -208,17 +219,18 @@ function M.build_spec(input, profiles)
spec.model = model or profile.model
spec.reasoning = reasoning or profile.reasoning
+ local inline = profile.inline == true or profile.layer == "workflow"
local system_messages = { { text = M.CHILD_ROLE } }
if profile.body and profile.body:match("%S") then
local manifest = { owner = info_or_err.session_id, agent = profile.name }
- if profile.layer == "workflow" then manifest.inline = true end
+ if inline then manifest.inline = true end
system_messages[#system_messages + 1] = {
text = profile.body,
metadata = { subagents = manifest },
}
end
spec.system_messages = system_messages
- if profile.layer == "workflow" then
+ if inline then
spec.presentation_system_prompt = profile.body
end
@@ -262,44 +274,6 @@ local function read_stored(conv)
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.
@@ -323,9 +297,6 @@ 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 })
@@ -527,6 +498,9 @@ function M.spawn(spec)
pcall(agent.set_message_metadata, agent, turn_index, turn_metadata)
end
card:done(result.status, result.error)
+ if type(spec.on_settle) == "function" then
+ pcall(spec.on_settle, result)
+ end
return result
end