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
47 onResidentSpawned func(subagents.ResidentChildSpec, string)
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)
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 onResidentSpawned: cfg.OnResidentSpawned,
107 }
108 if rt.activeProvider == nil {
109 rt.activeProvider = func() string {
110 rt.settingsMu.RLock()
111 defer rt.settingsMu.RUnlock()
112 return rt.provider
113 }
114 }
115 if rt.activeModel == nil {
116 rt.activeModel = func() string {
117 rt.settingsMu.RLock()
118 defer rt.settingsMu.RUnlock()
119 return rt.model
120 }
121 }
122 if rt.activeReasoning == nil {
123 rt.activeReasoning = func() string {
124 rt.settingsMu.RLock()
125 defer rt.settingsMu.RUnlock()
126 return rt.reasoning
127 }
128 }
129 // Packaged agents have an explicit capability ceiling and cannot delegate.
130 if cfg.Args.PermissionSet == nil {
131 root := rt.root
132 if strings.TrimSpace(root) == "" {
133 root = filepath.Join(ZutHome(), "subagents")
134 rt.root = root
135 }
136 rt.resident = subagents.NewResidentManagerWithPolicy(root, cfg.Policy, func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
137 return newResidentChildRunner(residentChildArgs(rt.args, rt.credentialProvider, spec), spec, journal)
138 })
139 rt.resident.SetCompletionObserver(cfg.ResidentCompletion)
140 rt.resident.SetAcceptedObserver(cfg.OnResidentSpawned)
141 }
142 return rt
143}
144
145// ResidentManager returns the in-process runtime.
146func (rt *subagentRuntime) ResidentManager() *subagents.ResidentManager {
147 if rt == nil {
148 return nil
149 }
150 return rt.resident
151}
152
153func (rt *subagentRuntime) residentManagerForTools() *subagents.ResidentManager {
154 if rt == nil {
155 return nil
156 }
157 return rt.resident
158}
159
160func (rt *subagentRuntime) currentProvider() string {
161 if rt == nil || rt.activeProvider == nil {
162 return ""
163 }
164 return strings.TrimSpace(rt.activeProvider())
165}
166
167func (rt *subagentRuntime) currentModel() string {
168 if rt == nil || rt.activeModel == nil {
169 return ""
170 }
171 return strings.TrimSpace(rt.activeModel())
172}
173
174func (rt *subagentRuntime) SetModel(model string) {
175 if rt == nil {
176 return
177 }
178 rt.settingsMu.Lock()
179 rt.model = strings.TrimSpace(model)
180 rt.settingsMu.Unlock()
181}
182
183func (rt *subagentRuntime) SetReasoning(reasoning string) {
184 if rt == nil {
185 return
186 }
187 rt.settingsMu.Lock()
188 rt.reasoning = strings.TrimSpace(reasoning)
189 rt.settingsMu.Unlock()
190}
191
192func (rt *subagentRuntime) currentReasoning() string {
193 if rt == nil || rt.activeReasoning == nil {
194 return ""
195 }
196 return strings.TrimSpace(rt.activeReasoning())
197}
198
199func (rt *subagentRuntime) SetProvider(providerID string) {
200 if rt == nil {
201 return
202 }
203 providerID = strings.TrimSpace(providerID)
204 rt.settingsMu.Lock()
205 rt.provider = providerID
206 rt.settingsMu.Unlock()
207}
208
209func (rt *subagentRuntime) SetProviderSettings(baseURL string, insecureTLS bool) {
210 if rt == nil {
211 return
212 }
213 baseURL = strings.TrimSpace(baseURL)
214 rt.settingsMu.Lock()
215 rt.baseURL = baseURL
216 rt.insecureTLS = insecureTLS
217 rt.settingsMu.Unlock()
218}
219
220func (rt *subagentRuntime) SetFastMode(fastMode bool) {
221 if rt == nil {
222 return
223 }
224 rt.settingsMu.Lock()
225 rt.fastMode = fastMode
226 rt.settingsMu.Unlock()
227}
228
229func (rt *subagentRuntime) SetRepoRoot(repoRoot string) {
230 if rt == nil {
231 return
232 }
233 rt.settingsMu.Lock()
234 rt.repoRoot = repoRoot
235 rt.settingsMu.Unlock()
236}
237
238// snapshotConfiguration captures the runtime settings changed while an agent
239// candidate is being built. Transition callers restore it when candidate
240// preparation fails before the new agent is committed.
241func (rt *subagentRuntime) snapshotConfiguration() subagentRuntimeConfiguration {
242 if rt == nil {
243 return subagentRuntimeConfiguration{}
244 }
245 rt.settingsMu.RLock()
246 defer rt.settingsMu.RUnlock()
247 return subagentRuntimeConfiguration{
248 provider: rt.provider,
249 model: rt.model,
250 baseURL: rt.baseURL,
251 insecureTLS: rt.insecureTLS,
252 fastMode: rt.fastMode,
253 repoRoot: rt.repoRoot,
254 webSearchPolicy: rt.webSearchPolicy,
255 }
256}
257
258func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfiguration) {
259 if rt == nil {
260 return
261 }
262 rt.settingsMu.Lock()
263 rt.provider = snapshot.provider
264 rt.model = snapshot.model
265 rt.baseURL = snapshot.baseURL
266 rt.insecureTLS = snapshot.insecureTLS
267 rt.fastMode = snapshot.fastMode
268 rt.repoRoot = snapshot.repoRoot
269 rt.webSearchPolicy = snapshot.webSearchPolicy
270 rt.settingsMu.Unlock()
271}
272
273func (rt *subagentRuntime) SetActiveSession(sessionID string) {
274 if rt == nil {
275 return
276 }
277 rt.settingsMu.Lock()
278 rt.activeSession = strings.TrimSpace(sessionID)
279 rt.settingsMu.Unlock()
280}
281
282func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request tools.ResidentSpawnRequest, catalogue core.Registry) (subagents.ResidentChildSpec, error) {
283 if rt == nil {
284 return subagents.ResidentChildSpec{}, errors.New("resident runtime is unavailable")
285 }
286 rt.settingsMu.RLock()
287 activeProvider := strings.TrimSpace(rt.provider)
288 providerID, model, reasoning := activeProvider, rt.model, rt.reasoning
289 baseURL, insecureTLS := rt.baseURL, rt.insecureTLS
290 workspace, parentSession := rt.repoRoot, rt.activeSession
291 fastMode, policy := rt.fastMode, rt.policy
292 rt.settingsMu.RUnlock()
293 if strings.TrimSpace(request.Provider) != "" {
294 providerID = strings.TrimSpace(request.Provider)
295 }
296 if strings.TrimSpace(request.Model) != "" {
297 model = request.Model
298 }
299 if strings.TrimSpace(request.Reasoning) != "" {
300 reasoning = request.Reasoning
301 }
302 if request.FastMode != nil {
303 fastMode = *request.FastMode
304 }
305 if strings.TrimSpace(providerID) == "" || strings.TrimSpace(model) == "" {
306 return subagents.ResidentChildSpec{}, errors.New("resident child needs a provider and model")
307 }
308 if providerID != activeProvider {
309 // A custom endpoint and TLS exception belong to the provider that
310 // declared them. Never carry a parent route into an explicitly
311 // selected provider: Resolve must use that provider's own settings.
312 baseURL, insecureTLS = "", false
313 }
314 allTools := make([]string, 0, len(catalogue))
315 for name := range catalogue {
316 switch name {
317 case tools.SubagentSpawnToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName, tools.SubagentResumeToolName, "update_goal":
318 continue
319 }
320 allTools = append(allTools, name)
321 }
322 sort.Strings(allTools)
323 permitted := func(name string) bool { return policy.AllowsTool(name) }
324 var childTools []string
325 var err error
326 if request.Profile == nil || !request.Profile.ToolsDeclared {
327 for _, name := range allTools {
328 if permitted(name) {
329 childTools = append(childTools, name)
330 }
331 }
332 } else {
333 childTools, err = subagents.ResolveProfileTools(request.Profile, allTools, permitted)
334 if err != nil {
335 return subagents.ResidentChildSpec{}, err
336 }
337 }
338 workspaceMode := request.WorkspaceMode
339 if workspaceMode == "" {
340 workspaceMode = subagents.WorkspaceShared
341 }
342 spec := subagents.ResidentChildSpec{
343 ID: uuid.NewString(), SessionID: uuid.NewString(), ParentSessionID: parentSession,
344 Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model),
345 Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools,
346 RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required,
347 }
348 if request.Profile != nil {
349 spec.Profile = request.Profile.Name
350 spec.SystemPrompt = request.Profile.SystemPrompt
351 spec.SystemPromptMode = request.Profile.SystemPromptMode
352 spec.InheritProjectContext = request.Profile.InheritProjectContext
353 spec.InheritSkills = request.Profile.InheritSkills
354 }
355 return spec, nil
356}
357
358func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy) {
359 if rt == nil {
360 return
361 }
362 rt.settingsMu.Lock()
363 rt.webSearchPolicy = policy
364 rt.settingsMu.Unlock()
365}
366
367func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, error) {
368 if rt == nil {
369 return nil, nil
370 }
371 rt.settingsMu.RLock()
372 repoRoot := rt.repoRoot
373 rt.settingsMu.RUnlock()
374 return findSubagentProfile(repoRoot, name)
375}
376
377// InjectTools adds the canonical manager tools allowed by the host launch
378// policy. It deliberately mutates the supplied registry, matching the normal
379// interactive registry assembly path.
380func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry {
381 if reg == nil || rt == nil || rt.resident == nil || !autoSubagentsAnyToolAllowed(rt.args) {
382 return reg
383 }
384 if autoSubagentsToolAllowed(rt.args) {
385 spawn := &tools.SubagentSpawnTool{
386 ResidentManager: rt.residentManagerForTools(),
387 Enabled: func() bool { return true },
388 DefaultModel: func() string {
389 return rt.currentModel()
390 },
391 DefaultProvider: func() string {
392 return rt.currentProvider()
393 },
394 DefaultReasoning: func() string {
395 return rt.currentReasoning()
396 },
397 ResolveSubagent: rt.resolveSubagent,
398 BuildResidentSpec: func(ctx context.Context, request tools.ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
399 return rt.buildResidentChildSpec(ctx, request, reg)
400 },
401 OnResidentSpawned: rt.onResidentSpawned,
402 }
403 reg[spawn.Name()] = spawn
404 }
405 if autoSubagentsStatusToolAllowed(rt.args) {
406 status := &tools.SubagentStatusTool{
407 ResidentManager: rt.residentManagerForTools(),
408 Enabled: func() bool { return true },
409 }
410 reg[status.Name()] = status
411 }
412 if autoSubagentsStopToolAllowed(rt.args) {
413 stop := &tools.SubagentStopTool{
414 ResidentManager: rt.residentManagerForTools(),
415 Enabled: func() bool { return true },
416 }
417 reg[stop.Name()] = stop
418 }
419 if autoSubagentsResumeToolAllowed(rt.args) {
420 resume := &tools.SubagentResumeTool{
421 ResidentManager: rt.residentManagerForTools(),
422 Enabled: func() bool { return true },
423 }
424 reg[resume.Name()] = resume
425 }
426 return reg
427}
428
429func (rt *subagentRuntime) PrepareRegistry(reg core.Registry) core.Registry {
430 reg = rt.InjectTools(reg)
431 if rt != nil && rt.webSearchGuard != nil {
432 return rt.webSearchGuard.wrapRegistry(reg)
433 }
434 return reg
435}
436
437func (rt *subagentRuntime) PrepareResolvedRegistry(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry {
438 if rt != nil {
439 rt.SetWebSearchPolicy(webSearchPolicyForRegistry(policy, reg))
440 }
441 if policy != subagents.WebSearchAllow {
442 delete(reg, "web_search")
443 }
444 return rt.PrepareRegistry(reg)
445}
446
447func (rt *subagentRuntime) Close(ctx context.Context) error {
448 if rt == nil {
449 return nil
450 }
451 rt.closeMu.Lock()
452 defer rt.closeMu.Unlock()
453 if rt.closed {
454 return nil
455 }
456 var err error
457 if rt.resident != nil {
458 err = errors.Join(err, rt.resident.Close(ctx))
459 }
460 rt.closed = true
461 return err
462}
463
464func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
465 parseDuration := func(value string) time.Duration {
466 if strings.TrimSpace(value) == "" {
467 return 0
468 }
469 parsed, err := time.ParseDuration(strings.TrimSpace(value))
470 if err != nil || parsed <= 0 {
471 return 0
472 }
473 return parsed
474 }
475 return subagents.SubagentPolicy{
476 MaxConcurrent: cfg.MaxConcurrent,
477 QueueTimeout: parseDuration(cfg.QueueTimeout),
478 AllowedTools: append([]string(nil), cfg.AllowedTools...),
479 AllowedRoots: append([]string(nil), cfg.AllowedRoots...),
480 }
481}