Client-orchestrated voice agent: streaming TTS, --files sandbox, spoken approval#268
Client-orchestrated voice agent: streaming TTS, --files sandbox, spoken approval#268alexkroman wants to merge 105 commits into
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ict-invariance pyright rejects passing a narrowly-inferred dict literal to a dict[str, object] parameter because dict is invariant in its value type. Explicitly annotating _GEOCODE and _FORECAST as dict[str, object] widens the declared type and resolves the error without changing weather_tool.py's public interface. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…peline SDD) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gate Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a focused unit test for `_tool_capabilities` that asserts the exact list (both phrases, in order) when both web-search and weather tools are present — killing any mutation that drops or swaps either capability block. Also tighten `test_build_live_tools_has_weather_and_web_search_when_keyed` to assert the exact sorted set instead of two loose `in` checks, so a duplicated or extra tool is caught. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make `_events_from_chunk`'s `verbose` parameter keyword-only (FBT001). Add `stream` to `CompiledAgent` protocol so `_stream_graph`'s `graph.stream(…)` type-checks; narrow each yielded item with `isinstance(…, tuple)` instead of unpacking blindly. Narrow `_drive_graph`'s stream chunks to `dict` before passing to `_log_flow` (the protocol change exposed that assignment). No escape hatches added; `hasattr(graph, "stream")` guard still lets invoke-only test fakes take the `invoke` branch at runtime. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…table) Add three targeted assertions to tests/test_agent_cascade_weather.py to kill surviving mutants from the diff-scoped sweep: pin count=1 in the geocode URL, add a short daily-array test that kills the and->or length-guard mutation, and add an exact-dict assertion that pins the entire _WMO_DESCRIPTIONS table. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tests Remove the unreachable `yield # pragma: no cover` lines from the _Boom and _CliBoom stream-method fakes (a plain raising method is not a generator and works identically — the raise propagates before the for-loop iterates). Simplify _collect to drop the dead **kwargs branch (no caller passes kwargs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… 500-line gate The filler + planning-discard work and the #258 merge pushed engine.py and two test files over the 500-line file-length gate. Extract the Renderer/Player protocols and CascadeDeps into agent_cascade/_io.py (re-exported from engine), and consolidate the spoken-filler + planning-discard tests into test_agent_cascade_filler.py. Also drop the stale test_live_tui_launch.py (duplicate of this branch's test_live_tui_wiring.py) and retarget CascadeDeps.real patches at _io. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- New capability: voice y/n approval (unambiguous spoken token, fail-safe reject, keyboard fallback for risk.py-flagged destructive commands) as milestone M3 - Fix memory wiring to the idiomatic create_deep_agent(memory=) param - Fix Goal/Context contradiction (--files already edits today; new work is execute/memory/delegation/voice, not editing) - Clarify shell-rc write-deny only bites when cwd==$HOME; add bwrap --chdir - Restructure into M1 (execute+memory) / M2 (subagents) / M3 (voice approval) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on kills) Pre-existing branch debt from concurrent WIP, not part of M1: - engine.py: __all__ re-exports CascadeDeps/Renderer/Player (mypy --no-implicit-reexport) - filler test: import AIMessageChunk from its real source, not via the brain test module - cover weather _get_json net seam, brain._decide non-dict coercion, _runtime detach early return - kill surviving mutants: frozen dataclasses (Done/Failure/Timeout/SpeechDelta/ToolNotice/ ApprovalPause), _speaking init=False, _answered guard, _decide or->and, _stream_graph gated default; text.py clause-slice +1/+2 is an equivalent mutant (pragma: no mutate) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
brain._build_fs_backend now returns SandboxedShellBackend (a SandboxBackendProtocol), so deepagents binds a functional execute; execute joins _WRITE_TOOLS/interrupt_on; --files turns on MemoryMiddleware via memory=[./.deepagents/AGENTS.md]; _TOOL_LABELS[execute]=Running code; prompt advertises running code. (A002 per-file ignore for the CompiledAgent test fake.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ion kills - --files help string: read/write/run code, sandboxed; regenerate the run --help golden - REFERENCE.md + aai_cli/AGENTS.md: sandboxed execute + per-project memory (drop the stale 'execute is inert' / 'no shell' wording) - kill mutation survivors: sandbox _TIMEOUT_EXIT pinned to literal 124, virtual_mode default asserted; modals _answered initial-False pragma'd (behavior-tested but the mutation harness mis-selects covering tests for this Textual __init__ line) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l cap The voice TUI rendered a turn's answer *between* tool affordances and left an empty gap above it: begin_reply (fired by reply_started, which lands during the first tool call's spoken filler) eagerly mounted the AssistantMessage, so later tool lines mounted below it and the answer streamed into the early widget. Defer the reply widget to the first streamed sentence (show_agent_sentence already mounts lazily) so the answer always lands below every tool affordance, with no placeholder gap. Also replace the brittle recursion cap with a per-turn tool-call budget: ToolCallLimitMiddleware(run_limit=CascadeConfig.tool_call_limit=10, exit_behavior="continue") wired into the deepagents middleware stack. Once the budget is hit, further tool calls are blocked and the model is forced to answer with what it gathered — a graceful stop instead of GraphRecursionError surfaced as a raw turn error. langgraph's own recursion_limit rides the deepagents default as a far-off safety net. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Apply two principles to the live cascade's generated guidance layer: - Faithful reporting: whenever tools are bound, tell the model not to claim an action happened until the tool returns, and to admit failures briefly instead of inventing an answer. - Reversibility/consent: under --files, warn that file writes and code execution can't be undone, so confirm out loud before destructive actions and never narrate a change as done before it lands. Both live in build_system_prompt (tool-aware, non-overridable) rather than the user-overridable persona. Adds tests pinning each behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…to the greeting begin_reply stopped resetting _reply_msg in the prior ordering fix — it dropped the eager mount but also the reset. The greeting streams through show_agent_sentence (with no reply_done after it), so _reply_msg still pointed at the greeting when the first turn began; the answer then streamed into the greeting widget at the top, concatenating onto it and landing above the turn's tool affordances (tool calls appearing under the response). Reset _reply_msg to None in begin_reply (still deferring the mount): the next streamed sentence opens a fresh widget that mounts after the turn's tool lines, so the greeting stays its own line and the answer always renders below the tools. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gateway-bound (no model key), full sandboxed tools (no tools key), interrupt_on mirrors the caller's write tools so the subagent's own mutations stay gated. Includes the M2 plan. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Formalizes the resolved HITL spike: a real deepagents graph with a gated general-purpose subagent; the subagent's write pauses through build_streamer/_pending_writes/the approver, lands on approve, is skipped on reject. Ignore the deepagents-boundary test in tests-pyright (mirrors test_agent_cascade_brain/prompt/model). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…agent (M2) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pure phrase grammar for the hands-free approval gate: only an unambiguous action-bearing affirmative approves; bare yes, negations, unrelated/empty speech all reject. The risk-tier keyboard fallback lives in the engine wiring (next). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
resolve_approval(): destructive tier (risk.risk_warning fires) -> keyboard only; otherwise the engine's injected race outcome resolves it (keypress verbatim, spoken token via the grammar, timeout/ambiguous -> reject). Concurrency stays behind the await_outcome seam so it's hermetic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A --files approval modal can now be resolved by voice as well as a keypress: the engine routes the next final transcript during an approval pause to the open modal, which applies the grammar (spoken_decision) — an unambiguous affirmative approves, anything else rejects. Destructive commands (risk.risk_warning) ignore the spoken answer and require a keypress. - spoken_approval.spoken_decision: approve/reject/ignore(destructive) from a transcript - modals.ApprovalScreen.try_voice: resolve the open modal by voice (destructive -> ignore) - tui.submit_voice_approval: route a transcript to the open modal (UI-thread hop) - engine: _awaiting_approval gate + on_turn routes the next final transcript during a pause; run_cascade gains on_approval_voice; _exec wires it to the TUI Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add four short, spoken-safe guidance clauses to the live voice agent's system prompt, adapted from openclaw's prompt-engineering patterns: - persona latch: the operational rules outrank the user persona's style, so a chatty/in-character persona can't override brevity or honesty - retry-on-empty: rephrase a thin/empty lookup once before concluding - read-before-clobber (--files): read a file before overwriting, prefer merging over wholesale replacement unless asked - worked example in the no-tools path for the documented "offer to look it up, then go silent" failure Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tool dropped today's high/low (the outlook started at tomorrow), so "what's the high today?" had no datum and the model echoed the current temp. format_report now returns every interesting field: current temp (°C/°F), feels-like, humidity, wind, and condition; today's own high/low + rain chance; then the two-day outlook. The forecast query is widened to fetch those fields. Also declare langchain as a direct dependency (brain.py imports its public langchain.agents.middleware API, so depend on what you import) and restore the list-item entry in the brain module's mypy disable_error_code (the invariant middleware boundary, matching origin/main). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gaps Splitting tests/test_agent_cascade_brain.py (521 lines) under the 500-line limit surfaced a chain of gate failures, all fixed here: - Extract the build_streamer write-approval tests into a sibling file (test_agent_cascade_brain_approval.py) and add it to the pyright tests ignore list, mirroring the other deepagents-boundary brain test files (pyright-strict floods on the only-partially-typed graph; mypy is the net). - Cover tui.LiveAgentApp.submit_voice_approval (both the open-modal hop and the no-modal no-op) so patch coverage stays at 100%. - Pin ApprovalScreen's collapsed-by-default state in test_expand_toggles_detail_markup (it previously drove the toggle without asserting), killing the modals._expanded mutant. - Mark engine._awaiting_approval's init= unobservable (pragma: no mutate), as its sibling dataclass fields already are. - Fix two escape-hatch gate false positives the real merge-base exposed: a comment quoting "no cover" pragma text, and the cast\( pattern matching the new weather _forecast() function (now \bcast\(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QDMHVCtUfHETnXbRuic5qF
| producer = threading.Thread(target=produce, daemon=True) # pragma: no mutate | ||
| producer.start() | ||
| spoken: list[str] = [] | ||
| tail = self._consume(events, before, spoken) |
There was a problem hiding this comment.
_generate_reply no longer unconditionally clears _awaiting_approval on exit; a failure/timeout during an approval pause can leave the gate stuck and misroute later turns as approval input.
Details
✨ AI Reasoning
1) The reply loop can enter an approval-pause state that sets a gate flag.
2) The changed control flow removed unconditional cleanup that previously reset that flag after any exit path.
3) Early returns on timeout/failure now bypass a guaranteed clear.
4) That can leave the session in a stale approval-wait state, causing subsequent finalized turns to be misrouted instead of answered.
5) This is a direct control-flow bug introduced by the refactor, not a pre-existing condition.
🔧 How do I fix it?
Trace execution paths carefully. Ensure precondition checks happen before using values, validate ranges before checking impossible conditions, and don't check for states that the code has already ruled out.
Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
| def _is_boundary(text: str, index: int) -> bool: | ||
| """True when the char at ``index`` ends a clause: a terminator/separator that is the | ||
| last char or is followed by whitespace (so a '.' inside "$3.50" never splits).""" | ||
| return index + 1 == len(text) or text[index + 1].isspace() |
There was a problem hiding this comment.
_is_boundary now treats end-of-buffer as a boundary, so pop_clauses can flush punctuation at partial chunk ends (e.g., decimals split across deltas), producing incorrect clause segmentation during streaming.
Details
✨ AI Reasoning
1) The code is trying to split streamed reply text into speakable clauses without breaking mid-token.
2) The new condition marks end-of-buffer as a boundary.
3) In incremental streaming, buffers frequently end at temporary token boundaries, so punctuation at chunk end is not guaranteed to be final.
4) That makes premature clause emission possible, producing incorrect spoken output and transcript chunking.
5) This is not a stylistic preference; it changes runtime behavior in a way that can produce wrong results.
🔧 How do I fix it?
Trace execution paths carefully. Ensure precondition checks happen before using values, validate ranges before checking impossible conditions, and don't check for states that the code has already ruled out.
Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
| Marks the reply as speaking on the first spoken delta (so a UI interrupt can cut it). | ||
| Returns the new buffer, or ``None`` if a TTS failure cut the turn (the caller aborts).""" | ||
| if used_tool: | ||
| return buffer + item.text |
There was a problem hiding this comment.
Replacing list accumulation with 'buffer + item.text' causes repeated string allocations in the streaming reply loop; use list append + single join to avoid O(n^2) behavior.
Details
✨ AI Reasoning
This change is in the reply-leg streaming path that processes many incremental SpeechDelta objects. Previously, when a tool had been used the code accumulated pieces by appending to a list (O(1) per append) and joined once at the end. The new code returns buffer + item.text when used_tool is true, which performs a new string allocation/copy per delta, making the cost O(n^2) in the number/size of deltas. Because this runs while streaming replies (many small deltas), the string-concat-in-loop pattern creates repeated allocations and copying that will noticeably increase CPU and memory usage for longer replies. The fix is straightforward: revert to collecting fragments in a list and join once (or use an io.StringIO or list-append+join strategy).
🔧 How do I fix it?
Move constant work outside loops. Use StringBuilder instead of string concatenation in loops. Cache compiled regex patterns. Use hash-based lookups instead of nested loops. Batch database operations instead of N+1 queries.
Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
|
|
||
| def pending(self) -> int: | ||
| """How many unplayed samples are still queued (>0 while audio is audibly playing).""" | ||
| ... |
Summary
Builds out
assembly agent-cascade(a.k.a. thelivevoice agent): the same live terminal conversation asassembly agent, but client-orchestrated — the engine wires Streaming STT → the LLM Gateway → streaming TTS itself (via a deepagents reply brain) instead of talking to the Voice Agent endpoint. Sandbox-only, since streaming TTS has no prod host.Highlights across the branch:
brain.build_streamer): tokens stream from a deepagents graph, are buffered into clauses (pop_clauses), and each clause is synthesized with streaming TTS so audio starts on the first frame instead of after the whole reply.LiveAgentApp): transcript + animated voice bar (listening/thinking/speaking), no text input; falls back to plain line output for file/sample/--json/non-TTY.--files(off by default): swaps in a real-cwdSandboxedShellBackendwith OS-sandboxedexecute(sandbox-execon macOS,bwrapon Linux, refused elsewhere — never an unconfined fallback), write/edit/execute gated through an approval modal, plus durable per-project memory.tasktool); delegated writes surface at the parent approval gate.universal-3-5-pro; removes theassembly codecommand and relocates its shared modules intoagent_cascade/.105 commits, 142 files (+15,190 / −9,502).
This session's commit
The final commit (
test(live): split brain tests…) brings the tree green underscripts/check.sh, which surfaced a chain of gate failures once the full clone established the real merge-base:test_agent_cascade_brain.py(was 521 lines) under the 500-line gate; extracted the write-approval tests to a sibling file (added to the pyright tests ignore list, like the other deepagents-boundary brain tests).submit_voice_approval(kept patch coverage at 100%).modals._expandeddefault;engine._awaiting_approvalinit=).no coverpragma text, and thecast\(pattern matching the new_forecast()weather function (→\bcast\()../scripts/check.shprints All checks passed.mainThis branch conflicts with
main— recent merges (#262 making--filesdefault-on, #264 delegating context-window management to deepagents'SummarizationMiddleware, plus #261) touched the sameagent_cascade/files (notablyengine.py) and the docs. The conflicts will need resolving before this can land; I leftorigin/mainunmerged rather than guess at the resolution. Happy to do that as a follow-up if you'd like.🤖 Generated with Claude Code
Generated by Claude Code