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