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