summaryrefslogtreecommitdiff
path: root/subagents/spawn.lua
blob: a83e442fe9287c2f8dca341c959ccb4d1705aed6 (plain)
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
-- 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