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}