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