3939from aai_cli .agent_cascade ._runtime import (
4040 Worker as _Worker ,
4141)
42+ from aai_cli .agent_cascade ._runtime import (
43+ approval_deadline as _approval_deadline ,
44+ )
4245from aai_cli .agent_cascade ._runtime import (
4346 detach_executor_threads_since as _detach_executor_threads_since ,
4447)
4548from aai_cli .agent_cascade ._runtime import (
4649 executor_threads as _executor_threads ,
4750)
51+ from aai_cli .agent_cascade ._runtime import (
52+ final_tail as _final_tail ,
53+ )
54+ from aai_cli .agent_cascade ._runtime import (
55+ is_final_turn as _is_final_turn ,
56+ )
4857from aai_cli .agent_cascade ._runtime import (
4958 new_history as _new_history ,
5059)
@@ -100,10 +109,12 @@ class CascadeSession:
100109 init = False , # pragma: no mutate
101110 )
102111 # Rotates the per-tool spoken fillers across turns (fillers[_filler_index % len]) so the same
103- # tool doesn't repeat one phrase. The rotation test pins the exact phrase sequence, so a shifted
104- # default or mutated increment is caught; the field's `init=` is equivalent (never constructed
105- # positionally), like the sibling fields, hence the pragma.
112+ # tool doesn't repeat one phrase. The rotation test pins the exact phrase sequence; the field's
113+ # `init=` is equivalent (never constructed positionally), like the siblings, hence the pragma.
106114 _filler_index : int = field (default = 0 , init = False ) # pragma: no mutate
115+ # reply_started fired this turn (separate from _speaking) so an empty reply still brackets it.
116+ # Reset per turn in _generate_reply (the killable line); init= is equivalent, hence the pragma.
117+ _reply_started : bool = field (default = False , init = False ) # pragma: no mutate
107118
108119 def greet (self ) -> None :
109120 """Speak the opening greeting (if any) and seed it into the history so the
@@ -149,18 +160,16 @@ def _silence(self, *, audible_only: bool) -> bool:
149160 """Cancel an in-flight reply — signal the worker and flush queued audio — and report
150161 whether anything was cancelled.
151162
152- The audible cases are always cancelled: the greeting (enqueued with no worker), a reply
153- in its speak-and-enqueue phase (``_speaking``), and the *tail* of a reply whose worker
154- has finished enqueuing but whose audio is still draining (``pending() > 0``).
155-
156- ``audible_only`` decides whether the *thinking* phase counts too. A spoken barge-in
157- passes ``False`` to cancel even a reply still being generated — the user has moved on,
158- so it must not speak once it lands. A UI interrupt passes ``True`` to leave thinking
159- alone: there's no audio to cut and the blocking graph call can't observe the stop flag,
160- so cancelling would be a no-op — and crucially, returning False there lets the TUI's
161- Ctrl-C fall through to *quit* rather than be swallowed (you could otherwise never
162- Ctrl-C while the agent thinks). Setting the stop flag is harmless when nothing runs (the
163- next ``_start_reply`` clears it).
163+ The audible cases are always cancelled: the greeting (enqueued with no worker), a reply in
164+ its speak-and-enqueue phase (``_speaking``), and the *tail* of a reply whose worker finished
165+ enqueuing but whose audio is still draining (``pending() > 0``).
166+
167+ ``audible_only`` decides whether the *thinking* phase counts too. A spoken barge-in passes
168+ ``False`` to cancel even a reply still being generated. A UI interrupt passes ``True`` to
169+ leave thinking alone (no audio to cut, the blocking graph call can't see the stop flag) —
170+ and returning False there lets the TUI's Ctrl-C fall through to *quit* rather than be
171+ swallowed. Setting the stop flag is harmless when nothing runs (next ``_start_reply`` clears
172+ it).
164173 """
165174 in_flight = self ._speaking .is_set () or self .player .pending () > 0
166175 if not audible_only :
@@ -179,13 +188,12 @@ def _barge_in(self) -> None:
179188 def interrupt_reply (self ) -> bool :
180189 """Silence a *speaking* reply without waiting for it; True if one was audible.
181190
182- The UI-thread-safe counterpart to a spoken barge-in: the live TUI's Escape/Ctrl-C
183- calls this to silence the agent mid-reply (or mid-greeting) without the user having to
184- talk over it. Flushing the queued audio stops speech at once; a reply worker then sees
185- the stop flag, unwinds on its own, and emits ``reply_done`` so the front-end returns to
186- listening (the STT loop keeps running, so the next spoken turn is handled normally).
187- It deliberately does *not* join the worker — a join from the UI thread would deadlock
188- against the worker's own ``call_from_thread`` render hops.
191+ The UI-thread-safe counterpart to a spoken barge-in: the live TUI's Escape/Ctrl-C calls
192+ this to silence the agent mid-reply (or mid-greeting) without the user talking over it.
193+ Flushing the queued audio stops speech at once; a reply worker then sees the stop flag,
194+ unwinds, and emits ``reply_done`` so the front-end returns to listening (the STT loop keeps
195+ running). It deliberately does *not* join the worker — a join from the UI thread would
196+ deadlock against the worker's own ``call_from_thread`` render hops.
189197
190198 It reports False (and does nothing) while the reply is merely *thinking*, so the TUI's
191199 Ctrl-C falls through to quit instead of being swallowed by a no-op interrupt.
@@ -206,6 +214,7 @@ def _start_reply(self) -> None:
206214 def _generate_reply (self ) -> None :
207215 """Stream the LLM reply, speak each clause as it lands, and record what was spoken
208216 (so a barge-in still leaves the history alternating)."""
217+ self ._reply_started = False
209218 messages : list [ChatCompletionMessageParam ] = [
210219 {"role" : "system" , "content" : self .config .system_prompt },
211220 * self .history ,
@@ -219,16 +228,26 @@ def produce() -> None:
219228 producer = threading .Thread (target = produce , daemon = True ) # pragma: no mutate
220229 producer .start ()
221230 spoken : list [str ] = []
222- tail = self ._consume (events , before , spoken )
223- # On a clean finish ``tail`` is the unspoken remainder to flush as one last clause; on
224- # any cut (barge-in, TTS/leg failure, timeout) it is None and nothing more is spoken.
225- if tail is not None and tail .strip ():
226- self ._speak ([tail .strip ()], spoken )
227- # Always record what was spoken — even after a mid-turn leg failure — so the history
228- # stays alternating and the next turn has the partial answer as context.
229- self ._record_spoken (spoken )
230- self ._speaking .clear ()
231- self .renderer .reply_done (interrupted = self ._stop .is_set ())
231+ try :
232+ tail = self ._consume (events , before , spoken )
233+ # On a clean finish ``tail`` is the unspoken remainder to flush as one last clause; on a
234+ # cut (barge-in, TTS/leg failure, timeout) it is None and nothing more is spoken.
235+ if tail is not None and tail .strip ():
236+ self ._speak ([tail .strip ()], spoken )
237+ finally :
238+ # Always finalize the turn: clearing the gate keeps a failure between an ApprovalPause's
239+ # active/inactive events from wedging every later turn (and history stays alternating).
240+ self ._set_awaiting_approval (active = False )
241+ self ._record_spoken (spoken )
242+ self ._speaking .clear ()
243+ self ._emit_reply_started () # bracket reply_done even for an empty (silent) reply
244+ self .renderer .reply_done (interrupted = self ._stop .is_set ())
245+
246+ def _emit_reply_started (self ) -> None :
247+ """Fire ``reply_started`` once per turn (idempotent), so every ``reply_done`` is bracketed."""
248+ if not self ._reply_started :
249+ self ._reply_started = True
250+ self .renderer .reply_started ()
232251
233252 def _set_awaiting_approval (self , * , active : bool ) -> None :
234253 """Arm/disarm the voice-approval gate: while armed, ``on_turn`` routes the next final
@@ -246,6 +265,7 @@ def _consume(
246265 failure, or a leg failure/timeout — which also surfaces the error)."""
247266 deadline : float | None = time .monotonic () + _REPLY_TIMEOUT_SECONDS
248267 buffer = ""
268+ held : list [str ] = [] # post-tool narration, joined once at end (O(n) vs per-delta concat)
249269 spoke_filler = False # only the FIRST tool call of a turn says a spoken filler
250270 used_tool = False # once a tool ran, hold text unspoken so only the final answer is read
251271 while True :
@@ -257,7 +277,7 @@ def _consume(
257277 self ._surface_error (item .error , started = self ._speaking .is_set ())
258278 return None
259279 if isinstance (item , _Done ):
260- return buffer
280+ return _final_tail ( buffer , held , used_tool = used_tool )
261281 if isinstance (item , brain .ApprovalPause ):
262282 deadline = _approval_deadline (item )
263283 self ._set_awaiting_approval (active = item .active )
@@ -267,30 +287,33 @@ def _consume(
267287 return None
268288 spoke_filler = True
269289 used_tool = True
270- buffer = "" # drop any unspoken preamble — the answer comes after the tool
290+ buffer = "" # drop unspoken preamble + inter-tool narration; the answer follows
291+ held .clear ()
271292 continue
272293 if self ._stop .is_set ():
273294 return None
274- # item is a streamed SpeechDelta (every other case returned or continued above).
275- tail = self ._speak_delta (item , buffer , spoken , used_tool = used_tool )
295+ # item is a streamed SpeechDelta (every other case returned/ continued above).
296+ tail = self ._speak_delta (item , buffer , held , spoken , used_tool = used_tool )
276297 if tail is None :
277298 return None
278299 buffer = tail
279300
280301 def _speak_delta (
281- self , item : brain .SpeechDelta , buffer : str , spoken : list [str ], * , used_tool : bool
302+ self ,
303+ item : brain .SpeechDelta ,
304+ buffer : str ,
305+ held : list [str ],
306+ spoken : list [str ],
307+ * ,
308+ used_tool : bool ,
282309 ) -> str | None :
283- """Fold one streamed delta into the running buffer and speak any completed clauses.
284-
285- Before any tool call, clauses stream out as they land (low-latency speech). *After* a tool
286- call (``used_tool``) the deep agent tends to narrate verbose planning between tool calls;
287- that text is held in the buffer unspoken and discarded at the next tool call, so only the
288- final answer — whatever remains buffered when the stream finishes — is ever read aloud.
289-
290- Marks the reply as speaking on the first spoken delta (so a UI interrupt can cut it).
291- Returns the new buffer, or ``None`` if a TTS failure cut the turn (the caller aborts)."""
310+ """Fold one streamed delta into the reply, speaking any completed clauses. Pre-tool, clauses
311+ stream out as they land and the buffer is returned. *After* a tool call (``used_tool``) the
312+ verbose planning is appended to ``held`` unspoken (joined once at end of stream, so only the
313+ final answer is read — O(n)). Returns the new buffer, or ``None`` on a TTS failure."""
292314 if used_tool :
293- return buffer + item .text
315+ held .append (item .text )
316+ return buffer
294317 self ._mark_speaking ()
295318 buffer += item .text
296319 chunks , buffer = pop_clauses (buffer , min_chars = _MIN_CLAUSE_CHARS )
@@ -312,7 +335,7 @@ def _mark_speaking(self) -> None:
312335 filler. Sets ``_speaking`` (so a UI interrupt can cut it) and fires ``reply_started`` once."""
313336 if not self ._speaking .is_set ():
314337 self ._speaking .set ()
315- self .renderer . reply_started ()
338+ self ._emit_reply_started ()
316339
317340 def _speak_filler (self , fillers : tuple [str , ...]) -> bool :
318341 """Say a short spoken filler ("Let me check") for the first tool call of a turn, so a
@@ -408,7 +431,7 @@ def _surface_error(self, exc: CLIError, *, started: bool) -> None:
408431 spoken text already explains the turn). The caller still finalizes the turn."""
409432 self ._record_error (exc )
410433 if not started :
411- self .renderer . reply_started ()
434+ self ._emit_reply_started ()
412435 self .renderer .agent_transcript (f"(error: { exc .message } )" , interrupted = False )
413436
414437 def _record_error (self , exc : CLIError ) -> None :
@@ -424,25 +447,6 @@ def shutdown(self) -> None:
424447 self ._join_reply ()
425448
426449
427- def _approval_deadline (pause : brain .ApprovalPause ) -> float | None :
428- """The reply deadline across a write-approval pause: ``None`` (clock suspended) while the
429- user is deciding on a gated write — a slow y/n keypress must not trip the reply timeout — and
430- a fresh finite deadline once answered."""
431- return None if pause .active else time .monotonic () + _REPLY_TIMEOUT_SECONDS
432-
433-
434- def _is_final_turn (event : object , * , format_turns : bool ) -> bool :
435- """True for an end-of-turn that's the cue to generate a reply.
436-
437- With formatting on, wait for the *formatted* turn (better text for the LLM);
438- with it off the server never sets ``turn_is_formatted``, so a bare end-of-turn
439- is the cue — otherwise ``--no-format-turns`` would make the agent never reply.
440- """
441- if not bool (getattr (event , "end_of_turn" , False )):
442- return False
443- return bool (getattr (event , "turn_is_formatted" , False )) or not format_turns
444-
445-
446450def run_cascade (
447451 * ,
448452 renderer : Renderer ,
0 commit comments