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}
87
88// Name returns the shared facade name: this type is an internal
89// implementation and is never registered on its own.
90func (t *SubagentStatusTool) Name() string { return SubagentToolName }
91
92func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
93 if ctx != nil {
94 select {
95 case <-ctx.Done():
96 return core.ToolResult{}, ctx.Err()
97 default:
98 }
99 }
100 prefix := t.Name()
101 if t.ResidentManager == nil {
102 return protocolToolError(prefix + ": subagent runtime not available in this mode")
103 }
104 if t.Enabled == nil || !t.Enabled() {
105 return protocolToolError(prefix + ": subagent status is unavailable in this mode")
106 }
107
108 var args subagentStatusArgs
109 if err := decodeSubagentArgs(raw, &args); err != nil {
110 return core.ToolResult{}, err
111 }
112 snapshots := t.ResidentManager.Snapshot()
113 id := strings.TrimSpace(args.AgentID)
114 if id == "" {
115 if args.IncludeResult {
116 return protocolToolError(prefix + ": include_result requires agent_id")
117 }
118 if args.Watch != 0 {
119 return protocolToolError(prefix + ": watch requires agent_id")
120 }
121 entries := make([]subagentStatusEntry, 0, len(snapshots))
122 for _, snapshot := range snapshots {
123 entries = append(entries, publicResidentStatus(snapshot))
124 }
125 return renderSubagentStatus(subagentStatusResponse{Agents: entries})
126 }
127 snapshot, ok := findResidentStatusSnapshot(snapshots, id)
128 if !ok {
129 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
130 }
131 if args.Watch < 0 || args.Watch > subagentWatchMaxSeconds {
132 return protocolToolError(fmt.Sprintf("%s: watch must be between 1 and %d seconds", prefix, subagentWatchMaxSeconds))
133 }
134 if args.Watch > 0 && snapshot.OwnedElsewhere {
135 return protocolToolError(prefix + ": watch is unavailable: child is owned by another zut process")
136 }
137 entry := publicResidentStatus(snapshot)
138 response := subagentStatusResponse{Agent: &entry}
139 if args.Watch > 0 {
140 report, err := t.watch(ctx, snapshot.ID, time.Duration(args.Watch)*time.Second)
141 if err != nil {
142 return core.ToolResult{}, err
143 }
144 if report.EndState == "" {
145 report.EndState = entry.State
146 }
147 response.Watch = &report
148 entry.State = report.EndState
149 }
150 if live, ok := t.ResidentManager.Live(snapshot.ID); ok {
151 response.Activity = residentActivity(entry.State, live)
152 }
153 if args.IncludeResult {
154 result, err := t.ResidentManager.Result(snapshot.ID)
155 if err != nil {
156 message := "saved result is unavailable"
157 switch {
158 case snapshot.OwnedElsewhere:
159 message += ": child is owned by another zut process"
160 case os.IsNotExist(err):
161 message += ": no saved result exists yet"
162 case os.IsPermission(err):
163 message += ": permission denied"
164 default:
165 message += ": could not read or decode the saved result"
166 }
167 return protocolToolError(prefix + ": " + message)
168 }
169 response.Result = &result
170 }
171 return renderSubagentStatus(response)
172}
173
174// watch samples the child's live projection until the duration elapses, the
175// child leaves the running/queued states, or ctx is canceled. It records an
176// entry only when the observable activity changes.
177func (t *SubagentStatusTool) watch(ctx context.Context, id string, duration time.Duration) (subagentWatchReport, error) {
178 start := time.Now()
179 deadline := time.NewTimer(duration)
180 defer deadline.Stop()
181 ticker := time.NewTicker(subagentWatchInterval)
182 defer ticker.Stop()
183 report := subagentWatchReport{Timeline: []subagentWatchEntry{}}
184 last := ""
185 if ctx == nil {
186 ctx = context.Background()
187 }
188 for {
189 state, ok := t.ResidentManager.State(id)
190 if !ok {
191 break
192 }
193 report.EndState = state
194 var activity *subagentActivity
195 if live, ok := t.ResidentManager.Live(id); ok {
196 activity = residentActivity(state, live)
197 }
198 entry := subagentWatchEntry{State: state, Activity: activity}
199 if key := watchChangeKey(entry); key != last {
200 last = key
201 if len(report.Timeline) < subagentWatchMaxEntries {
202 entry.At = fmt.Sprintf("+%.1fs", time.Since(start).Seconds())
203 report.Timeline = append(report.Timeline, entry)
204 } else {
205 report.Truncated = true
206 }
207 }
208 if state != subagents.ResidentRunning && state != subagents.ResidentQueued {
209 break
210 }
211 select {
212 case <-ctx.Done():
213 return subagentWatchReport{}, ctx.Err()
214 case <-deadline.C:
215 report.Seconds = roundSeconds(time.Since(start))
216 return report, nil
217 case <-ticker.C:
218 }
219 }
220 report.Seconds = roundSeconds(time.Since(start))
221 return report, nil
222}
223
224// watchChangeKey ignores streamed text growth so the timeline records phase
225// and tool transitions rather than every delta.
226func watchChangeKey(entry subagentWatchEntry) string {
227 var key strings.Builder
228 key.WriteString(string(entry.State))
229 if entry.Activity != nil {
230 key.WriteString("|" + entry.Activity.Phase)
231 for _, tool := range entry.Activity.Tools {
232 key.WriteString("|" + tool.Name + ":" + tool.State)
233 }
234 }
235 return key.String()
236}
237
238func roundSeconds(d time.Duration) float64 {
239 return float64(d.Round(100*time.Millisecond)) / float64(time.Second)
240}
241
242// residentActivity summarizes a live snapshot. It returns nil when the child
243// is not running a turn.
244func residentActivity(state subagents.ResidentState, live subagents.ResidentLiveSnapshot) *subagentActivity {
245 if state != subagents.ResidentRunning {
246 return nil
247 }
248 activity := &subagentActivity{Text: tailRunes(live.AssistantText, subagentActivityText)}
249 for _, tool := range live.Tools {
250 activity.Tools = append(activity.Tools, subagentActivityTool{Name: tool.Name, State: string(tool.State), Args: headRunes(string(tool.Args), subagentActivityArgs)})
251 }
252 switch {
253 case len(activity.Tools) > 0:
254 activity.Phase = "tools"
255 case live.WaitingForModel:
256 activity.Phase = "waiting_for_model"
257 case activity.Text != "":
258 activity.Phase = "writing"
259 default:
260 activity.Phase = "working"
261 }
262 return activity
263}
264
265func headRunes(s string, limit int) string {
266 if utf8.RuneCountInString(s) <= limit {
267 return s
268 }
269 return string([]rune(s)[:limit]) + "…"
270}
271
272func tailRunes(s string, limit int) string {
273 runes := []rune(s)
274 if len(runes) <= limit {
275 return s
276 }
277 return "…" + string(runes[len(runes)-limit:])
278}
279
280func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id string) (subagents.ResidentSnapshot, bool) {
281 for _, snapshot := range snapshots {
282 if snapshot.ID == id {
283 return snapshot, true
284 }
285 }
286 var match subagents.ResidentSnapshot
287 hits := 0
288 for _, snapshot := range snapshots {
289 if strings.HasPrefix(snapshot.ID, id) {
290 match = snapshot
291 hits++
292 }
293 }
294 return match, hits == 1
295}
296
297func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry {
298 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}
299}
300
301func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {
302 data, err := json.Marshal(response)
303 if err != nil {
304 return core.ToolResult{}, fmt.Errorf("%s: encode status: %w", SubagentToolName, err)
305 }
306 return core.ToolResult{
307 Content: []provider.Content{provider.TextBlock{Text: string(data)}},
308 Details: response,
309 }, nil
310}