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