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