| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "strings" |
| 7 | |
| 8 | "reasonix/internal/skill" |
| 9 | "reasonix/internal/tool" |
| 10 | ) |
| 11 | |
| 12 | // ProfileDefinition is the delegation-facing narrowing of a stored Skill: what |
| 13 | // the worker is, never what one call wants of it. A field belongs here only if |
| 14 | // its value follows from the worker's identity; allowed-tools and read-only are |
| 15 | // ceilings, not grants. Profile names resolve at call time and must never enter |
| 16 | // tool schemas or the parent system prompt (prompt-cache stability). |
| 17 | type ProfileDefinition struct { |
| 18 | Name string |
| 19 | Body string |
| 20 | AllowedTools []string |
| 21 | Model string |
| 22 | Effort string |
| 23 | ReadOnly bool |
| 24 | // Invocation is "auto" or "manual". Explicit profile= on task/fleet may |
| 25 | // call manual profiles; automatic discovery still respects the index. |
| 26 | Invocation string |
| 27 | // NamedBuiltin is true for the built-in explore/research/review/ |
| 28 | // security-review profiles. Their body is still the full system prompt |
| 29 | // (no implicit concise default), matching custom profiles. |
| 30 | NamedBuiltin bool |
| 31 | } |
| 32 | |
| 33 | // ProfileLookup resolves a profile by exact skill name. Implementations read |
| 34 | // from the live Skill store; a nil lookup means profile= is unavailable. |
| 35 | type ProfileLookup func(name string) (ProfileDefinition, bool) |
| 36 | |
| 37 | // ProfileFromSkill narrows a stored Skill to the fields delegation may see. |
| 38 | // Routing metadata (triggers, auto-use, cost, freshness) stays behind: it |
| 39 | // decides when a worker is chosen, not how that worker thinks, and admitting it |
| 40 | // here is the first step from a profile toward a workflow language. |
| 41 | func ProfileFromSkill(sk skill.Skill) ProfileDefinition { |
| 42 | return ProfileDefinition{ |
| 43 | Name: sk.Name, |
| 44 | Body: sk.Body, |
| 45 | AllowedTools: sk.AllowedTools, |
| 46 | Model: sk.Model, |
| 47 | Effort: sk.Effort, |
| 48 | ReadOnly: sk.ReadOnly, |
| 49 | Invocation: sk.Invocation, |
| 50 | NamedBuiltin: NamedBuiltinProfile(sk.Name), |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | // ProfileExecSpec is the unified execution specification shared by task, |
| 55 | // fleet items, and run_skill profile runs. Call sites build a spec, then hand |
| 56 | // it to TaskTool.RunProfileSpec so runners cannot drift. Its members are the |
| 57 | // delegation boundary: place a new field in the member that decides its value, |
| 58 | // never in whichever one is closest to the call site. |
| 59 | type ProfileExecSpec struct { |
| 60 | Task TaskSpec |
| 61 | Worker WorkerSpec |
| 62 | Grant CapabilityGrant |
| 63 | Context ContextRequest |
| 64 | Sched SchedulerPolicy |
| 65 | } |
| 66 | |
| 67 | // TaskSpec is what one delegated run must accomplish. Every field is decided |
| 68 | // per call by the delegating parent, never by the worker's identity. |
| 69 | type TaskSpec struct { |
| 70 | // Objective is the task text handed to the child agent. |
| 71 | Objective string |
| 72 | // Description is an optional short UI label. |
| 73 | Description string |
| 74 | } |
| 75 | |
| 76 | // WorkerSpec is who carries the run out: the resolved profile identity and the |
| 77 | // provider runtime it thinks with. Fields here follow from the worker chosen, |
| 78 | // not from what this particular call asks for. |
| 79 | type WorkerSpec struct { |
| 80 | // Kind is the transcript kind: "task", "skill", or "fleet". |
| 81 | Kind string |
| 82 | // Name is the transcript / display name (profile name or "task"). |
| 83 | Name string |
| 84 | // Profile is the optional profile skill name (empty for ordinary task). |
| 85 | Profile string |
| 86 | // SystemPrompt is the full child system prompt. |
| 87 | SystemPrompt string |
| 88 | // UseProfilePrompt marks a profile body used verbatim, with no task default. |
| 89 | UseProfilePrompt bool |
| 90 | // Model/Effort are the already-resolved effective values for this run |
| 91 | // (after config override → call params → frontmatter → global → parent). |
| 92 | Model string |
| 93 | Effort string |
| 94 | } |
| 95 | |
| 96 | // CapabilityGrant is what the run may touch. Profile frontmatter supplies a |
| 97 | // ceiling and call arguments may only narrow it (see IntersectToolLists), so |
| 98 | // the effective grant is always the intersection of the two. |
| 99 | type CapabilityGrant struct { |
| 100 | // ReadOnly forces the read-only registry even when the profile can write. |
| 101 | ReadOnly bool |
| 102 | // AllowNoTools lets the parallel-research path run a child with no tools. |
| 103 | AllowNoTools bool |
| 104 | // CallTools is the optional per-call tools whitelist. |
| 105 | CallTools []string |
| 106 | // ProfileTools is the profile frontmatter allowed-tools ceiling. |
| 107 | ProfileTools []string |
| 108 | // WritePaths is the normalized write claim (empty for read-only). |
| 109 | WritePaths WritePathSet |
| 110 | } |
| 111 | |
| 112 | // ContextRequest is the context a child starts from, as opposed to the task it |
| 113 | // is given. Today that is only a prior transcript to resume. |
| 114 | type ContextRequest struct { |
| 115 | // ContinueFrom / ForkFrom are transcript continuation refs (writer path). |
| 116 | ContinueFrom string |
| 117 | ForkFrom string |
| 118 | // Ephemeral forces a non-persisted transcript for entry points that promise |
| 119 | // no durable host side effects, such as read_only_task. |
| 120 | Ephemeral bool |
| 121 | // Decisions, EvidenceSummary, FileAnchors, and OutputFormat are the only |
| 122 | // parent facts a child should start from. The parent transcript is not copied. |
| 123 | Decisions []acceptedDecision |
| 124 | EvidenceSummary string |
| 125 | FileAnchors []string |
| 126 | OutputFormat string |
| 127 | } |
| 128 | |
| 129 | // SchedulerPolicy is when and how the run executes. It never changes what the |
| 130 | // child is asked to do or what it is allowed to touch. |
| 131 | type SchedulerPolicy struct { |
| 132 | // MaxSteps is the optional per-call step budget (0 = default). |
| 133 | MaxSteps int |
| 134 | // MaxOutputTokens is an optional child completion cap (0 = inherit). |
| 135 | MaxOutputTokens int |
| 136 | // RunInBackground starts a jobs.Manager background job. |
| 137 | RunInBackground bool |
| 138 | // BackgroundWriter marks work already hosted by a parent background job |
| 139 | // (for example fleet). It participates in checkpoint writer exclusion |
| 140 | // without spawning a second nested job. |
| 141 | BackgroundWriter bool |
| 142 | // Nested marks nested sub-agent acquires (fail-fast on concurrency limits). |
| 143 | Nested bool |
| 144 | } |
| 145 | |
| 146 | // ResolveProfileDefinition looks up a profile and enforces the runAs=subagent |
| 147 | // contract. Explicit names may invoke invocation=manual profiles. |
| 148 | func ResolveProfileDefinition(lookup ProfileLookup, name string) (ProfileDefinition, error) { |
| 149 | name = strings.TrimSpace(name) |
| 150 | if name == "" { |
| 151 | return ProfileDefinition{}, fmt.Errorf("profile name is required") |
| 152 | } |
| 153 | if lookup == nil { |
| 154 | return ProfileDefinition{}, fmt.Errorf("profile resolution is not configured in this session") |
| 155 | } |
| 156 | def, ok := lookup(name) |
| 157 | if !ok { |
| 158 | return ProfileDefinition{}, fmt.Errorf("unknown profile %q", name) |
| 159 | } |
| 160 | if strings.TrimSpace(def.Name) == "" { |
| 161 | def.Name = name |
| 162 | } |
| 163 | return def, nil |
| 164 | } |
| 165 | |
| 166 | // IntersectToolLists returns the intersection of profile tools and call tools. |
| 167 | // Call parameters may only narrow permissions, never expand them. |
| 168 | // |
| 169 | // Rules: |
| 170 | // - both empty → nil (meaning "all tools allowed by the registry builder") |
| 171 | // - profile empty, call set → call list |
| 172 | // - call empty, profile set → profile list |
| 173 | // - both set → expand patterns against parent, then intersect; empty |
| 174 | // intersection is an error |
| 175 | func IntersectToolLists(parent *tool.Registry, profileTools, callTools []string) ([]string, error) { |
| 176 | profileTools = cleanToolList(profileTools) |
| 177 | callTools = cleanToolList(callTools) |
| 178 | if len(profileTools) == 0 { |
| 179 | return callTools, nil |
| 180 | } |
| 181 | if len(callTools) == 0 { |
| 182 | return profileTools, nil |
| 183 | } |
| 184 | // Imported profiles support wildcard tool names. Resolve both sides against |
| 185 | // the same live registry before comparing them so a profile pattern can be |
| 186 | // narrowed by a concrete call tool (and vice versa). |
| 187 | if parent != nil { |
| 188 | profileTools = expandToolPatterns(parent, profileTools) |
| 189 | callTools = expandToolPatterns(parent, callTools) |
| 190 | } |
| 191 | allowed := map[string]bool{} |
| 192 | for _, t := range profileTools { |
| 193 | allowed[t] = true |
| 194 | } |
| 195 | var out []string |
| 196 | seen := map[string]bool{} |
| 197 | for _, t := range callTools { |
| 198 | if !allowed[t] || seen[t] { |
| 199 | continue |
| 200 | } |
| 201 | seen[t] = true |
| 202 | out = append(out, t) |
| 203 | } |
| 204 | if len(out) == 0 { |
| 205 | return nil, fmt.Errorf("tools intersection is empty: call tools are not within the profile allowlist") |
| 206 | } |
| 207 | return out, nil |
| 208 | } |
| 209 | |
| 210 | // ResolveModelEffort applies the fixed priority: |
| 211 | // profile persistent config → call params → profile frontmatter → global |
| 212 | // subagent default. Empty results leave identity resolution to the parent. |
| 213 | func ResolveModelEffort(configModel, configEffort, callModel, callEffort, profileModel, profileEffort, globalModel, globalEffort string) (model, effort string) { |
| 214 | model = firstNonBlank( |
| 215 | strings.TrimSpace(configModel), |
| 216 | strings.TrimSpace(callModel), |
| 217 | strings.TrimSpace(profileModel), |
| 218 | strings.TrimSpace(globalModel), |
| 219 | ) |
| 220 | effort = firstNonBlank( |
| 221 | strings.TrimSpace(configEffort), |
| 222 | strings.TrimSpace(callEffort), |
| 223 | strings.TrimSpace(profileEffort), |
| 224 | strings.TrimSpace(globalEffort), |
| 225 | ) |
| 226 | return model, effort |
| 227 | } |
| 228 | |
| 229 | func firstNonBlank(values ...string) string { |
| 230 | for _, v := range values { |
| 231 | if strings.TrimSpace(v) != "" { |
| 232 | return strings.TrimSpace(v) |
| 233 | } |
| 234 | } |
| 235 | return "" |
| 236 | } |
| 237 | |
| 238 | func cleanToolList(names []string) []string { |
| 239 | if len(names) == 0 { |
| 240 | return nil |
| 241 | } |
| 242 | out := make([]string, 0, len(names)) |
| 243 | seen := map[string]bool{} |
| 244 | for _, n := range names { |
| 245 | n = strings.TrimSpace(n) |
| 246 | if n == "" || seen[n] { |
| 247 | continue |
| 248 | } |
| 249 | seen[n] = true |
| 250 | out = append(out, n) |
| 251 | } |
| 252 | return out |
| 253 | } |
| 254 | |
| 255 | // NamedBuiltinProfile reports whether name is a built-in named subagent profile. |
| 256 | func NamedBuiltinProfile(name string) bool { |
| 257 | switch strings.TrimSpace(name) { |
| 258 | case "explore", "research", "review", "security-review", "security_review": |
| 259 | return true |
| 260 | default: |
| 261 | return false |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | // parentSession returns the owning session, or empty when the caller asked for |
| 266 | // an ephemeral run so the store never persists a transcript for it. |
| 267 | func (c ContextRequest) parentSession(ctx context.Context) string { |
| 268 | if c.Ephemeral { |
| 269 | return "" |
| 270 | } |
| 271 | return ParentSession(ctx) |
| 272 | } |
| 273 |