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