1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8 "time"
9 "unicode/utf8"
10
11 "github.com/bnema/zut/packages/agent/subagents"
12 "github.com/bnema/zut/packages/core"
13 "github.com/bnema/zut/packages/provider"
14)
15
16// SubagentStatusTool reports live state for background sub-agents without
17// waiting for a worker to finish. An omitted agent_id lists the workers
18// visible to the current supervisor session; an agent_id queries one worker.
19// The result deliberately contains metadata only: it does not expose
20// transcripts, result output, credentials, provider settings, or filesystem
21// paths.
22type SubagentStatusTool struct {
23 Supervisor *subagents.Supervisor
24 Enabled func() bool
25}
26
27type subagentStatusArgs struct {
28 AgentID string `json:"agent_id,omitempty"`
29}
30
31type subagentStatusResponse struct {
32 Agent *subagentStatusEntry `json:"agent,omitempty"`
33 Agents []subagentStatusEntry `json:"agents"`
34}
35
36type subagentStatusEntry struct {
37 ID string `json:"agent_id"`
38 State string `json:"state"`
39 ProcessState string `json:"process_state"`
40 TurnState string `json:"turn_state"`
41 StartedAt time.Time `json:"started_at"`
42 UpdatedAt time.Time `json:"updated_at"`
43 FinishedAt *time.Time `json:"finished_at,omitempty"`
44 TaskSummary string `json:"task_summary,omitempty"`
45 Result *subagentStatusResult `json:"result,omitempty"`
46}
47
48type subagentStatusResult struct {
49 State string `json:"state"`
50 Available bool `json:"available"`
51 Ref string `json:"ref,omitempty"`
52}
53
54const subagentStatusSchema = `{
55 "type": "object",
56 "properties": {
57 "agent_id": {
58 "type": "string",
59 "description": "Optional worker id or unique id prefix. Omit it to list all visible background sub-agents."
60 }
61 }
62}`
63
64func (t *SubagentStatusTool) Name() string { return "subagent_status" }
65
66func (t *SubagentStatusTool) Description() string {
67 return "Query live status for one background sub-agent or list all visible workers without waiting for completion."
68}
69
70func (t *SubagentStatusTool) Schema() json.RawMessage {
71 return json.RawMessage(subagentStatusSchema)
72}
73
74func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
75 if ctx != nil {
76 select {
77 case <-ctx.Done():
78 return core.ToolResult{}, ctx.Err()
79 default:
80 }
81 }
82 prefix := t.Name()
83 if t.Supervisor == nil {
84 return protocolToolError(prefix + ": subagent supervisor not available in this mode")
85 }
86 if t.Enabled == nil || !t.Enabled() {
87 return protocolToolError(prefix + ": subagent status is unavailable in this mode")
88 }
89
90 var args subagentStatusArgs
91 if err := json.Unmarshal(raw, &args); err != nil {
92 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
93 }
94
95 // SnapshotAll is intentionally used for both list and single-worker
96 // queries. Besides being non-blocking, it applies the supervisor's active
97 // session visibility rules so an id lookup cannot bypass list scoping.
98 snapshots := t.Supervisor.SnapshotAll()
99 id := strings.TrimSpace(args.AgentID)
100 if id == "" {
101 entries := make([]subagentStatusEntry, 0, len(snapshots))
102 for _, snapshot := range snapshots {
103 entries = append(entries, publicSubagentStatus(snapshot))
104 }
105 return renderSubagentStatus(subagentStatusResponse{Agents: entries})
106 }
107
108 snapshot, ok := findSubagentStatusSnapshot(snapshots, id)
109 if !ok {
110 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
111 }
112 entry := publicSubagentStatus(snapshot)
113 return renderSubagentStatus(subagentStatusResponse{Agent: &entry})
114}
115
116func findSubagentStatusSnapshot(snapshots []subagents.AgentSnapshot, id string) (subagents.AgentSnapshot, bool) {
117 for _, snapshot := range snapshots {
118 if snapshot.ID == id {
119 return snapshot, true
120 }
121 }
122 var match subagents.AgentSnapshot
123 hits := 0
124 for _, snapshot := range snapshots {
125 if strings.HasPrefix(snapshot.ID, id) {
126 match = snapshot
127 hits++
128 }
129 }
130 return match, hits == 1
131}
132
133func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry {
134 entry := subagentStatusEntry{
135 ID: snapshot.ID,
136 State: publicSubagentState(snapshot),
137 ProcessState: string(snapshot.ProcessState),
138 TurnState: string(snapshot.TurnState),
139 StartedAt: snapshot.Started,
140 UpdatedAt: snapshot.UpdatedAt,
141 TaskSummary: summarizeSubagentTask(snapshot.Task),
142 }
143 if !snapshot.Finished.IsZero() {
144 finished := snapshot.Finished
145 entry.FinishedAt = &finished
146 }
147 if snapshot.Result != nil {
148 entry.Result = &subagentStatusResult{
149 State: publicResultState(snapshot.Result.Status),
150 Available: true,
151 Ref: snapshot.ResultRef,
152 }
153 }
154 return entry
155}
156
157func publicSubagentState(snapshot subagents.AgentSnapshot) string {
158 switch snapshot.Status {
159 case subagents.StatusPending:
160 return "starting"
161 case subagents.StatusRunning:
162 return "running"
163 case subagents.StatusDone:
164 return "completed"
165 case subagents.StatusFailed:
166 return "failed"
167 case subagents.StatusKilled:
168 return "cancelled"
169 case subagents.StatusDetached:
170 return "detached"
171 default:
172 return "unknown"
173 }
174}
175
176func publicResultState(status subagents.TurnStatus) string {
177 switch status {
178 case subagents.ResultSucceeded:
179 return "completed"
180 case subagents.ResultFailed:
181 return "failed"
182 case subagents.ResultCanceled:
183 return "cancelled"
184 default:
185 return "unknown"
186 }
187}
188
189// summarizeSubagentTask exposes only the first logical line and a small bound.
190// This keeps the status surface useful for identifying a worker without
191// returning the full prompt or any following context.
192func summarizeSubagentTask(task string) string {
193 firstLine := strings.TrimSpace(strings.SplitN(task, "\n", 2)[0])
194 firstLine = strings.Join(strings.Fields(firstLine), " ")
195 return truncateSubagentStatus(firstLine, 200)
196}
197
198func truncateSubagentStatus(value string, maxRunes int) string {
199 if maxRunes <= 0 {
200 return ""
201 }
202 if utf8.RuneCountInString(value) <= maxRunes {
203 return value
204 }
205 runes := []rune(value)
206 if maxRunes <= 3 {
207 return string(runes[:maxRunes])
208 }
209 return string(runes[:maxRunes-3]) + "..."
210}
211
212func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {
213 data, err := json.Marshal(response)
214 if err != nil {
215 return core.ToolResult{}, fmt.Errorf("%s: encode status: %w", "subagent_status", err)
216 }
217 return core.ToolResult{
218 Content: []provider.Content{provider.TextBlock{Text: string(data)}},
219 Details: response,
220 }, nil
221}