v0.16.0 042538b

subagent_runtime.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"os"
8	"path/filepath"
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)
17
18// subagentRuntime owns the manager-facing runtime assembled around a host
19// session. Keeping this state together is important: every injected manager
20// tool must use the same supervisor, model/provider selection, profile root,
21// policy, callbacks, and shutdown lifecycle.
22type subagentRuntime struct {
23	supervisor *subagents.Supervisor
24	args       Args
25	root       string
26
27	provider    string
28	model       string
29	reasoning   string
30	baseURL     string
31	insecureTLS bool
32	fastMode    bool
33	repoRoot    string
34
35	activeProvider  func() string
36	activeModel     func() string
37	activeReasoning func() string
38
39	apiKey          string
40	apiKeyProvider  string
41	webSearchPolicy subagents.WebSearchPolicy
42	webSearchGuard  *webSearchSessionGuard
43
44	onSpawned       func(*subagents.Agent, string, bool)
45	onResumed       func(*subagents.Agent, string, bool)
46	beforeResumed   func(*subagents.Agent, string, bool) func()
47	onStopRequested func(*subagents.Agent)
48
49	requiredReadyMu       sync.RWMutex
50	requiredReady         <-chan struct{}
51	requiredReadyErr      func() error
52	requiredContextMu     sync.Mutex
53	reportedRequiredUnmet map[string]struct{}
54	settingsMu            sync.RWMutex
55	closeMu               sync.Mutex
56	closed                bool
57}
58
59type subagentRuntimeConfiguration struct {
60	provider        string
61	model           string
62	baseURL         string
63	insecureTLS     bool
64	fastMode        bool
65	repoRoot        string
66	webSearchPolicy subagents.WebSearchPolicy
67}
68
69// subagentRuntimeConfig supplies the host-owned settings and seams needed to
70// assemble a subagentRuntime. The active selection callbacks are optional;
71// when omitted, the initial Provider/Model/Reasoning values are used.
72type subagentRuntimeConfig struct {
73	Context   context.Context
74	Args      Args
75	Root      string
76	RepoRoot  string
77	TraceDir  string
78	TraceMode subagents.TraceMode
79
80	Provider    string
81	Model       string
82	Reasoning   string
83	BaseURL     string
84	InsecureTLS bool
85	FastMode    bool
86
87	APIKey          string
88	Policy          subagents.SubagentPolicy
89	WebSearchPolicy subagents.WebSearchPolicy
90	WebSearchGuard  *webSearchSessionGuard
91	ActiveProvider  func() string
92	ActiveModel     func() string
93	ActiveReasoning func() string
94	NewRunner       func(*subagents.Agent) subagents.Runner
95
96	OnSpawned       func(*subagents.Agent, string, bool)
97	OnResumed       func(*subagents.Agent, string, bool)
98	BeforeResumed   func(*subagents.Agent, string, bool) func()
99	OnStopRequested func(*subagents.Agent)
100}
101
102func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
103	if cfg.Context == nil {
104		cfg.Context = context.Background()
105	}
106	if cfg.TraceDir == "" {
107		cfg.TraceDir = strings.TrimSpace(os.Getenv("ZUT_SUBAGENT_TRACE_DIR"))
108	}
109	if cfg.TraceMode == "" {
110		switch strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_SUBAGENT_TRACE_MODE"))) {
111		case "", string(subagents.TraceModeNormal):
112			cfg.TraceMode = subagents.TraceModeNormal
113		case string(subagents.TraceModeDetailed):
114			cfg.TraceMode = subagents.TraceModeDetailed
115		default:
116			fmt.Fprintf(os.Stderr, "zut: ignoring invalid ZUT_SUBAGENT_TRACE_MODE\n")
117			cfg.TraceMode = subagents.TraceModeNormal
118		}
119	}
120	if cfg.TraceDir == "" {
121		cfg.TraceMode = subagents.TraceModeNormal
122	}
123	rt := &subagentRuntime{
124		args:            cfg.Args,
125		root:            cfg.Root,
126		provider:        strings.TrimSpace(cfg.Provider),
127		model:           strings.TrimSpace(cfg.Model),
128		reasoning:       strings.TrimSpace(cfg.Reasoning),
129		baseURL:         strings.TrimSpace(cfg.BaseURL),
130		insecureTLS:     cfg.InsecureTLS,
131		fastMode:        cfg.FastMode,
132		repoRoot:        cfg.RepoRoot,
133		activeProvider:  cfg.ActiveProvider,
134		activeModel:     cfg.ActiveModel,
135		activeReasoning: cfg.ActiveReasoning,
136		apiKey:          cfg.APIKey,
137		webSearchPolicy: cfg.WebSearchPolicy,
138		webSearchGuard:  cfg.WebSearchGuard,
139		onSpawned:       cfg.OnSpawned,
140		onResumed:       cfg.OnResumed,
141		beforeResumed:   cfg.BeforeResumed,
142		onStopRequested: cfg.OnStopRequested,
143	}
144	if rt.activeProvider == nil {
145		rt.activeProvider = func() string {
146			rt.settingsMu.RLock()
147			defer rt.settingsMu.RUnlock()
148			return rt.provider
149		}
150	}
151	if rt.activeModel == nil {
152		rt.activeModel = func() string {
153			rt.settingsMu.RLock()
154			defer rt.settingsMu.RUnlock()
155			return rt.model
156		}
157	}
158	if rt.activeReasoning == nil {
159		rt.activeReasoning = func() string {
160			rt.settingsMu.RLock()
161			defer rt.settingsMu.RUnlock()
162			return rt.reasoning
163		}
164	}
165	if rt.apiKey != "" {
166		// A launch-time API key belongs to the provider selected at startup. It
167		// must not follow the live provider callback through a later transition.
168		rt.apiKeyProvider = canonicalProvider(rt.provider)
169	}
170
171	// Packaged agents have an explicit capability ceiling. Until worker
172	// capability propagation exists, they cannot expose delegation tools or a
173	// fresh unrestricted supervisor.
174	if cfg.Args.PermissionSet == nil {
175		root := rt.root
176		if strings.TrimSpace(root) == "" {
177			root = filepath.Join(ZutHome(), "subagents")
178			rt.root = root
179		}
180		rt.supervisor = subagents.New(subagents.Config{
181			Context:           cfg.Context,
182			Root:              root,
183			RepoRoot:          cfg.RepoRoot,
184			TraceDir:          cfg.TraceDir,
185			TraceMode:         cfg.TraceMode,
186			Provider:          rt.currentProvider(),
187			FastMode:          cfg.FastMode,
188			WebSearchPolicy:   cfg.WebSearchPolicy,
189			BaseURL:           cfg.BaseURL,
190			InsecureTLS:       cfg.InsecureTLS,
191			NewRunner:         cfg.NewRunner,
192			Policy:            cfg.Policy,
193			ResolveCredential: rt.resolveCredential,
194		})
195	}
196	return rt
197}
198
199func (rt *subagentRuntime) Supervisor() *subagents.Supervisor {
200	if rt == nil {
201		return nil
202	}
203	return rt.supervisor
204}
205
206func (rt *subagentRuntime) currentProvider() string {
207	if rt == nil || rt.activeProvider == nil {
208		return ""
209	}
210	return strings.TrimSpace(rt.activeProvider())
211}
212
213func (rt *subagentRuntime) currentModel() string {
214	if rt == nil || rt.activeModel == nil {
215		return ""
216	}
217	return strings.TrimSpace(rt.activeModel())
218}
219
220func (rt *subagentRuntime) SetModel(model string) {
221	if rt == nil {
222		return
223	}
224	rt.settingsMu.Lock()
225	rt.model = strings.TrimSpace(model)
226	rt.settingsMu.Unlock()
227}
228
229func (rt *subagentRuntime) SetReasoning(reasoning string) {
230	if rt == nil {
231		return
232	}
233	rt.settingsMu.Lock()
234	rt.reasoning = strings.TrimSpace(reasoning)
235	rt.settingsMu.Unlock()
236}
237
238func (rt *subagentRuntime) currentReasoning() string {
239	if rt == nil || rt.activeReasoning == nil {
240		return ""
241	}
242	return strings.TrimSpace(rt.activeReasoning())
243}
244
245func (rt *subagentRuntime) SetProvider(providerID string) {
246	if rt == nil {
247		return
248	}
249	providerID = strings.TrimSpace(providerID)
250	rt.settingsMu.Lock()
251	rt.provider = providerID
252	rt.settingsMu.Unlock()
253	if rt.supervisor != nil {
254		rt.supervisor.SetProvider(providerID)
255	}
256}
257
258func (rt *subagentRuntime) SetProviderSettings(baseURL string, insecureTLS bool) {
259	if rt == nil {
260		return
261	}
262	baseURL = strings.TrimSpace(baseURL)
263	rt.settingsMu.Lock()
264	rt.baseURL = baseURL
265	rt.insecureTLS = insecureTLS
266	rt.settingsMu.Unlock()
267	if rt.supervisor != nil {
268		rt.supervisor.SetProviderSettings(baseURL, insecureTLS)
269	}
270}
271
272func (rt *subagentRuntime) SetFastMode(fastMode bool) {
273	if rt == nil {
274		return
275	}
276	rt.settingsMu.Lock()
277	rt.fastMode = fastMode
278	rt.settingsMu.Unlock()
279	if rt.supervisor != nil {
280		rt.supervisor.SetFastMode(fastMode)
281	}
282}
283
284func (rt *subagentRuntime) SetRepoRoot(repoRoot string) {
285	if rt == nil {
286		return
287	}
288	rt.settingsMu.Lock()
289	rt.repoRoot = repoRoot
290	rt.settingsMu.Unlock()
291	if rt.supervisor != nil {
292		rt.supervisor.SetRepoRoot(repoRoot)
293	}
294}
295
296// snapshotConfiguration captures the runtime settings changed while an agent
297// candidate is being built. Transition callers restore it when candidate
298// preparation fails before the new agent is committed.
299func (rt *subagentRuntime) snapshotConfiguration() subagentRuntimeConfiguration {
300	if rt == nil {
301		return subagentRuntimeConfiguration{}
302	}
303	rt.settingsMu.RLock()
304	defer rt.settingsMu.RUnlock()
305	return subagentRuntimeConfiguration{
306		provider:        rt.provider,
307		model:           rt.model,
308		baseURL:         rt.baseURL,
309		insecureTLS:     rt.insecureTLS,
310		fastMode:        rt.fastMode,
311		repoRoot:        rt.repoRoot,
312		webSearchPolicy: rt.webSearchPolicy,
313	}
314}
315
316func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfiguration) {
317	if rt == nil {
318		return
319	}
320	rt.settingsMu.Lock()
321	rt.provider = snapshot.provider
322	rt.model = snapshot.model
323	rt.baseURL = snapshot.baseURL
324	rt.insecureTLS = snapshot.insecureTLS
325	rt.fastMode = snapshot.fastMode
326	rt.repoRoot = snapshot.repoRoot
327	rt.webSearchPolicy = snapshot.webSearchPolicy
328	rt.settingsMu.Unlock()
329	if rt.supervisor != nil {
330		rt.supervisor.SetProvider(snapshot.provider)
331		rt.supervisor.SetProviderSettings(snapshot.baseURL, snapshot.insecureTLS)
332		rt.supervisor.SetFastMode(snapshot.fastMode)
333		rt.supervisor.SetRepoRoot(snapshot.repoRoot)
334		rt.supervisor.SetWebSearchPolicy(snapshot.webSearchPolicy)
335	}
336}
337
338func (rt *subagentRuntime) SetActiveSession(sessionID string) {
339	if rt != nil && rt.supervisor != nil {
340		rt.supervisor.SetActiveSession(sessionID)
341	}
342}
343
344func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy) {
345	if rt == nil {
346		return
347	}
348	rt.settingsMu.Lock()
349	rt.webSearchPolicy = policy
350	rt.settingsMu.Unlock()
351	if rt.supervisor != nil {
352		rt.supervisor.SetWebSearchPolicy(policy)
353	}
354}
355
356func (rt *subagentRuntime) resolveCredential(ctx context.Context, providerID string) (subagents.Credential, error) {
357	childProvider := canonicalProvider(providerID)
358	if childProvider == "" {
359		childProvider = canonicalProvider(rt.currentProvider())
360	}
361	explicit := ""
362	// A launch-time API key is scoped to the provider selected at startup.
363	// Never send it to an explicitly different child provider; resolve that
364	// provider's own credential instead.
365	if rt.apiKey != "" && childProvider == rt.apiKeyProvider {
366		explicit = rt.apiKey
367	}
368	if childProvider == "ollama" && explicit == "" {
369		return subagents.Credential{Value: "ollama", Method: "apikey"}, nil
370	}
371	credential, method, accountID, err := ResolveCredentialFullContext(ctx, childProvider, explicit)
372	if err != nil {
373		// Providers backed only by a local endpoint do not need a key. Leave
374		// stdin untouched and let the child resolve that endpoint.
375		if !CredentialAvailable(childProvider) {
376			return subagents.Credential{}, nil
377		}
378		return subagents.Credential{}, err
379	}
380	return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil
381}
382
383func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, error) {
384	if rt == nil {
385		return nil, nil
386	}
387	rt.settingsMu.RLock()
388	repoRoot := rt.repoRoot
389	rt.settingsMu.RUnlock()
390	return findSubagentProfile(repoRoot, name)
391}
392
393// InjectTools adds the canonical manager tools allowed by the host launch
394// policy. It deliberately mutates the supplied registry, matching the normal
395// interactive registry assembly path.
396func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry {
397	if reg == nil || rt == nil || rt.supervisor == nil || !autoSubagentsAnyToolAllowed(rt.args) {
398		return reg
399	}
400	if autoSubagentsToolAllowed(rt.args) {
401		spawn := &tools.SubagentSpawnTool{
402			Supervisor: rt.supervisor,
403			Enabled:    func() bool { return true },
404			DefaultModel: func() string {
405				return rt.currentModel()
406			},
407			DefaultProvider: func() string {
408				return rt.currentProvider()
409			},
410			DefaultReasoning: func() string {
411				return rt.currentReasoning()
412			},
413			ResolveSubagent: rt.resolveSubagent,
414			OnSpawned:       rt.onSpawned,
415		}
416		reg[spawn.Name()] = spawn
417	}
418	if autoSubagentsStatusToolAllowed(rt.args) {
419		status := &tools.SubagentStatusTool{
420			Supervisor: rt.supervisor,
421			Enabled:    func() bool { return true },
422		}
423		reg[status.Name()] = status
424	}
425	if autoSubagentsStopToolAllowed(rt.args) {
426		stop := &tools.SubagentStopTool{
427			Supervisor:      rt.supervisor,
428			Enabled:         func() bool { return true },
429			OnStopRequested: rt.onStopRequested,
430		}
431		reg[stop.Name()] = stop
432	}
433	if autoSubagentsResumeToolAllowed(rt.args) {
434		resume := &tools.SubagentResumeTool{
435			Supervisor:    rt.supervisor,
436			Enabled:       func() bool { return true },
437			BeforeResumed: rt.beforeResumed,
438			OnResumed:     rt.onResumed,
439		}
440		reg[resume.Name()] = resume
441	}
442	return reg
443}
444
445func (rt *subagentRuntime) PrepareRegistry(reg core.Registry) core.Registry {
446	reg = rt.InjectTools(reg)
447	if rt != nil && rt.webSearchGuard != nil {
448		return rt.webSearchGuard.wrapRegistry(reg)
449	}
450	return reg
451}
452
453func (rt *subagentRuntime) PrepareResolvedRegistry(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry {
454	// Keep the supervisor's capability policy in lockstep with the resolved
455	// parent registry. This is especially important after entry.pre refreshes
456	// permissions: workers launched after the refresh must not retain the old
457	// parent allow decision.
458	if rt != nil {
459		rt.SetWebSearchPolicy(webSearchPolicyForRegistry(policy, reg))
460	}
461	if policy != subagents.WebSearchAllow {
462		delete(reg, "web_search")
463	}
464	return rt.PrepareRegistry(reg)
465}
466
467func (rt *subagentRuntime) Close(ctx context.Context) error {
468	if rt == nil || rt.supervisor == nil {
469		return nil
470	}
471	rt.closeMu.Lock()
472	defer rt.closeMu.Unlock()
473	if rt.closed {
474		return nil
475	}
476	stopErr := rt.supervisor.StopAllContext(ctx)
477	closeErr := rt.supervisor.CloseContext(ctx)
478	if err := errors.Join(stopErr, closeErr); err != nil {
479		return err
480	}
481	rt.closed = true
482	return nil
483}
484
485func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
486	parseDuration := func(value string) time.Duration {
487		if strings.TrimSpace(value) == "" {
488			return 0
489		}
490		parsed, err := time.ParseDuration(strings.TrimSpace(value))
491		if err != nil || parsed <= 0 {
492			return 0
493		}
494		return parsed
495	}
496	return subagents.SubagentPolicy{
497		MaxConcurrent:          cfg.MaxConcurrent,
498		MaxConcurrentPerParent: cfg.MaxConcurrentPerParent,
499		QueueTimeout:           parseDuration(cfg.QueueTimeout),
500		StartupTimeout:         parseDuration(cfg.StartupTimeout),
501		DefaultTimeout:         parseDuration(cfg.DefaultTimeout),
502		MaxOutputBytes:         cfg.MaxOutputBytes,
503		MaxOutputLines:         cfg.MaxOutputLines,
504		AllowedTools:           append([]string(nil), cfg.AllowedTools...),
505		AllowedRoots:           append([]string(nil), cfg.AllowedRoots...),
506		HeartbeatInterval:      parseDuration(cfg.HeartbeatInterval),
507		IdleTimeout:            parseDuration(cfg.IdleTimeout),
508		ReconnectTimeout:       parseDuration(cfg.ReconnectTimeout),
509		CancelGracePeriod:      parseDuration(cfg.CancelGracePeriod),
510	}
511}

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 477

↑↓ moveenter openesc close