]> git.giacomolli.uk - onechat/blob - subagent_run.go
Stop a subagent with /stop NAME
[onechat] / subagent_run.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "slices"
9 "strings"
10 "sync"
11 )
12
13 const (
14 // subagentNote is the first message of the log of a subagent.
15 subagentNote = "subagent"
16
17 // subagentNoteEffort follows subagentNote when the subagent has its own
18 // effort.
19 subagentNoteEffort = " at effort "
20 )
21
22 // errSubagentClosed tells that the runs of the subagents ended with the loop.
23 var errSubagentClosed = errors.New("the daemon stops, so no subagent starts")
24
25 // SubagentRuns runs the subagents of one agent in the background. Each run is a
26 // goroutine with its own context, which only Stop and Close end. A run is one
27 // or more request chains. The first request of each chain holds the view of the
28 // agent at the start of the run, then the state of the turn, then the whole log
29 // of the subagent. A message of Tell that comes during a chain joins its next
30 // request after the tool results. One that comes after the final answer starts
31 // one more chain, which reads the log again. The report of the run goes to
32 // report: the final answer of each chain, or the error of a failed call. Stop
33 // ends one run, and its report says that it stopped. At the stop of the daemon
34 // the runs end with no report.
35 //
36 // Spawn and Tell split the view with the layout of the agent, which has no
37 // lock. They are tools, so they run in the goroutine of the turns, as each
38 // split of a turn does.
39 type SubagentRuns struct {
40 agent *Agent
41 memory *Memory
42 subagents *Subagents
43 layout *ViewLayout
44 client ChainClient
45 state func() string
46 report func(text string, stopped bool)
47 note func(text string) error
48 notify func(Event)
49
50 mutex sync.Mutex
51 running map[string]*SubagentRun
52 closed bool
53 workers sync.WaitGroup
54 }
55
56 // Spawn starts one subagent for each task and returns their names at once. It
57 // waits until the view has no pending summary, and gives the view to each
58 // subagent. A nil effort is the effort of the agent.
59 func (r *SubagentRuns) Spawn(ctx context.Context, tasks []string, effort *string) (string, error) {
60 if len(tasks) == 0 {
61 return "", errors.New("no tasks")
62 }
63
64 r.mutex.Lock()
65 closed := r.closed
66 r.mutex.Unlock()
67
68 if closed {
69 return "", errSubagentClosed
70 }
71
72 note := subagentNote
73 runEffort := r.agent.config.Effort
74 if effort != nil && !slices.Contains(promptEfforts, *effort) {
75 return "", fmt.Errorf("no effort %q: one of %s", *effort, strings.Join(promptEfforts, ", "))
76 }
77
78 if effort != nil && *effort != runEffort {
79 note += subagentNoteEffort + *effort
80 runEffort = *effort
81 }
82
83 err := r.memory.Settle(ctx)
84 if err != nil {
85 return "", fmt.Errorf("wait for the summaries: %w", err)
86 }
87
88 view, err := r.layout.Split(r.memory.ViewLines(nil))
89 if err != nil {
90 return "", err
91 }
92
93 names := make([]string, 0, len(tasks))
94 for _, task := range tasks {
95 name, err := r.create(ctx, note, task, view, runEffort)
96 if err != nil && len(names) > 0 {
97 return "", fmt.Errorf("started %s, then: %w", strings.Join(names, ", "), err)
98 }
99
100 if err != nil {
101 return "", err
102 }
103
104 names = append(names, name)
105 }
106
107 return "Started " + strings.Join(names, ", ") + ". Each one's report will reach you as a message when it finishes.", nil
108 }
109
110 // Tell sends message to the subagent name. A running subagent reads it between
111 // its tool calls, and its report answers it. A finished subagent goes on from
112 // its log with a new run, with the view of now and the effort of its note.
113 func (r *SubagentRuns) Tell(ctx context.Context, name, message string) (string, error) {
114 r.mutex.Lock()
115 run, found := r.running[name]
116 if found {
117 defer r.mutex.Unlock()
118
119 told, err := run.subagent.Append("user", message)
120 if err != nil {
121 return "", err
122 }
123
124 run.pending = append(run.pending, told)
125
126 return "Sent. " + name + " reads it between its tool calls, and its report answers it.", nil
127 }
128
129 closed := r.closed
130 r.mutex.Unlock()
131
132 if closed {
133 return "", errSubagentClosed
134 }
135
136 subagent, err := r.subagents.Get(ctx, name)
137 if err != nil {
138 return "", err
139 }
140
141 effort, err := r.effort(subagent)
142 if err != nil {
143 return "", err
144 }
145
146 err = r.memory.Settle(ctx)
147 if err != nil {
148 return "", fmt.Errorf("wait for the summaries: %w", err)
149 }
150
151 view, err := r.layout.Split(r.memory.ViewLines(nil))
152 if err != nil {
153 return "", err
154 }
155
156 _, err = subagent.Append("user", message)
157 if err == nil {
158 err = subagent.Flush(ctx)
159 }
160
161 if err != nil {
162 return "", err
163 }
164
165 err = r.start(ctx, &SubagentRun{
166 subagent: subagent,
167 view: view,
168 effort: effort,
169 })
170 if err != nil {
171 return "", err
172 }
173
174 return "Sent. " + name + "'s reply will reach you as a message.", nil
175 }
176
177 // Stop ends the run of the subagent name and tells if it ran. The chat gets the
178 // note "Stopped by the user: NAME." under the mutex, so the note comes before
179 // the report of the run.
180 func (r *SubagentRuns) Stop(name string) (bool, error) {
181 r.mutex.Lock()
182 defer r.mutex.Unlock()
183
184 run, found := r.running[name]
185 if !found {
186 return false, nil
187 }
188
189 err := r.note("Stopped by the user: " + name + ".")
190 if err != nil {
191 return true, err
192 }
193
194 run.stopped = true
195 run.cancel()
196
197 return true, nil
198 }
199
200 // Close ends every run and waits for them. No subagent starts after Close.
201 func (r *SubagentRuns) Close() {
202 r.mutex.Lock()
203 r.closed = true
204 for _, run := range r.running {
205 run.cancel()
206 }
207 r.mutex.Unlock()
208
209 r.workers.Wait()
210 }
211
212 // create makes a new subagent with its note, its task, and its work directory,
213 // and starts its run.
214 func (r *SubagentRuns) create(ctx context.Context, note, task string, view []string, effort string) (string, error) {
215 subagent, err := r.subagents.Create(ctx)
216 if err != nil {
217 return "", err
218 }
219
220 for _, message := range [][2]string{{"note", note}, {"user", task}} {
221 _, err = subagent.Append(message[0], message[1])
222 if err != nil {
223 return "", err
224 }
225 }
226
227 err = subagent.Flush(ctx)
228 if err != nil {
229 return "", err
230 }
231
232 err = os.MkdirAll(subagent.WorkDir(), 0o700)
233 if err != nil {
234 return "", fmt.Errorf("create work directory: %w", err)
235 }
236
237 return subagent.name, r.start(ctx, &SubagentRun{
238 subagent: subagent,
239 view: view,
240 effort: effort,
241 })
242 }
243
244 // effort returns the effort of subagent from the note at the start of its log:
245 // the effort of the agent, or the effort that the note names.
246 func (r *SubagentRuns) effort(subagent *Subagent) (string, error) {
247 note, err := subagent.First()
248 if err != nil {
249 return "", err
250 }
251
252 effort, own := strings.CutPrefix(note.Text, subagentNote+subagentNoteEffort)
253 switch {
254 case note.Kind != "note":
255 case own:
256 return effort, nil
257 case note.Text == subagentNote:
258 return r.agent.config.Effort, nil
259 }
260
261 return "", fmt.Errorf("the log of %s does not start with a subagent note", subagent.name)
262 }
263
264 // start runs run in the background. Its context keeps the values of ctx, but
265 // only Stop and Close end it.
266 func (r *SubagentRuns) start(ctx context.Context, run *SubagentRun) error {
267 ctx, run.cancel = context.WithCancel(context.WithoutCancel(ctx))
268
269 r.mutex.Lock()
270 _, found := r.running[run.subagent.name]
271 if r.closed || found {
272 r.mutex.Unlock()
273 run.cancel()
274
275 if found {
276 return fmt.Errorf("%s runs already", run.subagent.name)
277 }
278
279 return errSubagentClosed
280 }
281
282 r.running[run.subagent.name] = run
283 r.workers.Add(1)
284 r.mutex.Unlock()
285
286 r.notify(Event{
287 Kind: "subagent",
288 Text: run.subagent.name,
289 })
290
291 go r.run(ctx, run)
292
293 return nil
294 }
295
296 // run runs the chains of run and reports its end, unless Close ended it. A
297 // chain follows while messages wait after the final answer of the chain before
298 // it, and no chain failed. A chain that Stop ends gives the reply "stopped".
299 func (r *SubagentRuns) run(ctx context.Context, run *SubagentRun) {
300 defer r.workers.Done()
301 defer run.cancel()
302
303 var replies []string
304 stopped := false
305 for more := true; more; {
306 reply, err := r.chain(ctx, run)
307
308 r.mutex.Lock()
309 stopped = run.stopped
310 switch {
311 case err != nil && stopped:
312 reply = "stopped"
313 case err != nil:
314 reply = "failed: " + err.Error()
315 }
316
317 more = err == nil && ctx.Err() == nil && len(run.pending) > 0
318 run.pending = nil
319 if !more {
320 delete(r.running, run.subagent.name)
321 }
322 r.mutex.Unlock()
323
324 replies = append(replies, reply)
325 }
326
327 if ctx.Err() != nil && !stopped {
328 return
329 }
330
331 r.report(subagentReport(run.subagent.name, replies), stopped)
332 }
333
334 // chain runs one request chain of run and returns its final answer.
335 func (r *SubagentRuns) chain(ctx context.Context, run *SubagentRun) (string, error) {
336 instructions, err := r.agent.SubagentPrompt()
337 if err != nil {
338 return "", err
339 }
340
341 log, err := run.subagent.Show()
342 if err != nil {
343 return "", err
344 }
345
346 input := make([]any, 0, len(run.view)+2)
347 for _, text := range run.view {
348 input = append(input, modelUserMessage(text))
349 }
350
351 input = append(input, modelUserMessage(r.state()), modelUserMessage(log))
352 chain := &Chain{
353 client: r.client,
354 tools: &Toolbox{
355 log: run.subagent,
356 chat: r.memory,
357 subagents: r.subagents,
358 workdir: run.subagent.WorkDir(),
359 },
360 request: ModelRequest{
361 Model: r.agent.config.Model,
362 Effort: run.effort,
363 Instructions: instructions,
364 Tools: promptSubagentTools(),
365 Agent: run.subagent.name,
366 },
367 take: func() []Message {
368 return r.take(run)
369 },
370 }
371
372 return chain.Run(ctx, input)
373 }
374
375 // take returns the messages of Tell that wait for a request of run, and
376 // removes them.
377 func (r *SubagentRuns) take(run *SubagentRun) []Message {
378 r.mutex.Lock()
379 defer r.mutex.Unlock()
380
381 messages := run.pending
382 run.pending = nil
383
384 return messages
385 }
386
387 // SubagentRun is one run of a subagent: the view that its requests get, its
388 // effort, the end of its context, the messages of Tell that wait for a request,
389 // and whether Stop ended it. The mutex of SubagentRuns protects the messages
390 // and the stop.
391 type SubagentRun struct {
392 subagent *Subagent
393 view []string
394 effort string
395 cancel context.CancelFunc
396 pending []Message
397 stopped bool
398 }
399
400 // subagentReport returns the report of the subagent name with its replies: the
401 // name in brackets, a new line, the replies, and the zoom that gives its whole
402 // chat.
403 func subagentReport(name string, replies []string) string {
404 return "[" + name + "]\n" + strings.Join(replies, "\n\n") + "\n\nFull chat: zoom(\"" + name + "\")"
405 }