v0.20.0 29be61c

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
47	settingsMu sync.RWMutex
48	closeMu    sync.Mutex
49	closed     bool
50}
51
52type subagentRuntimeConfiguration struct {
53	provider        string
54	model           string
55	baseURL         string
56	insecureTLS     bool
57	fastMode        bool
58	repoRoot        string
59	webSearchPolicy subagents.WebSearchPolicy
60}
61
62// subagentRuntimeConfig supplies the host-owned settings and seams needed to
63// assemble a subagentRuntime. The active selection callbacks are optional;
64// when omitted, the initial Provider/Model/Reasoning values are used.
65type subagentRuntimeConfig struct {
66	Args     Args
67	Root     string
68	RepoRoot string
69
70	Provider    string
71	Model       string
72	Reasoning   string
73	BaseURL     string
74	InsecureTLS bool
75	FastMode    bool
76
77	Policy             subagents.SubagentPolicy
78	WebSearchPolicy    subagents.WebSearchPolicy
79	WebSearchGuard     *webSearchSessionGuard
80	ActiveProvider     func() string
81	ActiveModel        func() string
82	ActiveReasoning    func() string
83	ResidentCompletion func(subagents.ResidentCompletion)
84	OnResidentSpawned  func(subagents.ResidentChildSpec, string, string)
85}
86
87func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
88	rt := &subagentRuntime{
89		args:               cfg.Args,
90		root:               cfg.Root,
91		provider:           strings.TrimSpace(cfg.Provider),
92		credentialProvider: strings.TrimSpace(cfg.Provider),
93		model:              strings.TrimSpace(cfg.Model),
94		reasoning:          strings.TrimSpace(cfg.Reasoning),
95		baseURL:            strings.TrimSpace(cfg.BaseURL),
96		insecureTLS:        cfg.InsecureTLS,
97		fastMode:           cfg.FastMode,
98		repoRoot:           cfg.RepoRoot,
99		activeProvider:     cfg.ActiveProvider,
100		activeModel:        cfg.ActiveModel,
101		activeReasoning:    cfg.ActiveReasoning,
102		webSearchPolicy:    cfg.WebSearchPolicy,
103		policy:             cfg.Policy,
104		webSearchGuard:     cfg.WebSearchGuard,
105	}
106	if rt.activeProvider == nil {
107		rt.activeProvider = func() string {
108			rt.settingsMu.RLock()
109			defer rt.settingsMu.RUnlock()
110			return rt.provider
111		}
112	}
113	if rt.activeModel == nil {
114		rt.activeModel = func() string {
115			rt.settingsMu.RLock()
116			defer rt.settingsMu.RUnlock()
117			return rt.model
118		}
119	}
120	if rt.activeReasoning == nil {
121		rt.activeReasoning = func() string {
122			rt.settingsMu.RLock()
123			defer rt.settingsMu.RUnlock()
124			return rt.reasoning
125		}
126	}
127	// Packaged agents have an explicit capability ceiling and cannot delegate.
128	if cfg.Args.PermissionSet == nil {
129		root := rt.root
130		if strings.TrimSpace(root) == "" {
131			root = filepath.Join(ZutHome(), "subagents")
132			rt.root = root
133		}
134		rt.resident = subagents.NewResidentManagerWithPolicy(root, cfg.Policy, func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
135			return newResidentChildRunner(residentChildArgs(rt.args, rt.credentialProvider, spec), spec, journal)
136		})
137		rt.resident.SetCompletionObserver(cfg.ResidentCompletion)
138		rt.resident.SetAcceptedObserver(cfg.OnResidentSpawned)
139	}
140	return rt
141}
142
143// ResidentManager returns the in-process runtime.
144func (rt *subagentRuntime) ResidentManager() *subagents.ResidentManager {
145	if rt == nil {
146		return nil
147	}
148	return rt.resident
149}
150
151func (rt *subagentRuntime) residentManagerForTools() *subagents.ResidentManager {
152	if rt == nil {
153		return nil
154	}
155	return rt.resident
156}
157
158func (rt *subagentRuntime) currentProvider() string {
159	if rt == nil || rt.activeProvider == nil {
160		return ""
161	}
162	return strings.TrimSpace(rt.activeProvider())
163}
164
165func (rt *subagentRuntime) currentModel() string {
166	if rt == nil || rt.activeModel == nil {
167		return ""
168	}
169	return strings.TrimSpace(rt.activeModel())
170}
171
172func (rt *subagentRuntime) SetModel(model string) {
173	if rt == nil {
174		return
175	}
176	rt.settingsMu.Lock()
177	rt.model = strings.TrimSpace(model)
178	rt.settingsMu.Unlock()
179}
180
181func (rt *subagentRuntime) SetReasoning(reasoning string) {
182	if rt == nil {
183		return
184	}
185	rt.settingsMu.Lock()
186	rt.reasoning = strings.TrimSpace(reasoning)
187	rt.settingsMu.Unlock()
188}
189
190func (rt *subagentRuntime) currentReasoning() string {
191	if rt == nil || rt.activeReasoning == nil {
192		return ""
193	}
194	return strings.TrimSpace(rt.activeReasoning())
195}
196
197func (rt *subagentRuntime) SetProvider(providerID string) {
198	if rt == nil {
199		return
200	}
201	providerID = strings.TrimSpace(providerID)
202	rt.settingsMu.Lock()
203	rt.provider = providerID
204	rt.settingsMu.Unlock()
205}
206
207func (rt *subagentRuntime) SetProviderSettings(baseURL string, insecureTLS bool) {
208	if rt == nil {
209		return
210	}
211	baseURL = strings.TrimSpace(baseURL)
212	rt.settingsMu.Lock()
213	rt.baseURL = baseURL
214	rt.insecureTLS = insecureTLS
215	rt.settingsMu.Unlock()
216}
217
218func (rt *subagentRuntime) SetFastMode(fastMode bool) {
219	if rt == nil {
220		return
221	}
222	rt.settingsMu.Lock()
223	rt.fastMode = fastMode
224	rt.settingsMu.Unlock()
225}
226
227func (rt *subagentRuntime) SetRepoRoot(repoRoot string) {
228	if rt == nil {
229		return
230	}
231	rt.settingsMu.Lock()
232	rt.repoRoot = repoRoot
233	rt.settingsMu.Unlock()
234}
235
236// snapshotConfiguration captures the runtime settings changed while an agent
237// candidate is being built. Transition callers restore it when candidate
238// preparation fails before the new agent is committed.
239func (rt *subagentRuntime) snapshotConfiguration() subagentRuntimeConfiguration {
240	if rt == nil {
241		return subagentRuntimeConfiguration{}
242	}
243	rt.settingsMu.RLock()
244	defer rt.settingsMu.RUnlock()
245	return subagentRuntimeConfiguration{
246		provider:        rt.provider,
247		model:           rt.model,
248		baseURL:         rt.baseURL,
249		insecureTLS:     rt.insecureTLS,
250		fastMode:        rt.fastMode,
251		repoRoot:        rt.repoRoot,
252		webSearchPolicy: rt.webSearchPolicy,
253	}
254}
255
256func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfiguration) {
257	if rt == nil {
258		return
259	}
260	rt.settingsMu.Lock()
261	rt.provider = snapshot.provider
262	rt.model = snapshot.model
263	rt.baseURL = snapshot.baseURL
264	rt.insecureTLS = snapshot.insecureTLS
265	rt.fastMode = snapshot.fastMode
266	rt.repoRoot = snapshot.repoRoot
267	rt.webSearchPolicy = snapshot.webSearchPolicy
268	rt.settingsMu.Unlock()
269}
270
271func (rt *subagentRuntime) SetActiveSession(sessionID string) {
272	if rt == nil {
273		return
274	}
275	rt.settingsMu.Lock()
276	rt.activeSession = strings.TrimSpace(sessionID)
277	rt.settingsMu.Unlock()
278}
279
280func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request tools.ResidentSpawnRequest, catalogue core.Registry) (subagents.ResidentChildSpec, error) {
281	if rt == nil {
282		return subagents.ResidentChildSpec{}, errors.New("resident runtime is unavailable")
283	}
284	rt.settingsMu.RLock()
285	activeProvider := strings.TrimSpace(rt.provider)
286	providerID, model, reasoning := activeProvider, rt.model, rt.reasoning
287	baseURL, insecureTLS := rt.baseURL, rt.insecureTLS
288	workspace, parentSession := rt.repoRoot, rt.activeSession
289	fastMode, policy := rt.fastMode, rt.policy
290	rt.settingsMu.RUnlock()
291	if strings.TrimSpace(request.Provider) != "" {
292		providerID = strings.TrimSpace(request.Provider)
293	}
294	if strings.TrimSpace(request.Model) != "" {
295		model = request.Model
296	}
297	if strings.TrimSpace(request.Reasoning) != "" {
298		reasoning = request.Reasoning
299	}
300	if request.FastMode != nil {
301		fastMode = *request.FastMode
302	}
303	if strings.TrimSpace(providerID) == "" || strings.TrimSpace(model) == "" {
304		return subagents.ResidentChildSpec{}, errors.New("resident child needs a provider and model")
305	}
306	if providerID != activeProvider {
307		// A custom endpoint and TLS exception belong to the provider that
308		// declared them. Never carry a parent route into an explicitly
309		// selected provider: Resolve must use that provider's own settings.
310		baseURL, insecureTLS = "", false
311	}
312	allTools := make([]string, 0, len(catalogue))
313	for name := range catalogue {
314		switch name {
315		case tools.SubagentSpawnToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName, tools.SubagentResumeToolName, "update_goal":
316			continue
317		}
318		allTools = append(allTools, name)
319	}
320	sort.Strings(allTools)
321	permitted := func(name string) bool { return policy.AllowsTool(name) }
322	var childTools []string
323	var err error
324	if request.Profile == nil || !request.Profile.ToolsDeclared {
325		for _, name := range allTools {
326			if permitted(name) {
327				childTools = append(childTools, name)
328			}
329		}
330	} else {
331		childTools, err = subagents.ResolveProfileTools(request.Profile, allTools, permitted)
332		if err != nil {
333			return subagents.ResidentChildSpec{}, err
334		}
335	}
336	workspaceMode := request.WorkspaceMode
337	if workspaceMode == "" {
338		workspaceMode = subagents.WorkspaceShared
339	}
340	spec := subagents.ResidentChildSpec{
341		ID: uuid.NewString(), SessionID: uuid.NewString(), ParentSessionID: parentSession,
342		Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model),
343		Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools,
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	return spec, nil
354}
355
356func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy) {
357	if rt == nil {
358		return
359	}
360	rt.settingsMu.Lock()
361	rt.webSearchPolicy = policy
362	rt.settingsMu.Unlock()
363}
364
365func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, error) {
366	if rt == nil {
367		return nil, nil
368	}
369	rt.settingsMu.RLock()
370	repoRoot := rt.repoRoot
371	rt.settingsMu.RUnlock()
372	return findSubagentProfile(repoRoot, name)
373}
374
375// InjectTools adds the canonical manager tools allowed by the host launch
376// policy. It deliberately mutates the supplied registry, matching the normal
377// interactive registry assembly path.
378func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry {
379	if reg == nil || rt == nil || rt.resident == nil || !autoSubagentsAnyToolAllowed(rt.args) {
380		return reg
381	}
382	if autoSubagentsToolAllowed(rt.args) {
383		spawn := &tools.SubagentSpawnTool{
384			ResidentManager: rt.residentManagerForTools(),
385			Enabled:         func() bool { return true },
386			DefaultModel: func() string {
387				return rt.currentModel()
388			},
389			DefaultProvider: func() string {
390				return rt.currentProvider()
391			},
392			DefaultReasoning: func() string {
393				return rt.currentReasoning()
394			},
395			ResolveSubagent: rt.resolveSubagent,
396			BuildResidentSpec: func(ctx context.Context, request tools.ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
397				return rt.buildResidentChildSpec(ctx, request, reg)
398			},
399		}
400		reg[spawn.Name()] = spawn
401	}
402	if autoSubagentsStatusToolAllowed(rt.args) {
403		status := &tools.SubagentStatusTool{
404			ResidentManager: rt.residentManagerForTools(),
405			Enabled:         func() bool { return true },
406		}
407		reg[status.Name()] = status
408	}
409	if autoSubagentsStopToolAllowed(rt.args) {
410		stop := &tools.SubagentStopTool{
411			ResidentManager: rt.residentManagerForTools(),
412			Enabled:         func() bool { return true },
413		}
414		reg[stop.Name()] = stop
415	}
416	if autoSubagentsResumeToolAllowed(rt.args) {
417		resume := &tools.SubagentResumeTool{
418			ResidentManager: rt.residentManagerForTools(),
419			Enabled:         func() bool { return true },
420		}
421		reg[resume.Name()] = resume
422	}
423	return reg
424}
425
426func (rt *subagentRuntime) PrepareRegistry(reg core.Registry) core.Registry {
427	reg = rt.InjectTools(reg)
428	if rt != nil && rt.webSearchGuard != nil {
429		return rt.webSearchGuard.wrapRegistry(reg)
430	}
431	return reg
432}
433
434func (rt *subagentRuntime) PrepareResolvedRegistry(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry {
435	if rt != nil {
436		rt.SetWebSearchPolicy(webSearchPolicyForRegistry(policy, reg))
437	}
438	if policy != subagents.WebSearchAllow {
439		delete(reg, "web_search")
440	}
441	return rt.PrepareRegistry(reg)
442}
443
444func (rt *subagentRuntime) Close(ctx context.Context) error {
445	if rt == nil {
446		return nil
447	}
448	rt.closeMu.Lock()
449	defer rt.closeMu.Unlock()
450	if rt.closed {
451		return nil
452	}
453	var err error
454	if rt.resident != nil {
455		err = errors.Join(err, rt.resident.Close(ctx))
456	}
457	rt.closed = true
458	return err
459}
460
461func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
462	parseDuration := func(value string) time.Duration {
463		if strings.TrimSpace(value) == "" {
464			return 0
465		}
466		parsed, err := time.ParseDuration(strings.TrimSpace(value))
467		if err != nil || parsed <= 0 {
468			return 0
469		}
470		return parsed
471	}
472	return subagents.SubagentPolicy{
473		MaxConcurrent: cfg.MaxConcurrent,
474		QueueTimeout:  parseDuration(cfg.QueueTimeout),
475		AllowedTools:  append([]string(nil), cfg.AllowedTools...),
476		AllowedRoots:  append([]string(nil), cfg.AllowedRoots...),
477	}
478}

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