main 4509ec7

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

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