summaryrefslogtreecommitdiff
path: root/subagents/profiles.lua
blob: 4ae9f916ef7cdafd8f9ee946b831d355429e4118 (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
-- Discover agent profiles: Markdown files with YAML frontmatter.
--
-- Two layers are read, lowest precedence first:
--
--   1. ${XDG_CONFIG_HOME:-$HOME/.config}/panto/agents/**/*.md   (user)
--   2. <cwd>/.panto/agents/**/*.md                              (project)
--
-- A project profile shadows a user profile with the same resolved name. Both
-- layers are walked recursively; nesting is organisational only and never part
-- of a profile's name.
--
-- Recognised frontmatter keys, all optional:
--
--   name        the profile's identity; defaults to the filename stem
--   description one line shown to the primary in the subagents.run schema
--   model       full `provider:model` only
--   reasoning   passed through as written; the runtime validates it
--
-- Unknown keys are ignored so a profile written for another harness still
-- loads. A `model` that is not Panto's `provider:model` syntax is dropped with
-- a warning and the child inherits the primary model — a foreign model
-- spelling must not cost the user a working prompt. Nothing else here is
-- fatal either: an unreadable file, broken YAML, or a duplicate name inside
-- one layer produces a warning and discovery continues. Warnings are returned
-- rather than logged because an extension has no logging channel; the caller
-- decides where they surface.
--
-- Files are read eagerly. Profiles are small and the whole set is needed to
-- build the tool description at activation anyway, so lazy bodies would buy
-- nothing.

local frontmatter = require("subagents.frontmatter")
local paths = require("subagents.paths")

local MODEL_PATTERN = "^[^%s:]+:[^%s:]+$"

local M = {}

local function trim(s)
    return (s:gsub("^%s+", ""):gsub("%s+$", ""))
end

local function string_field(data, key)
    local value = data and data[key]
    if type(value) ~= "string" then
        return nil
    end
    value = trim(value)
    if value == "" then
        return nil
    end
    return value
end

-- Build one profile from a file. Appends to `warnings` on anything odd.
local function load_profile(path, layer, warnings)
    local text, err = paths.read_file(path)
    if not text then
        warnings[#warnings + 1] = string.format("profile %s: %s", path, tostring(err))
        return nil
    end

    local data, body, warning = frontmatter.parse(text)
    local name = string_field(data, "name") or paths.stem(path)
    if warning then
        warnings[#warnings + 1] = string.format("profile %s: %s", name, warning)
    end

    local model = string_field(data, "model")
    if model and not model:match(MODEL_PATTERN) then
        warnings[#warnings + 1] =
            string.format("profile %s: ignoring model '%s' (not provider:model)", name, model)
        model = nil
    end

    return {
        name = name,
        description = string_field(data, "description") or "",
        model = model,
        reasoning = string_field(data, "reasoning"),
        body = body,
        path = path,
        layer = layer,
    }
end

-- discover(roots) -> { list = {...}, by_name = {...}, warnings = {...} }
--
-- `roots` defaults to the two config layers and exists so tests can point
-- discovery at temporary directories. `list` is sorted by name.
function M.discover(roots)
    roots = roots or paths.config_roots("agents")

    local by_name = {}
    local warnings = {}

    for _, root in ipairs(roots) do
        local seen = {}
        for _, path in ipairs(paths.walk(root, ".md")) do
            local profile = load_profile(path, root, warnings)
            if profile then
                if seen[profile.name] then
                    warnings[#warnings + 1] = string.format(
                        "profile %s: %s shadows %s in the same layer",
                        profile.name, path, seen[profile.name])
                end
                seen[profile.name] = path
                by_name[profile.name] = profile
            end
        end
    end

    local list = {}
    for _, profile in pairs(by_name) do
        list[#list + 1] = profile
    end
    table.sort(list, function(a, b) return a.name < b.name end)

    return { list = list, by_name = by_name, warnings = warnings }
end

return M