summaryrefslogtreecommitdiff
path: root/init.lua
blob: 3175cfa67fb89b0627e22e54eb2f442b5a0c21d4 (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
-- panto-subagents: the `subagents` extension entry point.
--
-- Activation happens once per panto session (a new session, `/new`, or
-- `/resume` rebuilds the whole Lua state and runs this again). It reads the
-- layered `[subagents]` settings, discovers the agent profiles once, registers
-- the four model-facing tools, and registers a `/workflow:<name>` command for
-- every valid TOML workflow.
--
-- Discovery order is deterministic and side-effect free apart from
-- registration, because pantograph evaluates every candidate extension file
-- but only activates the ones its allow/deny policy permits.
--
-- The discovered profile set is captured here and threaded into the run and
-- models handlers, so both tools describe and resolve exactly the profiles
-- named in the tool description the model was shown. Profile warnings (a
-- foreign `model` spelling, a duplicate name in one layer) have no logging
-- channel in an extension, so a bounded number of them ride along at the end
-- of the run description — the one place the user and the model both see the
-- profile list.
--
-- Activation also subscribes to the session lifecycle: interruption cancels
-- live work, ordinary turn boundaries reap only settled jobs so background
-- workflows survive, and session teardown cancels everything. Settled progress
-- cards remain attached to their transcript entries until the host destroys
-- those components.
--
-- If the host predates the model-resolution seam, activation fails loudly
-- instead of registering tools that cannot work.

local jobs = require("subagents.jobs")
local luatool = require("subagents.luatool")
local models = require("subagents.models")
local paths = require("subagents.paths")
local progress = require("subagents.progress")
local run = require("subagents.run")
local spawn = require("subagents.spawn")
local toml_workflows = require("subagents.toml_workflows")
local workflow_api = require("subagents.workflow")

local MAX_SHOWN_WARNINGS = 5
local DEFAULT_MAX_CONCURRENT = 5
local DIM, RESET = "\27[2m", "\27[0m"

local function host()
    return require("panto").ext
end

-- Events are optional: a host without the bus, or a print-mode session with no
-- components, simply never calls back and every child still runs.
local function subscribe(ext, name, handler)
    if type(ext.on) == "function" then
        pcall(ext.on, name, handler)
    end
end

local function wrap_plain(text, width)
    width = math.max(1, width or 1)
    local lines, from = {}, 1
    while #text - from + 1 > width do
        local window = text:sub(from, from + width - 1)
        local cut = window:match("^.*() ") or width
        lines[#lines + 1] = text:sub(from, from + cut - 1):gsub("%s+$", "")
        from = from + cut
        while from <= #text and text:sub(from, from) == " " do
            from = from + 1
        end
    end
    lines[#lines + 1] = text:sub(from)
    return lines
end

local function install_header(ext, profiles, workflows)
    local profile_names, workflow_names = {}, {}
    for _, profile in ipairs(profiles.list) do
        profile_names[#profile_names + 1] = profile.name
    end
    for _, workflow in ipairs(workflows.list) do
        if workflow.definition then workflow_names[#workflow_names + 1] = workflow.name end
    end
    table.sort(profile_names)
    table.sort(workflow_names)

    subscribe(ext, "session_start", function(event)
        local inner = event:get_component()
        event:set_component({
            render = function(_, width)
                local lines = inner:render(width)
                local extras = {}
                for _, inventory in ipairs({
                    { "subagents", profile_names },
                    { "workflows", workflow_names },
                }) do
                    local value = #inventory[2] == 0 and "(none)" or table.concat(inventory[2], ", ")
                    for _, line in ipairs(wrap_plain(" " .. inventory[1] .. ": " .. value, width)) do
                        extras[#extras + 1] = DIM .. line .. RESET
                    end
                end
                local at = (#lines > 0 and lines[#lines] == "") and #lines or (#lines + 1)
                for index = #extras, 1, -1 do
                    table.insert(lines, at, extras[index])
                end
                return lines
            end,
        })
    end)
end

local function run_description(profiles)
    local lines = {
        "Delegate a task to a subagent and wait for its report. Start a new child from an agent profile or an inline system prompt, or continue a child you started earlier in this session.",
        "",
        "Agent profiles:",
    }
    if #profiles.list == 0 then
        lines[#lines + 1] = "  (none found; define them in .panto/agents/*.md)"
    else
        for _, profile in ipairs(profiles.list) do
            if profile.description ~= "" then
                lines[#lines + 1] = string.format("  %s — %s", profile.name, profile.description)
            else
                lines[#lines + 1] = "  " .. profile.name
            end
        end
    end

    lines[#lines + 1] = ""
    lines[#lines + 1] = "Rules:"
    lines[#lines + 1] = "- Pass exactly one of `agent` (start from a profile), `system_prompt` (start without a profile), or `id` (continue a child). `prompt` is always required and must be non-empty."
    lines[#lines + 1] = "- Omit `model` and `reasoning` normally; a child inherits yours. Call subagents.models before choosing an unfamiliar model or reasoning level."
    lines[#lines + 1] = "- A child shares your workspace and tools but not your conversation, and cannot ask the user questions. Put every piece of task context it needs into `prompt`."
    lines[#lines + 1] = "- To delegate in parallel, emit several subagents.run calls in one tool batch; they run concurrently and one failure does not discard the others."
    lines[#lines + 1] = "- Every started child reports an id. Pass that id back to continue the same conversation."

    if #profiles.warnings > 0 then
        lines[#lines + 1] = ""
        lines[#lines + 1] = "Profile warnings:"
        for index, warning in ipairs(profiles.warnings) do
            if index > MAX_SHOWN_WARNINGS then
                lines[#lines + 1] = string.format("  (%d more)", #profiles.warnings - MAX_SHOWN_WARNINGS)
                break
            end
            lines[#lines + 1] = "  " .. warning
        end
    end

    return table.concat(lines, "\n")
end

local SUBAGENTS_GUIDANCE = [=[
## Subagents

Use `subagents.run` for one delegation, or emit several `subagents.run` calls in one tool batch for simple independent parallel work. Use `subagents.lua` for dynamic fan-out, branching, staged waves, or work that should continue while you interact with the user.

`subagents.lua` takes Lua `source` and optional inline `agents`; it has no shared prompt. Put the complete, specific context each worker needs in that worker's `prompt`. Start a background workflow and save its returned id:

```lua
return subagents.workflow(function(ctx)
  local a = ctx:agent{name="api", system_prompt="Research APIs.", prompt="Inspect the API surface."}
  local b = ctx:agent{name="tests", system_prompt="Research tests.", prompt="Inspect test coverage."}
  local research = ctx:await({a, b}, "all")

  local implementation = ctx:agent{
    name="implement",
    system_prompt="Implement focused changes.",
    prompt="Implement using this research:\n" .. research[1].output .. "\n" .. research[2].output,
  }:await()

  local reviewers = {
    ctx:agent{name="correctness", system_prompt="Review correctness.", prompt=implementation.output},
    ctx:agent{name="simplicity", system_prompt="Review for simplicity.", prompt=implementation.output},
  }
  local reviews = ctx:await(reviewers, "all")
  return reviews[1].output .. "\n\n" .. reviews[2].output
end)
```

**Deterministic code drives; agents advise.** A workflow is code you control wrapped around judgment you cannot. Put every decision that can be nailed down into Lua — loops, counts, ordering, retries, pass/fail gates — and spend agents only on discovery and judgment. When Lua needs a fact an agent found (a list to fan out over, a verdict, a chosen option), tell that agent to end its report with one machine-parseable line, parse it, and let the parsed value shape the rest of the graph. The sandbox has `json.encode(value)` and `json.decode(text)`, which returns the value or `nil, message`.

```lua
return subagents.workflow(function(ctx)
  local found = ctx:agent{
    name="inventory",
    system_prompt="Inventory work items. Read-only.",
    prompt="List every open PR in owner/repo. End with exactly one line: RESULT={\"prs\":[<numbers>]}",
  }:await()

  local line = found.output and found.output:match("RESULT=([^\n]+)")
  local items = line and json.decode(line)
  if not items or not items.prs then return "inventory produced no parseable RESULT line" end

  local reviewers = {}
  for _, pr in ipairs(items.prs) do
    reviewers[#reviewers + 1] = ctx:agent{
      name = "review-" .. pr,
      system_prompt = "Review one pull request.",
      prompt = "Review PR #" .. pr .. " in owner/repo.",
    }
  end

  local reviews, out = ctx:await(reviewers, "all"), {}
  for _, review in ipairs(reviews) do out[#out + 1] = review.output end
  return table.concat(out, "\n\n")
end)
```

Every `ctx:agent` needs a non-empty `name` unique within that workflow. A workflow callback must return a string. Inspect a running or finished workflow with a later `subagents.lua` call, for example:

```lua
local w = subagents.workflows["workflow-1"]
return w.status .. (w.result and ("\n" .. w.result) or "")
```

Workflow fields are `id`, `status`, `result`, `error`, and `agents`. Agents are available by name or iteration and expose `name`, `status`, `output`, `error`, and `id`. Records are read-only. Workflow completion wakes you with its id; use another `subagents.lua` call to retrieve whichever outputs you need.
]=]

local function install_guidance(ext)
    subscribe(ext, "session_start", function()
        local primary = ext.agent
        if primary and type(primary.add_system_message) == "function" then
            primary:add_system_message(SUBAGENTS_GUIDANCE)
        end
    end)
end

local function configured_max_concurrent(ext)
    local value = DEFAULT_MAX_CONCURRENT
    local layers = ext.dirs and ext.dirs.layers
    if type(layers) ~= "table" then
        return value
    end

    local ok_toml, toml = pcall(require, "toml")
    if not ok_toml or type(toml) ~= "table" or type(toml.parse) ~= "function" then
        error("panto-subagents: the 'toml2lua' rock is required to read configuration")
    end

    for _, layer in ipairs(layers) do
        if type(layer) == "table" and type(layer.dir) == "string" then
            local path = layer.dir .. "/config.toml"
            local text = paths.read_file(path)
            if text then
                local parsed_ok, parsed = pcall(toml.parse, text, { strict = true })
                if not parsed_ok or type(parsed) ~= "table" then
                    error("panto-subagents: could not parse " .. path)
                end
                local section = parsed.subagents
                if section ~= nil and type(section) ~= "table" then
                    error("panto-subagents: [subagents] must be a table in " .. path)
                end
                local configured = section and section.max_concurrent
                if configured ~= nil then
                    if type(configured) ~= "number" or configured < 1 or configured ~= math.floor(configured) then
                        error("panto-subagents: subagents.max_concurrent must be a positive integer in " .. path)
                    end
                    value = configured
                end
            end
        end
    end
    return value
end

local function activate()
    local ext = host()
    if type(ext.resolve_model) ~= "function" or type(require("panto").agent) ~= "function" then
        error("panto-subagents: this pantograph is too old for subagents (panto.ext.resolve_model is missing)")
    end

    jobs.MAX_CONCURRENT = configured_max_concurrent(ext)
    ext.workflows = workflow_api.workflows

    -- Discover through spawn so the workflow lanes, which resolve profiles
    -- lazily, share the exact set this tool description advertises.
    local profiles = spawn.profiles()

    -- Escape reaches the children through the turn, not through a tool: the
    -- primary is parked inside a tool call when they are running.
    progress.begin_replay()
    -- Startup session replay fires tool lifecycle events before the first
    -- live turn. After that boundary, progress.claim must stay presentation-
    -- only and never rescan the child catalog for ordinary calls.
    subscribe(ext, "turn_start", function()
        progress.begin_live_turn()
    end)
    subscribe(ext, "turn_interrupt", function()
        workflow_api.cancel_all(false)
        jobs.cancel_all()
    end)
    subscribe(ext, "turn_end", function()
        jobs.reap()
        progress.reset()
    end)
    subscribe(ext, "session_end", function()
        workflow_api.cancel_all(true)
        jobs.cancel_all()
        jobs.close_all()
        progress.reset()
    end)
    -- The entry for a delegation call is where that call's children render.
    subscribe(ext, "tool_call_complete", function(event)
        progress.claim(event)
    end)
    subscribe(ext, "tool_result", function(event)
        progress.settle(event)
    end)
    subscribe(ext, "tool_collapse", function(event)
        progress.collapse(event)
    end)

    ext.register_tool {
        name = "subagents.run",
        description = run_description(profiles),
        schema = {
            type = "object",
            properties = {
                agent = { type = "string", description = "Profile name for a new child. Mutually exclusive with `system_prompt` and `id`." },
                system_prompt = { type = "string", description = "System prompt for a new child without a profile. Mutually exclusive with `agent` and `id`." },
                id = { type = "string", description = "Id of a child started earlier in this session, to continue it. Mutually exclusive with `agent` and `system_prompt`." },
                prompt = { type = "string", description = "The complete task for the child. It sees none of this conversation." },
                model = { type = "string", description = "Optional `provider:model` override. Omit to inherit; query available options with `subagents.models`." },
                reasoning = { type = "string", description = "Optional reasoning level override. Omit to inherit." },
            },
            required = { "prompt" },
        },
        handler = function(input, context)
            progress.bind(context)
            local result = run.handle(input, profiles)
            progress.settle(context)
            return result
        end,
    }

    ext.register_tool {
        name = "subagents.models",
        description = "Query the configured model catalog: no arguments for the inherited model plus provider counts, `model` for an exact lookup including valid reasoning levels, or `provider`/`query` for a bounded search.",
        schema = {
            type = "object",
            properties = {
                provider = { type = "string", description = "Restrict a search to one provider." },
                query = { type = "string", description = "Substring to search model names for." },
                limit = { type = "integer", description = "Maximum matches to return (1-50, default 10).", minimum = 1, maximum = 50 },
                model = { type = "string", description = "Exact `provider:model` to look up." },
            },
        },
        handler = function(input)
            return models.handle(input)
        end,
    }

    ext.register_tool {
        name = "subagents.lua",
        description = "Run sandboxed Lua that starts background subagent workflows or inspects existing ones. `source` can call subagents.workflow(function(ctx) ... end), which returns a workflow id immediately, and can read immutable records from subagents.workflows. Optional `agents` define profiles available only to workflows started by this call.",
        schema = {
            type = "object",
            properties = {
                source = { type = "string", description = "Lua source that starts a workflow or inspects subagents.workflows." },
                agents = {
                    type = "array",
                    description = "Agent profiles available only to this workflow. Inline profiles shadow discovered profiles with the same name.",
                    items = {
                        type = "object",
                        properties = {
                            name = { type = "string", description = "Profile name used by ctx:agent." },
                            description = { type = "string", description = "Optional human-readable purpose." },
                            system_prompt = { type = "string", description = "System prompt for this profile." },
                        },
                        required = { "name", "system_prompt" },
                    },
                },
            },
            required = { "source" },
        },
        handler = function(input, context)
            progress.bind(context)
            local result = luatool.handle(input, profiles, context)
            progress.settle(context)
            return result
        end,
    }

    ext.register_tool {
        name = "subagents.workflow",
        description = "Run a discovered TOML workflow by `name`: a fixed dependency graph of subagents. Every step receives `prompt` as the workflow input, and a step with `needs` also receives those steps' outputs. For dynamic graphs, use `subagents.lua`.",
        schema = {
            type = "object",
            properties = {
                name = { type = "string", description = "Name of a discovered workflow." },
                prompt = { type = "string", description = "The workflow input, given to every step." },
            },
            required = { "name", "prompt" },
        },
        handler = function(input, context)
            progress.bind(context)
            local result = toml_workflows.handle(input, profiles)
            progress.settle(context)
            return result
        end,
    }

    local workflows = toml_workflows.discover_and_register(profiles)
    install_guidance(ext)
    install_header(ext, profiles, workflows)
end

return {
    name = "subagents",
    activate = activate,
}