v0.38.1 969e52a

subagent_factory.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"strings"
8	"sync"
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	"github.com/bnema/zut/packages/provider"
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	agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry)
42	// A resident child inherits the parent's resolved step limit, including the
43	// unlimited default. Its ceiling is the provider context window, with one
44	// compaction recovery per accepted turn, plus any explicit parent
45	// --max-steps. There is no child-only step budget.
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	// The nudge reads the agent's own per-turn usage gauge. It stays advisory:
58	// the provider context window is the hard ceiling on a child turn.
59	configureResidentContextNudge(agent, resolved.ContextWindow)
60	rootCacheID := strings.TrimSpace(spec.RootCacheID)
61	if rootCacheID == "" {
62		// Journals written before root cache identity was persisted can still
63		// resume safely: their immediate parent was the only available root.
64		rootCacheID = strings.TrimSpace(spec.ParentSessionID)
65		if rootCacheID == "" {
66			rootCacheID = spec.SessionID
67		}
68	}
69	if err := agent.BindRequestIdentity(rootCacheID, spec.SessionID); err != nil {
70		return nil, fmt.Errorf("bind resident child %q request identity: %w", spec.ID, err)
71	}
72	if journal != nil {
73		messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
74		if err != nil {
75			return nil, fmt.Errorf("restore resident child %q transcript: %w", spec.ID, err)
76		}
77		if len(messages) > 0 {
78			agent.SetMessages(messages)
79		}
80	}
81	limit := agent.MaxSteps
82	return func(ctx context.Context, prompt string) error {
83		if ctx == nil {
84			ctx = context.Background()
85		}
86		turnCtx, cancel := context.WithCancel(ctx)
87		defer cancel()
88		var journalErr error
89		var journalMu sync.Mutex
90		checkJournal := func() error {
91			journalMu.Lock()
92			defer journalMu.Unlock()
93			if journalErr == nil {
94				return nil
95			}
96			return fmt.Errorf("persist resident child transcript: %w", journalErr)
97		}
98		// usedSteps is the step high-water mark of this accepted turn. The sink
99		// runs on the runner goroutine, so it needs no lock; the mark bounds the
100		// continuation after a compaction recovery so an explicit parent limit
101		// is never replenished.
102		usedSteps := 0
103		sink := func(event core.AgentEvent) {
104			if turnStart, ok := event.(core.EvTurnStart); ok && turnStart.Step > usedSteps {
105				usedSteps = turnStart.Step
106			}
107			if err := journal.RecordAgentEvent(event); err != nil {
108				journalMu.Lock()
109				if journalErr == nil {
110					journalErr = err
111					cancel()
112				}
113				journalMu.Unlock()
114			}
115		}
116		err := agent.Prompt(turnCtx, prompt, nil, sink)
117		if journalFailure := checkJournal(); journalFailure != nil {
118			return journalFailure
119		}
120		if err != nil && turnCtx.Err() == nil && provider.IsContextOverflowError(err) {
121			err = recoverResidentContextOverflow(turnCtx, agent, journal, sink, limit, usedSteps, err)
122			if journalFailure := checkJournal(); journalFailure != nil {
123				return journalFailure
124			}
125		}
126		return err
127	}, nil
128}
129
130// A compaction recovery never rewrites a transcript with nothing worth
131// summarizing: the child task and the current exchange must survive verbatim.
132const (
133	residentContextRecoveryMinimumMessages = 4
134	residentContextRecoveryKeepTail        = 2
135)
136
137// recoverResidentContextOverflow gives one accepted resident turn a second
138// chance after the provider rejected it for exceeding the model context
139// window. It compacts the transcript, journals the replacement transcript, and
140// continues the same turn. Recovery happens at most once per call, and any
141// compaction or persistence failure restores the exact pre-compaction
142// transcript before returning the original error.
143func recoverResidentContextOverflow(ctx context.Context, agent *core.Agent, journal *subagents.ResidentJournal, sink func(core.AgentEvent), limit, usedSteps int, overflowErr error) error {
144	if agent == nil || journal == nil {
145		return overflowErr
146	}
147	before := agent.Messages()
148	if len(before) < residentContextRecoveryMinimumMessages {
149		// Summarizing a transcript this short would replace the instruction
150		// rather than shrink prior work.
151		return overflowErr
152	}
153	if _, err := agent.CompactWithEvents(ctx, residentContextRecoveryKeepTail, sink); err != nil {
154		agent.SetMessages(before)
155		return errors.Join(overflowErr, fmt.Errorf("compact resident child transcript: %w", err))
156	}
157	if err := journal.RecordCompacted(residentCheckpointMessages(agent.Messages())); err != nil {
158		// Memory must not run ahead of durable history: a later resume would
159		// otherwise replay the pre-compaction transcript.
160		agent.SetMessages(before)
161		return errors.Join(overflowErr, fmt.Errorf("persist resident child compaction: %w", err))
162	}
163	// The summarization request's usage describes the pre-compaction
164	// transcript. It must not arm the next reminder, which would report a
165	// false percentage for the compacted transcript the continuation sends.
166	agent.SeedLastTurnUsage(provider.Usage{})
167	if limit > 0 {
168		remaining := limit - usedSteps
169		if remaining <= 0 {
170			// An explicit parent limit is already spent. The checkpoint stays
171			// durable so an explicit resume starts from the compacted
172			// transcript, but this turn is not continued.
173			return overflowErr
174		}
175		agent.MaxSteps = remaining
176		defer func() { agent.MaxSteps = limit }()
177	}
178	return agent.Continue(ctx, sink)
179}
180
181// residentCheckpointMessages builds the durable replacement transcript of a
182// compaction. Host-authored internal context is dropped: it belongs to the
183// live request, and a replayed reminder would survive as a stale instruction
184// that the resumed child would still obey.
185func residentCheckpointMessages(messages []provider.Message) []provider.Message {
186	checkpoint := make([]provider.Message, 0, len(messages))
187	for _, message := range messages {
188		if core.IsInternalContextMessage(message) {
189			continue
190		}
191		checkpoint = append(checkpoint, message)
192	}
193	return checkpoint
194}
195
196// residentContextReminderBands are the context-usage percentages that arm the
197// resident reminder. Each band fires at most once per child runner, so a child
198// whose prompt keeps growing toward the window is told to wrap up again
199// without turning the reminder into a budget.
200var residentContextReminderBands = [...]int{85, 90, 95}
201
202func configureResidentContextNudge(agent *core.Agent, contextMax int) {
203	if agent == nil {
204		return
205	}
206	// deliveredBand is owned by the runner closure and never reset from
207	// Resume or journal paths. The injected developer-context message persists
208	// via appendDynamicContext and is not replayed verbatim from the child
209	// journal after restart; restarting a child rests the ladder.
210	deliveredBand := -1
211	agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) {
212		if contextMax <= 0 {
213			return true, "", ""
214		}
215		usage := agent.LastTurnUsage()
216		used := usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens
217		band := residentContextReminderBand(used, contextMax)
218		if band <= deliveredBand {
219			return true, "", ""
220		}
221		deliveredBand = band
222		return true, "", fmt.Sprintf("Context usage is at %d%% of the model window. Finish the current task, report results, limits, and remaining verifications, and do not start broad new work.", used*100/contextMax)
223	}
224}
225
226// residentContextReminderBand returns the highest armed band crossed by the
227// current prompt size, or -1 while usage stays below the first band. A direct
228// jump to a higher band delivers one reminder for that band.
229func residentContextReminderBand(used, contextMax int) int {
230	if contextMax <= 0 {
231		return -1
232	}
233	band := -1
234	for _, candidate := range residentContextReminderBands {
235		if used*100 >= contextMax*candidate {
236			band = candidate
237		}
238	}
239	return band
240}
241
242// residentChildArgs applies the complete durable child specification without
243// rediscovering a mutable profile file. That preserves the profile inheritance
244// decision accepted at spawn time across explicit resume and restart.
245func residentChildArgs(args Args, parentProvider string, spec subagents.ResidentChildSpec) Args {
246	next := args
247	// Primary delegation policy belongs to the parent, not its workers. A child
248	// receives its accepted profile instructions and exact child tool list.
249	next.Orchestrate = false
250	next.ResidentChild = true
251	// An explicit CLI key belongs to the provider that resolved the parent.
252	// A child can select another provider, but it must resolve that provider's
253	// own credential rather than forwarding or persisting the parent's key.
254	if canonicalProvider(parentProvider) != canonicalProvider(spec.Provider) {
255		next.APIKey = ""
256	}
257	next.Provider = spec.Provider
258	next.BaseURL = spec.BaseURL
259	next.InsecureTLS = spec.InsecureTLS
260	next.Model = spec.Model
261	next.Reasoning = spec.Reasoning
262	next.FastMode = spec.FastMode
263	next.FastModeSet = true
264	if strings.TrimSpace(spec.Workspace) != "" {
265		next.CWD = spec.Workspace
266	}
267	if spec.InheritSkills != nil && !*spec.InheritSkills {
268		next.NoSkill = true
269	}
270	if spec.InheritProjectContext != nil && !*spec.InheritProjectContext {
271		next.NoContextFiles = true
272	}
273	return next
274}
275
276// residentChildRegistry applies an already validated, exact child tool list.
277// It always strips delegation and host-goal mutation tools: child agents are
278// independent workers, not nested orchestrators or goal owners.
279func residentChildRegistry(catalogue core.Registry, names []string) (core.Registry, error) {
280	registry := make(core.Registry, len(names))
281	for _, name := range names {
282		name = strings.TrimSpace(name)
283		if name == "" {
284			return nil, fmt.Errorf("resident child has an empty tool name")
285		}
286		if name == tools.SubagentSpawnToolName || name == tools.SubagentStatusToolName || name == tools.SubagentStopToolName || name == tools.SubagentResumeToolName || name == "update_goal" {
287			return nil, fmt.Errorf("resident child tool %q is not allowed", name)
288		}
289		tool, ok := catalogue[name]
290		if !ok {
291			return nil, fmt.Errorf("resident child declares unavailable tool %q", name)
292		}
293		registry[name] = tool
294	}
295	return registry, nil
296}

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