v0.33.0 653c635

subagent_runtime.go RawHistoryFolder

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

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 477

↑↓ moveenter openesc close