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 agent.ContextWindow = resolved.ContextWindow
42 agent.MaxTokens = resolved.MaxOutput
43 agent.Reasoning = resolved.Reasoning
44 agent.Temperature = resolved.Temperature
45 agent.FastMode = resolved.FastMode
46 if journal != nil {
47 baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")
48 agent.SeedCost(baseline.Usage)
49 }
50 rootCacheID := strings.TrimSpace(spec.RootCacheID)
51 if rootCacheID == "" {
52 // Journals written before root cache identity was persisted can still
53 // resume safely: their immediate parent was the only available root.
54 rootCacheID = strings.TrimSpace(spec.ParentSessionID)
55 if rootCacheID == "" {
56 rootCacheID = spec.SessionID
57 }
58 }
59 if err := agent.BindRequestIdentity(rootCacheID, spec.SessionID); err != nil {
60 return nil, fmt.Errorf("bind resident child %q request identity: %w", spec.ID, err)
61 }
62 if journal != nil {
63 messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
64 if err != nil {
65 return nil, fmt.Errorf("restore resident child %q transcript: %w", spec.ID, err)
66 }
67 if len(messages) > 0 {
68 agent.SetMessages(messages)
69 }
70 }
71 return func(ctx context.Context, prompt string) error {
72 if ctx == nil {
73 ctx = context.Background()
74 }
75 turnCtx, cancel := context.WithCancel(ctx)
76 defer cancel()
77 var journalErr error
78 var journalMu sync.Mutex
79 err := agent.Prompt(turnCtx, prompt, nil, func(event core.AgentEvent) {
80 if err := journal.RecordAgentEvent(event); err != nil {
81 journalMu.Lock()
82 if journalErr == nil {
83 journalErr = err
84 cancel()
85 }
86 journalMu.Unlock()
87 }
88 })
89 journalMu.Lock()
90 defer journalMu.Unlock()
91 if journalErr != nil {
92 return fmt.Errorf("persist resident child transcript: %w", journalErr)
93 }
94 return err
95 }, nil
96}
97
98// residentChildArgs applies the complete durable child specification without
99// rediscovering a mutable profile file. That preserves the profile inheritance
100// decision accepted at spawn time across explicit resume and restart.
101func residentChildArgs(args Args, parentProvider string, spec subagents.ResidentChildSpec) Args {
102 next := args
103 // An explicit CLI key belongs to the provider that resolved the parent.
104 // A child can select another provider, but it must resolve that provider's
105 // own credential rather than forwarding or persisting the parent's key.
106 if canonicalProvider(parentProvider) != canonicalProvider(spec.Provider) {
107 next.APIKey = ""
108 }
109 next.Provider = spec.Provider
110 next.BaseURL = spec.BaseURL
111 next.InsecureTLS = spec.InsecureTLS
112 next.Model = spec.Model
113 next.Reasoning = spec.Reasoning
114 next.FastMode = spec.FastMode
115 next.FastModeSet = true
116 if strings.TrimSpace(spec.Workspace) != "" {
117 next.CWD = spec.Workspace
118 }
119 if spec.InheritSkills != nil && !*spec.InheritSkills {
120 next.NoSkill = true
121 }
122 if spec.InheritProjectContext != nil && !*spec.InheritProjectContext {
123 next.NoContextFiles = true
124 }
125 return next
126}
127
128// residentChildRegistry applies an already validated, exact child tool list.
129// It always strips delegation and host-goal mutation tools: child agents are
130// independent workers, not nested orchestrators or goal owners.
131func residentChildRegistry(catalogue core.Registry, names []string) (core.Registry, error) {
132 registry := make(core.Registry, len(names))
133 for _, name := range names {
134 name = strings.TrimSpace(name)
135 if name == "" {
136 return nil, fmt.Errorf("resident child has an empty tool name")
137 }
138 if name == tools.SubagentSpawnToolName || name == tools.SubagentStatusToolName || name == tools.SubagentStopToolName || name == tools.SubagentResumeToolName || name == "update_goal" {
139 return nil, fmt.Errorf("resident child tool %q is not allowed", name)
140 }
141 tool, ok := catalogue[name]
142 if !ok {
143 return nil, fmt.Errorf("resident child declares unavailable tool %q", name)
144 }
145 registry[name] = tool
146 }
147 return registry, nil
148}