DEV Community

prodbymarcu
prodbymarcu

Posted on

Why does your voice agent go silent mid-call?

cover

"I'm on a call, Bender isn't responding. I heard you but you're not responding." That message started a week of digging that ended with six plumbing bugs and one provider stall that looked exactly like them. Not one of them was the model getting dumber. Every one had the same shape: a piece of plumbing gave a confident wrong answer about whether the call was happening, and the code believed it.

If you're building anything that has to know when a human is on the other end, you'll hit these. I hit all six on one Mac.

The watcher died exactly when a call connected

The watcher spawned the voice loop through a hardcoded interpreter path pointing at .venv. The directory had been renamed to venv at some point and the old path was dead, so Popen raised FileNotFoundError inside the watcher, which killed the watcher at the moment a call came in. The call answered. Nothing spawned. No process left running to log why.

The fix is boring on purpose. Resolve the interpreter from a candidate list, probe each one with an import, fall back to sys.executable, and wrap the spawn so a failure gets logged, because the process that watches for failures shouldn't be the one that dies.

Detection was blind, and the watchdog called blindness death

No camera on this Mac, so FaceTime draws the in-call UI somewhere the accessibility tree can't reach: zero AX nodes, and the window itself only says "connect a camera". The helper probe returned idle through a 448 second call. On the camera-less machine both detectors did the same for an entire live call. The watchdog read idle, decided the call was over, and closed a live session about 14 seconds in.

The replacement signal came from the audio side. The loop holds two virtual audio devices during a call, and both read IsRunningSomewhere = True while it's up, False when it isn't. That's the detector now. The watchdog rules changed with it: explicit ended closes immediately, four consecutive bad readings before it hangs up, and an undetermined reading keeps the session alive.

One layer down, the same bug in smaller type. The watcher used to close on a single unknown, and detection blinks to unknown for a poll or two while a history database copy races, so one blink killed calls. Six consecutive unknown readings, about three seconds, is the bar now. Group calls needed their own path too, because they never emit ringing at all, they go straight to connected.

The hangup signal was backwards

CallHistory has no row while a call is up. The row shows up when the call completes, carrying the final duration. The old check read duration == 0 as "still live", which is wrong in both directions, because a failed dial leaves 0.0 sitting there forever.

The signal that survives is a snapshot. Record the newest Z_PK when the session starts, and any newer row means the call ended. That's the only honest hangup source on this machine, since the process holding the audio device can never detect its own call ending.

Audio hangs with the process still alive

sounddevice deadlocked inside OpenAndSetupOneAudioUnit, waiting in mach_msg. sample <pid> showed the exact frame, and a bare InputStream hung the same way. sudo killall coreaudiod unsticks it, because the daemon relaunches on demand and the device open goes back to instant.

It returned a few days later and coreaudiod was innocent. The holder was audiomxd, in R state since boot, 29 days at roughly 94% CPU, with load average between 48 and 88 and zero percent idle. Killing that daemon unstuck audio immediately. The rule now: when an audio open hangs, read process state before you restart the obvious daemon.

The loop kept the failure alive

This one made it look permanent. A stuck loop holds the virtual audio device, so detection reads "connected", so the watcher spawns another loop, and two loops fight over one device. That wedge survives coreaudiod restarts, which is why it read as a hardware problem.

The proof was two runs. With the watcher stopped and stale processes killed, audio opened instantly. With them running, every open hung. The rule that came out: one loop per call, zero loops when idle, enforced from both sides, plus a watchdog that kills duplicates mid-call and stale loops when no call is live.

The call that never rang

Above all of that sat a failure no agent code could touch. A screen sharing session was up, and macOS suppressed the incoming call before it rang. The log said it with no ambiguity: outcome: suppressed; reason: display shared. One notification setting, the one for when you're mirroring or sharing the display, was off. Turning it on gave 48 out of 48 allowed resolutions and zero suppressions over the next five minutes. The agent didn't miss the call, the operating system never let it exist.

The provider added four minutes of dead air

With the plumbing fixed, a live call still went quiet for four minutes. The log had the shape of it: dispatch at 20:19:03, first tool output at 20:23:07, and the tool round itself took 3.1 seconds. The next turn did it again, dispatch at 20:23:18, done at 20:27:31, which is 4 minutes 13 seconds of dead air around 11.6 seconds of work. No client timeout existed anywhere, so a congested provider queue looked exactly like a hang.

The fix is a 35 second socket read timeout on every brain call. Streaming deltas reset it continuously, so it bounds dead air without cutting off a model that's genuinely thinking. Behind it sits a fallback model handed the same conversation, measured at 1.9 seconds through one call path and 1.8 through the other. A separate limit bit me on reconnect: the greeting carried 2,500 characters of call tail, tripped Context append text must not exceed 500 tokens three times, and died silently every time. Capped at 900 characters.

The rules that came out of it

Fail open. An undetermined reading keeps the session alive, and only an explicit ending closes it. A watchdog that treats "I can't tell" as "it's over" will kill live calls on any machine where detection is inherently blind, and that's most machines.

One owner. Exactly one process may hold the audio device per call, and zero when idle. Every duplicate is a future feedback loop.

Process alive is not the same as working. A wedged daemon keeps its PID, its memory, and its state, then stops at the first line of real work. Test the thing itself, not the process table. The health script opens the audio device on every run now, and its silence is what tells me everything is fine.

The self-healer runs every five minutes and has five jobs: kill duplicates during a call, kill a wedged loop so the watcher respawns clean, kill stale loops when no call is live, restart the audio daemon when a device open hangs, and kick the watcher if it's down. It says nothing unless it acts.

What I'd check first

If your agent goes silent, don't start at the model. Start at the last place that reported what was happening, and ask what it prints when it doesn't know.

Where has a watchdog in your stack reported confidence it hadn't earned?

Top comments (0)