v0.35.0 21873e8

subagent_factory.go RawHistoryFolder

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}

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 477

↑↓ moveenter openesc close