Skip to content

Add a fifth bus state, working, for live tool-call activity #18

Description

@keebo

Problem

The face only ever reflects four states — idle, listening, thinking, speaking — pulled from ask_stream's text-delta events. There's no signal at all for a tool call actually running (Bash, a file write, a dispatched subagent): the face reads idle or thinking the whole time real work is happening underneath.

What this adds

A fifth bus state, working, watched from content_block_start rather than text deltas — set the instant a tool_use/server_tool_use block begins, re-asserted back to thinking when text resumes. The playback side (mouth.py) no longer assumes a turn is over just because its local speech queue drained; it checks a new WarmBrain.turn_active property first, so a filler line finishing mid-tool-call doesn't prematurely flip the face back to idle.

Companion issue on the ai-visualizer side covers the actual face treatments and a background-job marker-file convention that reuses this same state: jaredrhod/ai-visualizer#4

The diff

backtalk/brain.py — the working/thinking state watch, plus the new turn_active property:

diff --git a/backtalk/brain.py b/backtalk/brain.py
index 9600786..22cd103 100644
--- a/backtalk/brain.py
+++ b/backtalk/brain.py
@@ -42,6 +42,7 @@ except ImportError:                       # older SDKs: nothing to silence
 
 from backtalk.config import CFG, DISCIPLINE
 from backtalk.vlog import log
+from backtalk import signals
 
 _SENTENCE_END = re.compile(r"(?<=[.!?])\s")
 
@@ -82,6 +83,13 @@ class WarmBrain:
         # ResultMessage — i.e. the shared message pipe may hold leftovers.
         self._dirty = False
 
+    @property
+    def turn_active(self) -> bool:
+        """True from the moment a turn is sent until its ResultMessage
+        lands. The mouth reads this before declaring idle, so a turn
+        that goes quiet mid-tool-call doesn't read as nothing happening."""
+        return self._dirty
+
     async def start(self):
         mode = CFG["permission_mode"]
         if mode == "default":
@@ -300,7 +308,20 @@ class WarmBrain:
                 t = type(msg).__name__
                 if t == "StreamEvent":
                     ev = getattr(msg, "event", {}) or {}
-                    if ev.get("type") == "content_block_delta":
+                    if ev.get("type") == "content_block_start":
+                        # Tool calls carry no text delta, so without this
+                        # the face reads "idle" the whole time a background
+                        # task (Bash, a file write, a dispatched subagent)
+                        # is actually running. A text block starting again
+                        # means the model resumed composing after a tool
+                        # result came back.
+                        cbt = (ev.get("content_block") or {}).get("type")
+                        if cbt in ("tool_use", "server_tool_use"):
+                            signals.set_state("working")
+                            signals.static_start()
+                        elif cbt == "text":
+                            signals.set_state("thinking")
+                    elif ev.get("type") == "content_block_delta":
                         delta = ev.get("delta", {}) or {}
                         if delta.get("type") == "text_delta":
                             buf += delta.get("text", "")

backtalk/mouth.py — don't declare idle on queue-drain unless the turn has actually ended:

diff --git a/backtalk/mouth.py b/backtalk/mouth.py
index 9cdc2f4..c505ef1 100644
--- a/backtalk/mouth.py
+++ b/backtalk/mouth.py
@@ -268,11 +268,21 @@ class Mouth:
         self.ducker = Ducker()  # public: PTT ducks for the USER's voice too
         self._worker = threading.Thread(target=self._run, daemon=True)
         self._worker.start()
+        # Late-bound (main.py sets this once the brain exists): tells the
+        # worker whether a turn is still in flight, so draining the local
+        # speech queue mid-turn doesn't get mistaken for the reply being over.
+        self._turn_active = None
 
     @property
     def speaking(self) -> bool:
         return self._speaking.is_set()
 
+    def nothing_queued(self) -> bool:
+        """Nothing left to play right now. Used at turn-end to settle the
+        face when a tool call was the very last thing (no trailing text
+        ever arrived to trigger the worker's own idle check)."""
+        return self._q.empty() and not self._speaking.is_set()
+
     def say(self, text: str):
         """Queue text (split to sentences) for speech."""
         for s in split_sentences(text):
@@ -337,7 +347,13 @@ class Mouth:
                     # the gap between two sentences of the same reply.
                     signals.reply_done()
                     self.ducker.speech_end()
-                    signals.set_state("idle")
+                    # Only declare idle if the brain agrees the turn is
+                    # actually over. Mid-turn (a tool call about to run,
+                    # or more text still coming) this queue drains too —
+                    # leave the state as brain.ask_stream last set it
+                    # rather than flashing idle and back.
+                    if self._turn_active is None or not self._turn_active():
+                        signals.set_state("idle")

backtalk/signals.py — bus-contract docstring update:

diff --git a/backtalk/signals.py b/backtalk/signals.py
index fc3a91b..576cfc8 100644
--- a/backtalk/signals.py
+++ b/backtalk/signals.py
@@ -20,10 +20,15 @@
 The voice line leaves notes; faces read the notes. That one dumb trick
 is the whole integration surface:
 
-  .voice_state        idle | listening | thinking | speaking
+  .voice_state        idle | listening | thinking | working | speaking
   .voice_waveform     JSON {ts, samples: [64 floats]} while audio plays
   .voice_loading_pid  exists while the thinking sound is playing
 
+"working" is distinct from "thinking": thinking is the model composing
+a reply with nothing to show yet; working is a tool call actually
+running (a file write, a shell command, a dispatched subagent) — set
+from brain.ask_stream the moment a tool_use content block starts.
+
 Written to signals_dir (default: the repo root). Visualizers built on
 this contract just work.

backtalk/main.py — three small hunks: the permission-gate restores working instead of thinking when it resumes, the post-turn settle logic now covers a turn whose last event was a silent tool call, and mouth._turn_active gets wired to the brain:

diff --git a/backtalk/main.py b/backtalk/main.py
index 92731d4..9fb947f 100644
--- a/backtalk/main.py
+++ b/backtalk/main.py
@@ -273,7 +273,7 @@ def make_permission_gate(mouth):
                 interrupt=False)
         approved = _norm_speech(answer) in _YES
         # the model keeps working either way: restore the working state
-        signals.set_state("thinking")
+        signals.set_state("working")
         signals.static_start()
         if approved:
             log("[perm]   approved by voice")
@@ -616,10 +616,14 @@ async def speak_reply(brain: WarmBrain, mouth: Mouth, text: str):
         if batch:
             mouth.say_chunk(" ".join(batch), pending)
             pending = []
-        if first:
-            # Zero sentences yielded (brain error / empty turn): nothing
-            # will ever dequeue, so nothing resets the bus — park it here.
-            signals.static_stop()
+        # ask_stream only returns after its ResultMessage, so the SDK-side
+        # turn is fully over here. If nothing is left queued or playing —
+        # whether because zero sentences were ever spoken, or because the
+        # very last thing that happened was a silent tool call — nothing
+        # else is coming to reset the bus, so settle it now. Otherwise the
+        # mouth's own worker will settle it once playback actually drains.
+        signals.static_stop()
+        if mouth.nothing_queued():
             signals.set_state("idle")
     except asyncio.CancelledError:
@@ -667,6 +671,7 @@ async def amain():
     brain = WarmBrain(model=model,
                       can_use_tool=make_permission_gate(mouth),
                       resume_id=resume_id)
+    mouth._turn_active = lambda: brain.turn_active
 
     mode = ("hands-free listening (the talk key still works)"
             if _MIC["mode"] == "open"

(That last file, main.py, also picked up an unrelated audio-flush timing fix in the same commit on my fork — left out here since it's a separate bug, not part of this feature.)

Built and running for a few days now; happy to answer questions on any of it.

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions