v0.33.0 653c635

subagent_factory.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"strings"
8	"sync"
9	"sync/atomic"
10
11	"github.com/bnema/zut/packages/agent/subagents"
12	"github.com/bnema/zut/packages/agent/tools"
13	"github.com/bnema/zut/packages/core"
14)
15
16// newResidentChildRunner is the host-owned construction boundary for one
17// resident child. It resolves a fresh provider client and a fresh core.Agent
18// exactly once for the child's durable session; no subprocess configuration or
19// credential transfer is involved.
20func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
21	if strings.TrimSpace(spec.SessionID) == "" {
22		return nil, fmt.Errorf("resident child %q has no session identity", spec.ID)
23	}
24	resolved, err := Resolve(args, true)
25	if err != nil {
26		return nil, fmt.Errorf("resolve resident child %q: %w", spec.ID, err)
27	}
28
29	registry, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools)
30	if err != nil {
31		return nil, err
32	}
33	system := resolved.SystemPrompt
34	if extra := strings.TrimSpace(spec.SystemPrompt); extra != "" {
35		if spec.SystemPromptMode == "replace" {
36			system = extra
37		} else {
38			system = strings.TrimSpace(system) + "\n\n" + extra
39		}
40	}
41	if err := subagents.ValidateResidentBudget(spec); err != nil {
42		return nil, fmt.Errorf("resident child %q: %w", spec.ID, err)
43	}
44	budgetLimit := spec.BudgetLimit
45	agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry)
46	agent.MaxSteps = resolved.MaxSteps
47	agent.ContextWindow = resolved.ContextWindow
48	agent.MaxTokens = resolved.MaxOutput
49	agent.Reasoning = resolved.Reasoning
50	agent.Temperature = resolved.Temperature
51	agent.FastMode = resolved.FastMode
52	var baseline subagents.ResidentUsageSnapshot
53	if journal != nil {
54		baseline = journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")
55		agent.SeedCost(baseline.Usage)
56	}
57	budget := subagents.NewRolloutBudget(budgetLimit, baseline.Usage)
58	configureResidentBudget(agent, budget)
59	rootCacheID := strings.TrimSpace(spec.RootCacheID)
60	if rootCacheID == "" {
61		// Journals written before root cache identity was persisted can still
62		// resume safely: their immediate parent was the only available root.
63		rootCacheID = strings.TrimSpace(spec.ParentSessionID)
64		if rootCacheID == "" {
65			rootCacheID = spec.SessionID
66		}
67	}
68	if err := agent.BindRequestIdentity(rootCacheID, spec.SessionID); err != nil {
69		return nil, fmt.Errorf("bind resident child %q request identity: %w", spec.ID, err)
70	}
71	if journal != nil {
72		messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
73		if err != nil {
74			return nil, fmt.Errorf("restore resident child %q transcript: %w", spec.ID, err)
75		}
76		if len(messages) > 0 {
77			agent.SetMessages(messages)
78		}
79	}
80	return func(ctx context.Context, prompt string) error {
81		if ctx == nil {
82			ctx = context.Background()
83		}
84		turnCtx, cancel := context.WithCancel(ctx)
85		defer cancel()
86		budget.SetBaseline(journal.BudgetBaseline())
87		startedExhausted := budget.Snapshot().State == subagents.BudgetExceeded
88		var budgetStopped atomic.Bool
89		var journalErr error
90		var journalMu sync.Mutex
91		err := agent.Prompt(turnCtx, prompt, nil, func(event core.AgentEvent) {
92			if end, ok := event.(core.EvTurnEnd); ok && end.Err != nil && budget.Snapshot().State == subagents.BudgetExceeded {
93				budgetStopped.Store(true)
94			}
95			if usage, ok := event.(core.EvUsage); ok {
96				budget.Observe(usage.Cumulative)
97			}
98			if err := journal.RecordAgentEvent(event); err != nil {
99				journalMu.Lock()
100				if journalErr == nil {
101					journalErr = err
102					cancel()
103				}
104				journalMu.Unlock()
105			}
106		})
107		journalMu.Lock()
108		defer journalMu.Unlock()
109		if journalErr != nil {
110			return fmt.Errorf("persist resident child transcript: %w", journalErr)
111		}
112		if budget.Snapshot().State == subagents.BudgetExceeded {
113			if !startedExhausted && !budgetStopped.Load() && err == nil {
114				return nil
115			}
116			return errors.Join(err, subagents.ErrBudgetExceeded)
117		}
118		return err
119	}, nil
120}
121
122func configureResidentBudget(agent *core.Agent, budget *subagents.RolloutBudget) {
123	if agent == nil || budget == nil {
124		return
125	}
126	agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) {
127		if budget.Snapshot().State == subagents.BudgetExceeded {
128			return false, subagents.ErrBudgetExceeded.Error(), ""
129		}
130		return true, "", ""
131	}
132}
133
134// residentChildArgs applies the complete durable child specification without
135// rediscovering a mutable profile file. That preserves the profile inheritance
136// decision accepted at spawn time across explicit resume and restart.
137func residentChildArgs(args Args, parentProvider string, spec subagents.ResidentChildSpec) Args {
138	next := args
139	// Primary delegation policy belongs to the parent, not its workers. A child
140	// receives its accepted profile instructions and exact child tool list.
141	next.Orchestrate = false
142	next.ResidentChild = true
143	// An explicit CLI key belongs to the provider that resolved the parent.
144	// A child can select another provider, but it must resolve that provider's
145	// own credential rather than forwarding or persisting the parent's key.
146	if canonicalProvider(parentProvider) != canonicalProvider(spec.Provider) {
147		next.APIKey = ""
148	}
149	next.Provider = spec.Provider
150	next.BaseURL = spec.BaseURL
151	next.InsecureTLS = spec.InsecureTLS
152	next.Model = spec.Model
153	next.Reasoning = spec.Reasoning
154	next.FastMode = spec.FastMode
155	next.FastModeSet = true
156	if strings.TrimSpace(spec.Workspace) != "" {
157		next.CWD = spec.Workspace
158	}
159	if spec.InheritSkills != nil && !*spec.InheritSkills {
160		next.NoSkill = true
161	}
162	if spec.InheritProjectContext != nil && !*spec.InheritProjectContext {
163		next.NoContextFiles = true
164	}
165	return next
166}
167
168// residentChildRegistry applies an already validated, exact child tool list.
169// It always strips delegation and host-goal mutation tools: child agents are
170// independent workers, not nested orchestrators or goal owners.
171func residentChildRegistry(catalogue core.Registry, names []string) (core.Registry, error) {
172	registry := make(core.Registry, len(names))
173	for _, name := range names {
174		name = strings.TrimSpace(name)
175		if name == "" {
176			return nil, fmt.Errorf("resident child has an empty tool name")
177		}
178		if name == tools.SubagentSpawnToolName || name == tools.SubagentStatusToolName || name == tools.SubagentStopToolName || name == tools.SubagentResumeToolName || name == "update_goal" {
179			return nil, fmt.Errorf("resident child tool %q is not allowed", name)
180		}
181		tool, ok := catalogue[name]
182		if !ok {
183			return nil, fmt.Errorf("resident child declares unavailable tool %q", name)
184		}
185		registry[name] = tool
186	}
187	return registry, nil
188}

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