You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[bug] The agent's own voice can issue commands — the always-on control listener is the only path with no echo guard, and a partial "stop" exits hands-free #386
The one listener that runs while the agent is talking is the one with no echo guard
handleControlResult (packages/sdk/src/voice/use-voice.ts:834-862) is the always-on control-word
recognizer. Its stated purpose is to catch a command "at ANY moment — while TTS plays, while the
agent is processing, while the mic is muted", and its own comment says it is "the ONLY one
running while the agent speaks".
It checks, in order: commandsEnabled → stop-speech (gated on ttsSpeaking) → matchVoiceCommand
→ dispatch mute / unmute / exit / next.
It never asks whether the audio was the agent's own voice. No isEchoing, no shouldIgnoreResult, no speakEndedAt tail.
Every other path guards this, and the guard exists because it already happened
machine.ts:43-54 — isEchoing() / shouldIgnoreResult(), whose docstring reads: "This is the
guard that, duplicated inline, let the agent transcribe itself and reply to nothing." So
speaker→mic bleed is an observed, fixed failure in this product, not a theoretical one.
handleResult (use-voice.ts:897+) — applies it: "ECHO (ALL MODES): while the agent speaks OR
within its ~0.8s echo tail — it's the agent's own voice, not you."
shouldScanGateTranscript (convo.ts) — takes an explicit echoing parameter, documented as "so the agent's own voice can't issue commands to it."
Three places reason carefully about this. The fourth — the only one that runs exclusively inside
the echo window — does not.
The concrete failure, and it needs no user input at all
The control listener judges interim results (onResult is wired to interim + final; commandStateFor("partial", …)). A single-word command must BE the whole utterance — and for an
interim, "the whole utterance" is whatever has been transcribed so far.
EXIT_BY_LANG.en contains the bare word "stop" (added by #331).
So: the agent says "Stop me if this is wrong" → the recognizer's first interim is "stop" →
whole-utterance match → exit → hands-free is torn down, mid-sentence, with the user having
said nothing.
Multi-word phrases match as a whole-word run inside a longer utterance, which widens it further:
the agent says
fires
"Stop me if…" / any clause whose first interim is "stop"
exit
"say mute mute to silence me" (an agent explaining its own controls)
mute
"the next agent in the chain is the builder"
next — teleports the user away
next and scrap are gated on the consumer opting in, and scrap is additionally withheld from
partials (#342) — that gate is right, and it is exactly the reasoning this path needs for the other
three. mute and exit have no equivalent.
While muted it is worse in kind, not degree: the muted branch matches unmute, so the agent's own
voice can re-open a microphone the user just closed.
On acoustic echo cancellation
Browser AEC removes much of the speaker→mic path on one device and is why this is intermittent
rather than constant. It is not a guarantee — different output device, speakerphone, a phone on a
desk, headphones off — and the codebase's own history is the evidence: the main-path echo guard was
added because the agent was transcribing itself through the same microphone.
Suggested fix
Apply the guard this path already has a tested implementation of:
Drop control results while isEchoing({ ttsSpeaking, speakEndedAt }, now) — except the
stop-speech check, which must keep running, since interrupting playback is the one command whose
entire purpose is to fire while the agent talks. That asymmetry is the reason this listener
exists, and it is expressible in one condition rather than by omitting the guard wholesale.
Related: #385 (the same "stop" built-in, reached from the other side — a user phrase the
built-in outranks). Fixing #385 alone leaves this path open; fixing this alone leaves #385 open.
The one listener that runs while the agent is talking is the one with no echo guard
handleControlResult(packages/sdk/src/voice/use-voice.ts:834-862) is the always-on control-wordrecognizer. Its stated purpose is to catch a command "at ANY moment — while TTS plays, while the
agent is processing, while the mic is muted", and its own comment says it is "the ONLY one
running while the agent speaks".
It checks, in order:
commandsEnabled→ stop-speech (gated onttsSpeaking) →matchVoiceCommand→ dispatch
mute/unmute/exit/next.It never asks whether the audio was the agent's own voice. No
isEchoing, noshouldIgnoreResult, nospeakEndedAttail.Every other path guards this, and the guard exists because it already happened
machine.ts:43-54—isEchoing()/shouldIgnoreResult(), whose docstring reads: "This is theguard that, duplicated inline, let the agent transcribe itself and reply to nothing." So
speaker→mic bleed is an observed, fixed failure in this product, not a theoretical one.
handleResult(use-voice.ts:897+) — applies it: "ECHO (ALL MODES): while the agent speaks ORwithin its ~0.8s echo tail — it's the agent's own voice, not you."
shouldScanGateTranscript(convo.ts) — takes an explicitechoingparameter, documented as"so the agent's own voice can't issue commands to it."
Three places reason carefully about this. The fourth — the only one that runs exclusively inside
the echo window — does not.
The concrete failure, and it needs no user input at all
The control listener judges interim results (
onResultis wired to interim + final;commandStateFor("partial", …)). A single-word command must BE the whole utterance — and for aninterim, "the whole utterance" is whatever has been transcribed so far.
EXIT_BY_LANG.encontains the bare word"stop"(added by #331).So: the agent says "Stop me if this is wrong" → the recognizer's first interim is
"stop"→whole-utterance match →
exit→ hands-free is torn down, mid-sentence, with the user havingsaid nothing.
Multi-word phrases match as a whole-word run inside a longer utterance, which widens it further:
nextandscrapare gated on the consumer opting in, andscrapis additionally withheld frompartials (#342) — that gate is right, and it is exactly the reasoning this path needs for the other
three.
muteandexithave no equivalent.While muted it is worse in kind, not degree: the muted branch matches
unmute, so the agent's ownvoice can re-open a microphone the user just closed.
On acoustic echo cancellation
Browser AEC removes much of the speaker→mic path on one device and is why this is intermittent
rather than constant. It is not a guarantee — different output device, speakerphone, a phone on a
desk, headphones off — and the codebase's own history is the evidence: the main-path echo guard was
added because the agent was transcribing itself through the same microphone.
Suggested fix
Apply the guard this path already has a tested implementation of:
isEchoing({ ttsSpeaking, speakEndedAt }, now)— except thestop-speech check, which must keep running, since interrupting playback is the one command whose
entire purpose is to fire while the agent talks. That asymmetry is the reason this listener
exists, and it is expressible in one condition rather than by omitting the guard wholesale.
exitfrom interim results the way Delete a single turn — by button and by voice ("scrap that") — because a noise message keeps shaping every later reply #342 withholdsscrap. Exit is notdestructive to data, but it is destructive to the session, and a partial is the only place a bare
"stop"can match a word the agent is still in the middle of saying.Related: #385 (the same
"stop"built-in, reached from the other side — a user phrase thebuilt-in outranks). Fixing #385 alone leaves this path open; fixing this alone leaves #385 open.
Files:
packages/sdk/src/voice/use-voice.ts:834-862,packages/sdk/src/voice/machine.ts:43-54,packages/sdk/src/voice/convo.ts(EXIT_BY_LANG,shouldScanGateTranscript,phraseMatchesTranscript).