1package agent
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "strings"
9 "sync"
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 "github.com/bnema/zut/packages/provider"
15)
16
17// newResidentChildRunner is the host-owned construction boundary for one
18// resident child. It resolves a fresh provider client and a fresh core.Agent
19// exactly once for the child's durable session; no subprocess configuration or
20// credential transfer is involved.
21func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
22 if strings.TrimSpace(spec.SessionID) == "" {
23 return nil, fmt.Errorf("resident child %q has no session identity", spec.ID)
24 }
25 resolved, err := Resolve(args, true)
26 if err != nil {
27 return nil, fmt.Errorf("resolve resident child %q: %w", spec.ID, err)
28 }
29
30 registry, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools)
31 if err != nil {
32 return nil, err
33 }
34 system := resolved.SystemPrompt
35 if extra := strings.TrimSpace(spec.SystemPrompt); extra != "" {
36 if spec.SystemPromptMode == "replace" {
37 system = extra
38 } else {
39 system = strings.TrimSpace(system) + "\n\n" + extra
40 }
41 }
42 budgetLimit := subagents.EffectiveBudgetLimit(spec.BudgetLimit, resolved.ContextWindow)
43 if guidance := subagents.BudgetSystemPrompt(budgetLimit); guidance != "" {
44 system = strings.TrimSpace(system) + "\n\n" + guidance
45 }
46 agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry)
47 agent.MaxSteps = resolved.MaxSteps
48 agent.ContextWindow = resolved.ContextWindow
49 agent.MaxTokens = resolved.MaxOutput
50 agent.Reasoning = resolved.Reasoning
51 agent.Temperature = resolved.Temperature
52 agent.FastMode = resolved.FastMode
53 var baseline subagents.ResidentUsageSnapshot
54 if journal != nil {
55 baseline = journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")
56 agent.SeedCost(baseline.Usage)
57 }
58 budget := subagents.NewRolloutBudget(budgetLimit, baseline.Usage)
59 configureResidentBudget(agent, budget)
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 return func(ctx context.Context, prompt string) error {
82 if ctx == nil {
83 ctx = context.Background()
84 }
85 turnCtx, cancel := context.WithCancel(ctx)
86 defer cancel()
87 startedExhausted := budget.Snapshot().State == subagents.BudgetExceeded
88 var journalErr error
89 var journalMu sync.Mutex
90 err := agent.Prompt(turnCtx, prompt, nil, func(event core.AgentEvent) {
91 if usage, ok := event.(core.EvUsage); ok {
92 budget.Observe(usage.Cumulative)
93 }
94 if err := journal.RecordAgentEvent(event); err != nil {
95 journalMu.Lock()
96 if journalErr == nil {
97 journalErr = err
98 cancel()
99 }
100 journalMu.Unlock()
101 }
102 })
103 journalMu.Lock()
104 defer journalMu.Unlock()
105 if journalErr != nil {
106 return fmt.Errorf("persist resident child transcript: %w", journalErr)
107 }
108 if budget.Snapshot().State == subagents.BudgetExceeded {
109 if !startedExhausted && err == nil {
110 return nil
111 }
112 return errors.Join(err, subagents.ErrBudgetExceeded)
113 }
114 return err
115 }, nil
116}
117
118func configureResidentBudget(agent *core.Agent, budget *subagents.RolloutBudget) {
119 if agent == nil || budget == nil {
120 return
121 }
122 agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) {
123 instruction, exceeded := budget.TurnContext()
124 if exceeded {
125 return false, subagents.ErrBudgetExceeded.Error(), ""
126 }
127 snapshot := budget.Snapshot()
128 if snapshot.Limit > 0 {
129 remaining := residentBudgetOutputLimit(snapshot.Limit - snapshot.Used)
130 if agent.MaxTokens <= 0 || agent.MaxTokens > remaining {
131 agent.MaxTokens = remaining
132 }
133 }
134 if snapshot.State == subagents.BudgetFinalizing {
135 agent.SetTools(core.Registry{})
136 }
137 return true, "", instruction
138 }
139 agent.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) {
140 state := budget.Snapshot().State
141 if state == subagents.BudgetFinalizing || state == subagents.BudgetExceeded {
142 return false, "subagent rollout budget is reserved for the final response; tools are disabled", nil
143 }
144 return true, "", nil
145 }
146}
147
148func residentBudgetOutputLimit(remaining int64) int {
149 if remaining <= 1 {
150 return 1
151 }
152 maxInt := int64(^uint(0) >> 1)
153 if remaining > maxInt {
154 return int(maxInt)
155 }
156 return int(remaining)
157}
158
159// residentChildArgs applies the complete durable child specification without
160// rediscovering a mutable profile file. That preserves the profile inheritance
161// decision accepted at spawn time across explicit resume and restart.
162func residentChildArgs(args Args, parentProvider string, spec subagents.ResidentChildSpec) Args {
163 next := args
164 // Primary delegation policy belongs to the parent, not its workers. A child
165 // receives its accepted profile instructions and exact child tool list.
166 next.Orchestrate = false
167 next.ResidentChild = true
168 // An explicit CLI key belongs to the provider that resolved the parent.
169 // A child can select another provider, but it must resolve that provider's
170 // own credential rather than forwarding or persisting the parent's key.
171 if canonicalProvider(parentProvider) != canonicalProvider(spec.Provider) {
172 next.APIKey = ""
173 }
174 next.Provider = spec.Provider
175 next.BaseURL = spec.BaseURL
176 next.InsecureTLS = spec.InsecureTLS
177 next.Model = spec.Model
178 next.Reasoning = spec.Reasoning
179 next.FastMode = spec.FastMode
180 next.FastModeSet = true
181 if strings.TrimSpace(spec.Workspace) != "" {
182 next.CWD = spec.Workspace
183 }
184 if spec.InheritSkills != nil && !*spec.InheritSkills {
185 next.NoSkill = true
186 }
187 if spec.InheritProjectContext != nil && !*spec.InheritProjectContext {
188 next.NoContextFiles = true
189 }
190 return next
191}
192
193// residentChildRegistry applies an already validated, exact child tool list.
194// It always strips delegation and host-goal mutation tools: child agents are
195// independent workers, not nested orchestrators or goal owners.
196func residentChildRegistry(catalogue core.Registry, names []string) (core.Registry, error) {
197 registry := make(core.Registry, len(names))
198 for _, name := range names {
199 name = strings.TrimSpace(name)
200 if name == "" {
201 return nil, fmt.Errorf("resident child has an empty tool name")
202 }
203 if name == tools.SubagentSpawnToolName || name == tools.SubagentStatusToolName || name == tools.SubagentStopToolName || name == tools.SubagentResumeToolName || name == "update_goal" {
204 return nil, fmt.Errorf("resident child tool %q is not allowed", name)
205 }
206 tool, ok := catalogue[name]
207 if !ok {
208 return nil, fmt.Errorf("resident child declares unavailable tool %q", name)
209 }
210 registry[name] = tool
211 }
212 return registry, nil
213}