Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cmd/odek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1339,7 +1339,8 @@ const globalConfigTemplate = `{
"subagent": {
"max_concurrency": 3,
"timeout_seconds": 1800,
"max_iterations": 15
"max_iterations": 15,
"default_profile": "default"
},
"limits": {
"max_runtime_seconds": 0,
Expand Down Expand Up @@ -2267,6 +2268,10 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d
profiles: tcfg.Profiles,
artifactsRoot: artifactsRoot, // empty ⇒ no artifact dirs created
},
&listSubagentProfilesTool{
profiles: tcfg.Profiles,
defaultProfile: tcfg.Subagent.DefaultProfile,
},
&readFileTool{dangerousConfig: dc},
&writeFileTool{dangerousConfig: dc, restrictToCWD: true},
&searchFilesTool{dangerousConfig: dc},
Expand Down
108 changes: 108 additions & 0 deletions cmd/odek/profiles_tool.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package main

// list_subagent_profiles — built-in discovery tool for operator-defined
// sub-agent capability profiles (P4 follow-up).
//
// The delegate_tasks schema accepts a profile NAME, but profile names only
// exist in the operator's config — the model has no way to know what is
// available, let alone which envelope fits the task. This tool closes that
// gap: it renders the resolved profiles map (including the built-in default
// profile injected by the config pipeline) as JSON the model can pick from.
//
// Security posture: strictly read-only over operator config. Profiles are
// operator-authored only (project-level profiles are stripped at load), so
// the listing can never be poisoned by a cloned repository. The output
// carries permission metadata only — no secrets, no paths, no tool args.

import (
"encoding/json"
"fmt"
"sort"

"github.com/BackendStack21/odek/internal/config"
)

// listSubagentProfilesTool renders the resolved capability profiles plus
// the effective default (subagent.default_profile) for model consumption.
type listSubagentProfilesTool struct {
// profiles is the operator's resolved capability profiles map, as
// handed to delegateTasksTool. After the config pipeline runs, it
// always contains the built-in "default" profile unless the operator
// disabled it (subagent.default_profile="none") or overrode the name.
profiles map[string]config.ProfileConfig

// defaultProfile is the resolved Subagent.DefaultProfile value: a
// profile name, or config.DefaultProfileDisabled when the operator
// opted out. It is echoed so the model knows which envelope applies
// when delegate_tasks omits the profile field.
defaultProfile string
}

func (t *listSubagentProfilesTool) Name() string { return "list_subagent_profiles" }

func (t *listSubagentProfilesTool) Description() string {
return "List the sub-agent capability profiles available for delegate_tasks: the " +
"operator-defined profiles (top-level profiles config) plus the built-in default. " +
"Each entry carries name, description, max_risk ceiling, tool filters and an " +
"is_default marker; default_profile reports which envelope applies when " +
"delegate_tasks omits the profile field. Invoke this BEFORE delegate_tasks when " +
"a task should run under a specific capability profile, then pass the chosen " +
"name in the profile field. Unknown names fail the task."
}

func (t *listSubagentProfilesTool) Schema() any {
// No parameters — the listing is static per-run config state.
return map[string]any{
"type": "object",
"properties": map[string]any{},
}
}

// profileEntry is the JSON shape of one capability profile.
type profileEntry struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
MaxRisk string `json:"max_risk,omitempty"`
ToolsEnabled []string `json:"tools_enabled,omitempty"`
ToolsDisabled []string `json:"tools_disabled,omitempty"`
IsDefault bool `json:"is_default,omitempty"`
}

func (t *listSubagentProfilesTool) Call(_ string) (string, error) {
defaultActive := t.defaultProfile != "" && t.defaultProfile != config.DefaultProfileDisabled

out := struct {
Profiles []profileEntry `json:"profiles"`
DefaultProfile string `json:"default_profile"`
}{
Profiles: make([]profileEntry, 0, len(t.profiles)),
DefaultProfile: t.defaultProfile,
}

names := make([]string, 0, len(t.profiles))
for name := range t.profiles {
names = append(names, name)
}
sort.Strings(names)

for _, name := range names {
prof := t.profiles[name]
entry := profileEntry{
Name: name,
Description: prof.Description,
MaxRisk: prof.MaxRisk,
IsDefault: defaultActive && name == t.defaultProfile,
}
if prof.Tools != nil {
entry.ToolsEnabled = prof.Tools.Enabled
entry.ToolsDisabled = prof.Tools.Disabled
}
out.Profiles = append(out.Profiles, entry)
}

buf, err := json.Marshal(out)
if err != nil {
return "", fmt.Errorf("list_subagent_profiles: %w", err)
}
return string(buf), nil
}
194 changes: 194 additions & 0 deletions cmd/odek/profiles_tool_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package main

import (
"encoding/json"
"reflect"
"strings"
"testing"

"github.com/BackendStack21/odek/internal/config"
)

// Tests for the list_subagent_profiles built-in tool (P4 follow-up): the
// parent LLM must be able to discover the operator-defined capability
// profiles (plus the built-in default) on demand, so it can pick the right
// profile name for delegate_tasks. The tool is read-only and argument-free.

func newProfilesToolForTest(profiles map[string]config.ProfileConfig, defaultProfile string) *listSubagentProfilesTool {
return &listSubagentProfilesTool{
profiles: profiles,
defaultProfile: defaultProfile,
}
}

func decodeProfilesToolOutput(t *testing.T, out string) map[string]any {
t.Helper()
var decoded map[string]any
if err := json.Unmarshal([]byte(out), &decoded); err != nil {
t.Fatalf("Call output is not valid JSON: %v\nraw: %s", err, out)
}
return decoded
}

// TestListSubagentProfiles_NameAndSchema pins the tool's registration
// surface: the name the model invokes and an empty parameter schema (the
// tool takes no arguments).
func TestListSubagentProfiles_NameAndSchema(t *testing.T) {
tool := newProfilesToolForTest(nil, config.DefaultProfileName)
if tool.Name() != "list_subagent_profiles" {
t.Errorf("Name() = %q, want %q", tool.Name(), "list_subagent_profiles")
}
if strings.TrimSpace(tool.Description()) == "" {
t.Error("Description() must be non-empty — it is the model's only usage guide")
}
schema, ok := tool.Schema().(map[string]any)
if !ok {
t.Fatalf("Schema() = %T, want map[string]any", tool.Schema())
}
if schema["type"] != "object" {
t.Errorf("schema type = %v, want object", schema["type"])
}
}

// TestListSubagentProfiles_ListsOperatorProfiles covers the core render:
// operator-defined profiles appear with name, description, max_risk and
// tool filters, sorted by name for deterministic output.
func TestListSubagentProfiles_ListsOperatorProfiles(t *testing.T) {
profiles := map[string]config.ProfileConfig{
"judge": {
Description: "Read-only reviewer",
MaxRisk: "safe",
Tools: &config.ToolConfig{Disabled: []string{"shell", "write_file"}},
},
"builder": {
MaxRisk: "local_write",
Tools: &config.ToolConfig{Enabled: []string{"shell", "read_file"}},
},
}
tool := newProfilesToolForTest(profiles, "default")
out, err := tool.Call("{}")
if err != nil {
t.Fatalf("Call: %v", err)
}
decoded := decodeProfilesToolOutput(t, out)

if got := decoded["default_profile"]; got != "default" {
t.Errorf("default_profile = %v, want %q", got, "default")
}
raw, ok := decoded["profiles"].([]any)
if !ok {
t.Fatalf("profiles = %T, want array", decoded["profiles"])
}
if len(raw) != 2 {
t.Fatalf("profiles len = %d, want 2", len(raw))
}
// Sorted by name: builder < judge.
first, _ := raw[0].(map[string]any)
second, _ := raw[1].(map[string]any)
if first["name"] != "builder" || second["name"] != "judge" {
t.Errorf("profile order = [%v, %v], want [builder judge]", first["name"], second["name"])
}
if first["max_risk"] != "local_write" {
t.Errorf("builder max_risk = %v, want local_write", first["max_risk"])
}
if second["description"] != "Read-only reviewer" {
t.Errorf("judge description = %v, want %q", second["description"], "Read-only reviewer")
}
if second["max_risk"] != "safe" {
t.Errorf("judge max_risk = %v, want safe", second["max_risk"])
}
disabled, ok := second["tools_disabled"].([]any)
if !ok || !reflect.DeepEqual(disabled, []any{"shell", "write_file"}) {
t.Errorf("judge tools_disabled = %v, want [shell write_file]", second["tools_disabled"])
}
enabled, ok := first["tools_enabled"].([]any)
if !ok || !reflect.DeepEqual(enabled, []any{"shell", "read_file"}) {
t.Errorf("builder tools_enabled = %v, want [shell read_file]", first["tools_enabled"])
}
}

// TestListSubagentProfiles_MarksEffectiveDefault covers the is_default
// marker: exactly the entry matching the resolved subagent.default_profile
// is flagged, so the model knows which envelope applies when delegate_tasks
// omits the profile field.
func TestListSubagentProfiles_MarksEffectiveDefault(t *testing.T) {
profiles := map[string]config.ProfileConfig{
"default": {MaxRisk: "local_write"},
"judge": {MaxRisk: "safe"},
}
tool := newProfilesToolForTest(profiles, "default")
out, err := tool.Call("{}")
if err != nil {
t.Fatalf("Call: %v", err)
}
decoded := decodeProfilesToolOutput(t, out)
raw := decoded["profiles"].([]any)
defaults := 0
for _, e := range raw {
entry := e.(map[string]any)
isDefault, _ := entry["is_default"].(bool)
switch entry["name"] {
case "default":
if !isDefault {
t.Error("built-in default entry must carry is_default=true")
}
defaults++
case "judge":
if isDefault {
t.Error("non-default entry must not carry is_default=true")
}
}
}
if defaults != 1 {
t.Errorf("is_default marked on %d entries, want exactly 1", defaults)
}
}

// TestListSubagentProfiles_DisabledDefault covers subagent.default_profile
// = "none": the echo reports "none" and no entry is flagged as default.
func TestListSubagentProfiles_DisabledDefault(t *testing.T) {
profiles := map[string]config.ProfileConfig{
"judge": {MaxRisk: "safe"},
}
tool := newProfilesToolForTest(profiles, config.DefaultProfileDisabled)
out, err := tool.Call("{}")
if err != nil {
t.Fatalf("Call: %v", err)
}
decoded := decodeProfilesToolOutput(t, out)
if got := decoded["default_profile"]; got != "none" {
t.Errorf("default_profile = %v, want %q", got, "none")
}
for _, e := range decoded["profiles"].([]any) {
entry := e.(map[string]any)
if isDefault, _ := entry["is_default"].(bool); isDefault {
t.Errorf("entry %v must not be flagged default when the default is disabled", entry["name"])
}
}
}

// TestListSubagentProfiles_EmptyProfilesRendersArray pins that an operator
// with no profiles (and a disabled or absent built-in) still gets a JSON
// array, not null — models handle [] more reliably.
func TestListSubagentProfiles_EmptyProfilesRendersArray(t *testing.T) {
tool := newProfilesToolForTest(nil, config.DefaultProfileDisabled)
out, err := tool.Call("{}")
if err != nil {
t.Fatalf("Call: %v", err)
}
if !strings.Contains(out, `"profiles": []`) && !strings.Contains(out, "\"profiles\":[]") {
t.Errorf("empty profiles must render as [], got: %s", out)
}
}

// TestListSubagentProfiles_IgnoresArgs pins argument tolerance: the tool
// takes no parameters, so any args payload (valid or not) must be accepted
// rather than rejected.
func TestListSubagentProfiles_IgnoresArgs(t *testing.T) {
tool := newProfilesToolForTest(nil, config.DefaultProfileName)
for _, args := range []string{"{}", "", "not json at all", `{"unexpected":1}`} {
if _, err := tool.Call(args); err != nil {
t.Errorf("Call(%q) = %v, want nil (args are ignored)", args, err)
}
}
}
10 changes: 9 additions & 1 deletion cmd/odek/subagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -583,10 +583,18 @@ func subagentCmd(args []string) error {
// P4: a profile may be selected by the operator's direct --profile flag
// or by the parent via the task file; the flag outranks the file.
profileName := resolveProfileName(cfg.profile, taskProfile)
if profileName == "" && resolved.Subagent.DefaultProfile != config.DefaultProfileDisabled {
// Operator's default envelope (P4): the built-in "default" profile
// unless the operator overrode the name. Explicit task/flag
// selection outranks it, and "none" is honored only from the
// operator's own config — a task file or flag can never strip the
// operator's envelope.
profileName = resolved.Subagent.DefaultProfile
}
if profileName != "" {
prof, ok := resolved.Profiles[profileName]
if !ok {
return fmt.Errorf("unknown profile %q (define it in the top-level profiles config section)", profileName)
return fmt.Errorf("unknown profile %q (define it in the top-level profiles config section, or adjust subagent.default_profile)", profileName)
}
applyProfile(&resolved.Dangerous, prof)
profileTools = prof.Tools
Expand Down
Loading
Loading