1package agent
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
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)
17
18// subagentRuntime owns the manager-facing runtime assembled around a host
19// session. Keeping this state together is important: every injected manager
20// tool must use the same supervisor, model/provider selection, profile root,
21// policy, callbacks, and shutdown lifecycle.
22type subagentRuntime struct {
23 supervisor *subagents.Supervisor
24 args Args
25 root string
26
27 provider string
28 model string
29 reasoning string
30 baseURL string
31 insecureTLS bool
32 fastMode bool
33 repoRoot string
34
35 activeProvider func() string
36 activeModel func() string
37 activeReasoning func() string
38
39 apiKey string
40 apiKeyProvider string
41 webSearchPolicy subagents.WebSearchPolicy
42 webSearchGuard *webSearchSessionGuard
43
44 onSpawned func(*subagents.Agent, string, bool)
45 onResumed func(*subagents.Agent, string, bool)
46 beforeResumed func(*subagents.Agent, string, bool) func()
47 onStopRequested func(*subagents.Agent)
48
49 requiredReadyMu sync.RWMutex
50 requiredReady <-chan struct{}
51 requiredReadyErr func() error
52 requiredContextMu sync.Mutex
53 reportedRequiredUnmet map[string]struct{}
54 settingsMu sync.RWMutex
55 closeMu sync.Mutex
56 closed bool
57}
58
59type subagentRuntimeConfiguration struct {
60 provider string
61 model string
62 baseURL string
63 insecureTLS bool
64 fastMode bool
65 repoRoot string
66 webSearchPolicy subagents.WebSearchPolicy
67}
68
69// subagentRuntimeConfig supplies the host-owned settings and seams needed to
70// assemble a subagentRuntime. The active selection callbacks are optional;
71// when omitted, the initial Provider/Model/Reasoning values are used.
72type subagentRuntimeConfig struct {
73 Context context.Context
74 Args Args
75 Root string
76 RepoRoot string
77 TraceDir string
78 TraceMode subagents.TraceMode
79
80 Provider string
81 Model string
82 Reasoning string
83 BaseURL string
84 InsecureTLS bool
85 FastMode bool
86
87 APIKey string
88 Policy subagents.SubagentPolicy
89 WebSearchPolicy subagents.WebSearchPolicy
90 WebSearchGuard *webSearchSessionGuard
91 ActiveProvider func() string
92 ActiveModel func() string
93 ActiveReasoning func() string
94 NewRunner func(*subagents.Agent) subagents.Runner
95
96 OnSpawned func(*subagents.Agent, string, bool)
97 OnResumed func(*subagents.Agent, string, bool)
98 BeforeResumed func(*subagents.Agent, string, bool) func()
99 OnStopRequested func(*subagents.Agent)
100}
101
102func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
103 if cfg.Context == nil {
104 cfg.Context = context.Background()
105 }
106 if cfg.TraceDir == "" {
107 cfg.TraceDir = strings.TrimSpace(os.Getenv("ZUT_SUBAGENT_TRACE_DIR"))
108 }
109 if cfg.TraceMode == "" {
110 switch strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_SUBAGENT_TRACE_MODE"))) {
111 case "", string(subagents.TraceModeNormal):
112 cfg.TraceMode = subagents.TraceModeNormal
113 case string(subagents.TraceModeDetailed):
114 cfg.TraceMode = subagents.TraceModeDetailed
115 default:
116 fmt.Fprintf(os.Stderr, "zut: ignoring invalid ZUT_SUBAGENT_TRACE_MODE\n")
117 cfg.TraceMode = subagents.TraceModeNormal
118 }
119 }
120 if cfg.TraceDir == "" {
121 cfg.TraceMode = subagents.TraceModeNormal
122 }
123 rt := &subagentRuntime{
124 args: cfg.Args,
125 root: cfg.Root,
126 provider: strings.TrimSpace(cfg.Provider),
127 model: strings.TrimSpace(cfg.Model),
128 reasoning: strings.TrimSpace(cfg.Reasoning),
129 baseURL: strings.TrimSpace(cfg.BaseURL),
130 insecureTLS: cfg.InsecureTLS,
131 fastMode: cfg.FastMode,
132 repoRoot: cfg.RepoRoot,
133 activeProvider: cfg.ActiveProvider,
134 activeModel: cfg.ActiveModel,
135 activeReasoning: cfg.ActiveReasoning,
136 apiKey: cfg.APIKey,
137 webSearchPolicy: cfg.WebSearchPolicy,
138 webSearchGuard: cfg.WebSearchGuard,
139 onSpawned: cfg.OnSpawned,
140 onResumed: cfg.OnResumed,
141 beforeResumed: cfg.BeforeResumed,
142 onStopRequested: cfg.OnStopRequested,
143 }
144 if rt.activeProvider == nil {
145 rt.activeProvider = func() string {
146 rt.settingsMu.RLock()
147 defer rt.settingsMu.RUnlock()
148 return rt.provider
149 }
150 }
151 if rt.activeModel == nil {
152 rt.activeModel = func() string {
153 rt.settingsMu.RLock()
154 defer rt.settingsMu.RUnlock()
155 return rt.model
156 }
157 }
158 if rt.activeReasoning == nil {
159 rt.activeReasoning = func() string {
160 rt.settingsMu.RLock()
161 defer rt.settingsMu.RUnlock()
162 return rt.reasoning
163 }
164 }
165 if rt.apiKey != "" {
166 // A launch-time API key belongs to the provider selected at startup. It
167 // must not follow the live provider callback through a later transition.
168 rt.apiKeyProvider = canonicalProvider(rt.provider)
169 }
170
171 // Packaged agents have an explicit capability ceiling. Until worker
172 // capability propagation exists, they cannot expose delegation tools or a
173 // fresh unrestricted supervisor.
174 if cfg.Args.PermissionSet == nil {
175 root := rt.root
176 if strings.TrimSpace(root) == "" {
177 root = filepath.Join(ZutHome(), "subagents")
178 rt.root = root
179 }
180 rt.supervisor = subagents.New(subagents.Config{
181 Context: cfg.Context,
182 Root: root,
183 RepoRoot: cfg.RepoRoot,
184 TraceDir: cfg.TraceDir,
185 TraceMode: cfg.TraceMode,
186 Provider: rt.currentProvider(),
187 FastMode: cfg.FastMode,
188 WebSearchPolicy: cfg.WebSearchPolicy,
189 BaseURL: cfg.BaseURL,
190 InsecureTLS: cfg.InsecureTLS,
191 NewRunner: cfg.NewRunner,
192 Policy: cfg.Policy,
193 ResolveCredential: rt.resolveCredential,
194 })
195 }
196 return rt
197}
198
199func (rt *subagentRuntime) Supervisor() *subagents.Supervisor {
200 if rt == nil {
201 return nil
202 }
203 return rt.supervisor
204}
205
206func (rt *subagentRuntime) currentProvider() string {
207 if rt == nil || rt.activeProvider == nil {
208 return ""
209 }
210 return strings.TrimSpace(rt.activeProvider())
211}
212
213func (rt *subagentRuntime) currentModel() string {
214 if rt == nil || rt.activeModel == nil {
215 return ""
216 }
217 return strings.TrimSpace(rt.activeModel())
218}
219
220func (rt *subagentRuntime) SetModel(model string) {
221 if rt == nil {
222 return
223 }
224 rt.settingsMu.Lock()
225 rt.model = strings.TrimSpace(model)
226 rt.settingsMu.Unlock()
227}
228
229func (rt *subagentRuntime) SetReasoning(reasoning string) {
230 if rt == nil {
231 return
232 }
233 rt.settingsMu.Lock()
234 rt.reasoning = strings.TrimSpace(reasoning)
235 rt.settingsMu.Unlock()
236}
237
238func (rt *subagentRuntime) currentReasoning() string {
239 if rt == nil || rt.activeReasoning == nil {
240 return ""
241 }
242 return strings.TrimSpace(rt.activeReasoning())
243}
244
245func (rt *subagentRuntime) SetProvider(providerID string) {
246 if rt == nil {
247 return
248 }
249 providerID = strings.TrimSpace(providerID)
250 rt.settingsMu.Lock()
251 rt.provider = providerID
252 rt.settingsMu.Unlock()
253 if rt.supervisor != nil {
254 rt.supervisor.SetProvider(providerID)
255 }
256}
257
258func (rt *subagentRuntime) SetProviderSettings(baseURL string, insecureTLS bool) {
259 if rt == nil {
260 return
261 }
262 baseURL = strings.TrimSpace(baseURL)
263 rt.settingsMu.Lock()
264 rt.baseURL = baseURL
265 rt.insecureTLS = insecureTLS
266 rt.settingsMu.Unlock()
267 if rt.supervisor != nil {
268 rt.supervisor.SetProviderSettings(baseURL, insecureTLS)
269 }
270}
271
272func (rt *subagentRuntime) SetFastMode(fastMode bool) {
273 if rt == nil {
274 return
275 }
276 rt.settingsMu.Lock()
277 rt.fastMode = fastMode
278 rt.settingsMu.Unlock()
279 if rt.supervisor != nil {
280 rt.supervisor.SetFastMode(fastMode)
281 }
282}
283
284func (rt *subagentRuntime) SetRepoRoot(repoRoot string) {
285 if rt == nil {
286 return
287 }
288 rt.settingsMu.Lock()
289 rt.repoRoot = repoRoot
290 rt.settingsMu.Unlock()
291 if rt.supervisor != nil {
292 rt.supervisor.SetRepoRoot(repoRoot)
293 }
294}
295
296// snapshotConfiguration captures the runtime settings changed while an agent
297// candidate is being built. Transition callers restore it when candidate
298// preparation fails before the new agent is committed.
299func (rt *subagentRuntime) snapshotConfiguration() subagentRuntimeConfiguration {
300 if rt == nil {
301 return subagentRuntimeConfiguration{}
302 }
303 rt.settingsMu.RLock()
304 defer rt.settingsMu.RUnlock()
305 return subagentRuntimeConfiguration{
306 provider: rt.provider,
307 model: rt.model,
308 baseURL: rt.baseURL,
309 insecureTLS: rt.insecureTLS,
310 fastMode: rt.fastMode,
311 repoRoot: rt.repoRoot,
312 webSearchPolicy: rt.webSearchPolicy,
313 }
314}
315
316func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfiguration) {
317 if rt == nil {
318 return
319 }
320 rt.settingsMu.Lock()
321 rt.provider = snapshot.provider
322 rt.model = snapshot.model
323 rt.baseURL = snapshot.baseURL
324 rt.insecureTLS = snapshot.insecureTLS
325 rt.fastMode = snapshot.fastMode
326 rt.repoRoot = snapshot.repoRoot
327 rt.webSearchPolicy = snapshot.webSearchPolicy
328 rt.settingsMu.Unlock()
329 if rt.supervisor != nil {
330 rt.supervisor.SetProvider(snapshot.provider)
331 rt.supervisor.SetProviderSettings(snapshot.baseURL, snapshot.insecureTLS)
332 rt.supervisor.SetFastMode(snapshot.fastMode)
333 rt.supervisor.SetRepoRoot(snapshot.repoRoot)
334 rt.supervisor.SetWebSearchPolicy(snapshot.webSearchPolicy)
335 }
336}
337
338func (rt *subagentRuntime) SetActiveSession(sessionID string) {
339 if rt != nil && rt.supervisor != nil {
340 rt.supervisor.SetActiveSession(sessionID)
341 }
342}
343
344func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy) {
345 if rt == nil {
346 return
347 }
348 rt.settingsMu.Lock()
349 rt.webSearchPolicy = policy
350 rt.settingsMu.Unlock()
351 if rt.supervisor != nil {
352 rt.supervisor.SetWebSearchPolicy(policy)
353 }
354}
355
356func (rt *subagentRuntime) resolveCredential(ctx context.Context, providerID string) (subagents.Credential, error) {
357 childProvider := canonicalProvider(providerID)
358 if childProvider == "" {
359 childProvider = canonicalProvider(rt.currentProvider())
360 }
361 explicit := ""
362 // A launch-time API key is scoped to the provider selected at startup.
363 // Never send it to an explicitly different child provider; resolve that
364 // provider's own credential instead.
365 if rt.apiKey != "" && childProvider == rt.apiKeyProvider {
366 explicit = rt.apiKey
367 }
368 if childProvider == "ollama" && explicit == "" {
369 return subagents.Credential{Value: "ollama", Method: "apikey"}, nil
370 }
371 credential, method, accountID, err := ResolveCredentialFullContext(ctx, childProvider, explicit)
372 if err != nil {
373 // Providers backed only by a local endpoint do not need a key. Leave
374 // stdin untouched and let the child resolve that endpoint.
375 if !CredentialAvailable(childProvider) {
376 return subagents.Credential{}, nil
377 }
378 return subagents.Credential{}, err
379 }
380 return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil
381}
382
383func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, error) {
384 if rt == nil {
385 return nil, nil
386 }
387 rt.settingsMu.RLock()
388 repoRoot := rt.repoRoot
389 rt.settingsMu.RUnlock()
390 return findSubagentProfile(repoRoot, name)
391}
392
393// InjectTools adds the canonical manager tools allowed by the host launch
394// policy. It deliberately mutates the supplied registry, matching the normal
395// interactive registry assembly path.
396func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry {
397 if reg == nil || rt == nil || rt.supervisor == nil || !autoSubagentsAnyToolAllowed(rt.args) {
398 return reg
399 }
400 if autoSubagentsToolAllowed(rt.args) {
401 spawn := &tools.SubagentSpawnTool{
402 Supervisor: rt.supervisor,
403 Enabled: func() bool { return true },
404 DefaultModel: func() string {
405 return rt.currentModel()
406 },
407 DefaultProvider: func() string {
408 return rt.currentProvider()
409 },
410 DefaultReasoning: func() string {
411 return rt.currentReasoning()
412 },
413 ResolveSubagent: rt.resolveSubagent,
414 OnSpawned: rt.onSpawned,
415 }
416 reg[spawn.Name()] = spawn
417 }
418 if autoSubagentsStatusToolAllowed(rt.args) {
419 status := &tools.SubagentStatusTool{
420 Supervisor: rt.supervisor,
421 Enabled: func() bool { return true },
422 }
423 reg[status.Name()] = status
424 }
425 if autoSubagentsStopToolAllowed(rt.args) {
426 stop := &tools.SubagentStopTool{
427 Supervisor: rt.supervisor,
428 Enabled: func() bool { return true },
429 OnStopRequested: rt.onStopRequested,
430 }
431 reg[stop.Name()] = stop
432 }
433 if autoSubagentsResumeToolAllowed(rt.args) {
434 resume := &tools.SubagentResumeTool{
435 Supervisor: rt.supervisor,
436 Enabled: func() bool { return true },
437 BeforeResumed: rt.beforeResumed,
438 OnResumed: rt.onResumed,
439 }
440 reg[resume.Name()] = resume
441 }
442 return reg
443}
444
445func (rt *subagentRuntime) PrepareRegistry(reg core.Registry) core.Registry {
446 reg = rt.InjectTools(reg)
447 if rt != nil && rt.webSearchGuard != nil {
448 return rt.webSearchGuard.wrapRegistry(reg)
449 }
450 return reg
451}
452
453func (rt *subagentRuntime) PrepareResolvedRegistry(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry {
454 // Keep the supervisor's capability policy in lockstep with the resolved
455 // parent registry. This is especially important after entry.pre refreshes
456 // permissions: workers launched after the refresh must not retain the old
457 // parent allow decision.
458 if rt != nil {
459 rt.SetWebSearchPolicy(webSearchPolicyForRegistry(policy, reg))
460 }
461 if policy != subagents.WebSearchAllow {
462 delete(reg, "web_search")
463 }
464 return rt.PrepareRegistry(reg)
465}
466
467func (rt *subagentRuntime) Close(ctx context.Context) error {
468 if rt == nil || rt.supervisor == nil {
469 return nil
470 }
471 rt.closeMu.Lock()
472 defer rt.closeMu.Unlock()
473 if rt.closed {
474 return nil
475 }
476 stopErr := rt.supervisor.StopAllContext(ctx)
477 closeErr := rt.supervisor.CloseContext(ctx)
478 if err := errors.Join(stopErr, closeErr); err != nil {
479 return err
480 }
481 rt.closed = true
482 return nil
483}
484
485func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
486 parseDuration := func(value string) time.Duration {
487 if strings.TrimSpace(value) == "" {
488 return 0
489 }
490 parsed, err := time.ParseDuration(strings.TrimSpace(value))
491 if err != nil || parsed <= 0 {
492 return 0
493 }
494 return parsed
495 }
496 return subagents.SubagentPolicy{
497 MaxConcurrent: cfg.MaxConcurrent,
498 MaxConcurrentPerParent: cfg.MaxConcurrentPerParent,
499 QueueTimeout: parseDuration(cfg.QueueTimeout),
500 StartupTimeout: parseDuration(cfg.StartupTimeout),
501 DefaultTimeout: parseDuration(cfg.DefaultTimeout),
502 MaxOutputBytes: cfg.MaxOutputBytes,
503 MaxOutputLines: cfg.MaxOutputLines,
504 AllowedTools: append([]string(nil), cfg.AllowedTools...),
505 AllowedRoots: append([]string(nil), cfg.AllowedRoots...),
506 HeartbeatInterval: parseDuration(cfg.HeartbeatInterval),
507 IdleTimeout: parseDuration(cfg.IdleTimeout),
508 ReconnectTimeout: parseDuration(cfg.ReconnectTimeout),
509 CancelGracePeriod: parseDuration(cfg.CancelGracePeriod),
510 }
511}