summaryrefslogtreecommitdiff
path: root/subagents/models.lua
blob: 511aa511e48ad006deeba6fdfda3c38452cba953 (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
-- The `subagents.models` tool: a bounded window onto the model catalog.
--
-- The catalog is far too large to inline into `subagents.run`'s description or
-- schema, so it is queried on demand instead. Four forms, chosen in this order:
--
--   { agent = "reviewer" }        what that profile will actually run on
--   { model = "anthropic:sonnet"} exact lookup: wire name, reasoning levels
--   { provider =, query =, limit=} bounded search
--   { }                           inherited model/reasoning + provider counts
--
-- The agent form is the join the primary actually wants before overriding a
-- profile: it reports the profile's own model, or says the profile inherits
-- the primary model and shows what that is.
--
-- Results are short readable lines, not JSON. A truncated search says so
-- explicitly so the primary refines the query instead of assuming it saw
-- everything; `limit` is clamped to 1..50 (default 10) so no query can dump
-- the whole registry into the conversation.
--
-- The host owns the catalog and the provider-specific reasoning rules,
-- including effort levels a Lua protocol reports dynamically. Anything this
-- tool cannot confirm is still validated at spawn time — the catalog is
-- advice, the runtime is authoritative.

local spawn = require("subagents.spawn")

local DEFAULT_LIMIT = 10
local MAX_LIMIT = 50

local M = {}

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

local function ask(q)
    local ok, result = pcall(host().models, q)
    if not ok then
        return nil, tostring(result)
    end
    if type(result) ~= "table" then
        return nil, "the host returned no model information"
    end
    return result
end

local function format_overview(result)
    local out = {
        "inherited model: " .. (result.model or "(unknown)"),
        "inherited reasoning: " .. (result.reasoning or "(provider default)"),
    }
    local providers = result.providers or {}
    if #providers == 0 then
        out[#out + 1] = "providers: (none configured)"
        return table.concat(out, "\n")
    end
    out[#out + 1] = "providers:"
    for _, provider in ipairs(providers) do
        out[#out + 1] = string.format("  %s (%s, %d models)",
            tostring(provider.name), tostring(provider.style or "?"), tonumber(provider.models) or 0)
    end
    return table.concat(out, "\n")
end

local function format_match(match)
    local detail = {}
    if match.wire_model then
        detail[#detail + 1] = "wire " .. tostring(match.wire_model)
    end
    if match.reasoning_default then
        detail[#detail + 1] = "default reasoning " .. tostring(match.reasoning_default)
    end
    if match.context_window then
        detail[#detail + 1] = "context " .. tostring(match.context_window)
    end
    if match.max_tokens then
        detail[#detail + 1] = "max tokens " .. tostring(match.max_tokens)
    end
    local line = "  " .. tostring(match.ref)
    if #detail > 0 then
        line = line .. " — " .. table.concat(detail, ", ")
    end
    return line
end

local function format_exact(result, ref)
    if not result.found then
        return string.format(
            "No configured model matches '%s'. Search with subagents.models { query = \"...\" }.", ref)
    end
    local out = { "model: " .. tostring(result.ref or ref) }
    if result.wire_model then
        out[#out + 1] = "wire model: " .. tostring(result.wire_model)
    end
    out[#out + 1] = "default reasoning: " .. (result.reasoning_default or "(provider default)")
    local levels = result.reasoning_levels
    if type(levels) == "table" and #levels > 0 then
        out[#out + 1] = "reasoning levels: " .. table.concat(levels, ", ")
    end
    if result.context_window then
        out[#out + 1] = "context window: " .. tostring(result.context_window)
    end
    if result.max_tokens then
        out[#out + 1] = "max tokens: " .. tostring(result.max_tokens)
    end
    return table.concat(out, "\n")
end

local function format_search(result)
    local matches = result.matches or {}
    if #matches == 0 then
        return "No models match that query."
    end
    local out = { string.format("%d match(es):", #matches) }
    for _, match in ipairs(matches) do
        out[#out + 1] = format_match(match)
    end
    if result.truncated then
        out[#out + 1] = string.format("%d shown, more exist — refine the query", #matches)
    end
    return table.concat(out, "\n")
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("Error: `%s` must be a non-empty string when given.", field)
    end
    return value, nil
end

local function clamp_limit(value)
    local limit = tonumber(value) or DEFAULT_LIMIT
    limit = math.floor(limit)
    if limit < 1 then
        return 1
    end
    if limit > MAX_LIMIT then
        return MAX_LIMIT
    end
    return limit
end

-- The agent form: report the profile's effective model, or say it inherits.
local function describe_agent(name, profiles)
    profiles = spawn.profiles(profiles)
    local profile = (profiles.by_name or {})[name]
    if not profile then
        return string.format("Error: unknown agent '%s'; known: %s", name, spawn.agent_names(profiles))
    end

    local out = { "agent: " .. profile.name }
    if profile.description ~= "" then
        out[#out + 1] = "description: " .. profile.description
    end

    if profile.model then
        local result, err = ask({ model = profile.model })
        if not result then
            return "Error: " .. err
        end
        out[#out + 1] = format_exact(result, profile.model)
    else
        out[#out + 1] = "model: inherits the primary model"
        local result, err = ask({})
        if not result then
            return "Error: " .. err
        end
        out[#out + 1] = format_overview(result)
    end

    if profile.reasoning then
        out[#out + 1] = "profile reasoning: " .. profile.reasoning
    end
    return table.concat(out, "\n")
end

function M.handle(input, profiles)
    input = input or {}
    if type(input) ~= "table" then
        return "Error: expected a table of arguments."
    end

    local agent, err = optional_string(input.agent, "agent")
    if err then
        return err
    end
    local model
    model, err = optional_string(input.model, "model")
    if err then
        return err
    end
    local provider
    provider, err = optional_string(input.provider, "provider")
    if err then
        return err
    end
    local text
    text, err = optional_string(input.query, "query")
    if err then
        return err
    end

    if agent then
        return describe_agent(agent, profiles)
    end

    if model then
        local result, ask_err = ask({ model = model })
        if not result then
            return "Error: " .. ask_err
        end
        return format_exact(result, model)
    end

    if provider or text then
        local result, ask_err = ask({ provider = provider, query = text, limit = clamp_limit(input.limit) })
        if not result then
            return "Error: " .. ask_err
        end
        return format_search(result)
    end

    local result, ask_err = ask({})
    if not result then
        return "Error: " .. ask_err
    end
    return format_overview(result)
end

return M