v0.27.0 01c3d31

subagent_factory.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"strings"
9	"sync"
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	"github.com/bnema/zut/packages/provider"
15)
16
17// newResidentChildRunner is the host-owned construction boundary for one
18// resident child. It resolves a fresh provider client and a fresh core.Agent
19// exactly once for the child's durable session; no subprocess configuration or
20// credential transfer is involved.
21func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
22	if strings.TrimSpace(spec.SessionID) == "" {
23		return nil, fmt.Errorf("resident child %q has no session identity", spec.ID)
24	}
25	resolved, err := Resolve(args, true)
26	if err != nil {
27		return nil, fmt.Errorf("resolve resident child %q: %w", spec.ID, err)
28	}
29
30	registry, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools)
31	if err != nil {
32		return nil, err
33	}
34	system := resolved.SystemPrompt
35	if extra := strings.TrimSpace(spec.SystemPrompt); extra != "" {
36		if spec.SystemPromptMode == "replace" {
37			system = extra
38		} else {
39			system = strings.TrimSpace(system) + "\n\n" + extra
40		}
41	}
42	budgetLimit := subagents.EffectiveBudgetLimit(spec.BudgetLimit, resolved.ContextWindow)
43	if guidance := subagents.BudgetSystemPrompt(budgetLimit); guidance != "" {
44		system = strings.TrimSpace(system) + "\n\n" + guidance
45	}
46	agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry)
47	agent.MaxSteps = resolved.MaxSteps
48	agent.ContextWindow = resolved.ContextWindow
49	agent.MaxTokens = resolved.MaxOutput
50	agent.Reasoning = resolved.Reasoning
51	agent.Temperature = resolved.Temperature
52	agent.FastMode = resolved.FastMode
53	var baseline subagents.ResidentUsageSnapshot
54	if journal != nil {
55		baseline = journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")
56		agent.SeedCost(baseline.Usage)
57	}
58	budget := subagents.NewRolloutBudget(budgetLimit, baseline.Usage)
59	configureResidentBudget(agent, budget)
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		startedExhausted := budget.Snapshot().State == subagents.BudgetExceeded
88		var journalErr error
89		var journalMu sync.Mutex
90		err := agent.Prompt(turnCtx, prompt, nil, func(event core.AgentEvent) {
91			if usage, ok := event.(core.EvUsage); ok {
92				budget.Observe(usage.Cumulative)
93			}
94			if err := journal.RecordAgentEvent(event); err != nil {
95				journalMu.Lock()
96				if journalErr == nil {
97					journalErr = err
98					cancel()
99				}
100				journalMu.Unlock()
101			}
102		})
103		journalMu.Lock()
104		defer journalMu.Unlock()
105		if journalErr != nil {
106			return fmt.Errorf("persist resident child transcript: %w", journalErr)
107		}
108		if budget.Snapshot().State == subagents.BudgetExceeded {
109			if !startedExhausted && err == nil {
110				return nil
111			}
112			return errors.Join(err, subagents.ErrBudgetExceeded)
113		}
114		return err
115	}, nil
116}
117
118func configureResidentBudget(agent *core.Agent, budget *subagents.RolloutBudget) {
119	if agent == nil || budget == nil {
120		return
121	}
122	agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) {
123		instruction, exceeded := budget.TurnContext()
124		if exceeded {
125			return false, subagents.ErrBudgetExceeded.Error(), ""
126		}
127		snapshot := budget.Snapshot()
128		if snapshot.Limit > 0 {
129			remaining := residentBudgetOutputLimit(snapshot.Limit - snapshot.Used)
130			if agent.MaxTokens <= 0 || agent.MaxTokens > remaining {
131				agent.MaxTokens = remaining
132			}
133		}
134		if snapshot.State == subagents.BudgetFinalizing {
135			agent.SetTools(core.Registry{})
136		}
137		return true, "", instruction
138	}
139	agent.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) {
140		state := budget.Snapshot().State
141		if state == subagents.BudgetFinalizing || state == subagents.BudgetExceeded {
142			return false, "subagent rollout budget is reserved for the final response; tools are disabled", nil
143		}
144		return true, "", nil
145	}
146}
147
148func residentBudgetOutputLimit(remaining int64) int {
149	if remaining <= 1 {
150		return 1
151	}
152	maxInt := int64(^uint(0) >> 1)
153	if remaining > maxInt {
154		return int(maxInt)
155	}
156	return int(remaining)
157}
158
159// residentChildArgs applies the complete durable child specification without
160// rediscovering a mutable profile file. That preserves the profile inheritance
161// decision accepted at spawn time across explicit resume and restart.
162func residentChildArgs(args Args, parentProvider string, spec subagents.ResidentChildSpec) Args {
163	next := args
164	// Primary delegation policy belongs to the parent, not its workers. A child
165	// receives its accepted profile instructions and exact child tool list.
166	next.Orchestrate = false
167	next.ResidentChild = true
168	// An explicit CLI key belongs to the provider that resolved the parent.
169	// A child can select another provider, but it must resolve that provider's
170	// own credential rather than forwarding or persisting the parent's key.
171	if canonicalProvider(parentProvider) != canonicalProvider(spec.Provider) {
172		next.APIKey = ""
173	}
174	next.Provider = spec.Provider
175	next.BaseURL = spec.BaseURL
176	next.InsecureTLS = spec.InsecureTLS
177	next.Model = spec.Model
178	next.Reasoning = spec.Reasoning
179	next.FastMode = spec.FastMode
180	next.FastModeSet = true
181	if strings.TrimSpace(spec.Workspace) != "" {
182		next.CWD = spec.Workspace
183	}
184	if spec.InheritSkills != nil && !*spec.InheritSkills {
185		next.NoSkill = true
186	}
187	if spec.InheritProjectContext != nil && !*spec.InheritProjectContext {
188		next.NoContextFiles = true
189	}
190	return next
191}
192
193// residentChildRegistry applies an already validated, exact child tool list.
194// It always strips delegation and host-goal mutation tools: child agents are
195// independent workers, not nested orchestrators or goal owners.
196func residentChildRegistry(catalogue core.Registry, names []string) (core.Registry, error) {
197	registry := make(core.Registry, len(names))
198	for _, name := range names {
199		name = strings.TrimSpace(name)
200		if name == "" {
201			return nil, fmt.Errorf("resident child has an empty tool name")
202		}
203		if name == tools.SubagentSpawnToolName || name == tools.SubagentStatusToolName || name == tools.SubagentStopToolName || name == tools.SubagentResumeToolName || name == "update_goal" {
204			return nil, fmt.Errorf("resident child tool %q is not allowed", name)
205		}
206		tool, ok := catalogue[name]
207		if !ok {
208			return nil, fmt.Errorf("resident child declares unavailable tool %q", name)
209		}
210		registry[name] = tool
211	}
212	return registry, nil
213}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close