v0.38.1 969e52a

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	return spec, nil
392}
393
394func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string {
395	hasWebTool := false
396	for _, name := range selected {
397		if tools.IsWebCapabilityName(name) {
398			hasWebTool = true
399			break
400		}
401	}
402	if !hasWebTool {
403		return selected
404	}
405	available := make(map[string]struct{}, len(catalogue))
406	selectedSet := make(map[string]struct{}, len(selected))
407	for _, name := range catalogue {
408		available[name] = struct{}{}
409	}
410	for _, name := range selected {
411		if permitted == nil || permitted(name) {
412			selectedSet[name] = struct{}{}
413		}
414	}
415	for _, name := range tools.WebCapabilityNames {
416		if _, ok := available[name]; ok && (permitted == nil || permitted(name)) {
417			selectedSet[name] = struct{}{}
418		}
419	}
420	out := make([]string, 0, len(selectedSet))
421	for name := range selectedSet {
422		out = append(out, name)
423	}
424	sort.Strings(out)
425	return out
426}
427
428func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy) {
429	if rt == nil {
430		return
431	}
432	rt.settingsMu.Lock()
433	if policy != subagents.WebSearchAllow && rt.webSearchPolicy == subagents.WebSearchAllow && rt.browsingStore != nil {
434		rt.browsingStore.Clear()
435	}
436	rt.webSearchPolicy = policy
437	rt.settingsMu.Unlock()
438}
439
440func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, error) {
441	if rt == nil {
442		return nil, nil
443	}
444	rt.settingsMu.RLock()
445	repoRoot := rt.repoRoot
446	rt.settingsMu.RUnlock()
447	return findSubagentProfile(repoRoot, name)
448}
449
450// InjectTools adds the canonical manager tools allowed by the host launch
451// policy. It deliberately mutates the supplied registry, matching the normal
452// interactive registry assembly path.
453func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry {
454	if reg == nil || rt == nil || rt.resident == nil || !autoSubagentsAnyToolAllowed(rt.args) {
455		return reg
456	}
457	if autoSubagentsToolAllowed(rt.args) {
458		spawn := &tools.SubagentSpawnTool{
459			ResidentManager: rt.residentManagerForTools(),
460			Enabled:         func() bool { return true },
461			DefaultModel: func() string {
462				return rt.currentModel()
463			},
464			DefaultProvider: func() string {
465				return rt.currentProvider()
466			},
467			DefaultReasoning: func() string {
468				return rt.currentReasoning()
469			},
470			ResolveSubagent: rt.resolveSubagent,
471			BuildResidentSpec: func(ctx context.Context, request tools.ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
472				return rt.buildResidentChildSpec(ctx, request, reg)
473			},
474		}
475		reg[spawn.Name()] = spawn
476	}
477	if autoSubagentsStatusToolAllowed(rt.args) {
478		status := &tools.SubagentStatusTool{
479			ResidentManager: rt.residentManagerForTools(),
480			Enabled:         func() bool { return true },
481		}
482		reg[status.Name()] = status
483	}
484	if autoSubagentsStopToolAllowed(rt.args) {
485		stop := &tools.SubagentStopTool{
486			ResidentManager: rt.residentManagerForTools(),
487			Enabled:         func() bool { return true },
488		}
489		reg[stop.Name()] = stop
490	}
491	if autoSubagentsResumeToolAllowed(rt.args) {
492		resume := &tools.SubagentResumeTool{
493			ResidentManager: rt.residentManagerForTools(),
494			Enabled:         func() bool { return true },
495		}
496		reg[resume.Name()] = resume
497	}
498	return reg
499}
500
501func (rt *subagentRuntime) PrepareRegistry(reg core.Registry) core.Registry {
502	reg = rt.InjectTools(reg)
503	if rt != nil {
504		rt.settingsMu.Lock()
505		rt.browsingStore = tools.ReuseBrowsingStore(reg, rt.browsingStore)
506		rt.settingsMu.Unlock()
507	}
508	if rt != nil && rt.webSearchGuard != nil {
509		return rt.webSearchGuard.wrapRegistry(reg)
510	}
511	return reg
512}
513
514func (rt *subagentRuntime) PrepareResolvedRegistry(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry {
515	effectivePolicy := webSearchPolicyForRegistry(policy, reg)
516	if rt != nil {
517		rt.SetWebSearchPolicy(effectivePolicy)
518	}
519	if effectivePolicy != subagents.WebSearchAllow {
520		tools.RemoveWebCapabilities(reg)
521	}
522	return rt.PrepareRegistry(reg)
523}
524
525func (rt *subagentRuntime) Close(ctx context.Context) error {
526	if rt == nil {
527		return nil
528	}
529	rt.closeMu.Lock()
530	defer rt.closeMu.Unlock()
531	if rt.closed {
532		return nil
533	}
534	var err error
535	if rt.resident != nil {
536		err = errors.Join(err, rt.resident.Close(ctx))
537	}
538	rt.closed = true
539	return err
540}
541
542func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
543	parseDuration := func(value string) time.Duration {
544		if strings.TrimSpace(value) == "" {
545			return 0
546		}
547		parsed, err := time.ParseDuration(strings.TrimSpace(value))
548		if err != nil || parsed <= 0 {
549			return 0
550		}
551		return parsed
552	}
553	return subagents.SubagentPolicy{
554		MaxConcurrent: cfg.MaxConcurrent,
555		QueueTimeout:  parseDuration(cfg.QueueTimeout),
556		AllowedTools:  append([]string(nil), cfg.AllowedTools...),
557		AllowedRoots:  append([]string(nil), cfg.AllowedRoots...),
558	}
559}

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