1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "strings"
9
10 "github.com/bnema/zut/packages/agent/subagents"
11 "github.com/bnema/zut/packages/core"
12 "github.com/bnema/zut/packages/provider"
13)
14
15// SubagentStatusTool reports live state for background sub-agents without
16// waiting for a child to finish. An omitted agent_id lists resident children;
17// an agent_id queries one child.
18// By default the result contains metadata only. Explicit include_result reads
19// the bounded durable outcome/handoff for one child without model execution.
20type SubagentStatusTool struct {
21 ResidentManager *subagents.ResidentManager
22 Enabled func() bool
23}
24
25type subagentStatusArgs struct {
26 AgentID string `json:"agent_id,omitempty"`
27 IncludeResult bool `json:"include_result,omitempty"`
28}
29
30type subagentStatusResponse struct {
31 Agent *subagentStatusEntry `json:"agent,omitempty"`
32 Agents []subagentStatusEntry `json:"agents"`
33 Result *subagents.ResidentResult `json:"result,omitempty"`
34}
35
36type subagentStatusEntry struct {
37 ID string `json:"agent_id"`
38 State subagents.ResidentState `json:"state"`
39 Profile string `json:"profile,omitempty"`
40 Provider string `json:"provider"`
41 Model string `json:"model"`
42 Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"`
43 Required bool `json:"required,omitempty"`
44 OwnedElsewhere bool `json:"owned_elsewhere,omitempty"`
45 Budget *subagents.BudgetSnapshot `json:"budget,omitempty"`
46 BudgetSource string `json:"budget_source,omitempty"`
47}
48
49const subagentStatusSchema = `{
50 "type": "object",
51 "properties": {
52 "agent_id": {
53 "type": "string",
54 "description": "Optional child id or unique id prefix. Omit it to list all resident sub-agents."
55 },
56 "include_result": {
57 "type": "boolean",
58 "description": "With agent_id, retrieve the saved terminal result or partial budget handoff without running the child."
59 }
60 }
61}`
62
63func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName }
64
65func (t *SubagentStatusTool) Description() string {
66 return "Query live status for one background sub-agent or list all visible workers without waiting for completion. Set include_result with agent_id to retrieve its saved result or partial handoff without model execution."
67}
68
69func (t *SubagentStatusTool) Schema() json.RawMessage {
70 return json.RawMessage(subagentStatusSchema)
71}
72
73func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
74 if ctx != nil {
75 select {
76 case <-ctx.Done():
77 return core.ToolResult{}, ctx.Err()
78 default:
79 }
80 }
81 prefix := t.Name()
82 if t.ResidentManager == nil {
83 return protocolToolError(prefix + ": subagent runtime not available in this mode")
84 }
85 if t.Enabled == nil || !t.Enabled() {
86 return protocolToolError(prefix + ": subagent status is unavailable in this mode")
87 }
88
89 var args subagentStatusArgs
90 if err := decodeSubagentArgs(raw, &args); err != nil {
91 return core.ToolResult{}, err
92 }
93 snapshots := t.ResidentManager.Snapshot()
94 id := strings.TrimSpace(args.AgentID)
95 if id == "" {
96 if args.IncludeResult {
97 return protocolToolError(prefix + ": include_result requires agent_id")
98 }
99 entries := make([]subagentStatusEntry, 0, len(snapshots))
100 for _, snapshot := range snapshots {
101 entries = append(entries, publicResidentStatus(snapshot))
102 }
103 return renderSubagentStatus(subagentStatusResponse{Agents: entries})
104 }
105 snapshot, ok := findResidentStatusSnapshot(snapshots, id)
106 if !ok {
107 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
108 }
109 entry := publicResidentStatus(snapshot)
110 response := subagentStatusResponse{Agent: &entry}
111 if args.IncludeResult {
112 result, err := t.ResidentManager.Result(snapshot.ID)
113 if err != nil {
114 message := "saved result is unavailable"
115 switch {
116 case snapshot.OwnedElsewhere:
117 message += ": child is owned by another zut process"
118 case os.IsNotExist(err):
119 message += ": no saved result exists yet"
120 case os.IsPermission(err):
121 message += ": permission denied"
122 default:
123 message += ": could not read or decode the saved result"
124 }
125 return protocolToolError(prefix + ": " + message)
126 }
127 response.Result = &result
128 }
129 return renderSubagentStatus(response)
130}
131
132func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id string) (subagents.ResidentSnapshot, bool) {
133 for _, snapshot := range snapshots {
134 if snapshot.ID == id {
135 return snapshot, true
136 }
137 }
138 var match subagents.ResidentSnapshot
139 hits := 0
140 for _, snapshot := range snapshots {
141 if strings.HasPrefix(snapshot.ID, id) {
142 match = snapshot
143 hits++
144 }
145 }
146 return match, hits == 1
147}
148
149func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry {
150 entry := 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, BudgetSource: snapshot.BudgetSource}
151 if snapshot.Budget.Limit > 0 {
152 budget := snapshot.Budget
153 entry.Budget = &budget
154 }
155 return entry
156}
157
158func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {
159 data, err := json.Marshal(response)
160 if err != nil {
161 return core.ToolResult{}, fmt.Errorf("%s: encode status: %w", "subagent_status", err)
162 }
163 return core.ToolResult{
164 Content: []provider.Content{provider.TextBlock{Text: string(data)}},
165 Details: response,
166 }, nil
167}