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 return spec, nil
392}
393
394func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string {
395 hasWebTool := false
396 for _, name := range selected {
397 if tools.IsWebCapabilityName(name) {
398 hasWebTool = true
399 break
400 }
401 }
402 if !hasWebTool {
403 return selected
404 }
405 available := make(map[string]struct{}, len(catalogue))
406 selectedSet := make(map[string]struct{}, len(selected))
407 for _, name := range catalogue {
408 available[name] = struct{}{}
409 }
410 for _, name := range selected {
411 if permitted == nil || permitted(name) {
412 selectedSet[name] = struct{}{}
413 }
414 }
415 for _, name := range tools.WebCapabilityNames {
416 if _, ok := available[name]; ok && (permitted == nil || permitted(name)) {
417 selectedSet[name] = struct{}{}
418 }
419 }
420 out := make([]string, 0, len(selectedSet))
421 for name := range selectedSet {
422 out = append(out, name)
423 }
424 sort.Strings(out)
425 return out
426}
427
428func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy) {
429 if rt == nil {
430 return
431 }
432 rt.settingsMu.Lock()
433 if policy != subagents.WebSearchAllow && rt.webSearchPolicy == subagents.WebSearchAllow && rt.browsingStore != nil {
434 rt.browsingStore.Clear()
435 }
436 rt.webSearchPolicy = policy
437 rt.settingsMu.Unlock()
438}
439
440func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, error) {
441 if rt == nil {
442 return nil, nil
443 }
444 rt.settingsMu.RLock()
445 repoRoot := rt.repoRoot
446 rt.settingsMu.RUnlock()
447 return findSubagentProfile(repoRoot, name)
448}
449
450// InjectTools adds the canonical manager tools allowed by the host launch
451// policy. It deliberately mutates the supplied registry, matching the normal
452// interactive registry assembly path.
453func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry {
454 if reg == nil || rt == nil || rt.resident == nil || !autoSubagentsAnyToolAllowed(rt.args) {
455 return reg
456 }
457 if autoSubagentsToolAllowed(rt.args) {
458 spawn := &tools.SubagentSpawnTool{
459 ResidentManager: rt.residentManagerForTools(),
460 Enabled: func() bool { return true },
461 DefaultModel: func() string {
462 return rt.currentModel()
463 },
464 DefaultProvider: func() string {
465 return rt.currentProvider()
466 },
467 DefaultReasoning: func() string {
468 return rt.currentReasoning()
469 },
470 ResolveSubagent: rt.resolveSubagent,
471 BuildResidentSpec: func(ctx context.Context, request tools.ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
472 return rt.buildResidentChildSpec(ctx, request, reg)
473 },
474 }
475 reg[spawn.Name()] = spawn
476 }
477 if autoSubagentsStatusToolAllowed(rt.args) {
478 status := &tools.SubagentStatusTool{
479 ResidentManager: rt.residentManagerForTools(),
480 Enabled: func() bool { return true },
481 }
482 reg[status.Name()] = status
483 }
484 if autoSubagentsStopToolAllowed(rt.args) {
485 stop := &tools.SubagentStopTool{
486 ResidentManager: rt.residentManagerForTools(),
487 Enabled: func() bool { return true },
488 }
489 reg[stop.Name()] = stop
490 }
491 if autoSubagentsResumeToolAllowed(rt.args) {
492 resume := &tools.SubagentResumeTool{
493 ResidentManager: rt.residentManagerForTools(),
494 Enabled: func() bool { return true },
495 }
496 reg[resume.Name()] = resume
497 }
498 return reg
499}
500
501func (rt *subagentRuntime) PrepareRegistry(reg core.Registry) core.Registry {
502 reg = rt.InjectTools(reg)
503 if rt != nil {
504 rt.settingsMu.Lock()
505 rt.browsingStore = tools.ReuseBrowsingStore(reg, rt.browsingStore)
506 rt.settingsMu.Unlock()
507 }
508 if rt != nil && rt.webSearchGuard != nil {
509 return rt.webSearchGuard.wrapRegistry(reg)
510 }
511 return reg
512}
513
514func (rt *subagentRuntime) PrepareResolvedRegistry(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry {
515 effectivePolicy := webSearchPolicyForRegistry(policy, reg)
516 if rt != nil {
517 rt.SetWebSearchPolicy(effectivePolicy)
518 }
519 if effectivePolicy != subagents.WebSearchAllow {
520 tools.RemoveWebCapabilities(reg)
521 }
522 return rt.PrepareRegistry(reg)
523}
524
525func (rt *subagentRuntime) Close(ctx context.Context) error {
526 if rt == nil {
527 return nil
528 }
529 rt.closeMu.Lock()
530 defer rt.closeMu.Unlock()
531 if rt.closed {
532 return nil
533 }
534 var err error
535 if rt.resident != nil {
536 err = errors.Join(err, rt.resident.Close(ctx))
537 }
538 rt.closed = true
539 return err
540}
541
542func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
543 parseDuration := func(value string) time.Duration {
544 if strings.TrimSpace(value) == "" {
545 return 0
546 }
547 parsed, err := time.ParseDuration(strings.TrimSpace(value))
548 if err != nil || parsed <= 0 {
549 return 0
550 }
551 return parsed
552 }
553 return subagents.SubagentPolicy{
554 MaxConcurrent: cfg.MaxConcurrent,
555 QueueTimeout: parseDuration(cfg.QueueTimeout),
556 AllowedTools: append([]string(nil), cfg.AllowedTools...),
557 AllowedRoots: append([]string(nil), cfg.AllowedRoots...),
558 }
559}