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