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