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