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)
12
13// SubagentResumeTool gives a sub-agent a follow-up turn while preserving its
14// existing session context. Every explicit follow-up starts a fresh observable
15// run. For an idle live worker, the follow-up is delivered directly; for an
16// active live worker, it is queued for the next available message turn; for a
17// terminal worker, the supervisor restarts the retained session with the
18// follow-up as its initial turn.
19type SubagentResumeTool struct {
20 Supervisor *subagents.Supervisor
21 Enabled func() bool
22
23 // BeforeResumed, when set, registers the follow-up before the supervisor
24 // sends or queues its prompt. It returns a cleanup for resume failures that
25 // happen before delivery. The interactive host uses this to avoid losing a
26 // fast idle or restarted-worker turn.
27 BeforeResumed func(agent *subagents.Agent, prompt string, required bool) func()
28
29 // OnResumed receives the worker and follow-up after a successful resume.
30 // It remains available for callers that only need post-success
31 // notification; tracking callers should prefer BeforeResumed.
32 OnResumed func(agent *subagents.Agent, prompt string, required bool)
33}
34
35type subagentResumeArgs struct {
36 AgentID string `json:"agent_id"`
37 Prompt string `json:"prompt"`
38 Required bool `json:"required,omitempty"`
39}
40
41const subagentResumeSchema = `{
42 "type": "object",
43 "properties": {
44 "agent_id": {
45 "type": "string",
46 "description": "Worker id or unique id prefix for the sub-agent to continue."
47 },
48 "prompt": {
49 "type": "string",
50 "description": "New manager follow-up for the sub-agent. Its earlier task and conversation remain available in the retained session."
51 },
52 "required": {
53 "type": "boolean",
54 "description": "Set true to make this asynchronous follow-up mandatory before the parent's terminal response. Failed, timed-out, or canceled required work remains required automatically; indeterminate work needs explicit user reconciliation."
55 }
56 },
57 "required": ["agent_id", "prompt"]
58}`
59
60func (t *SubagentResumeTool) Name() string { return SubagentResumeToolName }
61
62func (t *SubagentResumeTool) Description() string {
63 return "Continue a sub-agent with a fresh turn budget and retained session. Set required=true for a mandatory follow-up; retries of failed, timed-out, or canceled required work stay required and wait event-driven for the new outcome. Indeterminate work requires explicit user reconciliation."
64}
65
66func (t *SubagentResumeTool) Schema() json.RawMessage {
67 return json.RawMessage(subagentResumeSchema)
68}
69
70func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
71 if ctx != nil {
72 select {
73 case <-ctx.Done():
74 return core.ToolResult{}, ctx.Err()
75 default:
76 }
77 }
78 prefix := t.Name()
79 if t.Supervisor == nil {
80 return protocolToolError(prefix + ": subagent supervisor not available in this mode")
81 }
82 if t.Enabled == nil || !t.Enabled() {
83 return protocolToolError(prefix + ": subagent management is unavailable in this mode")
84 }
85
86 var args subagentResumeArgs
87 if err := json.Unmarshal(raw, &args); err != nil {
88 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
89 }
90 id := strings.TrimSpace(args.AgentID)
91 if id == "" {
92 return protocolToolError(prefix + ": agent_id is required")
93 }
94 if strings.TrimSpace(args.Prompt) == "" {
95 return protocolToolError(prefix + ": prompt is required")
96 }
97 snapshot, ok := findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), id)
98 if !ok {
99 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
100 }
101 if snapshot.Requirement.State == subagents.RequirementIndeterminate {
102 return protocolToolError(fmt.Sprintf("%s: required outcome for %s is indeterminate after host restart; the user must inspect the durable result or external side effect, then explicitly use /subagents resume-session, /subagents restart-task, or /subagents remove", prefix, snapshot.ID))
103 }
104 required := args.Required || snapshot.Requirement.Unmet()
105 var before func(*subagents.Agent, string) func()
106 if t.BeforeResumed != nil {
107 before = func(agent *subagents.Agent, prompt string) func() {
108 return t.BeforeResumed(agent, prompt, required)
109 }
110 }
111 var resumed *subagents.Agent
112 var err error
113 if required {
114 resumed, err = t.Supervisor.ResumeRequiredWithPromptBefore(ctx, snapshot.ID, args.Prompt, before)
115 } else {
116 resumed, err = t.Supervisor.ResumeWithPromptBefore(ctx, snapshot.ID, args.Prompt, before)
117 }
118 if err != nil {
119 return protocolToolError(prefix + ": " + err.Error())
120 }
121 if t.BeforeResumed == nil && t.OnResumed != nil {
122 t.OnResumed(resumed, args.Prompt, required)
123 }
124 snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID)
125 if !ok {
126 return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix)
127 }
128 return renderSubagentAction("resumed", snapshot, t.Supervisor.TraceViews()[snapshot.ID])
129}