v0.13.0 befd698

subagent_requirements.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"sort"
8	"strings"
9
10	"github.com/bnema/zut/packages/agent/subagents"
11	"github.com/bnema/zut/packages/agent/tools"
12	"github.com/bnema/zut/packages/core"
13	"github.com/bnema/zut/packages/provider"
14)
15
16const requiredWorkerContextHeader = "[required-subagents update]"
17
18// WireRequiredWorkerGate composes host-enforced requirement handling with any
19// extension hooks already installed on the parent. Blocking required tool calls
20// handle the normal in-turn path; the before-turn hook covers cancellation,
21// restart, and session resume, while the assistant hook fails closed after an
22// unsuccessful required outcome.
23func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) {
24	if rt == nil || rt.supervisor == nil || parent == nil {
25		return
26	}
27	previousBeforeTurn := parent.BeforeTurnContext
28	parent.BeforeTurnContext = func(ctx context.Context, step int) (bool, string, string) {
29		var inherited string
30		if previousBeforeTurn != nil {
31			allowed, reason, contextText := previousBeforeTurn(ctx, step)
32			if !allowed {
33				return false, reason, contextText
34			}
35			inherited = contextText
36		}
37		if err := rt.waitRequiredWorkerReady(ctx); err != nil {
38			return false, fmt.Sprintf("loading persisted subagents failed: %v; resolve the supervisor reload error before continuing", err), inherited
39		}
40		required, err := rt.supervisor.WaitActiveRequirements(ctx)
41		if err != nil {
42			return false, fmt.Sprintf("waiting for required subagents: %v", err), inherited
43		}
44		update := rt.formatRequiredWorkerContext(required)
45		if update == "" {
46			return true, "", inherited
47		}
48		return true, "", joinRequiredContext(inherited, update)
49	}
50
51	previousBeforeTool := parent.BeforeToolExecute
52	parent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
53		unmet := rt.supervisor.UnmetRequirements()
54		if len(unmet) != 0 && !requiredRecoveryTool(call.Name) {
55			return false, formatRequiredWorkerBlock(unmet), nil
56		}
57		if previousBeforeTool != nil {
58			return previousBeforeTool(call)
59		}
60		return true, "", nil
61	}
62
63	previousBeforeAssistant := parent.BeforeAssistantMessage
64	parent.BeforeAssistantMessage = func(text string) (bool, string, string) {
65		unmet := rt.supervisor.UnmetRequirements()
66		if len(unmet) != 0 {
67			reason := formatRequiredWorkerBlock(unmet)
68			if rt.noteRequiredBlock(unmet) {
69				parent.QueueMessage(reason)
70			}
71			return false, reason, ""
72		}
73		rt.clearRequiredBlock()
74		if previousBeforeAssistant != nil {
75			allowed, reason, replacement := previousBeforeAssistant(text)
76			if !allowed {
77				return false, reason, replacement
78			}
79			rt.acknowledgeSatisfiedRequirements()
80			return true, reason, replacement
81		}
82		rt.acknowledgeSatisfiedRequirements()
83		return true, "", ""
84	}
85}
86
87// SetRequiredWorkerReady makes the gate wait for an asynchronous supervisor
88// reload before it checks persisted obligations. A nil channel means no reload
89// is pending. The error callback is read only after ready closes.
90func (rt *subagentRuntime) SetRequiredWorkerReady(ready <-chan struct{}, result func() error) {
91	if rt == nil {
92		return
93	}
94	rt.requiredReadyMu.Lock()
95	rt.requiredReady = ready
96	rt.requiredReadyErr = result
97	rt.requiredReadyMu.Unlock()
98}
99
100func (rt *subagentRuntime) waitRequiredWorkerReady(ctx context.Context) error {
101	rt.requiredReadyMu.RLock()
102	ready := rt.requiredReady
103	result := rt.requiredReadyErr
104	rt.requiredReadyMu.RUnlock()
105	if ready == nil {
106		return nil
107	}
108	select {
109	case <-ctx.Done():
110		return ctx.Err()
111	case <-ready:
112		if result != nil {
113			return result()
114		}
115		return nil
116	}
117}
118
119func (rt *subagentRuntime) acknowledgeSatisfiedRequirements() {
120	for _, snapshot := range rt.supervisor.RequiredSnapshots() {
121		if snapshot.Requirement.State == subagents.RequirementSatisfied && !snapshot.Requirement.Notified {
122			_ = rt.supervisor.MarkRequirementNotified(snapshot.ID)
123		}
124	}
125}
126
127const requiredWorkerContextRuneBudget = 6000
128
129func (rt *subagentRuntime) noteRequiredBlock(unmet []subagents.AgentSnapshot) bool {
130	ids := make([]string, 0, len(unmet))
131	for _, snapshot := range unmet {
132		ids = append(ids, fmt.Sprintf("%s:%s:%d:%s", snapshot.ID, snapshot.Requirement.State, snapshot.Requirement.TargetTurn, snapshot.Requirement.ErrorCode))
133	}
134	sort.Strings(ids)
135	key := strings.Join(ids, "\n")
136	rt.requiredContextMu.Lock()
137	defer rt.requiredContextMu.Unlock()
138	if key == rt.lastRequiredBlock {
139		return false
140	}
141	rt.lastRequiredBlock = key
142	return true
143}
144
145func (rt *subagentRuntime) clearRequiredBlock() {
146	rt.requiredContextMu.Lock()
147	rt.lastRequiredBlock = ""
148	rt.requiredContextMu.Unlock()
149}
150
151func (rt *subagentRuntime) formatRequiredWorkerContext(required []subagents.AgentSnapshot) string {
152	rt.requiredContextMu.Lock()
153	defer rt.requiredContextMu.Unlock()
154	contextText := formatRequiredWorkerContextWithReported(required, rt.reportedRequiredUnmet)
155	if rt.reportedRequiredUnmet == nil {
156		rt.reportedRequiredUnmet = make(map[string]struct{})
157	}
158	for _, snapshot := range required {
159		if snapshot.Requirement.Unmet() {
160			rt.reportedRequiredUnmet[snapshot.ID] = struct{}{}
161		}
162	}
163	return contextText
164}
165
166func requiredRecoveryTool(name string) bool {
167	switch name {
168	case tools.SubagentResumeToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName:
169		return true
170	default:
171		return false
172	}
173}
174
175func formatRequiredWorkerContextWithReported(required []subagents.AgentSnapshot, previouslyReported map[string]struct{}) string {
176	var items []subagents.AgentSnapshot
177	for _, snapshot := range required {
178		requirement := snapshot.Requirement
179		if !requirement.Required || requirement.State == subagents.RequirementPending {
180			continue
181		}
182		if requirement.Notified && !requirement.Unmet() {
183			continue
184		}
185		items = append(items, snapshot)
186	}
187	if len(items) == 0 {
188		return ""
189	}
190	var sb strings.Builder
191	sb.WriteString(requiredWorkerContextHeader)
192	sb.WriteString("\nThe host waited for required delegated work before this model turn. These outcomes are durable and authoritative:\n")
193	for _, snapshot := range items {
194		requirement := snapshot.Requirement
195		if requirement.Unmet() {
196			if _, reported := previouslyReported[snapshot.ID]; reported {
197				fmt.Fprintf(&sb, "\n- %s: %s (turn %d; previously reported; diagnostic: %s)\n", snapshot.ID, requirement.State, requirement.TargetTurn, subagents.ResultRef(snapshot.ID))
198				continue
199			}
200		}
201		fmt.Fprintf(&sb, "\n- %s: %s (turn %d)\n  task: %s\n", snapshot.ID, requirement.State, requirement.TargetTurn, strings.TrimSpace(snapshot.Task))
202		if requirement.ErrorCode != "" {
203			fmt.Fprintf(&sb, "  error_code: %s\n  diagnostic: %s\n", requirement.ErrorCode, subagents.ResultRef(snapshot.ID))
204		}
205		output := strings.TrimSpace(snapshot.LastAssistant)
206		if snapshot.Result != nil && strings.TrimSpace(snapshot.Result.Output) != "" {
207			output = strings.TrimSpace(snapshot.Result.Output)
208		}
209		if output != "" {
210			fmt.Fprintf(&sb, "  result:\n%s\n", indentRequiredOutput(output, "    ", 6000))
211		}
212	}
213	if hasIndeterminateRequirement(items) {
214		sb.WriteString("\nA required outcome is indeterminate after host restart. Do not retry it automatically: the user must inspect the durable result or external side effect, then explicitly resume, restart, or remove the terminal worker.")
215	} else if hasUnmetRequirement(items) {
216		sb.WriteString("\nAt least one required outcome is unresolved. Retry that worker with subagent_resume; do not emit a terminal answer. Only the user can waive it by removing the terminal worker with /subagents rm.")
217	} else {
218		sb.WriteString("\nAll listed requirements are satisfied. Use their results before answering.")
219	}
220	return truncateRequiredContext(sb.String(), requiredWorkerContextRuneBudget)
221}
222
223func truncateRequiredContext(text string, limit int) string {
224	runes := []rune(text)
225	if len(runes) <= limit {
226		return text
227	}
228	return string(runes[:limit])
229}
230
231func formatRequiredWorkerBlock(unmet []subagents.AgentSnapshot) string {
232	var sb strings.Builder
233	sb.WriteString(requiredWorkerContextHeader)
234	sb.WriteString("\nTerminal completion is blocked by required delegated work:")
235	for _, snapshot := range unmet {
236		fmt.Fprintf(&sb, "\n- %s: %s — %s", snapshot.ID, snapshot.Requirement.State, strings.TrimSpace(snapshot.Task))
237		if snapshot.Requirement.ErrorCode != "" {
238			fmt.Fprintf(&sb, " (%s; diagnostic: %s)", snapshot.Requirement.ErrorCode, subagents.ResultRef(snapshot.ID))
239		}
240	}
241	if hasIndeterminateRequirement(unmet) {
242		sb.WriteString("\nDo not retry an indeterminate outcome automatically. The user must inspect its durable result or external side effect, then explicitly resume, restart, or remove the worker.")
243	} else {
244		sb.WriteString("\nRetry unresolved work with subagent_resume. Do not claim completion. The user may explicitly waive a terminal worker with /subagents rm.")
245	}
246	return sb.String()
247}
248
249func hasIndeterminateRequirement(required []subagents.AgentSnapshot) bool {
250	for _, snapshot := range required {
251		if snapshot.Requirement.State == subagents.RequirementIndeterminate {
252			return true
253		}
254	}
255	return false
256}
257
258func hasUnmetRequirement(required []subagents.AgentSnapshot) bool {
259	for _, snapshot := range required {
260		if snapshot.Requirement.Unmet() {
261			return true
262		}
263	}
264	return false
265}
266
267func joinRequiredContext(first, second string) string {
268	first = strings.TrimSpace(first)
269	second = strings.TrimSpace(second)
270	switch {
271	case first == "":
272		return second
273	case second == "":
274		return first
275	default:
276		return first + "\n\n" + second
277	}
278}
279
280func indentRequiredOutput(text, prefix string, maxRunes int) string {
281	text = strings.TrimSpace(text)
282	runes := []rune(text)
283	if maxRunes > 0 && len(runes) > maxRunes {
284		text = string(runes[:maxRunes]) + "\n…(truncated)"
285	}
286	return prefix + strings.ReplaceAll(text, "\n", "\n"+prefix)
287}

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