返回 DeepSeek-Reasonix
subagents_app.go
根目录 / desktop / subagents_app.go
1 package main
2
3 import (
4 "context"
5 "fmt"
6 "io"
7 "strings"
8 "time"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/boot"
12 "reasonix/internal/config"
13 "reasonix/internal/control"
14 "reasonix/internal/event"
15 "reasonix/internal/permission"
16 "reasonix/internal/sandbox"
17 "reasonix/internal/skill"
18 "reasonix/internal/tool"
19 "reasonix/internal/tool/builtin"
20 )
21
22 // SubagentProfileInput is the desktop-bound shape for authoring a subagent
23 // profile. Named SubagentProfile* rather than bare Subagent* to stay distinct
24 // from internal/agent's Subagent* run-transcript types: this is a saved
25 // authoring profile (a skill file), not a runtime record of one execution.
26 //
27 // A profile is always written with runAs=subagent and invocation=manual — it
28 // stays invocable by name (/<name> <task>, run_skill) but never enters the pinned
29 // Skills index the model scans for candidates to call on its own initiative
30 // (see internal/skill/index.go). This is deliberate: a profile authored
31 // through a settings form has no triggers/auto-use tuning, so nothing about
32 // it signals the model should discover it unprompted.
33 type SubagentProfileInput struct {
34 Name string `json:"name"`
35 Description string `json:"description"`
36 SystemPrompt string `json:"systemPrompt"`
37 Color string `json:"color"`
38 Model string `json:"model"`
39 Effort string `json:"effort"`
40 AllowedTools []string `json:"allowedTools"`
41 // ReadOnly, when true, writes frontmatter read-only: true. Omitted/false
42 // keeps the legacy writable default for older profiles.
43 ReadOnly bool `json:"readOnly"`
44 // Scope is "project" or "global" (empty defaults to global on create).
45 Scope string `json:"scope"`
46 }
47
48 func createSubagentProfileScope(raw string) (skill.Scope, error) {
49 if strings.TrimSpace(raw) == "" {
50 return skill.ScopeGlobal, nil
51 }
52 return editableSubagentProfileScope(raw)
53 }
54
55 func editableSubagentProfileScope(raw string) (skill.Scope, error) {
56 switch strings.TrimSpace(raw) {
57 case "project":
58 return skill.ScopeProject, nil
59 case "global":
60 return skill.ScopeGlobal, nil
61 default:
62 return "", fmt.Errorf("unsupported subagent profile scope %q — manage custom-path skills from the Skills page", raw)
63 }
64 }
65
66 // CreateSubagentProfile writes a new user-authored subagent profile and
67 // returns its file path. Refuses a name that collides with a built-in
68 // subagent skill (explore/research/review/security-review) — Store.List's
69 // dedup rules let a same-named user file silently shadow the built-in
70 // everywhere, including the dedicated top-level explore/review tools, so this
71 // must be caught here rather than left to the generic CreateWithContent
72 // same-scope-only overwrite check.
73 func (a *App) CreateSubagentProfile(input SubagentProfileInput) (string, error) {
74 name := strings.TrimSpace(input.Name)
75 desc := strings.TrimSpace(input.Description)
76 if desc == "" {
77 return "", fmt.Errorf("description is required")
78 }
79 prompt := strings.TrimSpace(input.SystemPrompt)
80 if prompt == "" {
81 return "", fmt.Errorf("system prompt is required")
82 }
83 scope, err := createSubagentProfileScope(input.Scope)
84 if err != nil {
85 return "", err
86 }
87
88 _, ctrl := a.activeTabAndCtrl()
89 if ctrl == nil {
90 return "", fmt.Errorf("no active session")
91 }
92 // Refuse before writing anything: the post-save RefreshSkills rebuild is
93 // rejected while the controller has a running turn, pending prompt, or
94 // background jobs, and a profile file already written by then would strand
95 // the UI — the save reports failure, the list never refreshes, and a retry
96 // hits "already exists". Same precheck order as applyConfigChange.
97 if err := a.ensureActiveTabRebuildAllowed("subagents"); err != nil {
98 return "", err
99 }
100 occupied := make([]string, 0)
101 for _, existing := range ctrl.AllSkills() {
102 occupied = append(occupied, existing.Name, existing.SlashName())
103 }
104 for _, command := range ctrl.Commands() {
105 occupied = append(occupied, command.Name)
106 }
107 if host := ctrl.Host(); host != nil {
108 for _, prompt := range host.Prompts() {
109 occupied = append(occupied, prompt.Name)
110 }
111 }
112 if err := skill.ValidateSubagentProfileName(name, occupied); err != nil {
113 return "", err
114 }
115
116 content := skill.RenderSkillFile(skill.SkillFileOptions{
117 Name: name,
118 Description: desc,
119 Body: prompt,
120 RunAs: skill.RunSubagent,
121 Model: strings.TrimSpace(input.Model),
122 Effort: strings.TrimSpace(input.Effort),
123 AllowedTools: input.AllowedTools,
124 ReadOnly: input.ReadOnly,
125 Color: strings.TrimSpace(input.Color),
126 Invocation: "manual",
127 })
128 path, err := ctrl.CreateSkill(name, scope, content)
129 if err != nil {
130 return "", err
131 }
132 // Mirrors RefreshSkills/SetSkillEnabled: degrade a lease-held rebuild to a
133 // deferred warning (the file is already saved), fail hard on a real error.
134 if err := a.RefreshSkills(); err != nil {
135 return "", err
136 }
137 return path, nil
138 }
139
140 // UpdateSubagentProfile overwrites an existing user-authored subagent
141 // profile's content in place. name and scope are the profile's identity and
142 // are not editable through this call — the frontend keeps them read-only in
143 // the edit form, since renaming or re-scoping would mean moving the file
144 // (delete-then-create), a separate operation this repo doesn't support yet.
145 // input.Name/input.Scope are ignored in favor of the name/scope params.
146 //
147 // Only profiles this page could have written are editable — see
148 // editableSubagentProfile. This is the backend enforcement of the same rule
149 // the frontend applies by filtering its list to invocation=manual.
150 func (a *App) UpdateSubagentProfile(name, scope string, input SubagentProfileInput) error {
151 name = strings.TrimSpace(name)
152 if name == "" {
153 return fmt.Errorf("name is required")
154 }
155 desc := strings.TrimSpace(input.Description)
156 if desc == "" {
157 return fmt.Errorf("description is required")
158 }
159 prompt := strings.TrimSpace(input.SystemPrompt)
160 if prompt == "" {
161 return fmt.Errorf("system prompt is required")
162 }
163 targetScope, err := editableSubagentProfileScope(scope)
164 if err != nil {
165 return err
166 }
167
168 _, ctrl := a.activeTabAndCtrl()
169 if ctrl == nil {
170 return fmt.Errorf("no active session")
171 }
172 // See CreateSubagentProfile: refuse before writing so a busy-rejected
173 // rebuild can't leave the file changed while the UI reports failure.
174 if err := a.ensureActiveTabRebuildAllowed("subagents"); err != nil {
175 return err
176 }
177 found := false
178 for _, sk := range ctrl.AllSkills() {
179 if config.SkillNameKey(sk.Name) != config.SkillNameKey(name) {
180 continue
181 }
182 found = true
183 if sk.Scope != targetScope {
184 return fmt.Errorf("%q scope mismatch: requested %q, current scope is %q", name, targetScope, sk.Scope)
185 }
186 if err := skill.ValidateEditableSubagentProfile(sk); err != nil {
187 return err
188 }
189 break
190 }
191 if !found {
192 return fmt.Errorf("%q not found", name)
193 }
194
195 content := skill.RenderSkillFile(skill.SkillFileOptions{
196 Name: name,
197 Description: desc,
198 Body: prompt,
199 RunAs: skill.RunSubagent,
200 Model: strings.TrimSpace(input.Model),
201 Effort: strings.TrimSpace(input.Effort),
202 AllowedTools: input.AllowedTools,
203 ReadOnly: input.ReadOnly,
204 Color: strings.TrimSpace(input.Color),
205 Invocation: "manual",
206 })
207 if err := ctrl.UpdateSkill(name, targetScope, content); err != nil {
208 return err
209 }
210 if err := a.RefreshSkills(); err != nil {
211 return err
212 }
213 return nil
214 }
215
216 // DeleteSubagentProfile removes a user-authored subagent profile. scope must
217 // match what the caller most recently saw for this name (SkillView.Scope) —
218 // Store.Delete refuses a scope mismatch rather than guessing, so a stale
219 // client-side scope fails safely instead of deleting the wrong file.
220 func (a *App) DeleteSubagentProfile(name, scope string) error {
221 name = strings.TrimSpace(name)
222 if name == "" {
223 return fmt.Errorf("name is required")
224 }
225 targetScope, err := editableSubagentProfileScope(scope)
226 if err != nil {
227 return err
228 }
229 _, ctrl := a.activeTabAndCtrl()
230 if ctrl == nil {
231 return fmt.Errorf("no active session")
232 }
233 // See CreateSubagentProfile: refuse before deleting so a busy-rejected
234 // rebuild can't remove the file while the UI reports failure and keeps
235 // listing the profile.
236 if err := a.ensureActiveTabRebuildAllowed("subagents"); err != nil {
237 return err
238 }
239 // Re-resolve the target and apply the full profile-identity check before
240 // deleting: the generic DeleteSkill removes any user skill matching
241 // name+scope, so a stale UI list (the file changed after load) or a direct
242 // bridge call could otherwise delete an unrelated hand-authored skill this
243 // page never owned.
244 found := false
245 for _, sk := range ctrl.AllSkills() {
246 if config.SkillNameKey(sk.Name) != config.SkillNameKey(name) {
247 continue
248 }
249 found = true
250 if sk.Scope != targetScope {
251 return fmt.Errorf("%q scope mismatch: requested %q, current scope is %q", name, targetScope, sk.Scope)
252 }
253 if err := skill.ValidateEditableSubagentProfile(sk); err != nil {
254 return err
255 }
256 break
257 }
258 if !found {
259 return fmt.Errorf("%q not found", name)
260 }
261 if err := ctrl.DeleteSkill(name, targetScope); err != nil {
262 return err
263 }
264 if err := a.RefreshSkills(); err != nil {
265 return err
266 }
267 return nil
268 }
269
270 // TrySubagentProfile runs a subagent profile once, synchronously, fully
271 // isolated from any live session — it builds its own provider and tool
272 // registry straight from config, like the standalone `reasonix review` CLI
273 // command (internal/cli/review.go), and never touches Controller.RunSkill or
274 // any part of the Chat Runtime critical path. Because it needs nothing saved
275 // to disk, it runs directly against the caller's current form values (input),
276 // so a profile can be tried before Save.
277 //
278 // A try run is deliberately READ-ONLY regardless of the profile's tool scope:
279 // it is a settings-page preview, not a real work session, and it has no UI to
280 // answer approval prompts. ReadOnlySubagentToolRegistry strips writer tools
281 // and wraps bash in the permission-classified read-only command policy; the confined reader/
282 // search/fetch instances below enforce the same workspace boundaries the real
283 // boot path installs (boot.go addBuiltins), and the headless permission gate
284 // applies the user's configured deny rules.
285 func (a *App) TrySubagentProfile(input SubagentProfileInput, task string) (string, error) {
286 task = strings.TrimSpace(task)
287 if task == "" {
288 return "", fmt.Errorf("task is required")
289 }
290 prompt := strings.TrimSpace(input.SystemPrompt)
291 if prompt == "" {
292 return "", fmt.Errorf("system prompt is required")
293 }
294
295 // One try run at a time, cancellable from the settings page and aborted
296 // with the app context on shutdown — a runaway model loop must not burn
297 // through all 12 steps with no way to stop it.
298 base := a.ctx
299 if base == nil {
300 base = context.Background()
301 }
302 runCtx, cancel := context.WithCancel(base)
303 a.tryRunMu.Lock()
304 if a.tryRunCancel != nil {
305 a.tryRunMu.Unlock()
306 cancel()
307 return "", fmt.Errorf("another try run is still in progress — cancel it or wait for it to finish")
308 }
309 a.tryRunCancel = cancel
310 a.tryRunMu.Unlock()
311 defer func() {
312 a.tryRunMu.Lock()
313 a.tryRunCancel = nil
314 a.tryRunMu.Unlock()
315 cancel()
316 }()
317
318 // Resolve config against the active tab's workspace, not the desktop
319 // process's CWD — project-level reasonix.toml (sandbox roots, permissions)
320 // must apply to the try run exactly as it would to a real session there.
321 // Snapshot under the lock: WorkspaceRoot is rewritten under a.mu (spelling
322 // normalization, session-binding redirects) and must not be read bare.
323 root := ""
324 a.mu.RLock()
325 if tab := a.activeTabLocked(); tab != nil {
326 root = tab.WorkspaceRoot
327 }
328 a.mu.RUnlock()
329 cfg, err := config.LoadForRoot(root)
330 if err != nil {
331 return "", err
332 }
333 modelRef := strings.TrimSpace(input.Model)
334 if modelRef == "" {
335 modelRef = strings.TrimSpace(cfg.Agent.SubagentModel)
336 }
337 if modelRef == "" {
338 modelRef = cfg.DefaultModel
339 }
340 entry, ok := cfg.ResolveModel(modelRef)
341 if !ok {
342 return "", fmt.Errorf("unknown model %q", modelRef)
343 }
344 me := *entry
345 if effort := strings.TrimSpace(input.Effort); effort != "" {
346 normalized, err := config.NormalizeEffort(&me, effort)
347 if err != nil {
348 return "", err
349 }
350 me.Effort = normalized
351 if me.Kind == "anthropic" && me.Effort != "" && strings.TrimSpace(me.Thinking) == "" {
352 me.Thinking = "adaptive"
353 }
354 }
355 prov, err := boot.NewProviderWithProxy(&me, cfg.NetworkProxySpec())
356 if err != nil {
357 return "", err
358 }
359
360 reg := trySubagentToolRegistry(cfg, root, input.AllowedTools)
361
362 // The headless gate enforces the user's configured permission rules. A
363 // subagent has no UI to answer an Ask decision, so deny and ask both block.
364 policy := permission.New(cfg.Permissions.Mode, cfg.Permissions.Allow, cfg.Permissions.Ask, cfg.Permissions.Deny).
365 WithAllowDynamicBashFallback(cfg.Permissions.AllowDynamicBash)
366
367 result, err := agent.RunReadOnlySubAgentWithSession(runCtx, prov, reg, agent.NewSession(prompt), task, agent.Options{
368 MaxSteps: 12,
369 Temperature: cfg.Agent.Temperature,
370 Pricing: me.Price,
371 ContextWindow: me.ContextWindow,
372 Gate: trySubagentPermissionGate(policy),
373 }, event.Discard)
374 if err != nil {
375 return "", err
376 }
377 return result, nil
378 }
379
380 // trySubagentPermissionGate pins the settings-page try runner to an explicit
381 // non-interactive Ask posture. Unlike the legacy bootstrap gate, this fails
382 // closed when a configured rule or writer fallback needs approval: the try
383 // runner has no approval UI that could answer such a request.
384 func trySubagentPermissionGate(policy permission.Policy) agent.Gate {
385 return control.BuildHeadlessApprovalGate(policy, control.ToolApprovalAsk)
386 }
387
388 // CancelTrySubagentProfile aborts the in-flight settings-page try run, if
389 // any. The pending TrySubagentProfile call returns its context error.
390 func (a *App) CancelTrySubagentProfile() {
391 a.tryRunMu.Lock()
392 cancel := a.tryRunCancel
393 a.tryRunMu.Unlock()
394 if cancel != nil {
395 cancel()
396 }
397 }
398
399 // trySubagentToolRegistry builds the read-only, workspace-rooted tool set a
400 // try run may use. The parent set comes from builtin.Workspace — the same
401 // per-workspace assembly boot uses for desktop tabs — so every tool both
402 // enforces the configured confinement AND resolves relative paths against the
403 // active tab's root, not the desktop process CWD (which, in a multi-workspace
404 // session, would let a try run read or search a different project than the
405 // one on screen). Writers are stripped by the read-only registry — Workspace
406 // wiring them anyway keeps this byte-comparable with boot's assembly and safe
407 // if the read-only posture is ever relaxed.
408 func trySubagentToolRegistry(cfg *config.Config, root string, allowedTools []string) *tool.Registry {
409 writeRoots := cfg.WriteRootsForRoot(root)
410 forbidReadRoots := boot.RuntimeForbidReadRoots(cfg, root)
411 bashSpec := sandbox.Spec{
412 Mode: cfg.BashMode(),
413 WriteRoots: writeRoots,
414 ForbidReadRoots: forbidReadRoots,
415 Network: cfg.Sandbox.Network,
416 }
417 ws := builtin.Workspace{
418 Dir: root,
419 WriteRoots: writeRoots,
420 ForbidReadRoots: forbidReadRoots,
421 Bash: bashSpec,
422 BashTimeout: time.Duration(cfg.BashTimeoutSeconds()) * time.Second,
423 Search: builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, io.Discard),
424 ProxySpec: cfg.NetworkProxySpec(),
425 ReadPaths: builtin.NewPathResolver(),
426 SessionGuard: builtin.NewSessionDataGuard(config.MemoryUserDir(), cfg.AllowWriteRoots()),
427 ManagedConfig: builtin.NewManagedConfigPaths(config.ReasonixManagedConfigPaths()),
428 }
429 parentReg := tool.NewRegistry()
430 for _, tl := range ws.Tools() {
431 parentReg.Add(tl)
432 }
433 // ReadOnlySubagentToolRegistry treats an empty allowedTools as "all" (the
434 // "default all permissions" tool-scope option) and then keeps only
435 // read-only tools plus policy-wrapped bash.
436 return agent.ReadOnlySubagentToolRegistry(parentReg, allowedTools)
437 }
438
438 lines GO