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)
14
15// newResidentChildRunner is the host-owned construction boundary for one
16// resident child. It resolves a fresh provider client and a fresh core.Agent
17// exactly once for the child's durable session; no subprocess configuration or
18// credential transfer is involved.
19func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
20 if strings.TrimSpace(spec.SessionID) == "" {
21 return nil, fmt.Errorf("resident child %q has no session identity", spec.ID)
22 }
23 resolved, err := Resolve(args, true)
24 if err != nil {
25 return nil, fmt.Errorf("resolve resident child %q: %w", spec.ID, err)
26 }
27
28 registry, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools)
29 if err != nil {
30 return nil, err
31 }
32 system := resolved.SystemPrompt
33 if extra := strings.TrimSpace(spec.SystemPrompt); extra != "" {
34 if spec.SystemPromptMode == "replace" {
35 system = extra
36 } else {
37 system = strings.TrimSpace(system) + "\n\n" + extra
38 }
39 }
40 if err := subagents.ValidateResidentBudget(spec); err != nil {
41 return nil, fmt.Errorf("resident child %q: %w", spec.ID, err)
42 }
43 budgetLimit := spec.BudgetLimit
44 agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry)
45 agent.MaxSteps = resolved.MaxSteps
46 agent.ContextWindow = resolved.ContextWindow
47 agent.MaxTokens = resolved.MaxOutput
48 agent.Reasoning = resolved.Reasoning
49 agent.Temperature = resolved.Temperature
50 agent.FastMode = resolved.FastMode
51 var baseline subagents.ResidentUsageSnapshot
52 if journal != nil {
53 baseline = journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")
54 agent.SeedCost(baseline.Usage)
55 }
56 budget := subagents.NewRolloutBudget(budgetLimit, baseline.Usage)
57 configureResidentBudget(agent, budget)
58 rootCacheID := strings.TrimSpace(spec.RootCacheID)
59 if rootCacheID == "" {
60 // Journals written before root cache identity was persisted can still
61 // resume safely: their immediate parent was the only available root.
62 rootCacheID = strings.TrimSpace(spec.ParentSessionID)
63 if rootCacheID == "" {
64 rootCacheID = spec.SessionID
65 }
66 }
67 if err := agent.BindRequestIdentity(rootCacheID, spec.SessionID); err != nil {
68 return nil, fmt.Errorf("bind resident child %q request identity: %w", spec.ID, err)
69 }
70 if journal != nil {
71 messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
72 if err != nil {
73 return nil, fmt.Errorf("restore resident child %q transcript: %w", spec.ID, err)
74 }
75 if len(messages) > 0 {
76 agent.SetMessages(messages)
77 }
78 }
79 return func(ctx context.Context, prompt string) error {
80 if ctx == nil {
81 ctx = context.Background()
82 }
83 turnCtx, cancel := context.WithCancel(ctx)
84 defer cancel()
85 startedExhausted := budget.Snapshot().State == subagents.BudgetExceeded
86 var journalErr error
87 var journalMu sync.Mutex
88 err := agent.Prompt(turnCtx, prompt, nil, func(event core.AgentEvent) {
89 if usage, ok := event.(core.EvUsage); ok {
90 budget.Observe(usage.Cumulative)
91 }
92 if err := journal.RecordAgentEvent(event); err != nil {
93 journalMu.Lock()
94 if journalErr == nil {
95 journalErr = err
96 cancel()
97 }
98 journalMu.Unlock()
99 }
100 })
101 journalMu.Lock()
102 defer journalMu.Unlock()
103 if journalErr != nil {
104 return fmt.Errorf("persist resident child transcript: %w", journalErr)
105 }
106 if budget.Snapshot().State == subagents.BudgetExceeded {
107 if !startedExhausted && err == nil {
108 return nil
109 }
110 return errors.Join(err, subagents.ErrBudgetExceeded)
111 }
112 return err
113 }, nil
114}
115
116func configureResidentBudget(agent *core.Agent, budget *subagents.RolloutBudget) {
117 if agent == nil || budget == nil {
118 return
119 }
120 agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) {
121 if budget.Snapshot().State == subagents.BudgetExceeded {
122 return false, subagents.ErrBudgetExceeded.Error(), ""
123 }
124 return true, "", ""
125 }
126}
127
128// residentChildArgs applies the complete durable child specification without
129// rediscovering a mutable profile file. That preserves the profile inheritance
130// decision accepted at spawn time across explicit resume and restart.
131func residentChildArgs(args Args, parentProvider string, spec subagents.ResidentChildSpec) Args {
132 next := args
133 // Primary delegation policy belongs to the parent, not its workers. A child
134 // receives its accepted profile instructions and exact child tool list.
135 next.Orchestrate = false
136 next.ResidentChild = true
137 // An explicit CLI key belongs to the provider that resolved the parent.
138 // A child can select another provider, but it must resolve that provider's
139 // own credential rather than forwarding or persisting the parent's key.
140 if canonicalProvider(parentProvider) != canonicalProvider(spec.Provider) {
141 next.APIKey = ""
142 }
143 next.Provider = spec.Provider
144 next.BaseURL = spec.BaseURL
145 next.InsecureTLS = spec.InsecureTLS
146 next.Model = spec.Model
147 next.Reasoning = spec.Reasoning
148 next.FastMode = spec.FastMode
149 next.FastModeSet = true
150 if strings.TrimSpace(spec.Workspace) != "" {
151 next.CWD = spec.Workspace
152 }
153 if spec.InheritSkills != nil && !*spec.InheritSkills {
154 next.NoSkill = true
155 }
156 if spec.InheritProjectContext != nil && !*spec.InheritProjectContext {
157 next.NoContextFiles = true
158 }
159 return next
160}
161
162// residentChildRegistry applies an already validated, exact child tool list.
163// It always strips delegation and host-goal mutation tools: child agents are
164// independent workers, not nested orchestrators or goal owners.
165func residentChildRegistry(catalogue core.Registry, names []string) (core.Registry, error) {
166 registry := make(core.Registry, len(names))
167 for _, name := range names {
168 name = strings.TrimSpace(name)
169 if name == "" {
170 return nil, fmt.Errorf("resident child has an empty tool name")
171 }
172 if name == tools.SubagentSpawnToolName || name == tools.SubagentStatusToolName || name == tools.SubagentStopToolName || name == tools.SubagentResumeToolName || name == "update_goal" {
173 return nil, fmt.Errorf("resident child tool %q is not allowed", name)
174 }
175 tool, ok := catalogue[name]
176 if !ok {
177 return nil, fmt.Errorf("resident child declares unavailable tool %q", name)
178 }
179 registry[name] = tool
180 }
181 return registry, nil
182}