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