| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | |
| 7 | "reasonix/internal/tool" |
| 8 | ) |
| 9 | |
| 10 | // ProfileDefinition is the resolved, runtime-facing shape of a runAs=subagent |
| 11 | // skill used by task/fleet/run_skill. Profile names are resolved at call time |
| 12 | // from the Skill store and must never be written into tool schemas or the |
| 13 | // parent system prompt (prompt-cache stability). |
| 14 | type ProfileDefinition struct { |
| 15 | Name string |
| 16 | Body string |
| 17 | AllowedTools []string |
| 18 | Model string |
| 19 | Effort string |
| 20 | ReadOnly bool |
| 21 | // Invocation is "auto" or "manual". Explicit profile= on task/fleet may |
| 22 | // call manual profiles; automatic discovery still respects the index. |
| 23 | Invocation string |
| 24 | // NamedBuiltin is true for the built-in explore/research/review/ |
| 25 | // security-review profiles. Their body is still the full system prompt |
| 26 | // (no implicit concise default), matching custom profiles. |
| 27 | NamedBuiltin bool |
| 28 | } |
| 29 | |
| 30 | // ProfileLookup resolves a profile by exact skill name. Implementations read |
| 31 | // from the live Skill store; a nil lookup means profile= is unavailable. |
| 32 | type ProfileLookup func(name string) (ProfileDefinition, bool) |
| 33 | |
| 34 | // ProfileExecSpec is the unified execution specification shared by task, |
| 35 | // fleet items, and run_skill profile runs. Call sites build a spec, then hand |
| 36 | // it to TaskTool.RunProfileSpec so runners cannot drift. |
| 37 | type ProfileExecSpec struct { |
| 38 | // Kind is the transcript kind: "task", "skill", or "fleet". |
| 39 | Kind string |
| 40 | // Name is the transcript / display name (profile name or "task"). |
| 41 | Name string |
| 42 | // Profile is the optional profile skill name (empty for ordinary task). |
| 43 | Profile string |
| 44 | // Prompt is the user task text for the child agent. |
| 45 | Prompt string |
| 46 | // Description is an optional short UI label. |
| 47 | Description string |
| 48 | // SystemPrompt is the full child system prompt. When UseProfilePrompt is |
| 49 | // true this is exactly the profile body (no DefaultTaskSystemPrompt). |
| 50 | SystemPrompt string |
| 51 | // UseProfilePrompt marks custom/named-builtin profile system prompts so |
| 52 | // hosts do not append the ordinary concise task default. |
| 53 | UseProfilePrompt bool |
| 54 | // ReadOnly forces the read-only registry even when the profile can write. |
| 55 | ReadOnly bool |
| 56 | // AllowNoTools preserves the ordinary parallel research path for children |
| 57 | // that can answer directly without host tools. Explicit task/profile calls |
| 58 | // keep failing closed on an empty registry. |
| 59 | AllowNoTools bool |
| 60 | // CallTools is the optional per-call tools whitelist. |
| 61 | CallTools []string |
| 62 | // ProfileTools is the profile frontmatter allowed-tools list. |
| 63 | ProfileTools []string |
| 64 | // Model/Effort are the already-resolved effective values for this run |
| 65 | // (after config override → call params → frontmatter → global → parent). |
| 66 | Model string |
| 67 | Effort string |
| 68 | // WritePaths is the normalized write claim (empty for read-only). |
| 69 | WritePaths WritePathSet |
| 70 | // MaxSteps is the optional per-call step budget (0 = default). |
| 71 | MaxSteps int |
| 72 | // ContinueFrom / ForkFrom are transcript continuation refs (writer path). |
| 73 | ContinueFrom string |
| 74 | ForkFrom string |
| 75 | // RunInBackground starts a jobs.Manager background job. |
| 76 | RunInBackground bool |
| 77 | // BackgroundWriter marks work already hosted by a parent background job |
| 78 | // (for example fleet). It participates in checkpoint writer exclusion |
| 79 | // without spawning a second nested job. |
| 80 | BackgroundWriter bool |
| 81 | // Nested marks nested sub-agent acquires (fail-fast on concurrency limits). |
| 82 | Nested bool |
| 83 | } |
| 84 | |
| 85 | // ResolveProfileDefinition looks up a profile and enforces the runAs=subagent |
| 86 | // contract. Explicit names may invoke invocation=manual profiles. |
| 87 | func ResolveProfileDefinition(lookup ProfileLookup, name string) (ProfileDefinition, error) { |
| 88 | name = strings.TrimSpace(name) |
| 89 | if name == "" { |
| 90 | return ProfileDefinition{}, fmt.Errorf("profile name is required") |
| 91 | } |
| 92 | if lookup == nil { |
| 93 | return ProfileDefinition{}, fmt.Errorf("profile resolution is not configured in this session") |
| 94 | } |
| 95 | def, ok := lookup(name) |
| 96 | if !ok { |
| 97 | return ProfileDefinition{}, fmt.Errorf("unknown profile %q", name) |
| 98 | } |
| 99 | if strings.TrimSpace(def.Name) == "" { |
| 100 | def.Name = name |
| 101 | } |
| 102 | return def, nil |
| 103 | } |
| 104 | |
| 105 | // IntersectToolLists returns the intersection of profile tools and call tools. |
| 106 | // Call parameters may only narrow permissions, never expand them. |
| 107 | // |
| 108 | // Rules: |
| 109 | // - both empty → nil (meaning "all tools allowed by the registry builder") |
| 110 | // - profile empty, call set → call list |
| 111 | // - call empty, profile set → profile list |
| 112 | // - both set → expand patterns against parent, then intersect; empty |
| 113 | // intersection is an error |
| 114 | func IntersectToolLists(parent *tool.Registry, profileTools, callTools []string) ([]string, error) { |
| 115 | profileTools = cleanToolList(profileTools) |
| 116 | callTools = cleanToolList(callTools) |
| 117 | if len(profileTools) == 0 { |
| 118 | return callTools, nil |
| 119 | } |
| 120 | if len(callTools) == 0 { |
| 121 | return profileTools, nil |
| 122 | } |
| 123 | // Imported profiles support wildcard tool names. Resolve both sides against |
| 124 | // the same live registry before comparing them so a profile pattern can be |
| 125 | // narrowed by a concrete call tool (and vice versa). |
| 126 | if parent != nil { |
| 127 | profileTools = expandToolPatterns(parent, profileTools) |
| 128 | callTools = expandToolPatterns(parent, callTools) |
| 129 | } |
| 130 | allowed := map[string]bool{} |
| 131 | for _, t := range profileTools { |
| 132 | allowed[t] = true |
| 133 | } |
| 134 | var out []string |
| 135 | seen := map[string]bool{} |
| 136 | for _, t := range callTools { |
| 137 | if !allowed[t] || seen[t] { |
| 138 | continue |
| 139 | } |
| 140 | seen[t] = true |
| 141 | out = append(out, t) |
| 142 | } |
| 143 | if len(out) == 0 { |
| 144 | return nil, fmt.Errorf("tools intersection is empty: call tools are not within the profile allowlist") |
| 145 | } |
| 146 | return out, nil |
| 147 | } |
| 148 | |
| 149 | // ResolveModelEffort applies the fixed priority: |
| 150 | // profile persistent config → call params → profile frontmatter → global |
| 151 | // subagent default. Empty results leave identity resolution to the parent. |
| 152 | func ResolveModelEffort(configModel, configEffort, callModel, callEffort, profileModel, profileEffort, globalModel, globalEffort string) (model, effort string) { |
| 153 | model = firstNonBlank( |
| 154 | strings.TrimSpace(configModel), |
| 155 | strings.TrimSpace(callModel), |
| 156 | strings.TrimSpace(profileModel), |
| 157 | strings.TrimSpace(globalModel), |
| 158 | ) |
| 159 | effort = firstNonBlank( |
| 160 | strings.TrimSpace(configEffort), |
| 161 | strings.TrimSpace(callEffort), |
| 162 | strings.TrimSpace(profileEffort), |
| 163 | strings.TrimSpace(globalEffort), |
| 164 | ) |
| 165 | return model, effort |
| 166 | } |
| 167 | |
| 168 | func firstNonBlank(values ...string) string { |
| 169 | for _, v := range values { |
| 170 | if strings.TrimSpace(v) != "" { |
| 171 | return strings.TrimSpace(v) |
| 172 | } |
| 173 | } |
| 174 | return "" |
| 175 | } |
| 176 | |
| 177 | func cleanToolList(names []string) []string { |
| 178 | if len(names) == 0 { |
| 179 | return nil |
| 180 | } |
| 181 | out := make([]string, 0, len(names)) |
| 182 | seen := map[string]bool{} |
| 183 | for _, n := range names { |
| 184 | n = strings.TrimSpace(n) |
| 185 | if n == "" || seen[n] { |
| 186 | continue |
| 187 | } |
| 188 | seen[n] = true |
| 189 | out = append(out, n) |
| 190 | } |
| 191 | return out |
| 192 | } |
| 193 | |
| 194 | // NamedBuiltinProfile reports whether name is a built-in named subagent profile. |
| 195 | func NamedBuiltinProfile(name string) bool { |
| 196 | switch strings.TrimSpace(name) { |
| 197 | case "explore", "research", "review", "security-review", "security_review": |
| 198 | return true |
| 199 | default: |
| 200 | return false |
| 201 | } |
| 202 | } |
| 203 |