Series: AgentZero Lite — Part 7. The previous post (Part 6 — Step 1 Embedding an Open vLLM, Step 2 Akka Streams) laid out the roadmap. This one reports back: Step 2 is in the trunk, opt-in behind a feature flag, with eleven TestKit cases passing and the legacy batch path still primary until live testing validates the stream path.


TL;DR for the people who scroll

Implementation summary: 27 files, +2,062 LoC, eleven stream TestKit cases passing in 337 ms, full Debug build clean. Git commit 0a87d1b on main.


Why streams instead of "another async helper"

Part 6 made the architectural argument; here's the operational one. The old voice path was a sequence of awaited calls:

UtteranceEnded event → byte[] PCM → await STT → set txtInput.Text →
SendCurrentInput → reactor finishes → await TTS → Play(byte[])

It worked, but every link in that chain has a different reason to fail differently:

Link Failure mode the chain didn't model
Microphone OS-level buffer overflow if downstream stalls
STT Transient 5xx from the cloud provider, mid-utterance
LLM SSE token feed dropping mid-sentence
TTS Per-chunk failure (a single sentence rendered poorly)
Speaker Device disappeared (Bluetooth headset walked out of range)
User Talked over the AI mid-sentence (barge-in)

The batch chain reacted to all of these the same way: throw, log, drop the turn. A streaming pipeline lets each stage carry its own backpressure, retry, and cancellation policy without the others changing — the same shape Akka actors already gave us for terminal management, just now per-element instead of per-mailbox.


Topology — two graphs, one actor

flowchart TD
    Actor["<b>VoiceStreamActor</b><br/>/user/stage/voice<br/>• owns SharedKillSwitch<br/>• Tell-only protocol<br/>• holds materialized handles"]

    subgraph INPUT ["INPUT graph (mic → reactor)"]
        direction TB
        I1["Source.Queue&lt;MicFrame&gt;<br/>(64, OverflowStrategy.DropHead)"]
        I2["KillSwitches.Single"]
        I3["<b>VoiceSegmenterStage</b><br/>VAD + 1 s pre-roll + utterance FSM"]
        I4[".Async()"]
        I5["SelectAsync(p, sttPool.Ask)"]
        I6["Sink.ActorRefWithAck(Self)"]
        I1 --> I2 --> I3 --> I4 --> I5 --> I6
    end

    subgraph OUTPUT ["OUTPUT graph (LLM tokens → speaker)"]
        direction TB
        O1["Source.Queue&lt;string&gt;<br/>(64, OverflowStrategy.Backpressure)"]
        O2["KillSwitches.Single"]
        O3["<b>SentenceChunkerStage</b><br/>.?!\\n + min/max chunk len"]
        O4["TtsTextCleaner.StripMarkdown"]
        O5["SelectAsync(p, ttsPool.Ask)"]
        O6["Sink.ForEach(playback.Enqueue)"]
        O1 --> O2 --> O3 --> O4 --> O5 --> O6
    end

    Actor -- "owns + materializes" --> I1
    Actor -- "owns + materializes" --> O1

    classDef actor fill:#1f2937,stroke:#0ea5e9,color:#e5e7eb,stroke-width:2px
    classDef stage fill:#0b3d2e,stroke:#10b981,color:#d1fae5
    class Actor actor
    class I3,O3 stage

The crucial property is that the actor is the only thing the rest of the app talks to. WPF code never touches a graph or a kill switch. NAudio's capture callback Tells the actor with each MicFrame. The reactor's eventual reply Tells the actor with SpeakText (the OUTPUT graph then chunks per sentence and synthesizes in parallel with playback). The Cancel button Tells CancelInflight. Every cross-thread interaction is either a Tell or a Source.Queue.OfferAsync — both fire-and-forget, both backpressured by their own rules.