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}