v0.48.1 deaab59

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

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