Skip to content

Commit 03c3ef5

Browse files
authored
feat(subagents): profile discovery tool + built-in default profile (local_write) (#163)
* feat(subagents): list_subagent_profiles tool + ProfileConfig.description The delegate_tasks schema accepts a profile name, but names only exist in the operator's config — the model had no way to discover them. This adds: - list_subagent_profiles built-in tool: renders the resolved profiles map (name, description, max_risk, tool filters, is_default) plus the effective default_profile, sorted; read-only over operator config. - ProfileConfig.description: model-readable intent summary per profile. - SubagentConfig/Resolved .default_profile field (behavior lands with the built-in default profile in the follow-up commit). - delegate_tasks profile param now points at the discovery tool. * feat(subagents): built-in default profile — sub-agents capped at local_write Delegated sub-agents now run under a default capability envelope unless something more specific is selected: - Built-in "default" profile (max_risk: local_write) is materialized by the config pipeline unless the operator defines their own profile with that name or opts out via subagent.default_profile="none". - New subagent.default_profile config key (operator-controlled, rejected from project-level odek.json like the rest of the section) points the default at any defined profile; an unknown name fails closed at spawn. - Precedence: --profile flag > task-file profile > default envelope. "none" is honored only from operator config — a task file or flag can never strip the operator's envelope. - Behavior change, deliberate: trusted sub-agents are now clamped to local_write too (the envelope is applied before the P2/P3 trust lockdown, which still cannot be lifted by profile selection). Tasks needing code_execution/network_egress must select an explicit profile. - The built-in envelope is visible through list_subagent_profiles with an is_default marker and a model-readable description. * docs(subagents): default sub-agent profile + profile descriptions - CONFIG.md: subagent.default_profile row, description field, new 'Built-in default profile' section (precedence, operator-only opt-out, trusted-clamp behavior change, list_subagent_profiles discovery). - SECURITY.md: default-envelope semantics (built-in local_write cap also binds trusted sub-agents, 'none' honored only from operator config), updated pinned-by test list and fail-closed/residual-risk notes. - SUBAGENTS.md: default-envelope paragraph in the profiles section. - profiles.template.json: model-readable description on all 21 starter profiles (consumed by list_subagent_profiles). - Global config template: scaffold subagent.default_profile: "default" so the operator-visible default is stated in the config itself.
1 parent a9c7d15 commit 03c3ef5

12 files changed

Lines changed: 652 additions & 12 deletions

‎cmd/odek/main.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1339,7 +1339,8 @@ const globalConfigTemplate = `{
13391339
"subagent": {
13401340
"max_concurrency": 3,
13411341
"timeout_seconds": 1800,
1342-
"max_iterations": 15
1342+
"max_iterations": 15,
1343+
"default_profile": "default"
13431344
},
13441345
"limits": {
13451346
"max_runtime_seconds": 0,
@@ -2267,6 +2268,10 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d
22672268
profiles: tcfg.Profiles,
22682269
artifactsRoot: artifactsRoot, // empty ⇒ no artifact dirs created
22692270
},
2271+
&listSubagentProfilesTool{
2272+
profiles: tcfg.Profiles,
2273+
defaultProfile: tcfg.Subagent.DefaultProfile,
2274+
},
22702275
&readFileTool{dangerousConfig: dc},
22712276
&writeFileTool{dangerousConfig: dc, restrictToCWD: true},
22722277
&searchFilesTool{dangerousConfig: dc},

‎cmd/odek/profiles_tool.go‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package main
2+
3+
// list_subagent_profiles — built-in discovery tool for operator-defined
4+
// sub-agent capability profiles (P4 follow-up).
5+
//
6+
// The delegate_tasks schema accepts a profile NAME, but profile names only
7+
// exist in the operator's config — the model has no way to know what is
8+
// available, let alone which envelope fits the task. This tool closes that
9+
// gap: it renders the resolved profiles map (including the built-in default
10+
// profile injected by the config pipeline) as JSON the model can pick from.
11+
//
12+
// Security posture: strictly read-only over operator config. Profiles are
13+
// operator-authored only (project-level profiles are stripped at load), so
14+
// the listing can never be poisoned by a cloned repository. The output
15+
// carries permission metadata only — no secrets, no paths, no tool args.
16+
17+
import (
18+
"encoding/json"
19+
"fmt"
20+
"sort"
21+
22+
"github.com/BackendStack21/odek/internal/config"
23+
)
24+
25+
// listSubagentProfilesTool renders the resolved capability profiles plus
26+
// the effective default (subagent.default_profile) for model consumption.
27+
type listSubagentProfilesTool struct {
28+
// profiles is the operator's resolved capability profiles map, as
29+
// handed to delegateTasksTool. After the config pipeline runs, it
30+
// always contains the built-in "default" profile unless the operator
31+
// disabled it (subagent.default_profile="none") or overrode the name.
32+
profiles map[string]config.ProfileConfig
33+
34+
// defaultProfile is the resolved Subagent.DefaultProfile value: a
35+
// profile name, or config.DefaultProfileDisabled when the operator
36+
// opted out. It is echoed so the model knows which envelope applies
37+
// when delegate_tasks omits the profile field.
38+
defaultProfile string
39+
}
40+
41+
func (t *listSubagentProfilesTool) Name() string { return "list_subagent_profiles" }
42+
43+
func (t *listSubagentProfilesTool) Description() string {
44+
return "List the sub-agent capability profiles available for delegate_tasks: the " +
45+
"operator-defined profiles (top-level profiles config) plus the built-in default. " +
46+
"Each entry carries name, description, max_risk ceiling, tool filters and an " +
47+
"is_default marker; default_profile reports which envelope applies when " +
48+
"delegate_tasks omits the profile field. Invoke this BEFORE delegate_tasks when " +
49+
"a task should run under a specific capability profile, then pass the chosen " +
50+
"name in the profile field. Unknown names fail the task."
51+
}
52+
53+
func (t *listSubagentProfilesTool) Schema() any {
54+
// No parameters — the listing is static per-run config state.
55+
return map[string]any{
56+
"type": "object",
57+
"properties": map[string]any{},
58+
}
59+
}
60+
61+
// profileEntry is the JSON shape of one capability profile.
62+
type profileEntry struct {
63+
Name string `json:"name"`
64+
Description string `json:"description,omitempty"`
65+
MaxRisk string `json:"max_risk,omitempty"`
66+
ToolsEnabled []string `json:"tools_enabled,omitempty"`
67+
ToolsDisabled []string `json:"tools_disabled,omitempty"`
68+
IsDefault bool `json:"is_default,omitempty"`
69+
}
70+
71+
func (t *listSubagentProfilesTool) Call(_ string) (string, error) {
72+
defaultActive := t.defaultProfile != "" && t.defaultProfile != config.DefaultProfileDisabled
73+
74+
out := struct {
75+
Profiles []profileEntry `json:"profiles"`
76+
DefaultProfile string `json:"default_profile"`
77+
}{
78+
Profiles: make([]profileEntry, 0, len(t.profiles)),
79+
DefaultProfile: t.defaultProfile,
80+
}
81+
82+
names := make([]string, 0, len(t.profiles))
83+
for name := range t.profiles {
84+
names = append(names, name)
85+
}
86+
sort.Strings(names)
87+
88+
for _, name := range names {
89+
prof := t.profiles[name]
90+
entry := profileEntry{
91+
Name: name,
92+
Description: prof.Description,
93+
MaxRisk: prof.MaxRisk,
94+
IsDefault: defaultActive && name == t.defaultProfile,
95+
}
96+
if prof.Tools != nil {
97+
entry.ToolsEnabled = prof.Tools.Enabled
98+
entry.ToolsDisabled = prof.Tools.Disabled
99+
}
100+
out.Profiles = append(out.Profiles, entry)
101+
}
102+
103+
buf, err := json.Marshal(out)
104+
if err != nil {
105+
return "", fmt.Errorf("list_subagent_profiles: %w", err)
106+
}
107+
return string(buf), nil
108+
}

‎cmd/odek/profiles_tool_test.go‎

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"reflect"
6+
"strings"
7+
"testing"
8+
9+
"github.com/BackendStack21/odek/internal/config"
10+
)
11+
12+
// Tests for the list_subagent_profiles built-in tool (P4 follow-up): the
13+
// parent LLM must be able to discover the operator-defined capability
14+
// profiles (plus the built-in default) on demand, so it can pick the right
15+
// profile name for delegate_tasks. The tool is read-only and argument-free.
16+
17+
func newProfilesToolForTest(profiles map[string]config.ProfileConfig, defaultProfile string) *listSubagentProfilesTool {
18+
return &listSubagentProfilesTool{
19+
profiles: profiles,
20+
defaultProfile: defaultProfile,
21+
}
22+
}
23+
24+
func decodeProfilesToolOutput(t *testing.T, out string) map[string]any {
25+
t.Helper()
26+
var decoded map[string]any
27+
if err := json.Unmarshal([]byte(out), &decoded); err != nil {
28+
t.Fatalf("Call output is not valid JSON: %v\nraw: %s", err, out)
29+
}
30+
return decoded
31+
}
32+
33+
// TestListSubagentProfiles_NameAndSchema pins the tool's registration
34+
// surface: the name the model invokes and an empty parameter schema (the
35+
// tool takes no arguments).
36+
func TestListSubagentProfiles_NameAndSchema(t *testing.T) {
37+
tool := newProfilesToolForTest(nil, config.DefaultProfileName)
38+
if tool.Name() != "list_subagent_profiles" {
39+
t.Errorf("Name() = %q, want %q", tool.Name(), "list_subagent_profiles")
40+
}
41+
if strings.TrimSpace(tool.Description()) == "" {
42+
t.Error("Description() must be non-empty — it is the model's only usage guide")
43+
}
44+
schema, ok := tool.Schema().(map[string]any)
45+
if !ok {
46+
t.Fatalf("Schema() = %T, want map[string]any", tool.Schema())
47+
}
48+
if schema["type"] != "object" {
49+
t.Errorf("schema type = %v, want object", schema["type"])
50+
}
51+
}
52+
53+
// TestListSubagentProfiles_ListsOperatorProfiles covers the core render:
54+
// operator-defined profiles appear with name, description, max_risk and
55+
// tool filters, sorted by name for deterministic output.
56+
func TestListSubagentProfiles_ListsOperatorProfiles(t *testing.T) {
57+
profiles := map[string]config.ProfileConfig{
58+
"judge": {
59+
Description: "Read-only reviewer",
60+
MaxRisk: "safe",
61+
Tools: &config.ToolConfig{Disabled: []string{"shell", "write_file"}},
62+
},
63+
"builder": {
64+
MaxRisk: "local_write",
65+
Tools: &config.ToolConfig{Enabled: []string{"shell", "read_file"}},
66+
},
67+
}
68+
tool := newProfilesToolForTest(profiles, "default")
69+
out, err := tool.Call("{}")
70+
if err != nil {
71+
t.Fatalf("Call: %v", err)
72+
}
73+
decoded := decodeProfilesToolOutput(t, out)
74+
75+
if got := decoded["default_profile"]; got != "default" {
76+
t.Errorf("default_profile = %v, want %q", got, "default")
77+
}
78+
raw, ok := decoded["profiles"].([]any)
79+
if !ok {
80+
t.Fatalf("profiles = %T, want array", decoded["profiles"])
81+
}
82+
if len(raw) != 2 {
83+
t.Fatalf("profiles len = %d, want 2", len(raw))
84+
}
85+
// Sorted by name: builder < judge.
86+
first, _ := raw[0].(map[string]any)
87+
second, _ := raw[1].(map[string]any)
88+
if first["name"] != "builder" || second["name"] != "judge" {
89+
t.Errorf("profile order = [%v, %v], want [builder judge]", first["name"], second["name"])
90+
}
91+
if first["max_risk"] != "local_write" {
92+
t.Errorf("builder max_risk = %v, want local_write", first["max_risk"])
93+
}
94+
if second["description"] != "Read-only reviewer" {
95+
t.Errorf("judge description = %v, want %q", second["description"], "Read-only reviewer")
96+
}
97+
if second["max_risk"] != "safe" {
98+
t.Errorf("judge max_risk = %v, want safe", second["max_risk"])
99+
}
100+
disabled, ok := second["tools_disabled"].([]any)
101+
if !ok || !reflect.DeepEqual(disabled, []any{"shell", "write_file"}) {
102+
t.Errorf("judge tools_disabled = %v, want [shell write_file]", second["tools_disabled"])
103+
}
104+
enabled, ok := first["tools_enabled"].([]any)
105+
if !ok || !reflect.DeepEqual(enabled, []any{"shell", "read_file"}) {
106+
t.Errorf("builder tools_enabled = %v, want [shell read_file]", first["tools_enabled"])
107+
}
108+
}
109+
110+
// TestListSubagentProfiles_MarksEffectiveDefault covers the is_default
111+
// marker: exactly the entry matching the resolved subagent.default_profile
112+
// is flagged, so the model knows which envelope applies when delegate_tasks
113+
// omits the profile field.
114+
func TestListSubagentProfiles_MarksEffectiveDefault(t *testing.T) {
115+
profiles := map[string]config.ProfileConfig{
116+
"default": {MaxRisk: "local_write"},
117+
"judge": {MaxRisk: "safe"},
118+
}
119+
tool := newProfilesToolForTest(profiles, "default")
120+
out, err := tool.Call("{}")
121+
if err != nil {
122+
t.Fatalf("Call: %v", err)
123+
}
124+
decoded := decodeProfilesToolOutput(t, out)
125+
raw := decoded["profiles"].([]any)
126+
defaults := 0
127+
for _, e := range raw {
128+
entry := e.(map[string]any)
129+
isDefault, _ := entry["is_default"].(bool)
130+
switch entry["name"] {
131+
case "default":
132+
if !isDefault {
133+
t.Error("built-in default entry must carry is_default=true")
134+
}
135+
defaults++
136+
case "judge":
137+
if isDefault {
138+
t.Error("non-default entry must not carry is_default=true")
139+
}
140+
}
141+
}
142+
if defaults != 1 {
143+
t.Errorf("is_default marked on %d entries, want exactly 1", defaults)
144+
}
145+
}
146+
147+
// TestListSubagentProfiles_DisabledDefault covers subagent.default_profile
148+
// = "none": the echo reports "none" and no entry is flagged as default.
149+
func TestListSubagentProfiles_DisabledDefault(t *testing.T) {
150+
profiles := map[string]config.ProfileConfig{
151+
"judge": {MaxRisk: "safe"},
152+
}
153+
tool := newProfilesToolForTest(profiles, config.DefaultProfileDisabled)
154+
out, err := tool.Call("{}")
155+
if err != nil {
156+
t.Fatalf("Call: %v", err)
157+
}
158+
decoded := decodeProfilesToolOutput(t, out)
159+
if got := decoded["default_profile"]; got != "none" {
160+
t.Errorf("default_profile = %v, want %q", got, "none")
161+
}
162+
for _, e := range decoded["profiles"].([]any) {
163+
entry := e.(map[string]any)
164+
if isDefault, _ := entry["is_default"].(bool); isDefault {
165+
t.Errorf("entry %v must not be flagged default when the default is disabled", entry["name"])
166+
}
167+
}
168+
}
169+
170+
// TestListSubagentProfiles_EmptyProfilesRendersArray pins that an operator
171+
// with no profiles (and a disabled or absent built-in) still gets a JSON
172+
// array, not null — models handle [] more reliably.
173+
func TestListSubagentProfiles_EmptyProfilesRendersArray(t *testing.T) {
174+
tool := newProfilesToolForTest(nil, config.DefaultProfileDisabled)
175+
out, err := tool.Call("{}")
176+
if err != nil {
177+
t.Fatalf("Call: %v", err)
178+
}
179+
if !strings.Contains(out, `"profiles": []`) && !strings.Contains(out, "\"profiles\":[]") {
180+
t.Errorf("empty profiles must render as [], got: %s", out)
181+
}
182+
}
183+
184+
// TestListSubagentProfiles_IgnoresArgs pins argument tolerance: the tool
185+
// takes no parameters, so any args payload (valid or not) must be accepted
186+
// rather than rejected.
187+
func TestListSubagentProfiles_IgnoresArgs(t *testing.T) {
188+
tool := newProfilesToolForTest(nil, config.DefaultProfileName)
189+
for _, args := range []string{"{}", "", "not json at all", `{"unexpected":1}`} {
190+
if _, err := tool.Call(args); err != nil {
191+
t.Errorf("Call(%q) = %v, want nil (args are ignored)", args, err)
192+
}
193+
}
194+
}

‎cmd/odek/subagent.go‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -583,10 +583,18 @@ func subagentCmd(args []string) error {
583583
// P4: a profile may be selected by the operator's direct --profile flag
584584
// or by the parent via the task file; the flag outranks the file.
585585
profileName := resolveProfileName(cfg.profile, taskProfile)
586+
if profileName == "" && resolved.Subagent.DefaultProfile != config.DefaultProfileDisabled {
587+
// Operator's default envelope (P4): the built-in "default" profile
588+
// unless the operator overrode the name. Explicit task/flag
589+
// selection outranks it, and "none" is honored only from the
590+
// operator's own config — a task file or flag can never strip the
591+
// operator's envelope.
592+
profileName = resolved.Subagent.DefaultProfile
593+
}
586594
if profileName != "" {
587595
prof, ok := resolved.Profiles[profileName]
588596
if !ok {
589-
return fmt.Errorf("unknown profile %q (define it in the top-level profiles config section)", profileName)
597+
return fmt.Errorf("unknown profile %q (define it in the top-level profiles config section, or adjust subagent.default_profile)", profileName)
590598
}
591599
applyProfile(&resolved.Dangerous, prof)
592600
profileTools = prof.Tools

0 commit comments

Comments
 (0)