1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8
9 "github.com/bnema/zut/packages/agent/subagents"
10 "github.com/bnema/zut/packages/core"
11 "github.com/bnema/zut/packages/provider"
12)
13
14// SubagentStopTool lets the manager request termination of a stuck background
15// sub-agent. It uses the supervisor's graceful shutdown and force-stop
16// lifecycle rather than directly manipulating the worker process.
17type SubagentStopTool struct {
18 Supervisor *subagents.Supervisor
19 Enabled func() bool
20
21 // OnStopRequested registers non-blocking completion tracking before the
22 // shutdown request is sent. The interactive host uses it to deliver the
23 // terminal result even for workers restored by Reload.
24 OnStopRequested func(agent *subagents.Agent)
25}
26
27type subagentStopArgs struct {
28 AgentID string `json:"agent_id"`
29}
30
31type subagentActionResponse struct {
32 Action string `json:"action"`
33 Agent subagentStatusEntry `json:"agent"`
34}
35
36const subagentStopSchema = `{
37 "type": "object",
38 "properties": {
39 "agent_id": {
40 "type": "string",
41 "description": "Worker id or unique id prefix for the stuck background sub-agent to terminate."
42 }
43 },
44 "required": ["agent_id"]
45}`
46
47func (t *SubagentStopTool) Name() string { return "subagent_stop" }
48
49func (t *SubagentStopTool) Description() string {
50 return "Request termination of a stuck background sub-agent. It gracefully requests shutdown, then cancels or force-stops the worker when necessary."
51}
52
53func (t *SubagentStopTool) Schema() json.RawMessage {
54 return json.RawMessage(subagentStopSchema)
55}
56
57func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
58 if ctx != nil {
59 select {
60 case <-ctx.Done():
61 return core.ToolResult{}, ctx.Err()
62 default:
63 }
64 }
65 prefix := t.Name()
66 if t.Supervisor == nil {
67 return protocolToolError(prefix + ": subagent supervisor not available in this mode")
68 }
69 if t.Enabled == nil || !t.Enabled() {
70 return protocolToolError(prefix + ": auto-subagents is disabled. Ask the user to enable it from /settings before managing workers.")
71 }
72
73 var args subagentStopArgs
74 if err := json.Unmarshal(raw, &args); err != nil {
75 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
76 }
77 id := strings.TrimSpace(args.AgentID)
78 if id == "" {
79 return protocolToolError(prefix + ": agent_id is required")
80 }
81 snapshot, ok := findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), id)
82 if !ok {
83 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
84 }
85 if !subagentCanStop(snapshot) {
86 return protocolToolError(fmt.Sprintf("%s: agent %s has no live worker to stop", prefix, snapshot.ID))
87 }
88 if t.OnStopRequested != nil {
89 if agent := t.Supervisor.Get(snapshot.ID); agent != nil {
90 t.OnStopRequested(agent)
91 }
92 }
93 if err := t.Supervisor.StopContext(ctx, snapshot.ID); err != nil {
94 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
95 }
96
97 snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID)
98 if !ok {
99 return core.ToolResult{}, fmt.Errorf("%s: terminated agent disappeared from supervisor", prefix)
100 }
101 return renderSubagentAction("stop_requested", snapshot)
102}
103
104func subagentCanStop(snapshot subagents.AgentSnapshot) bool {
105 return snapshot.Status == subagents.StatusPending ||
106 snapshot.Status == subagents.StatusRunning ||
107 (snapshot.Status == subagents.StatusDetached && snapshot.ProcessState == subagents.ProcessAlive)
108}
109
110func renderSubagentAction(action string, snapshot subagents.AgentSnapshot) (core.ToolResult, error) {
111 response := subagentActionResponse{
112 Action: action,
113 Agent: publicSubagentStatus(snapshot),
114 }
115 data, err := json.Marshal(response)
116 if err != nil {
117 return core.ToolResult{}, fmt.Errorf("subagent action: encode response: %w", err)
118 }
119 return core.ToolResult{
120 Content: []provider.Content{provider.TextBlock{Text: string(data)}},
121 Details: response,
122 }, nil
123}