v0.27.1 131edba

subagent_runtime.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close