v0.14.0 75d4151

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

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