X-Git-Url: http://git.giacomolli.uk/onechat/blobdiff_plain/ddd0a50f7b801112376d7fb27bfd05d2fc26c2fd..33aa709aa4e59e05ada6940a1c055f5e5646a418:/subagent_run.go?ds=inline diff --git a/subagent_run.go b/subagent_run.go index 3ee98a0..16cf2bd 100644 --- a/subagent_run.go +++ b/subagent_run.go @@ -2,9 +2,12 @@ package main import ( "context" + "encoding/json" "errors" "fmt" + "maps" "os" + "path/filepath" "slices" "strings" "sync" @@ -17,21 +20,42 @@ const ( // subagentNoteEffort follows subagentNote when the subagent has its own // effort. subagentNoteEffort = " at effort " + + // subagentWorkFileName is the file of the names of the running + // subagents in the directory of an agent. + subagentWorkFileName = "work.json" +) + +// The statuses of the end of a run, in the work event of its report. +const ( + subagentFinished = "finished" + subagentStopped = "stopped" + subagentFailed = "failed" ) // errSubagentClosed tells that the runs of the subagents ended with the loop. var errSubagentClosed = errors.New("the daemon stops, so no subagent starts") -// SubagentRuns runs the subagents of one agent in the background. Each run is -// a goroutine with its own Chain and its own context, which only Close ends. -// The first request of each chain holds the view of the agent at the spawn, -// then the state of the turn, then the whole log of the subagent. The report of -// the run goes to report: its final answer, or the error of a failed call. At -// the stop of the daemon the runs end with no report. +// SubagentRuns runs the subagents of one agent in the background. Each run is a +// goroutine with its own context, which only Stop and Close end. A run is one +// or more request chains. The first request of each chain holds the view of the +// agent at the start of the run, then the state of the turn, then the whole log +// of the subagent. A message of Tell that comes during a chain joins its next +// request after the tool results. One that comes after the final answer starts +// one more chain, which reads the log again. The report of the run goes to +// report: the final answer of each chain, or the error of a failed call. Stop +// ends one run, and its report says that it stopped. At the stop of the daemon +// the runs end with no report. +// +// work.json in the directory of the agent holds the names of the running +// subagents. Each start and each end of a run writes it whole, but Close does +// not, so after a stop of the daemon it names the runs that the stop cut, and +// Resume goes on with them. The logs alone cannot tell this, because a stop +// can cut a run just after its final reply. // -// Spawn splits the view with the layout of the agent, which has no lock. Spawn -// is a tool, so it runs in the goroutine of the turns, as each split of a turn -// does. +// Spawn and Tell split the view with the layout of the agent, which has no +// lock. They are tools, so they run in the goroutine of the turns, as each +// split of a turn does. type SubagentRuns struct { agent *Agent memory *Memory @@ -39,7 +63,9 @@ type SubagentRuns struct { layout *ViewLayout client ChainClient state func() string - report func(text string, stopped bool) + report func(name, text, status string) + note func(text string) error + fail func(err error) notify func(Event) mutex sync.Mutex @@ -102,6 +128,139 @@ func (r *SubagentRuns) Spawn(ctx context.Context, tasks []string, effort *string return "Started " + strings.Join(names, ", ") + ". Each one's report will reach you as a message when it finishes.", nil } +// Tell sends message to the subagent name. A running subagent reads it between +// its tool calls, and its report answers it. A finished subagent goes on from +// its log with a new run, with the view of now and the effort of its note. +func (r *SubagentRuns) Tell(ctx context.Context, name, message string) (string, error) { + r.mutex.Lock() + run, found := r.running[name] + if found { + defer r.mutex.Unlock() + + told, err := run.subagent.Append("user", message) + if err != nil { + return "", err + } + + run.pending = append(run.pending, told) + + return "Sent. " + name + " reads it between its tool calls, and its report answers it.", nil + } + + closed := r.closed + r.mutex.Unlock() + + if closed { + return "", errSubagentClosed + } + + subagent, err := r.subagents.Get(ctx, name) + if err != nil { + return "", err + } + + effort, err := r.effort(subagent) + if err != nil { + return "", err + } + + err = r.memory.Settle(ctx) + if err != nil { + return "", fmt.Errorf("wait for the summaries: %w", err) + } + + view, err := r.layout.Split(r.memory.ViewLines(nil)) + if err != nil { + return "", err + } + + _, err = subagent.Append("user", message) + if err == nil { + err = subagent.Flush(ctx) + } + + if err != nil { + return "", err + } + + err = r.start(ctx, &SubagentRun{ + subagent: subagent, + view: view, + effort: effort, + }) + if err != nil { + return "", err + } + + return "Sent. " + name + "'s reply will reach you as a message.", nil +} + +// Stop ends the run of the subagent name and tells if it ran. The chat gets the +// note "Stopped by the user: NAME." under the mutex, so the note comes before +// the report of the run. +func (r *SubagentRuns) Stop(name string) (bool, error) { + r.mutex.Lock() + defer r.mutex.Unlock() + + run, found := r.running[name] + if !found { + return false, nil + } + + err := r.note("Stopped by the user: " + name + ".") + if err != nil { + return true, err + } + + run.stopped = true + run.cancel() + + return true, nil +} + +// Resume goes on with the runs that a stop of the daemon cut, as work.json +// names them. The chat gets the note "The daemon restarted, cutting NAMES; +// nothing was lost: go on.", and each cut subagent gets the same text as a +// message of Tell. A subagent that cannot go on gets a failed report. +func (r *SubagentRuns) Resume(ctx context.Context) error { + names, err := r.load() + if err != nil || len(names) == 0 { + return err + } + + text := "The daemon restarted, cutting " + strings.Join(names, ", ") + "; nothing was lost: go on." + err = r.note(text) + if err != nil { + return err + } + + for _, name := range names { + _, err = r.Tell(ctx, name, text) + if err != nil { + r.report(name, subagentReport(name, []string{"failed: " + err.Error()}), subagentFailed) + } + } + + // A subagent that cannot go on does not run, so work.json no longer + // names it, and the next start does not try it again. + r.mutex.Lock() + defer r.mutex.Unlock() + + return r.save() +} + +// Close ends every run and waits for them. No subagent starts after Close. +func (r *SubagentRuns) Close() { + r.mutex.Lock() + r.closed = true + for _, run := range r.running { + run.cancel() + } + r.mutex.Unlock() + + r.workers.Wait() +} + // create makes a new subagent with its note, its task, and its work directory, // and starts its run. func (r *SubagentRuns) create(ctx context.Context, note, task string, view []string, effort string) (string, error) { @@ -134,20 +293,54 @@ func (r *SubagentRuns) create(ctx context.Context, note, task string, view []str }) } +// effort returns the effort of subagent from the note at the start of its log: +// the effort of the agent, or the effort that the note names. +func (r *SubagentRuns) effort(subagent *Subagent) (string, error) { + note, err := subagent.First() + if err != nil { + return "", err + } + + effort, own := strings.CutPrefix(note.Text, subagentNote+subagentNoteEffort) + switch { + case note.Kind != "note": + case own: + return effort, nil + case note.Text == subagentNote: + return r.agent.config.Effort, nil + } + + return "", fmt.Errorf("the log of %s does not start with a subagent note", subagent.name) +} + // start runs run in the background. Its context keeps the values of ctx, but -// only Close ends it. +// only Stop and Close end it. func (r *SubagentRuns) start(ctx context.Context, run *SubagentRun) error { ctx, run.cancel = context.WithCancel(context.WithoutCancel(ctx)) r.mutex.Lock() - if r.closed { + _, found := r.running[run.subagent.name] + if r.closed || found { r.mutex.Unlock() run.cancel() + if found { + return fmt.Errorf("%s runs already", run.subagent.name) + } + return errSubagentClosed } r.running[run.subagent.name] = run + err := r.save() + if err != nil { + delete(r.running, run.subagent.name) + r.mutex.Unlock() + run.cancel() + + return err + } + r.workers.Add(1) r.mutex.Unlock() @@ -161,35 +354,112 @@ func (r *SubagentRuns) start(ctx context.Context, run *SubagentRun) error { return nil } -// run runs the chain of run and reports its end, unless Close ended it. +// load reads the names of work.json. A missing file names none. +func (r *SubagentRuns) load() ([]string, error) { + data, err := os.ReadFile(r.workPath()) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + + if err != nil { + return nil, fmt.Errorf("read running subagents: %w", err) + } + + var file SubagentWorkFile + err = json.Unmarshal(data, &file) + if err != nil { + return nil, fmt.Errorf("decode running subagents: %w", err) + } + + return file.Running, nil +} + +// save writes the names of the running subagents to work.json. The caller +// holds the mutex. +func (r *SubagentRuns) save() error { + file := SubagentWorkFile{ + Running: append([]string{}, slices.Sorted(maps.Keys(r.running))...), + } + + data, err := json.Marshal(file) + if err != nil { + return fmt.Errorf("encode running subagents: %w", err) + } + + err = writeFileAtomic(r.workPath(), append(data, '\n')) + if err != nil { + return fmt.Errorf("save running subagents: %w", err) + } + + return nil +} + +func (r *SubagentRuns) workPath() string { + return filepath.Join(r.agent.dir, subagentWorkFileName) +} + +// run runs the chains of run and reports its end, unless Close ended it. A +// chain follows while messages wait after the final answer of the chain before +// it, and no chain failed. A chain that Stop ends gives the reply "stopped". func (r *SubagentRuns) run(ctx context.Context, run *SubagentRun) { defer r.workers.Done() defer run.cancel() - reply := r.chain(ctx, run) + var replies []string + stopped := false + status := subagentFinished + for more := true; more; { + reply, err := r.chain(ctx, run) + + r.mutex.Lock() + stopped = run.stopped + switch { + case err != nil && stopped: + reply = "stopped" + case err != nil: + reply = "failed: " + err.Error() + status = subagentFailed + } - r.mutex.Lock() - delete(r.running, run.subagent.name) - r.mutex.Unlock() + more = err == nil && ctx.Err() == nil && len(run.pending) > 0 + run.pending = nil + var saveErr error + if !more { + delete(r.running, run.subagent.name) + if !r.closed { + saveErr = r.save() + } + } + r.mutex.Unlock() + + if saveErr != nil { + r.fail(saveErr) + } + + replies = append(replies, reply) + } - if ctx.Err() != nil { + if ctx.Err() != nil && !stopped { return } - r.report(subagentReport(run.subagent.name, []string{reply}), false) + if stopped { + status = subagentStopped + } + + r.report(run.subagent.name, subagentReport(run.subagent.name, replies), status) } -// chain runs one request chain of run and returns its final answer, or the -// error of the chain after "failed: ". -func (r *SubagentRuns) chain(ctx context.Context, run *SubagentRun) string { +// chain runs one request chain of run and returns its final answer. +func (r *SubagentRuns) chain(ctx context.Context, run *SubagentRun) (string, error) { instructions, err := r.agent.SubagentPrompt() if err != nil { - return "failed: " + err.Error() + return "", err } log, err := run.subagent.Show() if err != nil { - return "failed: " + err.Error() + return "", err } input := make([]any, 0, len(run.view)+2) @@ -214,37 +484,41 @@ func (r *SubagentRuns) chain(ctx context.Context, run *SubagentRun) string { Agent: run.subagent.name, }, take: func() []Message { - return nil + return r.take(run) }, } - reply, err := chain.Run(ctx, input) - if err != nil { - return "failed: " + err.Error() - } - - return reply + return chain.Run(ctx, input) } -// Close ends every run and waits for them. No subagent starts after Close. -func (r *SubagentRuns) Close() { +// take returns the messages of Tell that wait for a request of run, and +// removes them. +func (r *SubagentRuns) take(run *SubagentRun) []Message { r.mutex.Lock() - r.closed = true - for _, run := range r.running { - run.cancel() - } - r.mutex.Unlock() + defer r.mutex.Unlock() - r.workers.Wait() + messages := run.pending + run.pending = nil + + return messages } // SubagentRun is one run of a subagent: the view that its requests get, its -// effort, and the end of its context. +// effort, the end of its context, the messages of Tell that wait for a request, +// and whether Stop ended it. The mutex of SubagentRuns protects the messages +// and the stop. type SubagentRun struct { subagent *Subagent view []string effort string cancel context.CancelFunc + pending []Message + stopped bool +} + +// SubagentWorkFile is the content of work.json. +type SubagentWorkFile struct { + Running []string `json:"running"` } // subagentReport returns the report of the subagent name with its replies: the