summaryrefslogtreecommitdiff
path: root/init.lua
blob: 5f28308aca431735817c1f828a786d7f13d5f9b7 (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
-- 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 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 turn lifecycle: an interrupted turn asks
-- every live child to stop, and the end of a turn joins them and drops the
-- progress cards, which are presentation state that must not survive the turn
-- that produced it.
--
-- 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 progress = require("subagents.progress")
local run = require("subagents.run")
local spawn = require("subagents.spawn")
local toml_workflows = require("subagents.toml_workflows")

local MAX_SHOWN_WARNINGS = 5

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 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 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 a new child from that profile) or `id` (continue a child from an earlier result). `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 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

    -- 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.
    subscribe(ext, "turn_interrupt", function()
        jobs.cancel_all()
    end)
    subscribe(ext, "turn_end", function()
        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)

    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 `id`." },
                id = { type = "string", description = "Id of a child started earlier in this session, to continue it. Mutually exclusive with `agent`." },
                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." },
                reasoning = { type = "string", description = "Optional reasoning level override. Omit to inherit." },
            },
            required = { "prompt" },
        },
        handler = function(input, context)
            progress.bind(context)
            return run.handle(input, profiles)
        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, `provider`/`query` for a bounded search, or `agent` for what a profile will actually run on.",
        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." },
                agent = { type = "string", description = "Agent profile whose effective model to report." },
            },
        },
        handler = function(input)
            return models.handle(input, profiles)
        end,
    }

    ext.register_tool {
        name = "subagents.lua",
        description = "Run a one-off Lua workflow that fans several subagents out and combines their results. `source` must return subagents.workflow(function(ctx, input) ... end) and runs in a restricted environment with no filesystem, process, or module access.",
        schema = {
            type = "object",
            properties = {
                prompt = { type = "string", description = "The workflow input, passed to the callback as its second argument." },
                source = { type = "string", description = "Lua source returning subagents.workflow(function(ctx, input) ... end)." },
            },
            required = { "prompt", "source" },
        },
        handler = function(input, context)
            progress.bind(context)
            return luatool.handle(input, profiles)
        end,
    }

    ext.register_tool {
        name = "subagents.workflow",
        description = "Run a fixed dependency graph of subagents: `name` runs a discovered TOML workflow, or `steps` defines one inline. Every step receives `prompt` as the workflow input, and a step with `needs` also receives those steps' outputs.",
        schema = {
            type = "object",
            properties = {
                name = { type = "string", description = "Name of a discovered workflow. Mutually exclusive with `steps`." },
                prompt = { type = "string", description = "The workflow input, given to every step." },
                steps = {
                    type = "array",
                    description = "Inline workflow definition. Mutually exclusive with `name`.",
                    items = {
                        type = "object",
                        properties = {
                            id = { type = "string", description = "Unique step id." },
                            agent = { type = "string", description = "Agent profile to run this step." },
                            prompt = { type = "string", description = "Step instruction." },
                            model = { type = "string", description = "Optional `provider:model` override." },
                            reasoning = { type = "string", description = "Optional reasoning level override." },
                            needs = {
                                type = "array",
                                description = "Ids of steps whose output this step receives.",
                                items = { type = "string" },
                            },
                        },
                        required = { "id", "agent", "prompt" },
                    },
                },
            },
            required = { "prompt" },
        },
        handler = function(input, context)
            progress.bind(context)
            return toml_workflows.handle(input, profiles)
        end,
    }

    toml_workflows.discover_and_register(profiles)
end

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