v0.15.0 6b178f8

subagent_runtime.go RawHistoryFolder

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

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