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.
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
}
// 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",
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")
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)
var replies []string
stopped := false
+ status := subagentFinished
for more := true; more; {
reply, err := r.chain(ctx, run)
reply = "stopped"
case err != nil:
reply = "failed: " + err.Error()
+ status = subagentFailed
}
more = err == nil && ctx.Err() == nil && len(run.pending) > 0
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.
"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() {
<-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)
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)
"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) {
"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)
// 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
}
// 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:
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) {
// 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":
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":
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)},
}
// 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
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()
// 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) {