v0.24.0 3dbf466

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

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