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}
46
47const subagentStatusSchema = `{
48 "type": "object",
49 "properties": {
50 "agent_id": {
51 "type": "string",
52 "description": "Optional child id or unique id prefix. Omit it to list all resident sub-agents."
53 },
54 "include_result": {
55 "type": "boolean",
56 "description": "With agent_id, retrieve the saved terminal result without running the child."
57 }
58 }
59}`
60
61func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName }
62
63func (t *SubagentStatusTool) Description() string {
64 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 without model execution."
65}
66
67func (t *SubagentStatusTool) Schema() json.RawMessage {
68 return json.RawMessage(subagentStatusSchema)
69}
70
71func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
72 if ctx != nil {
73 select {
74 case <-ctx.Done():
75 return core.ToolResult{}, ctx.Err()
76 default:
77 }
78 }
79 prefix := t.Name()
80 if t.ResidentManager == nil {
81 return protocolToolError(prefix + ": subagent runtime not available in this mode")
82 }
83 if t.Enabled == nil || !t.Enabled() {
84 return protocolToolError(prefix + ": subagent status is unavailable in this mode")
85 }
86
87 var args subagentStatusArgs
88 if err := decodeSubagentArgs(raw, &args); err != nil {
89 return core.ToolResult{}, err
90 }
91 snapshots := t.ResidentManager.Snapshot()
92 id := strings.TrimSpace(args.AgentID)
93 if id == "" {
94 if args.IncludeResult {
95 return protocolToolError(prefix + ": include_result requires agent_id")
96 }
97 entries := make([]subagentStatusEntry, 0, len(snapshots))
98 for _, snapshot := range snapshots {
99 entries = append(entries, publicResidentStatus(snapshot))
100 }
101 return renderSubagentStatus(subagentStatusResponse{Agents: entries})
102 }
103 snapshot, ok := findResidentStatusSnapshot(snapshots, id)
104 if !ok {
105 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
106 }
107 entry := publicResidentStatus(snapshot)
108 response := subagentStatusResponse{Agent: &entry}
109 if args.IncludeResult {
110 result, err := t.ResidentManager.Result(snapshot.ID)
111 if err != nil {
112 message := "saved result is unavailable"
113 switch {
114 case snapshot.OwnedElsewhere:
115 message += ": child is owned by another zut process"
116 case os.IsNotExist(err):
117 message += ": no saved result exists yet"
118 case os.IsPermission(err):
119 message += ": permission denied"
120 default:
121 message += ": could not read or decode the saved result"
122 }
123 return protocolToolError(prefix + ": " + message)
124 }
125 response.Result = &result
126 }
127 return renderSubagentStatus(response)
128}
129
130func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id string) (subagents.ResidentSnapshot, bool) {
131 for _, snapshot := range snapshots {
132 if snapshot.ID == id {
133 return snapshot, true
134 }
135 }
136 var match subagents.ResidentSnapshot
137 hits := 0
138 for _, snapshot := range snapshots {
139 if strings.HasPrefix(snapshot.ID, id) {
140 match = snapshot
141 hits++
142 }
143 }
144 return match, hits == 1
145}
146
147func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry {
148 return 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}
149}
150
151func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {
152 data, err := json.Marshal(response)
153 if err != nil {
154 return core.ToolResult{}, fmt.Errorf("%s: encode status: %w", "subagent_status", err)
155 }
156 return core.ToolResult{
157 Content: []provider.Content{provider.TextBlock{Text: string(data)}},
158 Details: response,
159 }, nil
160}