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