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}