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