返回 DeepSeek-Reasonix
profile_spec.go
根目录 / internal / agent / profile_spec.go
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
203 lines GO