v0.17.0 949637b

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

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