1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
|
-- 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 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.
-- 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 | 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"
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
local system_prompt
system_prompt, err = optional_string(input.system_prompt, "system_prompt")
if err then
return nil, err
end
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
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
elseif system_prompt then
profile = {
name = "subagent",
body = system_prompt,
inline = true,
}
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 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 inline then manifest.inline = true end
system_messages[#system_messages + 1] = {
text = profile.body,
metadata = { subagents = manifest },
}
end
spec.system_messages = system_messages
if inline then
spec.presentation_system_prompt = profile.body
end
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 got, metadata = try(conv.message_metadata, conv, index)
if got and 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 got, metadata = try(conv.message_metadata, conv, index)
local mine = got and 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
-- 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 _, 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 turn_index
local got_conv, child_conv = try(agent.conversation, agent)
if got_conv and child_conv and type(child_conv.len) == "function" then
local got_len, length = try(child_conv.len, child_conv)
if got_len and type(length) == "number" then turn_index = length + 1 end
end
local card = progress.card(spec.label or "subagent", id, model_label, {
prompt = spec.prompt,
system_prompt = spec.presentation_system_prompt,
})
local owner_tool_call_id = progress.tool_call_id()
local turn_metadata = {
subagents = { model = model_label, reasoning = reasoning_label },
}
local sequence = card.sequence
if type(sequence) == "number" then turn_metadata.subagents.sequence = sequence end
if type(owner_tool_call_id) == "string" and owner_tool_call_id ~= "" then
turn_metadata.subagents.tool_call_id = owner_tool_call_id
end
-- 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 child_handle
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
turn_metadata.subagents.status = result.status
-- `agent:set_message_metadata` is deliberately refused while a pump
-- is live. The result has already been copied out, so joining here is
-- safe and releases the agent's mutation guard before the annotation.
if child_handle and child_handle.job then
pcall(child_handle.job.close, child_handle.job)
end
if turn_index and type(agent.set_message_metadata) == "function" then
-- The binding's annotation seam updates the already-written user
-- record, so cancellation has a durable presentation status even
-- though the stream correctly rolls its assistant messages back.
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
local start_err
child_handle, start_err = jobs.start {
id = id,
build = function(wake_fd)
local job, err = agent:run_async {
prompt = spec.prompt,
metadata = turn_metadata,
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,
}
local handle = child_handle
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
|