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