1package agent
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "path/filepath"
8 "sort"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/bnema/zut/packages/agent/extensions"
14 "github.com/bnema/zut/packages/agent/mcp"
15 "github.com/bnema/zut/packages/agent/subagents"
16 "github.com/bnema/zut/packages/agent/tools"
17 "github.com/bnema/zut/packages/core"
18 "github.com/google/uuid"
19)
20
21// subagentRuntime owns the manager-facing runtime assembled around a host
22// session. Every injected manager tool shares the same resident manager,
23// model/provider selection, profile root, policy, and shutdown lifecycle.
24type subagentRuntime struct {
25 resident *subagents.ResidentManager
26 args Args
27 root string
28
29 provider string
30 // credentialProvider is immutable: an explicit CLI key belongs to the
31 // provider resolved when this runtime was assembled, not to later UI
32 // provider selections.
33 credentialProvider string
34 model string
35 reasoning string
36 baseURL string
37 insecureTLS bool
38 fastMode bool
39 repoRoot string
40 contextWindow int
41
42 activeProvider func() string
43 activeModel func() string
44 activeReasoning func() string
45
46 webSearchPolicy subagents.WebSearchPolicy
47 policy subagents.SubagentPolicy
48 activeSession string
49 webSearchGuard *webSearchSessionGuard
50 browsingStore *tools.BrowsingStore
51
52 settingsMu sync.RWMutex
53 closeMu sync.Mutex
54 closed bool
55}
56
57type subagentRuntimeConfiguration struct {
58 provider string
59 model string
60 baseURL string
61 insecureTLS bool
62 fastMode bool
63 repoRoot string
64 contextWindow int
65 webSearchPolicy subagents.WebSearchPolicy
66}
67
68// subagentRuntimeConfig supplies the host-owned settings and seams needed to
69// assemble a subagentRuntime. The active selection callbacks are optional;
70// when omitted, the initial Provider/Model/Reasoning values are used.
71type subagentRuntimeConfig struct {
72 Args Args
73 Root string
74 RepoRoot string
75
76 Provider string
77 Model string
78 Reasoning string
79 BaseURL string
80 InsecureTLS bool
81 FastMode bool
82 ContextWindow int
83
84 Policy subagents.SubagentPolicy
85 WebSearchPolicy subagents.WebSearchPolicy
86 WebSearchGuard *webSearchSessionGuard
87 ActiveProvider func() string
88 ActiveModel func() string
89 ActiveReasoning func() string
90 ResidentCompletion func(subagents.ResidentCompletion) bool
91 OnResidentSpawned func(subagents.ResidentChildSpec, string, string)
92}
93
94func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
95 rt := &subagentRuntime{
96 args: cfg.Args,
97 root: cfg.Root,
98 provider: strings.TrimSpace(cfg.Provider),
99 credentialProvider: strings.TrimSpace(cfg.Provider),
100 model: strings.TrimSpace(cfg.Model),
101 reasoning: strings.TrimSpace(cfg.Reasoning),
102 baseURL: strings.TrimSpace(cfg.BaseURL),
103 insecureTLS: cfg.InsecureTLS,
104 fastMode: cfg.FastMode,
105 repoRoot: cfg.RepoRoot,
106 contextWindow: cfg.ContextWindow,
107 activeProvider: cfg.ActiveProvider,
108 activeModel: cfg.ActiveModel,
109 activeReasoning: cfg.ActiveReasoning,
110 webSearchPolicy: cfg.WebSearchPolicy,
111 policy: cfg.Policy,
112 webSearchGuard: cfg.WebSearchGuard,
113 }
114 if rt.activeProvider == nil {
115 rt.activeProvider = func() string {
116 rt.settingsMu.RLock()
117 defer rt.settingsMu.RUnlock()
118 return rt.provider
119 }
120 }
121 if rt.activeModel == nil {
122 rt.activeModel = func() string {
123 rt.settingsMu.RLock()
124 defer rt.settingsMu.RUnlock()
125 return rt.model
126 }
127 }
128 if rt.activeReasoning == nil {
129 rt.activeReasoning = func() string {
130 rt.settingsMu.RLock()
131 defer rt.settingsMu.RUnlock()
132 return rt.reasoning
133 }
134 }
135 // Packaged agents have an explicit capability ceiling and cannot delegate.
136 if cfg.Args.PermissionSet == nil {
137 root := rt.root
138 if strings.TrimSpace(root) == "" {
139 root = filepath.Join(ZutHome(), "subagents")
140 rt.root = root
141 }
142 rt.resident = subagents.NewResidentManagerWithPolicy(root, cfg.Policy, func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
143 return newResidentChildRunner(residentChildArgs(rt.args, rt.credentialProvider, spec), spec, journal)
144 })
145 rt.resident.SetCompletionObserver(cfg.ResidentCompletion)
146 rt.resident.SetAcceptedObserver(cfg.OnResidentSpawned)
147 }
148 return rt
149}
150
151// ResidentManager returns the in-process runtime.
152func (rt *subagentRuntime) ResidentManager() *subagents.ResidentManager {
153 if rt == nil {
154 return nil
155 }
156 return rt.resident
157}
158
159func (rt *subagentRuntime) residentManagerForTools() *subagents.ResidentManager {
160 if rt == nil {
161 return nil
162 }
163 return rt.resident
164}
165
166func (rt *subagentRuntime) currentProvider() string {
167 if rt == nil || rt.activeProvider == nil {
168 return ""
169 }
170 return strings.TrimSpace(rt.activeProvider())
171}
172
173func (rt *subagentRuntime) currentModel() string {
174 if rt == nil || rt.activeModel == nil {
175 return ""
176 }
177 return strings.TrimSpace(rt.activeModel())
178}
179
180func (rt *subagentRuntime) SetModel(model string) {
181 if rt == nil {
182 return
183 }
184 rt.settingsMu.Lock()
185 rt.model = strings.TrimSpace(model)
186 rt.settingsMu.Unlock()
187}
188
189func (rt *subagentRuntime) SetContextWindow(contextWindow int) {
190 if rt == nil {
191 return
192 }
193 rt.settingsMu.Lock()
194 rt.contextWindow = contextWindow
195 rt.settingsMu.Unlock()
196}
197
198func (rt *subagentRuntime) SetReasoning(reasoning string) {
199 if rt == nil {
200 return
201 }
202 rt.settingsMu.Lock()
203 rt.reasoning = strings.TrimSpace(reasoning)
204 rt.settingsMu.Unlock()
205}
206
207func (rt *subagentRuntime) currentReasoning() string {
208 if rt == nil || rt.activeReasoning == nil {
209 return ""
210 }
211 return strings.TrimSpace(rt.activeReasoning())
212}
213
214func (rt *subagentRuntime) SetProvider(providerID string) {
215 if rt == nil {
216 return
217 }
218 providerID = strings.TrimSpace(providerID)
219 rt.settingsMu.Lock()
220 rt.provider = providerID
221 rt.settingsMu.Unlock()
222}
223
224func (rt *subagentRuntime) SetProviderSettings(baseURL string, insecureTLS bool) {
225 if rt == nil {
226 return
227 }
228 baseURL = strings.TrimSpace(baseURL)
229 rt.settingsMu.Lock()
230 rt.baseURL = baseURL
231 rt.insecureTLS = insecureTLS
232 rt.settingsMu.Unlock()
233}
234
235func (rt *subagentRuntime) SetFastMode(fastMode bool) {
236 if rt == nil {
237 return
238 }
239 rt.settingsMu.Lock()
240 rt.fastMode = fastMode
241 rt.settingsMu.Unlock()
242}
243
244func (rt *subagentRuntime) SetRepoRoot(repoRoot string) {
245 if rt == nil {
246 return
247 }
248 rt.settingsMu.Lock()
249 rt.repoRoot = repoRoot
250 rt.settingsMu.Unlock()
251}
252
253// snapshotConfiguration captures the runtime settings changed while an agent
254// candidate is being built. Transition callers restore it when candidate
255// preparation fails before the new agent is committed.
256func (rt *subagentRuntime) snapshotConfiguration() subagentRuntimeConfiguration {
257 if rt == nil {
258 return subagentRuntimeConfiguration{}
259 }
260 rt.settingsMu.RLock()
261 defer rt.settingsMu.RUnlock()
262 return subagentRuntimeConfiguration{
263 provider: rt.provider,
264 model: rt.model,
265 baseURL: rt.baseURL,
266 insecureTLS: rt.insecureTLS,
267 fastMode: rt.fastMode,
268 repoRoot: rt.repoRoot,
269 contextWindow: rt.contextWindow,
270 webSearchPolicy: rt.webSearchPolicy,
271 }
272}
273
274func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfiguration) {
275 if rt == nil {
276 return
277 }
278 rt.settingsMu.Lock()
279 rt.provider = snapshot.provider
280 rt.model = snapshot.model
281 rt.baseURL = snapshot.baseURL
282 rt.insecureTLS = snapshot.insecureTLS
283 rt.fastMode = snapshot.fastMode
284 rt.repoRoot = snapshot.repoRoot
285 rt.contextWindow = snapshot.contextWindow
286 rt.webSearchPolicy = snapshot.webSearchPolicy
287 rt.settingsMu.Unlock()
288}
289
290func (rt *subagentRuntime) SetActiveSession(sessionID string) {
291 if rt == nil {
292 return
293 }
294 sessionID = strings.TrimSpace(sessionID)
295 rt.settingsMu.Lock()
296 if rt.activeSession != "" && rt.activeSession != sessionID && rt.browsingStore != nil {
297 rt.browsingStore.Clear()
298 rt.browsingStore = nil
299 }
300 rt.activeSession = sessionID
301 rt.settingsMu.Unlock()
302}
303
304func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request tools.ResidentSpawnRequest, catalogue core.Registry) (subagents.ResidentChildSpec, error) {
305 if rt == nil {
306 return subagents.ResidentChildSpec{}, errors.New("resident runtime is unavailable")
307 }
308 rt.settingsMu.RLock()
309 activeProvider := strings.TrimSpace(rt.provider)
310 providerID, model, reasoning := activeProvider, rt.model, rt.reasoning
311 baseURL, insecureTLS := rt.baseURL, rt.insecureTLS
312 workspace, parentSession := rt.repoRoot, rt.activeSession
313 fastMode, policy := rt.fastMode, rt.policy
314 rt.settingsMu.RUnlock()
315 if strings.TrimSpace(request.Provider) != "" {
316 providerID = strings.TrimSpace(request.Provider)
317 }
318 if strings.TrimSpace(request.Model) != "" {
319 model = request.Model
320 }
321 if strings.TrimSpace(request.Reasoning) != "" {
322 reasoning = request.Reasoning
323 }
324 if request.FastMode != nil {
325 fastMode = *request.FastMode
326 }
327 if strings.TrimSpace(providerID) == "" || strings.TrimSpace(model) == "" {
328 return subagents.ResidentChildSpec{}, errors.New("resident child needs a provider and model")
329 }
330 if providerID != activeProvider {
331 // A custom endpoint and TLS exception belong to the provider that
332 // declared them. Never carry a parent route into an explicitly
333 // selected provider: Resolve must use that provider's own settings.
334 baseURL, insecureTLS = "", false
335 }
336 workspaceMode := request.WorkspaceMode
337 if workspaceMode == "" {
338 workspaceMode = subagents.WorkspaceShared
339 }
340 spec := subagents.ResidentChildSpec{
341 ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession,
342 Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model),
343 Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode,
344 RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required,
345 }
346 if request.Profile != nil {
347 spec.Profile = request.Profile.Name
348 spec.SystemPrompt = request.Profile.SystemPrompt
349 spec.SystemPromptMode = request.Profile.SystemPromptMode
350 spec.InheritProjectContext = request.Profile.InheritProjectContext
351 spec.InheritSkills = request.Profile.InheritSkills
352 }
353 resolved, err := Resolve(residentChildArgs(rt.args, rt.credentialProvider, spec), false)
354 if err != nil {
355 return subagents.ResidentChildSpec{}, fmt.Errorf("resolve resident child model: %w", err)
356 }
357 // The child-safe catalogue is the intersection of parent-offered tools
358 // and tools available in the resolved child runtime. Parent-only
359 // scheduling/extension tools are never inherited.
360 allTools := make([]string, 0, len(catalogue))
361 for name := range catalogue {
362 if extensions.IsInteractiveTool(catalogue[name]) {
363 continue
364 }
365 switch name {
366 case tools.SubagentToolName, "update_goal", tools.PlanToolName:
367 continue
368 }
369 if _, ok := resolved.ToolRegistry[name]; !ok {
370 continue
371 }
372 allTools = append(allTools, name)
373 }
374 // A strict orchestrator holds only read-only MCP tools; its workers own
375 // MCP actions, so they receive every MCP tool of the shared manager.
376 if rt.args.Orchestrate {
377 for _, name := range childMCPTools(resolved.ToolRegistry) {
378 if _, inParent := catalogue[name]; !inParent {
379 allTools = append(allTools, name)
380 }
381 }
382 }
383 sort.Strings(allTools)
384 permitted := func(name string) bool {
385 if tools.IsWebCapabilityName(name) {
386 return policy.AllowsTool("web_search")
387 }
388 if mcp.IsMCPToolName(name) && policy.AllowsTool(mcpToolSelector) {
389 return true
390 }
391 return policy.AllowsTool(name)
392 }
393 var childTools []string
394 if request.Profile == nil || !request.Profile.ToolsDeclared {
395 for _, name := range allTools {
396 if permitted(name) {
397 childTools = append(childTools, name)
398 }
399 }
400 } else {
401 childTools, err = subagents.ResolveProfileTools(request.Profile, allTools, permitted)
402 if err != nil {
403 return subagents.ResidentChildSpec{}, err
404 }
405 // A profile may name "mcp" to receive every permitted MCP tool.
406 if toolListContains(request.Profile.Tools, mcpToolSelector) {
407 for _, name := range allTools {
408 if mcp.IsMCPToolName(name) && permitted(name) && !toolListContains(childTools, name) {
409 childTools = append(childTools, name)
410 }
411 }
412 }
413 }
414 childTools = expandWebCapabilityTools(childTools, allTools, permitted)
415 spec.Tools = childTools
416 return spec, nil
417}
418
419func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string {
420 hasWebTool := false
421 for _, name := range selected {
422 if tools.IsWebCapabilityName(name) {
423 hasWebTool = true
424 break
425 }
426 }
427 if !hasWebTool {
428 return selected
429 }
430 available := make(map[string]struct{}, len(catalogue))
431 selectedSet := make(map[string]struct{}, len(selected))
432 for _, name := range catalogue {
433 available[name] = struct{}{}
434 }
435 for _, name := range selected {
436 if permitted == nil || permitted(name) {
437 selectedSet[name] = struct{}{}
438 }
439 }
440 for _, name := range tools.WebCapabilityNames {
441 if _, ok := available[name]; ok && (permitted == nil || permitted(name)) {
442 selectedSet[name] = struct{}{}
443 }
444 }
445 out := make([]string, 0, len(selectedSet))
446 for name := range selectedSet {
447 out = append(out, name)
448 }
449 sort.Strings(out)
450 return out
451}
452
453func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy) {
454 if rt == nil {
455 return
456 }
457 rt.settingsMu.Lock()
458 if policy != subagents.WebSearchAllow && rt.webSearchPolicy == subagents.WebSearchAllow && rt.browsingStore != nil {
459 rt.browsingStore.Clear()
460 }
461 rt.webSearchPolicy = policy
462 rt.settingsMu.Unlock()
463}
464
465func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, error) {
466 if rt == nil {
467 return nil, nil
468 }
469 rt.settingsMu.RLock()
470 repoRoot := rt.repoRoot
471 rt.settingsMu.RUnlock()
472 return findSubagentProfile(repoRoot, name)
473}
474
475// InjectTools adds the canonical manager tools allowed by the host launch
476// policy. It deliberately mutates the supplied registry, matching the normal
477// interactive registry assembly path.
478func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry {
479 if reg == nil || rt == nil || rt.resident == nil || !autoSubagentsAnyToolAllowed(rt.args) {
480 return reg
481 }
482 facade := &tools.SubagentTool{}
483 if autoSubagentsToolAllowed(rt.args) {
484 facade.Spawn = &tools.SubagentSpawnTool{
485 ResidentManager: rt.residentManagerForTools(),
486 Enabled: func() bool { return true },
487 DefaultModel: func() string {
488 return rt.currentModel()
489 },
490 DefaultProvider: func() string {
491 return rt.currentProvider()
492 },
493 DefaultReasoning: func() string {
494 return rt.currentReasoning()
495 },
496 ResolveSubagent: rt.resolveSubagent,
497 BuildResidentSpec: func(ctx context.Context, request tools.ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
498 return rt.buildResidentChildSpec(ctx, request, reg)
499 },
500 }
501 }
502 if autoSubagentsStatusToolAllowed(rt.args) {
503 facade.Status = &tools.SubagentStatusTool{
504 ResidentManager: rt.residentManagerForTools(),
505 Enabled: func() bool { return true },
506 }
507 }
508 if autoSubagentsStopToolAllowed(rt.args) {
509 facade.Stop = &tools.SubagentStopTool{
510 ResidentManager: rt.residentManagerForTools(),
511 Enabled: func() bool { return true },
512 }
513 }
514 if autoSubagentsResumeToolAllowed(rt.args) {
515 facade.Resume = &tools.SubagentResumeTool{
516 ResidentManager: rt.residentManagerForTools(),
517 Enabled: func() bool { return true },
518 }
519 }
520 if autoSubagentsInterruptToolAllowed(rt.args) {
521 facade.Interrupt = &tools.SubagentInterruptTool{
522 ResidentManager: rt.residentManagerForTools(),
523 Enabled: func() bool { return true },
524 }
525 }
526 reg[facade.Name()] = facade
527 return reg
528}
529
530func (rt *subagentRuntime) PrepareRegistry(reg core.Registry) core.Registry {
531 reg = rt.InjectTools(reg)
532 if rt != nil {
533 rt.settingsMu.Lock()
534 rt.browsingStore = tools.ReuseBrowsingStore(reg, rt.browsingStore)
535 rt.settingsMu.Unlock()
536 }
537 if rt != nil && rt.webSearchGuard != nil {
538 return rt.webSearchGuard.wrapRegistry(reg)
539 }
540 return reg
541}
542
543func (rt *subagentRuntime) PrepareResolvedRegistry(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry {
544 effectivePolicy := webSearchPolicyForRegistry(policy, reg)
545 if rt != nil {
546 rt.SetWebSearchPolicy(effectivePolicy)
547 }
548 if effectivePolicy != subagents.WebSearchAllow {
549 tools.RemoveWebCapabilities(reg)
550 }
551 return rt.PrepareRegistry(reg)
552}
553
554func (rt *subagentRuntime) Close(ctx context.Context) error {
555 if rt == nil {
556 return nil
557 }
558 rt.closeMu.Lock()
559 defer rt.closeMu.Unlock()
560 if rt.closed {
561 return nil
562 }
563 var err error
564 if rt.resident != nil {
565 err = errors.Join(err, rt.resident.Close(ctx))
566 }
567 rt.closed = true
568 return err
569}
570
571func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
572 parseDuration := func(value string) time.Duration {
573 if strings.TrimSpace(value) == "" {
574 return 0
575 }
576 parsed, err := time.ParseDuration(strings.TrimSpace(value))
577 if err != nil || parsed <= 0 {
578 return 0
579 }
580 return parsed
581 }
582 return subagents.SubagentPolicy{
583 MaxConcurrent: cfg.MaxConcurrent,
584 QueueTimeout: parseDuration(cfg.QueueTimeout),
585 AllowedTools: append([]string(nil), cfg.AllowedTools...),
586 AllowedRoots: append([]string(nil), cfg.AllowedRoots...),
587 }
588}