From: Anderson A. Giacomolli Date: Sun, 8 Nov 2026 13:57:31 +0000 (-0300) Subject: Show the subagents in the clients X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/eeb821ff17ad79c87ca9b64cd9be26f8612f4a03 Show the subagents in the clients When a subagent starts a run, the terminal client and Telegram show "[NAME] started". The terminal shows the whole report, which starts with the name. Telegram shows only "[NAME] finished", "[NAME] stopped", or "[NAME] failed", because the reply of the agent in the turn of the report carries its content, and the phone then gets one message for each result. Neither client shows the tool calls of a subagent. The work event of a report gets the name of the subagent in its source and the end of the run in a new status field, so that a client needs no parse of the report text. Issue: b1ac698 --- diff --git a/README.rst b/README.rst index 39a1eff..c94f226 100644 --- a/README.rst +++ b/README.rst @@ -82,9 +82,12 @@ running turn, the line ``/stop NAME`` stops the subagent NAME, and Ctrl-D ends the client. The client shows the replies, the tool calls, and the first lines of each tool result as they happen. It also shows each message that the user sends from another client, with the client as -a mark, such as ``[telegram] text``. The daemon is the only process that -uses the token file while it runs, so ``onechat login`` and ``onechat -probe`` refuse to start until it stops. +a mark, such as ``[telegram] text``. When a subagent starts, the client +shows ``[NAME] started``, and it shows the whole report of the subagent, +which starts with ``[NAME]``, but not the tool calls of the subagent. +The daemon is the only process that uses the token file while it runs, +so ``onechat login`` and ``onechat probe`` refuse to start until it +stops. The agent runs commands with bash as the user in its working directory, with no sandbox. It has the rights of the user, also on the token file. @@ -153,9 +156,11 @@ characters, with their Markdown as Telegram formats: bold, italic, strikethrough, code, code blocks, links, and headings as bold text. When Telegram refuses the formats of a part, the bot sends the part again as plain text. Each error goes as a short message in plain text. It shows -the typing action while a turn runs. It does not send the tool calls. It -sends each message that the user types in the terminal client, with the -mark ``[terminal]``. +the typing action while a turn runs. It does not send the tool calls. +For a subagent it sends only ``[NAME] started`` and ``[NAME] finished``, +``[NAME] stopped``, or ``[NAME] failed``, because the reply of the agent +carries the content of the report. It sends each message that the user +types in the terminal client, with the mark ``[terminal]``. ``agent/telegram-offset.json`` holds the ID of the next update. The bot moves it past an update only after the message is durable in the log, so diff --git a/prompt.go b/prompt.go index ee86ebd..b9d9284 100644 --- a/prompt.go +++ b/prompt.go @@ -185,8 +185,8 @@ func promptSubagent(name, instructions string) string { } // promptTools returns the function tools of every call of the agent: the tools -// of promptSubagentTools, then spawn and tell. A compaction sends them too and never -// calls them, because a change of the tools ends the shared prompt cache. +// of promptSubagentTools, then spawn and tell. A compaction sends them too and +// never calls them, because a change of the tools ends the shared prompt cache. func promptTools() []ModelTool { return append(promptSubagentTools(), ModelTool{ Type: "function", diff --git a/subagent_run.go b/subagent_run.go index 20b15f2..4a88800 100644 --- a/subagent_run.go +++ b/subagent_run.go @@ -19,6 +19,13 @@ const ( subagentNoteEffort = " at effort " ) +// 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") @@ -43,7 +50,7 @@ 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 notify func(Event) @@ -302,6 +309,7 @@ func (r *SubagentRuns) run(ctx context.Context, run *SubagentRun) { var replies []string stopped := false + status := subagentFinished for more := true; more; { reply, err := r.chain(ctx, run) @@ -312,6 +320,7 @@ func (r *SubagentRuns) run(ctx context.Context, run *SubagentRun) { reply = "stopped" case err != nil: reply = "failed: " + err.Error() + status = subagentFailed } more = err == nil && ctx.Err() == nil && len(run.pending) > 0 @@ -328,7 +337,11 @@ func (r *SubagentRuns) run(ctx context.Context, run *SubagentRun) { return } - r.report(subagentReport(run.subagent.name, replies), stopped) + 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. diff --git a/subagent_run_test.go b/subagent_run_test.go index 1fd6a4a..5caf0d8 100644 --- a/subagent_run_test.go +++ b/subagent_run_test.go @@ -57,6 +57,13 @@ func TestSpawnRunsASubagentInTheBackground(t *testing.T) { "work: " + report, "reply: Noted.", }, test.Log(t)) + require.Equal(t, Event{ + Kind: "work", + ID: 4, + Text: report, + Source: name, + Status: subagentFinished, + }, test.events.LastEvent("work")) var first, second *ModelFakeCall for n := range server.Calls() { @@ -131,7 +138,7 @@ func TestReportJoinsTheNextRequestOfARunningTurn(t *testing.T) { <-arrived report := subagentReport("GreenOtter", []string{"It is done."}) - test.loop.Report(report, false) + test.loop.Report("GreenOtter", report, subagentFinished) close(release) test.events.WaitFor(t, "idle", 1) @@ -146,7 +153,7 @@ func TestReportOfAStoppedSubagentStartsNoTurn(t *testing.T) { server := newModelFake(t) test := newTestTurnLoop(t, server) report := subagentReport("GreenOtter", []string{"failed: context canceled"}) - test.loop.Report(report, true) + test.loop.Report("GreenOtter", report, subagentStopped) test.events.WaitFor(t, "work", 1) time.Sleep(50 * time.Millisecond) @@ -178,6 +185,7 @@ func TestFullContextGivesAFailedReport(t *testing.T) { "work: [" + name + "]\nfailed: model call: HTTP 400: " + subagentTestFullContext + "\n\nFull chat: zoom(\"" + name + "\")", "reply: It failed.", }, test.Log(t)) + require.Equal(t, subagentFailed, test.events.LastEvent("work").Status) } func TestSubagentHasItsOwnEffortAndNoSpawn(t *testing.T) { @@ -361,6 +369,7 @@ func TestStopEndsASubagentAndItsCommand(t *testing.T) { "note: Stopped by the user: " + name + ".", "work: " + subagentReport(name, []string{"stopped"}), }, test.Log(t)) + require.Equal(t, subagentStopped, test.events.LastEvent("work").Status) log, err := subagent.Show() require.NoError(t, err) diff --git a/telegram.go b/telegram.go index c78080d..c560104 100644 --- a/telegram.go +++ b/telegram.go @@ -163,7 +163,7 @@ type TelegramBot struct { // stop the agent. func (b *TelegramBot) Notify(event Event) { switch event.Kind { - case "busy", "idle", "reply", "error", "notice": + case "busy", "idle", "reply", "error", "notice", "subagent", "work": case "user": if event.Source == telegramSource { return @@ -540,11 +540,17 @@ func telegramSplitMessage(text string, limit int) []string { } // telegramText returns the text of event for the chat. A user message of -// another client has its source as a mark. +// another client has its source as a mark. A subagent shows only its start and +// how its run ended, because the reply of the agent carries the content of the +// report. func telegramText(event Event) string { switch event.Kind { case "user": return "[" + event.Source + "] " + event.Text + case "subagent": + return "[" + event.Text + "] started" + case "work": + return "[" + event.Source + "] " + event.Status case "error": return "Error: " + event.Text default: diff --git a/telegram_test.go b/telegram_test.go index c1f1230..5e50ec1 100644 --- a/telegram_test.go +++ b/telegram_test.go @@ -136,6 +136,49 @@ func TestStopFromTelegramInterruptsTheTurn(t *testing.T) { telegramWaitForSent(t, telegram, "No turn runs.", "No subagent BlueFox runs.") } +func TestBotShowsOnlyTheStartAndTheEndOfASubagent(t *testing.T) { + telegram := newTelegramFake(t) + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { + last := subagentTestLastItem(call) + switch { + case subagentTestCall(call): + // The report comes after the reply of the turn of the spawn. + require.Eventually(t, func() bool { + return slices.Contains(telegram.Sent(), "Started.") + }, 10*time.Second, time.Millisecond) + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("There are 3 files.")}, 100, 0, 5)) + case last["type"] == "function_call_output": + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Started.")}, 100, 0, 5)) + case strings.HasPrefix(subagentTestText(last), "["): + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It counted 3 files.")}, 100, 0, 5)) + default: + modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["Count the files."],"effort":null}`)}, 100, 0, 5)) + } + } + + bot, _ := telegramStartTestBot(t, telegram) + daemonStartTestWithBot(t, server, bot) + telegram.Add(TelegramUpdate{ + UpdateID: 1, + Message: telegramOwnerMessage("Count the files in a subagent."), + }) + + require.Eventually(t, func() bool { + return slices.Contains(telegram.Sent(), "It counted 3 files.") + }, 10*time.Second, time.Millisecond) + + sent := telegram.Sent() + name := strings.TrimSuffix(strings.TrimPrefix(sent[0], "["), "] started") + require.True(t, subagentNameValid(name), sent[0]) + require.Equal(t, []string{ + "[" + name + "] started", + "Started.", + "[" + name + "] finished", + "It counted 3 files.", + }, sent) +} + func TestBotStartsAtTheStoredOffset(t *testing.T) { server := newModelFake(t) server.reply = func(w http.ResponseWriter, call ModelFakeCall) { diff --git a/terminal.go b/terminal.go index 9e2b650..96a8cc5 100644 --- a/terminal.go +++ b/terminal.go @@ -149,7 +149,8 @@ func terminalCmpError(err, fallback error) error { // terminalEventText returns the text that the client shows for event, or "" for // none. The client shows a user message only from another source, such as // telegram, because the user typed the others. A tool result shows its first -// terminalResultLines lines. +// terminalResultLines lines. The report of a subagent shows whole, and it +// starts with the name of the subagent. func terminalEventText(event Event) string { switch event.Kind { case "ready": @@ -166,6 +167,10 @@ func terminalEventText(event Event) string { return "[tool] " + event.Text case "echo": return "[result] " + terminalFirstLines(event.Text, terminalResultLines) + case "subagent": + return "[" + event.Text + "] started" + case "work": + return event.Text case "busy": return "[working]" case "idle": diff --git a/terminal_test.go b/terminal_test.go index 91e43d2..62ffb04 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -58,6 +58,16 @@ func TestEventText(t *testing.T) { event: Event{Kind: "user", Text: "from the phone", Source: "telegram"}, text: "[telegram] from the phone", }, + { + name: "start of a subagent", + event: Event{Kind: "subagent", Text: "GreenOtter"}, + text: "[GreenOtter] started", + }, + { + name: "report of a subagent whole", + event: Event{Kind: "work", Text: "[GreenOtter]\nThere are 3 files.\n\nFull chat: zoom(\"GreenOtter\")", Source: "GreenOtter", Status: subagentFinished}, + text: "[GreenOtter]\nThere are 3 files.\n\nFull chat: zoom(\"GreenOtter\")", + }, { name: "long result cut", event: Event{Kind: "echo", Text: strings.Repeat("line\n", 12)}, diff --git a/turn.go b/turn.go index 3c1c921..deb47d9 100644 --- a/turn.go +++ b/turn.go @@ -53,15 +53,18 @@ func newTurnLoop(agent *Agent, memory *Memory, subagents *Subagents, client Chai } // Event tells the clients what the agent does as it happens. Kind is the kind -// of a logged message (user, reply, tool, echo, work), or one of ready, busy, -// idle, error, and subagent, which tells that the subagent Text starts a run. ID is the ID of a logged message. Source names the client that -// sent a user message, such as terminal or telegram, so that each client can -// show the messages of the others. +// of a logged message (user, reply, tool, echo, note, work), or one of ready, +// busy, idle, error, and subagent, which tells that the subagent Text starts a +// run. ID is the ID of a logged message. Source names the client that sent a +// user message, such as terminal or telegram, so that each client can show the +// messages of the others, or the subagent of a report. Status tells how the run +// of a report ended: finished, stopped, or failed. type Event struct { Kind string `json:"kind"` ID int `json:"id,omitempty"` Text string `json:"text,omitempty"` Source string `json:"source,omitempty"` + Status string `json:"status,omitempty"` } // TurnLoop runs the turns of one agent. A user message goes to the log when it @@ -112,18 +115,28 @@ func (l *TurnLoop) Submit(source, text string) (Message, error) { return message, nil } -// Report logs the report text of a subagent as a work message and tells the -// clients. Unless the subagent was stopped, the report joins the waiting -// messages and wakes the loop, as a user message of Submit does. A failed write -// stops the loop, because the log refuses all writes after it. -func (l *TurnLoop) Report(text string, stopped bool) { - message, err := l.record("work", "", text) +// Report logs the report text of the subagent name as a work message and tells +// the clients, with the status of the run. Unless the subagent was stopped, the +// report joins the waiting messages and wakes the loop, as a user message of +// Submit does. A failed write stops the loop, because the log refuses all +// writes after it. +func (l *TurnLoop) Report(name, text, status string) { + message, err := l.memory.Append("work", text) + if err == nil { + l.notify(Event{ + Kind: "work", + ID: message.ID, + Text: text, + Source: name, + Status: status, + }) + } l.mutex.Lock() switch { case err != nil: l.failure = fmt.Errorf("%w: %w", errChainLogWrite, err) - case !stopped: + case status != subagentStopped: l.pending = append(l.pending, message) } l.mutex.Unlock() diff --git a/turn_test.go b/turn_test.go index 928ac33..b0417cf 100644 --- a/turn_test.go +++ b/turn_test.go @@ -308,16 +308,21 @@ func (r *TurnEventRecorder) Count(kind string) int { // Last returns the text of the last event of kind. func (r *TurnEventRecorder) Last(kind string) string { + return r.LastEvent(kind).Text +} + +// LastEvent returns the last event of kind. +func (r *TurnEventRecorder) LastEvent(kind string) Event { r.mutex.Lock() defer r.mutex.Unlock() for n := len(r.events) - 1; n >= 0; n-- { if r.events[n].Kind == kind { - return r.events[n].Text + return r.events[n] } } - return "" + return Event{} } func (r *TurnEventRecorder) Add(event Event) {