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