]> git.giacomolli.uk - onechat/commitdiff
Show the subagents in the clients
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Sun, 8 Nov 2026 13:57:31 +0000 (10:57 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Sun, 8 Nov 2026 13:57:31 +0000 (10:57 -0300)
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

README.rst
prompt.go
subagent_run.go
subagent_run_test.go
telegram.go
telegram_test.go
terminal.go
terminal_test.go
turn.go
turn_test.go

index 39a1eff5af5ba4ee89d541dd0dbc961d5a7264d0..c94f226192c24ddecad6a899d3e16d3b4c7f3dc7 100644 (file)
@@ -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
 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.
 
 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
 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
 
 ``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
index ee86ebd74293e0345992cf89cd3fdb7fcb476d92..b9d928467c2d0ebb78f9a56ab17aa96f262c776a 100644 (file)
--- 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
 }
 
 // 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",
 func promptTools() []ModelTool {
        return append(promptSubagentTools(), ModelTool{
                Type:        "function",
index 20b15f24dbd4374329ff3cb138912fca9da5b53b..4a88800c3bed065aace3c242510d302a01ea9325 100644 (file)
@@ -19,6 +19,13 @@ const (
        subagentNoteEffort = " at effort "
 )
 
        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")
 
 // 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
        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)
 
        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
 
        var replies []string
        stopped := false
+       status := subagentFinished
        for more := true; more; {
                reply, err := r.chain(ctx, run)
 
        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()
                        reply = "stopped"
                case err != nil:
                        reply = "failed: " + err.Error()
+                       status = subagentFailed
                }
 
                more = err == nil && ctx.Err() == nil && len(run.pending) > 0
                }
 
                more = err == nil && ctx.Err() == nil && len(run.pending) > 0
@@ -328,7 +337,11 @@ func (r *SubagentRuns) run(ctx context.Context, run *SubagentRun) {
                return
        }
 
                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.
 }
 
 // chain runs one request chain of run and returns its final answer.
index 1fd6a4a898c32c92465c8b020d09885ad2b1f589..5caf0d886ef3a6855ef142f1ed28ca41efec46f9 100644 (file)
@@ -57,6 +57,13 @@ func TestSpawnRunsASubagentInTheBackground(t *testing.T) {
                "work: " + report,
                "reply: Noted.",
        }, test.Log(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() {
 
        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."})
 
        <-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)
        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"})
        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)
 
        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))
                "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) {
 }
 
 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))
                "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)
 
        log, err := subagent.Show()
        require.NoError(t, err)
index c78080d75edaad736b204c3a223901bd01200418..c560104c52b83cb4d590d60b939c2de04af277f2 100644 (file)
@@ -163,7 +163,7 @@ type TelegramBot struct {
 // stop the agent.
 func (b *TelegramBot) Notify(event Event) {
        switch event.Kind {
 // 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
        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
 }
 
 // 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
 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:
        case "error":
                return "Error: " + event.Text
        default:
index c1f123036a4d8806d5435d9583c0d4b46f5e85f7..5e50ec15755f1b8f3367a44f6120697dcddf6d9b 100644 (file)
@@ -136,6 +136,49 @@ func TestStopFromTelegramInterruptsTheTurn(t *testing.T) {
        telegramWaitForSent(t, telegram, "No turn runs.", "No subagent BlueFox runs.")
 }
 
        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) {
 func TestBotStartsAtTheStoredOffset(t *testing.T) {
        server := newModelFake(t)
        server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
index 9e2b6507ab5e7913ddac111a4d895564cf48b68a..96a8cc5cf5605671700802710ed757561a2b16e3 100644 (file)
@@ -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
 // 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":
 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)
                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":
        case "busy":
                return "[working]"
        case "idle":
index 91e43d2d84812e966eb8a35d00353449d739e33b..62ffb040abe37518915d7f15560e001cfe51fa17 100644 (file)
@@ -58,6 +58,16 @@ func TestEventText(t *testing.T) {
                        event: Event{Kind: "user", Text: "from the phone", Source: "telegram"},
                        text:  "[telegram] from the phone",
                },
                        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)},
                {
                        name:  "long result cut",
                        event: Event{Kind: "echo", Text: strings.Repeat("line\n", 12)},
diff --git a/turn.go b/turn.go
index 3c1c921ec2d1df76e82fdda8188e3366df8b6c09..deb47d9150cdc4d2fdda5df299edad9df7a50248 100644 (file)
--- 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
 }
 
 // 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"`
 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
 }
 
 // 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
 }
 
        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)
 
        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()
                l.pending = append(l.pending, message)
        }
        l.mutex.Unlock()
index 928ac334b9dcce7d38c0b357517de80c3597f8c6..b0417cfc7469781f3460fd021aeec49b4ad8f6e7 100644 (file)
@@ -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 {
 
 // 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 {
        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) {
 }
 
 func (r *TurnEventRecorder) Add(event Event) {