]> git.giacomolli.uk - onechat/commitdiff
Stop a subagent with /stop NAME
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Sun, 8 Nov 2026 13:53:29 +0000 (10:53 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Sun, 8 Nov 2026 13:53:29 +0000 (10:53 -0300)
The line "/stop NAME" in the terminal client and in Telegram stops the
running subagent NAME: its model call ends, and a running command is
killed with its process group. The chat gets the note "Stopped by the
user: NAME.", and then the report of the subagent with the reply
"stopped", which is logged but starts no turn, so the agent does not act
on a subagent that the user stopped. A name that does not run gives an
answer to the client. The bare "/stop" still interrupts only the turn of
the agent.

The note is logged under the mutex of the runs, which the run takes
before its report, so the note always comes before the report.

Issue: b1ac698

12 files changed:
README.rst
daemon.go
daemon_test.go
socket.go
stop.go
stop_test.go [new file with mode: 0644]
subagent_run.go
subagent_run_test.go
telegram.go
telegram_test.go
terminal.go
turn.go

index 3819e25bea5188c4a229be908f85415aec4b1508..39a1eff5af5ba4ee89d541dd0dbc961d5a7264d0 100644 (file)
@@ -78,12 +78,13 @@ Start the daemon, and then the client in another terminal:
 The daemon owns the root agent in ``agent/`` in the data directory and
 listens on the Unix socket ``onechat.sock`` there. In the client, each
 line is a message to the agent, the line ``/stop`` interrupts the
 The daemon owns the root agent in ``agent/`` in the data directory and
 listens on the Unix socket ``onechat.sock`` there. In the client, each
 line is a message to the agent, the line ``/stop`` interrupts the
-running turn, 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.
+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.
 
 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.
@@ -98,8 +99,10 @@ the repository. The steps of a subagent go to its own log in
 back to the agent as one message that starts with ``[NAME]``. The agent
 can send a subagent more messages: a running subagent reads them between
 its tool calls, and a finished one goes on from its log, and its reply
 back to the agent as one message that starts with ``[NAME]``. The agent
 can send a subagent more messages: a running subagent reads them between
 its tool calls, and a finished one goes on from its log, and its reply
-comes back in the same way. Each model call of a subagent has the name
-in the ``agent`` field of ``agent/usage.jsonl``.
+comes back in the same way. When the user stops a subagent, the chat
+gets a note, and the report of the subagent is logged, but it starts no
+turn. Each model call of a subagent has the name in the ``agent`` field
+of ``agent/usage.jsonl``.
 
 ``agent/config.json`` holds the settings of the agent. The first start
 writes it with the defaults and a random ID. Do not change the ID: the
 
 ``agent/config.json`` holds the settings of the agent. The first start
 writes it with the defaults and a random ID. Do not change the ID: the
@@ -143,15 +146,16 @@ new messages, so it needs no open port.
    {"token": "123456:ABC-token-from-BotFather", "owner": 123456789}
 
 The bot reads only the private chat of the owner and ignores all other
    {"token": "123456:ABC-token-from-BotFather", "owner": 123456789}
 
 The bot reads only the private chat of the owner and ignores all other
-updates. Each text message goes to the agent as a user message, and the
-line ``/stop`` interrupts the running turn. The bot sends the replies in
-parts of at most 4096 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]``.
+updates. Each text message goes to the agent as a user message, the line
+``/stop`` interrupts the running turn, and the line ``/stop NAME`` stops
+the subagent NAME. The bot sends the replies in parts of at most 4096
+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]``.
 
 ``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 3cef419bed9821e984274cf8e8c168b3a12a471b..a4041f1b10ff7feb555972aca563662c128ee2da 100644 (file)
--- a/daemon.go
+++ b/daemon.go
@@ -359,6 +359,13 @@ func daemonRunRequest(l *TurnLoop, request SocketRequest) error {
                }
 
                return nil
                }
 
                return nil
+       case "stop":
+               found, err := l.StopSubagent(request.Text)
+               if err == nil && !found {
+                       err = fmt.Errorf("no subagent %s runs", request.Text)
+               }
+
+               return err
        default:
                return fmt.Errorf("unknown request %q", request.Type)
        }
        default:
                return fmt.Errorf("unknown request %q", request.Type)
        }
index 4b0ba0a1238a6c4850dd6216825863e82836cec3..8bc8c6e9c3f290a5af3579556e55792ccc305360 100644 (file)
@@ -36,6 +36,8 @@ func TestClientsSeeTheTurnAsItHappens(t *testing.T) {
 
        first.Type(stopCommand)
        first.WaitFor(t, "[error] no turn runs")
 
        first.Type(stopCommand)
        first.WaitFor(t, "[error] no turn runs")
+       first.Type(stopCommand + " BlueFox")
+       first.WaitFor(t, "[error] no subagent BlueFox runs")
        require.NotContains(t, second.Output(), "no turn runs")
        require.NoError(t, first.Close())
 }
        require.NotContains(t, second.Output(), "no turn runs")
        require.NoError(t, first.Close())
 }
index a454728b1ba347d845454788a02f85b56874843b..36d664157ed8175085b02512c9435343ee16eea5 100644 (file)
--- a/socket.go
+++ b/socket.go
@@ -30,7 +30,7 @@ func checkSocketPath(path string) error {
 }
 
 // SocketRequest is one line from a client: "send" with the text of a user
 }
 
 // SocketRequest is one line from a client: "send" with the text of a user
-// message, or "interrupt".
+// message, "interrupt", or "stop" with the name of a subagent in Text.
 type SocketRequest struct {
        Type string `json:"type"`
        Text string `json:"text,omitempty"`
 type SocketRequest struct {
        Type string `json:"type"`
        Text string `json:"text,omitempty"`
diff --git a/stop.go b/stop.go
index 640464f9fb1c0ce47bde9ea8638512ea1276dd0e..6df8dd32c4de5208353b81992524654b9d6f8552 100644 (file)
--- a/stop.go
+++ b/stop.go
@@ -1,4 +1,21 @@
 package main
 
 package main
 
-// stopCommand is the input line that interrupts the running turn.
+import (
+       "strings"
+)
+
+// stopCommand is the input line that interrupts the running turn. The line
+// "/stop NAME" stops the subagent NAME.
 const stopCommand = "/stop"
 const stopCommand = "/stop"
+
+// stopSubagentName returns NAME of a line "/stop NAME", or false for any other
+// line.
+func stopSubagentName(line string) (string, bool) {
+       name, found := strings.CutPrefix(strings.TrimSpace(line), stopCommand+" ")
+       name = strings.TrimSpace(name)
+       if !found || name == "" {
+               return "", false
+       }
+
+       return name, true
+}
diff --git a/stop_test.go b/stop_test.go
new file mode 100644 (file)
index 0000000..1f87bea
--- /dev/null
@@ -0,0 +1,47 @@
+package main
+
+import (
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestStopNameReadsTheNameOfASubagent(t *testing.T) {
+       testCases := []struct {
+               name  string
+               line  string
+               found bool
+               agent string
+       }{
+               {
+                       name:  "a name after the command",
+                       line:  " /stop GreenOtter ",
+                       found: true,
+                       agent: "GreenOtter",
+               },
+               {
+                       name: "the command alone",
+                       line: "/stop",
+               },
+               {
+                       name: "the command with spaces only",
+                       line: "/stop   ",
+               },
+               {
+                       name: "a word that starts with the command",
+                       line: "/stopGreenOtter",
+               },
+               {
+                       name: "a message",
+                       line: "please /stop GreenOtter",
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       agent, found := stopSubagentName(tc.line)
+                       require.Equal(t, tc.found, found)
+                       require.Equal(t, tc.agent, agent)
+               })
+       }
+}
index b2e5e62526526d60fbf2b99cd2cad711e75b8c54..20b15f24dbd4374329ff3cb138912fca9da5b53b 100644 (file)
@@ -22,15 +22,16 @@ const (
 // 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")
 
-// SubagentRuns runs the subagents of one agent in the background. Each run is
-// a goroutine with its own context, which only Close ends. 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
+// 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
 // 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. At the
-// stop of the daemon the runs end with no report.
+// 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.
 //
 // 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
 //
 // 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
@@ -43,6 +44,7 @@ type SubagentRuns struct {
        client    ChainClient
        state     func() string
        report    func(text string, stopped bool)
        client    ChainClient
        state     func() string
        report    func(text string, stopped bool)
+       note      func(text string) error
        notify    func(Event)
 
        mutex   sync.Mutex
        notify    func(Event)
 
        mutex   sync.Mutex
@@ -172,6 +174,29 @@ func (r *SubagentRuns) Tell(ctx context.Context, name, message string) (string,
        return "Sent. " + name + "'s reply will reach you as a message.", nil
 }
 
        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
+}
+
 // Close ends every run and waits for them. No subagent starts after Close.
 func (r *SubagentRuns) Close() {
        r.mutex.Lock()
 // Close ends every run and waits for them. No subagent starts after Close.
 func (r *SubagentRuns) Close() {
        r.mutex.Lock()
@@ -237,7 +262,7 @@ func (r *SubagentRuns) effort(subagent *Subagent) (string, error) {
 }
 
 // start runs run in the background. Its context keeps the values of ctx, but
 }
 
 // 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))
 
 func (r *SubagentRuns) start(ctx context.Context, run *SubagentRun) error {
        ctx, run.cancel = context.WithCancel(context.WithoutCancel(ctx))
 
@@ -270,34 +295,40 @@ func (r *SubagentRuns) start(ctx context.Context, run *SubagentRun) error {
 
 // 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
 
 // 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.
+// 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()
 
        var replies []string
 func (r *SubagentRuns) run(ctx context.Context, run *SubagentRun) {
        defer r.workers.Done()
        defer run.cancel()
 
        var replies []string
+       stopped := false
        for more := true; more; {
                reply, err := r.chain(ctx, run)
        for more := true; more; {
                reply, err := r.chain(ctx, run)
-               if err != nil {
+
+               r.mutex.Lock()
+               stopped = run.stopped
+               switch {
+               case err != nil && stopped:
+                       reply = "stopped"
+               case err != nil:
                        reply = "failed: " + err.Error()
                }
 
                        reply = "failed: " + err.Error()
                }
 
-               replies = append(replies, reply)
-
-               r.mutex.Lock()
-               more = err == nil && len(run.pending) > 0
+               more = err == nil && ctx.Err() == nil && len(run.pending) > 0
                run.pending = nil
                if !more {
                        delete(r.running, run.subagent.name)
                }
                r.mutex.Unlock()
                run.pending = nil
                if !more {
                        delete(r.running, run.subagent.name)
                }
                r.mutex.Unlock()
+
+               replies = append(replies, reply)
        }
 
        }
 
-       if ctx.Err() != nil {
+       if ctx.Err() != nil && !stopped {
                return
        }
 
                return
        }
 
-       r.report(subagentReport(run.subagent.name, replies), false)
+       r.report(subagentReport(run.subagent.name, replies), stopped)
 }
 
 // chain runs one request chain of run and returns its final answer.
 }
 
 // chain runs one request chain of run and returns its final answer.
@@ -354,14 +385,16 @@ func (r *SubagentRuns) take(run *SubagentRun) []Message {
 }
 
 // SubagentRun is one run of a subagent: the view that its requests get, its
 }
 
 // SubagentRun is one run of a subagent: the view that its requests get, its
-// effort, the end of its context, and the messages of Tell that wait for a
-// request. The mutex of SubagentRuns protects the messages.
+// 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
 type SubagentRun struct {
        subagent *Subagent
        view     []string
        effort   string
        cancel   context.CancelFunc
        pending  []Message
+       stopped  bool
 }
 
 // subagentReport returns the report of the subagent name with its replies: the
 }
 
 // subagentReport returns the report of the subagent name with its replies: the
index fc13f605624c17e693a6ccb802c05e2c7418cc74..1fd6a4a898c32c92465c8b020d09885ad2b1f589 100644 (file)
@@ -330,6 +330,50 @@ func TestTellToAFinishedSubagentGoesOnFromItsLog(t *testing.T) {
        require.EqualError(t, err, "no agent BlueFox")
 }
 
        require.EqualError(t, err, "no agent BlueFox")
 }
 
+func TestStopEndsASubagentAndItsCommand(t *testing.T) {
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+               modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"touch started && sleep 30"}`)}, 100, 0, 5))
+       }
+
+       test := newTestTurnLoop(t, server)
+       runs := test.loop.runs
+       _, err := runs.Spawn(t.Context(), []string{"Wait long."}, nil)
+       require.NoError(t, err)
+
+       name := test.events.Last("subagent")
+       subagent, err := runs.subagents.Get(t.Context(), name)
+       require.NoError(t, err)
+
+       require.Eventually(t, func() bool {
+               _, err := os.Stat(filepath.Join(subagent.WorkDir(), "started"))
+               return err == nil
+       }, 10*time.Second, time.Millisecond)
+
+       start := time.Now()
+       found, err := test.loop.StopSubagent(name)
+       require.NoError(t, err)
+       require.True(t, found)
+
+       test.events.WaitFor(t, "work", 1)
+       require.Less(t, time.Since(start), 10*time.Second)
+       require.Equal(t, []string{
+               "note: Stopped by the user: " + name + ".",
+               "work: " + subagentReport(name, []string{"stopped"}),
+       }, test.Log(t))
+
+       log, err := subagent.Show()
+       require.NoError(t, err)
+       require.Contains(t, log, "echo: \n[interrupted]")
+
+       found, err = test.loop.StopSubagent(name)
+       require.NoError(t, err)
+       require.False(t, found)
+
+       time.Sleep(50 * time.Millisecond)
+       require.Zero(t, test.events.Count("busy"))
+}
+
 func TestCloseEndsTheSubagentsWithNoReport(t *testing.T) {
        server := newModelFake(t)
        arrived := make(chan struct{})
 func TestCloseEndsTheSubagentsWithNoReport(t *testing.T) {
        server := newModelFake(t)
        arrived := make(chan struct{})
index 6d5332de8c9868eeabc0210a566063be8ae320f7..c78080d75edaad736b204c3a223901bd01200418 100644 (file)
@@ -349,9 +349,9 @@ func (b *TelegramBot) pause(ctx context.Context, err error, wait, asked time.Dur
 
 // handle runs one update. A text message of the owner in the private chat goes
 // to the agent, and handle returns when it is durable in the log. The line
 
 // handle runs one update. A text message of the owner in the private chat goes
 // to the agent, and handle returns when it is durable in the log. The line
-// /stop interrupts the turn. Any other message of the owner gets the answer
-// that only text is supported. Every other update is ignored, and its content
-// is not logged.
+// /stop interrupts the turn, and the line /stop NAME stops the subagent NAME.
+// Any other message of the owner gets the answer that only text is supported.
+// Every other update is ignored, and its content is not logged.
 func (b *TelegramBot) handle(ctx context.Context, l *TurnLoop, update TelegramUpdate) error {
        message := update.Message
        if message == nil || message.Chat.Type != "private" || message.Chat.ID != b.owner || message.From == nil || message.From.ID != b.owner {
 func (b *TelegramBot) handle(ctx context.Context, l *TurnLoop, update TelegramUpdate) error {
        message := update.Message
        if message == nil || message.Chat.Type != "private" || message.Chat.ID != b.owner || message.From == nil || message.From.ID != b.owner {
@@ -359,6 +359,7 @@ func (b *TelegramBot) handle(ctx context.Context, l *TurnLoop, update TelegramUp
                return nil
        }
 
                return nil
        }
 
+       name, stop := stopSubagentName(message.Text)
        switch {
        case message.Text == "":
                b.notice(telegramTextOnly)
        switch {
        case message.Text == "":
                b.notice(telegramTextOnly)
@@ -366,6 +367,15 @@ func (b *TelegramBot) handle(ctx context.Context, l *TurnLoop, update TelegramUp
                if !l.Interrupt() {
                        b.notice("No turn runs.")
                }
                if !l.Interrupt() {
                        b.notice("No turn runs.")
                }
+       case stop:
+               found, err := l.StopSubagent(name)
+               if err != nil {
+                       return fmt.Errorf("stop subagent %s: %w", name, err)
+               }
+
+               if !found {
+                       b.notice("No subagent " + name + " runs.")
+               }
        default:
                _, err := l.Submit(telegramSource, message.Text)
                if err != nil {
        default:
                _, err := l.Submit(telegramSource, message.Text)
                if err != nil {
index 81e6e18f2299917dc03797d1d757e1757ce5d5f7..c1f123036a4d8806d5435d9583c0d4b46f5e85f7 100644 (file)
@@ -127,6 +127,13 @@ func TestStopFromTelegramInterruptsTheTurn(t *testing.T) {
        })
 
        telegramWaitForSent(t, telegram, "Error: interrupted", "No turn runs.")
        })
 
        telegramWaitForSent(t, telegram, "Error: interrupted", "No turn runs.")
+
+       telegram.Add(TelegramUpdate{
+               UpdateID: 4,
+               Message:  telegramOwnerMessage(stopCommand + " BlueFox"),
+       })
+
+       telegramWaitForSent(t, telegram, "No turn runs.", "No subagent BlueFox runs.")
 }
 
 func TestBotStartsAtTheStoredOffset(t *testing.T) {
 }
 
 func TestBotStartsAtTheStoredOffset(t *testing.T) {
index c785a969aef149ffd823e970d2aa1d04334d8919..9e2b6507ab5e7913ddac111a4d895564cf48b68a 100644 (file)
@@ -19,9 +19,10 @@ const terminalResultLines = 10
 var errTerminalDaemonClosed = errors.New("the daemon closed the connection")
 
 // terminal runs "onechat chat": the terminal client of the daemon. Each line of
 var errTerminalDaemonClosed = errors.New("the daemon closed the connection")
 
 // terminal runs "onechat chat": the terminal client of the daemon. Each line of
-// input is a user message, and the line /stop interrupts the running turn. The
-// client shows the replies, the tool calls, and their results as they happen.
-// It ends at the end of the input or when the daemon closes the connection.
+// input is a user message, the line /stop interrupts the running turn, and the
+// line /stop NAME stops the subagent NAME. The client shows the replies, the
+// tool calls, and their results as they happen. It ends at the end of the input
+// or when the daemon closes the connection.
 func terminal(ctx context.Context, in io.Reader, out io.Writer) error {
        dir, err := dataDir()
        if err != nil {
 func terminal(ctx context.Context, in io.Reader, out io.Writer) error {
        dir, err := dataDir()
        if err != nil {
@@ -84,6 +85,7 @@ func terminalSendLines(ctx context.Context, conn net.Conn, in io.Reader) error {
                        Text: line,
                }
 
                        Text: line,
                }
 
+               name, stop := stopSubagentName(line)
                switch strings.TrimSpace(line) {
                case "":
                        continue
                switch strings.TrimSpace(line) {
                case "":
                        continue
@@ -91,6 +93,13 @@ func terminalSendLines(ctx context.Context, conn net.Conn, in io.Reader) error {
                        request = SocketRequest{
                                Type: "interrupt",
                        }
                        request = SocketRequest{
                                Type: "interrupt",
                        }
+               default:
+                       if stop {
+                               request = SocketRequest{
+                                       Type: "stop",
+                                       Text: name,
+                               }
+                       }
                }
 
                err := encoder.Encode(request)
                }
 
                err := encoder.Encode(request)
diff --git a/turn.go b/turn.go
index 02a4a28513d3b6419d24b1e1b5929e1652a6a315..3c1c921ec2d1df76e82fdda8188e3366df8b6c09 100644 (file)
--- a/turn.go
+++ b/turn.go
@@ -36,6 +36,7 @@ func newTurnLoop(agent *Agent, memory *Memory, subagents *Subagents, client Chai
                client:    client,
                state:     l.state,
                report:    l.Report,
                client:    client,
                state:     l.state,
                report:    l.Report,
+               note:      l.note,
                notify:    notify,
                running:   make(map[string]*SubagentRun),
        }
                notify:    notify,
                running:   make(map[string]*SubagentRun),
        }
@@ -133,6 +134,12 @@ func (l *TurnLoop) Report(text string, stopped bool) {
        }
 }
 
        }
 }
 
+// StopSubagent ends the run of the subagent name and tells if it ran. Its
+// report is logged, but it starts no turn.
+func (l *TurnLoop) StopSubagent(name string) (bool, error) {
+       return l.runs.Stop(name)
+}
+
 // Interrupt ends the running turn. A running tool is killed, and the turn sends
 // no further request.
 func (l *TurnLoop) Interrupt() bool {
 // Interrupt ends the running turn. A running tool is killed, and the turn sends
 // no further request.
 func (l *TurnLoop) Interrupt() bool {
@@ -194,6 +201,13 @@ func (l *TurnLoop) Run(ctx context.Context) error {
        }
 }
 
        }
 }
 
+// note logs a note and tells the clients. A note starts no turn.
+func (l *TurnLoop) note(text string) error {
+       _, err := l.record("note", "", text)
+
+       return err
+}
+
 // record logs a message and tells the clients, with the client source of a user
 // message.
 func (l *TurnLoop) record(kind, source, text string) (Message, error) {
 // record logs a message and tells the clients, with the client source of a user
 // message.
 func (l *TurnLoop) record(kind, source, text string) (Message, error) {