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