1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8
9 "github.com/patriceckhart/zot/packages/agent/swarm"
10 "github.com/patriceckhart/zot/packages/core"
11 "github.com/patriceckhart/zot/packages/provider"
12)
13
14// SwarmSpawnTool lets the main agent fork a background sub-agent
15// against the host's cwd via swarm.Swarm.SpawnReq. The sub-agent runs
16// in parallel: the tool returns the agent id immediately and the main
17// turn continues uninterrupted. The user can monitor / chat with the
18// spawned agent via /swarm.
19//
20// Gated by the auto_swarm_enabled config flag at call time so a user
21// can flip it off mid-session and the next call refuses cleanly
22// without re-registering the tool.
23type SwarmSpawnTool struct {
24 // Swarm is the supervisor used to spawn agents. Nil means
25 // "auto-swarm not available in this mode" and the tool always
26 // errors.
27 Swarm *swarm.Swarm
28
29 // Enabled reads the live config flag. Lets users toggle from
30 // /settings without rebuilding the agent. When nil, the tool
31 // is treated as disabled.
32 Enabled func() bool
33
34 // DefaultModel and DefaultProvider return the host agent's resolved
35 // model and provider. They are used when the tool call omits both
36 // fields, so auto-swarm follows the same auth route as the user sees
37 // in the parent session.
38 DefaultModel func() string
39 DefaultProvider func() string
40
41 // OnSpawned, if set, is called after every successful spawn with
42 // the new agent + the task it was started with. Used by the
43 // interactive host to track agents and surface a summary back
44 // in the main chat once they all finish.
45 OnSpawned func(agent *swarm.Agent, task string)
46}
47
48type swarmSpawnArgs struct {
49 Task string `json:"task"`
50 Model string `json:"model,omitempty"`
51 Provider string `json:"provider,omitempty"`
52}
53
54const swarmSpawnSchema = `{
55 "type": "object",
56 "properties": {
57 "task": {
58 "type": "string",
59 "description": "The full task description for the sub-agent. Be specific: the sub-agent has the same tools (read/write/edit/bash) and shares this working directory, but starts with NO context from this conversation."
60 },
61 "model": {
62 "type": "string",
63 "description": "Optional model id to pin the sub-agent to. Normally omit both model and provider so the sub-agent inherits the host session's resolved provider/model/auth route. Do not infer provider from model name. If you override this, also provide provider."
64 },
65 "provider": {
66 "type": "string",
67 "description": "Optional provider id. Normally omit both model and provider so the sub-agent inherits the host session. If you override this, also provide model. Note: openai means public OpenAI API-key auth; openai-codex means ChatGPT/Codex subscription auth."
68 }
69 },
70 "required": ["task"]
71}`
72
73func (t *SwarmSpawnTool) Name() string { return "swarm_spawn" }
74func (t *SwarmSpawnTool) Description() string {
75 return "Spawn a background sub-agent to work on a parallel sub-task. Returns the sub-agent id immediately; the sub-agent keeps running while this conversation continues. Useful for splitting independent work (write tests while implementing a feature, refactor module A while drafting module B). The sub-agent shares this working directory and has the same tools."
76}
77func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) }
78
79func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
80 if t.Swarm == nil {
81 return toolErr("swarm_spawn: swarm supervisor not available in this mode"), nil
82 }
83 if t.Enabled == nil || !t.Enabled() {
84 return toolErr("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks."), nil
85 }
86 var a swarmSpawnArgs
87 if err := json.Unmarshal(raw, &a); err != nil {
88 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
89 }
90 task := strings.TrimSpace(a.Task)
91 if task == "" {
92 return toolErr("swarm_spawn: task is required"), nil
93 }
94
95 model := strings.TrimSpace(a.Model)
96 providerID := strings.TrimSpace(a.Provider)
97 if (model == "") != (providerID == "") {
98 return toolErr("swarm_spawn: omit both model/provider to inherit the host, or provide both explicitly"), nil
99 }
100 if model == "" && providerID == "" {
101 if t.DefaultModel != nil {
102 model = strings.TrimSpace(t.DefaultModel())
103 }
104 if t.DefaultProvider != nil {
105 providerID = strings.TrimSpace(t.DefaultProvider())
106 }
107 }
108
109 agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
110 Task: task,
111 Model: model,
112 Provider: providerID,
113 })
114 if err != nil {
115 return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err)
116 }
117 if t.OnSpawned != nil {
118 t.OnSpawned(agent, task)
119 }
120
121 var sb strings.Builder
122 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
123 fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
124 if model != "" {
125 fmt.Fprintf(&sb, "model: %s\n", model)
126 }
127 if providerID != "" {
128 fmt.Fprintf(&sb, "provider: %s\n", providerID)
129 }
130 sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ")
131 sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")
132 return core.ToolResult{
133 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
134 Details: map[string]any{
135 "agent_id": agent.ID,
136 "task": task,
137 "model": model,
138 "provider": providerID,
139 },
140 }, nil
141}
142
143func toolErr(msg string) core.ToolResult {
144 return core.ToolResult{
145 Content: []provider.Content{provider.TextBlock{Text: msg}},
146 IsError: true,
147 }
148}
149
150func truncateTask(s string, n int) string {
151 if len(s) <= n {
152 return s
153 }
154 return s[:n-3] + "..."
155}