1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "strings"
9 "time"
10 "unicode/utf8"
11
12 "github.com/bnema/zut/packages/agent/subagents"
13 "github.com/bnema/zut/packages/core"
14 "github.com/bnema/zut/packages/provider"
15)
16
17// SubagentStatusTool reports live state for background sub-agents without
18// waiting for a child to finish. An omitted agent_id lists resident children;
19// an agent_id queries one child.
20// By default the result contains metadata only. Explicit include_result reads
21// the bounded durable outcome/handoff for one child without model execution.
22type SubagentStatusTool struct {
23 ResidentManager *subagents.ResidentManager
24 Enabled func() bool
25}
26
27type subagentStatusArgs struct {
28 AgentID string `json:"agent_id,omitempty"`
29 IncludeResult bool `json:"include_result,omitempty"`
30 Watch int `json:"watch,omitempty"`
31}
32
33const (
34 subagentWatchMaxSeconds = 60
35 subagentWatchInterval = 250 * time.Millisecond
36 subagentWatchMaxEntries = 40
37 subagentActivityText = 300
38 subagentActivityArgs = 200
39)
40
41type subagentStatusResponse struct {
42 Agent *subagentStatusEntry `json:"agent,omitempty"`
43 Agents []subagentStatusEntry `json:"agents"`
44 Activity *subagentActivity `json:"activity,omitempty"`
45 Watch *subagentWatchReport `json:"watch,omitempty"`
46 Result *subagents.ResidentResult `json:"result,omitempty"`
47}
48
49// subagentActivity is a bounded view of the child's unfinished turn: what it
50// is doing now, the tail of visible text it is writing, and active tools.
51// Hidden reasoning is never exposed.
52type subagentActivity struct {
53 Phase string `json:"phase"`
54 Text string `json:"text,omitempty"`
55 Tools []subagentActivityTool `json:"tools,omitempty"`
56}
57
58type subagentActivityTool struct {
59 Name string `json:"name"`
60 State string `json:"state"`
61 Args string `json:"args,omitempty"`
62}
63
64type subagentWatchReport struct {
65 Seconds float64 `json:"observed_seconds"`
66 EndState subagents.ResidentState `json:"end_state"`
67 Timeline []subagentWatchEntry `json:"timeline"`
68 Truncated bool `json:"truncated,omitempty"`
69}
70
71type subagentWatchEntry struct {
72 At string `json:"at"`
73 State subagents.ResidentState `json:"state"`
74 Activity *subagentActivity `json:"activity,omitempty"`
75}
76
77type subagentStatusEntry struct {
78 ID string `json:"agent_id"`
79 State subagents.ResidentState `json:"state"`
80 Profile string `json:"profile,omitempty"`
81 Provider string `json:"provider"`
82 Model string `json:"model"`
83 Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"`
84 Required bool `json:"required,omitempty"`
85 OwnedElsewhere bool `json:"owned_elsewhere,omitempty"`
86 // PendingFollowUps counts accepted follow-ups the child has not read yet.
87 PendingFollowUps int `json:"pending_followups,omitempty"`
88}
89
90// Name returns the shared facade name: this type is an internal
91// implementation and is never registered on its own.
92func (t *SubagentStatusTool) Name() string { return SubagentToolName }
93
94func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
95 if ctx != nil {
96 select {
97 case <-ctx.Done():
98 return core.ToolResult{}, ctx.Err()
99 default:
100 }
101 }
102 prefix := t.Name()
103 if t.ResidentManager == nil {
104 return protocolToolError(prefix + ": subagent runtime not available in this mode")
105 }
106 if t.Enabled == nil || !t.Enabled() {
107 return protocolToolError(prefix + ": subagent status is unavailable in this mode")
108 }
109
110 var args subagentStatusArgs
111 if err := decodeSubagentArgs(raw, &args); err != nil {
112 return core.ToolResult{}, err
113 }
114 snapshots := t.ResidentManager.Snapshot()
115 id := strings.TrimSpace(args.AgentID)
116 if id == "" {
117 if args.IncludeResult {
118 return protocolToolError(prefix + ": include_result requires agent_id")
119 }
120 if args.Watch != 0 {
121 return protocolToolError(prefix + ": watch requires agent_id")
122 }
123 entries := make([]subagentStatusEntry, 0, len(snapshots))
124 for _, snapshot := range snapshots {
125 entries = append(entries, publicResidentStatus(snapshot))
126 }
127 return renderSubagentStatus(subagentStatusResponse{Agents: entries})
128 }
129 snapshot, ok := findResidentStatusSnapshot(snapshots, id)
130 if !ok {
131 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
132 }
133 if args.Watch < 0 || args.Watch > subagentWatchMaxSeconds {
134 return protocolToolError(fmt.Sprintf("%s: watch must be between 1 and %d seconds", prefix, subagentWatchMaxSeconds))
135 }
136 if args.Watch > 0 && snapshot.OwnedElsewhere {
137 return protocolToolError(prefix + ": watch is unavailable: child is owned by another zut process")
138 }
139 entry := publicResidentStatus(snapshot)
140 response := subagentStatusResponse{Agent: &entry}
141 if args.Watch > 0 {
142 report, err := t.watch(ctx, snapshot.ID, time.Duration(args.Watch)*time.Second)
143 if err != nil {
144 return core.ToolResult{}, err
145 }
146 if report.EndState == "" {
147 report.EndState = entry.State
148 }
149 response.Watch = &report
150 entry.State = report.EndState
151 }
152 if live, ok := t.ResidentManager.Live(snapshot.ID); ok {
153 response.Activity = residentActivity(entry.State, live)
154 }
155 if args.IncludeResult {
156 result, err := t.ResidentManager.Result(snapshot.ID)
157 if err != nil {
158 message := "saved result is unavailable"
159 switch {
160 case snapshot.OwnedElsewhere:
161 message += ": child is owned by another zut process"
162 case os.IsNotExist(err):
163 message += ": no saved result exists yet"
164 case os.IsPermission(err):
165 message += ": permission denied"
166 default:
167 message += ": could not read or decode the saved result"
168 }
169 return protocolToolError(prefix + ": " + message)
170 }
171 response.Result = &result
172 }
173 return renderSubagentStatus(response)
174}
175
176// watch samples the child's live projection until the duration elapses, the
177// child leaves the running/queued states, or ctx is canceled. It records an
178// entry only when the observable activity changes.
179func (t *SubagentStatusTool) watch(ctx context.Context, id string, duration time.Duration) (subagentWatchReport, error) {
180 start := time.Now()
181 deadline := time.NewTimer(duration)
182 defer deadline.Stop()
183 ticker := time.NewTicker(subagentWatchInterval)
184 defer ticker.Stop()
185 report := subagentWatchReport{Timeline: []subagentWatchEntry{}}
186 last := ""
187 if ctx == nil {
188 ctx = context.Background()
189 }
190 for {
191 state, ok := t.ResidentManager.State(id)
192 if !ok {
193 break
194 }
195 report.EndState = state
196 var activity *subagentActivity
197 if live, ok := t.ResidentManager.Live(id); ok {
198 activity = residentActivity(state, live)
199 }
200 entry := subagentWatchEntry{State: state, Activity: activity}
201 if key := watchChangeKey(entry); key != last {
202 last = key
203 if len(report.Timeline) < subagentWatchMaxEntries {
204 entry.At = fmt.Sprintf("+%.1fs", time.Since(start).Seconds())
205 report.Timeline = append(report.Timeline, entry)
206 } else {
207 report.Truncated = true
208 }
209 }
210 if state != subagents.ResidentRunning && state != subagents.ResidentQueued {
211 break
212 }
213 select {
214 case <-ctx.Done():
215 return subagentWatchReport{}, ctx.Err()
216 case <-deadline.C:
217 report.Seconds = roundSeconds(time.Since(start))
218 return report, nil
219 case <-ticker.C:
220 }
221 }
222 report.Seconds = roundSeconds(time.Since(start))
223 return report, nil
224}
225
226// watchChangeKey ignores streamed text growth so the timeline records phase
227// and tool transitions rather than every delta.
228func watchChangeKey(entry subagentWatchEntry) string {
229 var key strings.Builder
230 key.WriteString(string(entry.State))
231 if entry.Activity != nil {
232 key.WriteString("|" + entry.Activity.Phase)
233 for _, tool := range entry.Activity.Tools {
234 key.WriteString("|" + tool.Name + ":" + tool.State)
235 }
236 }
237 return key.String()
238}
239
240func roundSeconds(d time.Duration) float64 {
241 return float64(d.Round(100*time.Millisecond)) / float64(time.Second)
242}
243
244// residentActivity summarizes a live snapshot. It returns nil when the child
245// is not running a turn.
246func residentActivity(state subagents.ResidentState, live subagents.ResidentLiveSnapshot) *subagentActivity {
247 if state != subagents.ResidentRunning {
248 return nil
249 }
250 activity := &subagentActivity{Text: tailRunes(live.AssistantText, subagentActivityText)}
251 for _, tool := range live.Tools {
252 activity.Tools = append(activity.Tools, subagentActivityTool{Name: tool.Name, State: string(tool.State), Args: headRunes(string(tool.Args), subagentActivityArgs)})
253 }
254 switch {
255 case len(activity.Tools) > 0:
256 activity.Phase = "tools"
257 case live.WaitingForModel:
258 activity.Phase = "waiting_for_model"
259 case activity.Text != "":
260 activity.Phase = "writing"
261 default:
262 activity.Phase = "working"
263 }
264 return activity
265}
266
267func headRunes(s string, limit int) string {
268 if utf8.RuneCountInString(s) <= limit {
269 return s
270 }
271 return string([]rune(s)[:limit]) + "…"
272}
273
274func tailRunes(s string, limit int) string {
275 runes := []rune(s)
276 if len(runes) <= limit {
277 return s
278 }
279 return "…" + string(runes[len(runes)-limit:])
280}
281
282func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id string) (subagents.ResidentSnapshot, bool) {
283 for _, snapshot := range snapshots {
284 if snapshot.ID == id {
285 return snapshot, true
286 }
287 }
288 var match subagents.ResidentSnapshot
289 hits := 0
290 for _, snapshot := range snapshots {
291 if strings.HasPrefix(snapshot.ID, id) {
292 match = snapshot
293 hits++
294 }
295 }
296 return match, hits == 1
297}
298
299func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry {
300 return subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere, PendingFollowUps: snapshot.PendingFollowUps}
301}
302
303func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {
304 data, err := json.Marshal(response)
305 if err != nil {
306 return core.ToolResult{}, fmt.Errorf("%s: encode status: %w", SubagentToolName, err)
307 }
308 return core.ToolResult{
309 Content: []provider.Content{provider.TextBlock{Text: string(data)}},
310 Details: response,
311 }, nil
312}