]> git.giacomolli.uk - onechat/blob - subagent_run.go
Complete context on forwarded local errors
[onechat] / subagent_run.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "log/slog"
9 "maps"
10 "os"
11 "path/filepath"
12 "slices"
13 "strings"
14 "sync"
15 "time"
16 )
17
18 const (
19 // subagentNote is the first message of the log of a subagent.
20 subagentNote = "subagent"
21
22 // subagentNoteEffort follows subagentNote when the subagent has its own
23 // effort.
24 subagentNoteEffort = " at effort "
25
26 // subagentWorkFileName is the file of the names of the running
27 // subagents in the directory of an agent.
28 subagentWorkFileName = "work.json"
29 )
30
31 // The statuses of the end of a run, in the work event of its report.
32 const (
33 subagentFinished = "finished"
34 subagentStopped = "stopped"
35 subagentFailed = "failed"
36 )
37
38 // errSubagentClosed tells that the runs of the subagents ended with the loop.
39 var errSubagentClosed = errors.New("the daemon stops, so no subagent starts")
40
41 // SubagentRuns runs the subagents of one agent in the background. Each run is a
42 // goroutine with its own context, which only Stop and Close end. A run is one
43 // or more request chains. The first request of each chain holds the view of the
44 // agent at the start of the run, then the state of the turn, then the whole log
45 // of the subagent. A message of Tell that comes during a chain joins its next
46 // request after the tool results. One that comes after the final answer starts
47 // one more chain, which reads the log again. The report of the run goes to
48 // report: the final answer of each chain, or the error of a failed call. The
49 // logger gets one line at the end of a run: done, failed with the cause, or
50 // stopped. Stop ends one run, and its report says that it stopped. At the stop
51 // of the daemon the runs end with no report.
52 //
53 // work.json in the directory of the agent holds the names of the running
54 // subagents. Each start and each end of a run writes it whole, but Close does
55 // not, so after a stop of the daemon it names the runs that the stop cut, and
56 // Resume goes on with them. The logs alone cannot tell this, because a stop can
57 // cut a run just after its final reply.
58 //
59 // Spawn and Tell split the view with the layout of the agent, which has no
60 // lock. They are tools, so they run in the goroutine of the turns, as each
61 // split of a turn does.
62 type SubagentRuns struct {
63 agent *Agent
64 memory *Memory
65 subagents *Subagents
66 layout *ViewLayout
67 client ChainClient
68 state func() string
69 report func(name, text, status string)
70 note func(text string) error
71 fail func(err error)
72 logger *slog.Logger
73 notify func(Event)
74
75 mutex sync.Mutex
76 running map[string]*SubagentRun
77 closed bool
78 workers sync.WaitGroup
79 }
80
81 // SubagentRun is one run of a subagent: the view that its requests get, its
82 // effort, the end of its context, the messages of Tell that wait for a request,
83 // whether Stop ended it, and the time it started. The mutex of SubagentRuns
84 // protects the messages and the stop.
85 type SubagentRun struct {
86 subagent *Subagent
87 view []string
88 effort string
89 cancel context.CancelFunc
90 pending []Message
91 stopped bool
92 started time.Time
93 }
94
95 // SubagentWorkFile is the content of work.json.
96 type SubagentWorkFile struct {
97 Running []string `json:"running"`
98 }
99
100 // Spawn starts one subagent for each task and returns their names at once. It
101 // waits until the view has no pending summary, and gives the view to each
102 // subagent. A nil effort is the effort of the agent.
103 func (r *SubagentRuns) Spawn(ctx context.Context, tasks []string, effort *string) (string, error) {
104 if len(tasks) == 0 {
105 return "", errors.New("no tasks")
106 }
107
108 r.mutex.Lock()
109
110 closed := r.closed
111 r.mutex.Unlock()
112
113 if closed {
114 return "", errSubagentClosed
115 }
116
117 note := subagentNote
118 runEffort := r.agent.config.Effort
119 if effort != nil && !slices.Contains(promptEfforts, *effort) {
120 return "", fmt.Errorf("no effort %q: one of %s", *effort, strings.Join(promptEfforts, ", "))
121 }
122
123 if effort != nil && *effort != runEffort {
124 note += subagentNoteEffort + *effort
125 runEffort = *effort
126 }
127
128 err := r.memory.Settle(ctx)
129 if err != nil {
130 return "", fmt.Errorf("wait for the summaries: %w", err)
131 }
132
133 view, err := r.layout.Split(r.memory.ViewLines(nil))
134 if err != nil {
135 return "", fmt.Errorf("split worker view: %w", err)
136 }
137
138 names := make([]string, 0, len(tasks))
139 for _, task := range tasks {
140 name, err := r.create(ctx, note, task, view, runEffort)
141 if err != nil && len(names) > 0 {
142 return "", fmt.Errorf("started %s, then: %w", strings.Join(names, ", "), err)
143 }
144
145 if err != nil {
146 return "", fmt.Errorf("create worker: %w", err)
147 }
148
149 names = append(names, name)
150 }
151
152 return "Started " + strings.Join(names, ", ") + ". Each one's report will reach you as a message when it finishes.", nil
153 }
154
155 // create makes a new subagent with its note, its task, and its work directory,
156 // and starts its run.
157 func (r *SubagentRuns) create(ctx context.Context, note, task string, view []string, effort string) (string, error) {
158 subagent, err := r.subagents.Create(ctx)
159 if err != nil {
160 return "", fmt.Errorf("create worker log: %w", err)
161 }
162
163 for _, message := range [][2]string{{"note", note}, {"user", task}} {
164 _, err = subagent.Append(message[0], message[1])
165 if err != nil {
166 return "", fmt.Errorf("append worker message: %w", err)
167 }
168 }
169
170 err = subagent.Flush(ctx)
171 if err != nil {
172 return "", fmt.Errorf("flush worker log: %w", err)
173 }
174
175 err = os.MkdirAll(subagent.WorkDir(), 0o700)
176 if err != nil {
177 return "", fmt.Errorf("create work directory: %w", err)
178 }
179
180 err = r.start(ctx, &SubagentRun{
181 subagent: subagent,
182 view: view,
183 effort: effort,
184 })
185 if err != nil {
186 return subagent.name, fmt.Errorf("start created worker: %w", err)
187 }
188
189 return subagent.name, nil
190 }
191
192 // start runs run in the background and logs its start. Its context keeps the
193 // values of ctx, but only Stop and Close end it.
194 func (r *SubagentRuns) start(ctx context.Context, run *SubagentRun) error {
195 ctx, run.cancel = context.WithCancel(context.WithoutCancel(ctx))
196
197 r.mutex.Lock()
198
199 _, found := r.running[run.subagent.name]
200 if r.closed || found {
201 r.mutex.Unlock()
202 run.cancel()
203
204 if found {
205 return fmt.Errorf("%s runs already", run.subagent.name)
206 }
207
208 return errSubagentClosed
209 }
210
211 r.running[run.subagent.name] = run
212 err := r.save()
213 if err != nil {
214 delete(r.running, run.subagent.name)
215 r.mutex.Unlock()
216 run.cancel()
217
218 if err != nil {
219 return fmt.Errorf("save running workers: %w", err)
220 }
221
222 return nil
223 }
224
225 r.workers.Add(1)
226 r.mutex.Unlock()
227
228 run.started = r.subagents.clock()
229 r.logger.Info("subagent started", "name", run.subagent.name)
230 r.notify(Event{
231 Kind: "subagent",
232 Text: run.subagent.name,
233 })
234
235 go r.run(ctx, run)
236
237 return nil
238 }
239
240 // save writes the names of the running subagents to work.json. The caller holds
241 // the mutex.
242 func (r *SubagentRuns) save() error {
243 file := SubagentWorkFile{
244 Running: append([]string{}, slices.Sorted(maps.Keys(r.running))...),
245 }
246
247 data, err := json.Marshal(file)
248 if err != nil {
249 return fmt.Errorf("encode running subagents: %w", err)
250 }
251
252 err = writeFileAtomic(r.workPath(), append(data, '\n'))
253 if err != nil {
254 return fmt.Errorf("save running subagents: %w", err)
255 }
256
257 return nil
258 }
259
260 // run runs the chains of run and reports and logs its end with the length of
261 // the run, unless Close ended it. A chain follows while messages wait after the
262 // final answer of the chain before it, and no chain failed. A chain that Stop
263 // ends gives the reply "stopped".
264 func (r *SubagentRuns) run(ctx context.Context, run *SubagentRun) {
265 defer r.workers.Done()
266 defer run.cancel()
267
268 var (
269 replies []string
270 failure error
271 )
272
273 stopped := false
274 status := subagentFinished
275 for more := true; more; {
276 reply, err := r.chain(ctx, run)
277
278 r.mutex.Lock()
279
280 stopped = run.stopped
281 switch {
282 case err != nil && stopped:
283 reply = "stopped"
284 case err != nil:
285 reply = "failed: " + err.Error()
286 status = subagentFailed
287 failure = err
288 }
289
290 more = err == nil && ctx.Err() == nil && len(run.pending) > 0
291 run.pending = nil
292
293 var saveErr error
294
295 if !more {
296 delete(r.running, run.subagent.name)
297
298 if !r.closed {
299 saveErr = r.save()
300 }
301 }
302
303 r.mutex.Unlock()
304
305 if saveErr != nil {
306 r.fail(saveErr)
307 }
308
309 replies = append(replies, reply)
310 }
311
312 if ctx.Err() != nil && !stopped {
313 return
314 }
315
316 if stopped {
317 status = subagentStopped
318 }
319
320 length := r.subagents.clock().Sub(run.started)
321 switch status {
322 case subagentStopped:
323 r.logger.Info("subagent stopped", "name", run.subagent.name, "after", length)
324 case subagentFailed:
325 r.logger.Error("subagent failed", "name", run.subagent.name, "after", length, "err", failure)
326 default:
327 r.logger.Info("subagent done", "name", run.subagent.name, "after", length)
328 }
329
330 r.report(run.subagent.name, subagentReport(run.subagent.name, replies), status)
331 }
332
333 func (r *SubagentRuns) workPath() string {
334 return filepath.Join(r.agent.dir, subagentWorkFileName)
335 }
336
337 // chain runs one request chain of run and returns its final answer.
338 func (r *SubagentRuns) chain(ctx context.Context, run *SubagentRun) (string, error) {
339 instructions, err := r.agent.SubagentPrompt()
340 if err != nil {
341 return "", fmt.Errorf("read worker instructions: %w", err)
342 }
343
344 log, err := run.subagent.Show()
345 if err != nil {
346 return "", fmt.Errorf("read worker log: %w", err)
347 }
348
349 input := make([]any, 0, len(run.view)+2)
350 for _, text := range run.view {
351 input = append(input, modelUserMessage(text))
352 }
353
354 input = append(input, modelUserMessage(r.state()), modelUserMessage(log))
355 chain := &Chain{
356 client: r.client,
357 images: r.agent.Images(),
358 tools: &Toolbox{
359 log: run.subagent,
360 chat: r.memory,
361 subagents: r.subagents,
362 workdir: run.subagent.WorkDir(),
363 images: r.agent.Images(),
364 },
365 request: ModelRequest{
366 Model: r.agent.config.Model,
367 Effort: run.effort,
368 Instructions: instructions,
369 Tools: promptSubagentTools(),
370 Agent: run.subagent.name,
371 },
372 take: func() []Message {
373 return r.take(run)
374 },
375 }
376
377 text, err := chain.Run(ctx, input)
378 if err != nil {
379 return text, fmt.Errorf("run worker chain: %w", err)
380 }
381
382 return text, nil
383 }
384
385 // take returns the messages of Tell that wait for a request of run, and removes
386 // them.
387 func (r *SubagentRuns) take(run *SubagentRun) []Message {
388 r.mutex.Lock()
389 defer r.mutex.Unlock()
390
391 messages := run.pending
392 run.pending = nil
393
394 return messages
395 }
396
397 // Stop ends the run of the subagent name and tells if it ran. The chat gets the
398 // note "Stopped by the user: NAME." under the mutex, so the note comes before
399 // the report of the run.
400 func (r *SubagentRuns) Stop(name string) (bool, error) {
401 r.mutex.Lock()
402 defer r.mutex.Unlock()
403
404 run, found := r.running[name]
405 if !found {
406 return false, nil
407 }
408
409 err := r.note("Stopped by the user: " + name + ".")
410 if err != nil {
411 return true, err
412 }
413
414 run.stopped = true
415 run.cancel()
416
417 return true, nil
418 }
419
420 // Resume goes on with the runs that a stop of the daemon cut, as work.json
421 // names them. The chat gets the note "The daemon restarted, cutting NAMES;
422 // nothing was lost: go on.", and each cut subagent gets the same text as a
423 // message of Tell. A subagent that cannot go on gets a failed report.
424 func (r *SubagentRuns) Resume(ctx context.Context) error {
425 names, err := r.load()
426 if err != nil || len(names) == 0 {
427 if err != nil {
428 return fmt.Errorf("load interrupted workers: %w", err)
429 }
430
431 return nil
432 }
433
434 text := "The daemon restarted, cutting " + strings.Join(names, ", ") + "; nothing was lost: go on."
435 err = r.note(text)
436 if err != nil {
437 return fmt.Errorf("record interrupted workers: %w", err)
438 }
439
440 for _, name := range names {
441 _, err = r.Tell(ctx, name, text)
442 if err != nil {
443 r.logger.Error("subagent lost at restart", "name", name, "err", err)
444 r.report(name, subagentReport(name, []string{"failed: " + err.Error()}), subagentFailed)
445 }
446 }
447
448 // A subagent that cannot go on does not run, so work.json no longer
449 // names it, and the next start does not try it again.
450 r.mutex.Lock()
451 defer r.mutex.Unlock()
452
453 err = r.save()
454 if err != nil {
455 return fmt.Errorf("save resumed workers: %w", err)
456 }
457
458 return nil
459 }
460
461 // load reads the names of work.json. A missing file names none.
462 func (r *SubagentRuns) load() ([]string, error) {
463 data, err := os.ReadFile(r.workPath())
464 if errors.Is(err, os.ErrNotExist) {
465 return nil, nil
466 }
467
468 if err != nil {
469 return nil, fmt.Errorf("read running subagents: %w", err)
470 }
471
472 var file SubagentWorkFile
473
474 err = json.Unmarshal(data, &file)
475 if err != nil {
476 return nil, fmt.Errorf("decode running subagents: %w", err)
477 }
478
479 return file.Running, nil
480 }
481
482 // Tell sends message to the subagent name. A running subagent reads it between
483 // its tool calls, and its report answers it. A finished subagent goes on from
484 // its log with a new run, with the view of now and the effort of its note.
485 func (r *SubagentRuns) Tell(ctx context.Context, name, message string) (string, error) {
486 r.mutex.Lock()
487
488 run, found := r.running[name]
489 if found {
490 defer r.mutex.Unlock()
491
492 told, err := run.subagent.Append("user", message)
493 if err != nil {
494 return "", fmt.Errorf("append running worker message: %w", err)
495 }
496
497 run.pending = append(run.pending, told)
498
499 return "Sent. " + name + " reads it between its tool calls, and its report answers it.", nil
500 }
501
502 closed := r.closed
503 r.mutex.Unlock()
504
505 if closed {
506 return "", errSubagentClosed
507 }
508
509 subagent, err := r.subagents.Get(ctx, name)
510 if err != nil {
511 return "", fmt.Errorf("open worker log: %w", err)
512 }
513
514 effort, err := r.effort(subagent)
515 if err != nil {
516 return "", fmt.Errorf("read worker effort: %w", err)
517 }
518
519 err = r.memory.Settle(ctx)
520 if err != nil {
521 return "", fmt.Errorf("wait for the summaries: %w", err)
522 }
523
524 view, err := r.layout.Split(r.memory.ViewLines(nil))
525 if err != nil {
526 return "", fmt.Errorf("split worker view: %w", err)
527 }
528
529 _, err = subagent.Append("user", message)
530 if err == nil {
531 err = subagent.Flush(ctx)
532 }
533
534 if err != nil {
535 return "", fmt.Errorf("save worker message: %w", err)
536 }
537
538 err = r.start(ctx, &SubagentRun{
539 subagent: subagent,
540 view: view,
541 effort: effort,
542 })
543 if err != nil {
544 return "", fmt.Errorf("start told worker: %w", err)
545 }
546
547 return "Sent. " + name + "'s reply will reach you as a message.", nil
548 }
549
550 // effort returns the effort of subagent from the note at the start of its log:
551 // the effort of the agent, or the effort that the note names.
552 func (r *SubagentRuns) effort(subagent *Subagent) (string, error) {
553 note, err := subagent.First()
554 if err != nil {
555 return "", fmt.Errorf("read worker note: %w", err)
556 }
557
558 effort, own := strings.CutPrefix(note.Text, subagentNote+subagentNoteEffort)
559 switch {
560 case note.Kind != "note":
561 case own:
562 return effort, nil
563 case note.Text == subagentNote:
564 return r.agent.config.Effort, nil
565 }
566
567 return "", fmt.Errorf("the log of %s does not start with a subagent note", subagent.name)
568 }
569
570 // Snapshot locks the run state, memory, and subagent logs while snapshot reads
571 // the agent files. The lock order agrees with Stop, which logs under the run
572 // lock.
573 func (r *SubagentRuns) Snapshot(ctx context.Context, snapshot func() error) error {
574 r.mutex.Lock()
575 defer r.mutex.Unlock()
576
577 return r.memory.Snapshot(ctx, func() error {
578 return r.subagents.Snapshot(ctx, snapshot)
579 })
580 }
581
582 // Close ends every run and waits for them. No subagent starts after Close.
583 func (r *SubagentRuns) Close() {
584 r.mutex.Lock()
585
586 r.closed = true
587 for _, run := range r.running {
588 run.cancel()
589 }
590
591 r.mutex.Unlock()
592
593 r.workers.Wait()
594 }
595
596 // subagentReport returns the report of the subagent name with its replies: the
597 // name in brackets, a new line, the replies, and the zoom that gives its whole
598 // chat.
599 func subagentReport(name string, replies []string) string {
600 return "[" + name + "]\n" + strings.Join(replies, "\n\n") + "\n\nFull chat: zoom(\"" + name + "\")"
601 }