v0.43.0 63d78c8

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

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