1package agent
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "sync"
8
9 "github.com/bnema/zut/packages/agent/subagents"
10 "github.com/bnema/zut/packages/agent/tools"
11 "github.com/bnema/zut/packages/core"
12)
13
14// newResidentChildRunner is the host-owned construction boundary for one
15// resident child. It resolves a fresh provider client and a fresh core.Agent
16// exactly once for the child's durable session; no subprocess configuration or
17// credential transfer is involved.
18func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
19 if strings.TrimSpace(spec.SessionID) == "" {
20 return nil, fmt.Errorf("resident child %q has no session identity", spec.ID)
21 }
22 resolved, err := Resolve(args, true)
23 if err != nil {
24 return nil, fmt.Errorf("resolve resident child %q: %w", spec.ID, err)
25 }
26
27 registry, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools)
28 if err != nil {
29 return nil, err
30 }
31 system := resolved.SystemPrompt
32 if extra := strings.TrimSpace(spec.SystemPrompt); extra != "" {
33 if spec.SystemPromptMode == "replace" {
34 system = extra
35 } else {
36 system = strings.TrimSpace(system) + "\n\n" + extra
37 }
38 }
39 agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry)
40 agent.MaxSteps = resolved.MaxSteps
41 if agent.MaxSteps <= 0 {
42 // Resident execution must stay bounded once the cumulative rollout
43 // budget is gone. An unlimited parent default must not propagate to
44 // children; explicit parent limits are preserved above.
45 agent.MaxSteps = 50
46 }
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 // The nudge reads the agent's own per-turn usage gauge. No cumulative
58 // budget remains: execution stays bounded through the MaxSteps default.
59 configureResidentContextNudge(agent, resolved.ContextWindow)
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 var journalErr error
88 var journalMu sync.Mutex
89 err := agent.Prompt(turnCtx, prompt, nil, func(event core.AgentEvent) {
90 if err := journal.RecordAgentEvent(event); err != nil {
91 journalMu.Lock()
92 if journalErr == nil {
93 journalErr = err
94 cancel()
95 }
96 journalMu.Unlock()
97 }
98 })
99 journalMu.Lock()
100 defer journalMu.Unlock()
101 if journalErr != nil {
102 return fmt.Errorf("persist resident child transcript: %w", journalErr)
103 }
104 return err
105 }, nil
106}
107
108// residentContextNudgePercent is the single context-usage threshold for the
109// resident reminder. It is a completion aid, not a cost guard: execution
110// stays bounded through the finite MaxSteps default.
111const residentContextNudgePercent = 85
112
113func configureResidentContextNudge(agent *core.Agent, contextMax int) {
114 if agent == nil {
115 return
116 }
117 // notified is owned by the runner closure and never reset from Resume
118 // or journal paths. The injected developer-context message persists via
119 // appendDynamicContext and is not replayed verbatim from the child
120 // journal after restart; re-nudging after restart is acceptable.
121 var notified bool
122 agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) {
123 if notified || contextMax <= 0 {
124 return true, "", ""
125 }
126 usage := agent.LastTurnUsage()
127 used := usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens
128 if used*100 < contextMax*residentContextNudgePercent {
129 return true, "", ""
130 }
131 notified = true
132 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)
133 }
134}
135
136// residentChildArgs applies the complete durable child specification without
137// rediscovering a mutable profile file. That preserves the profile inheritance
138// decision accepted at spawn time across explicit resume and restart.
139func residentChildArgs(args Args, parentProvider string, spec subagents.ResidentChildSpec) Args {
140 next := args
141 // Primary delegation policy belongs to the parent, not its workers. A child
142 // receives its accepted profile instructions and exact child tool list.
143 next.Orchestrate = false
144 next.ResidentChild = true
145 // An explicit CLI key belongs to the provider that resolved the parent.
146 // A child can select another provider, but it must resolve that provider's
147 // own credential rather than forwarding or persisting the parent's key.
148 if canonicalProvider(parentProvider) != canonicalProvider(spec.Provider) {
149 next.APIKey = ""
150 }
151 next.Provider = spec.Provider
152 next.BaseURL = spec.BaseURL
153 next.InsecureTLS = spec.InsecureTLS
154 next.Model = spec.Model
155 next.Reasoning = spec.Reasoning
156 next.FastMode = spec.FastMode
157 next.FastModeSet = true
158 if strings.TrimSpace(spec.Workspace) != "" {
159 next.CWD = spec.Workspace
160 }
161 if spec.InheritSkills != nil && !*spec.InheritSkills {
162 next.NoSkill = true
163 }
164 if spec.InheritProjectContext != nil && !*spec.InheritProjectContext {
165 next.NoContextFiles = true
166 }
167 return next
168}
169
170// residentChildRegistry applies an already validated, exact child tool list.
171// It always strips delegation and host-goal mutation tools: child agents are
172// independent workers, not nested orchestrators or goal owners.
173func residentChildRegistry(catalogue core.Registry, names []string) (core.Registry, error) {
174 registry := make(core.Registry, len(names))
175 for _, name := range names {
176 name = strings.TrimSpace(name)
177 if name == "" {
178 return nil, fmt.Errorf("resident child has an empty tool name")
179 }
180 if name == tools.SubagentSpawnToolName || name == tools.SubagentStatusToolName || name == tools.SubagentStopToolName || name == tools.SubagentResumeToolName || name == "update_goal" {
181 return nil, fmt.Errorf("resident child tool %q is not allowed", name)
182 }
183 tool, ok := catalogue[name]
184 if !ok {
185 return nil, fmt.Errorf("resident child declares unavailable tool %q", name)
186 }
187 registry[name] = tool
188 }
189 return registry, nil
190}