Skip to content

[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

Description

@serge-ivo

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-54isEchoing() / 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 → exithands-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:

  1. 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.
  2. Consider withholding exit from 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 withholds scrap. Exit is not
    destructive 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 the
built-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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingvoiceVoice / STT / TTS

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions