Skip to content

feat: add DuplexModel for full-duplex speech models - #6677

Draft
longcw wants to merge 2 commits into
mainfrom
longc/duplex-model
Draft

feat: add DuplexModel for full-duplex speech models#6677
longcw wants to merge 2 commits into
mainfrom
longc/duplex-model

Conversation

@longcw

@longcw longcw commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

A full-duplex speech model listens and speaks at the same time. Its audio streams continuously whether or not it is speaking, it decides for itself when to talk, and it can be neither interrupted nor truncated.

That does not fit RealtimeModel, whose surface assumes a response the client can act on. Server-side turn detection decides when a response starts, but the response is still an object the client can trigger, cancel, truncate and commit against. A duplex model has none of that, and its audio neither begins nor ends with one.

DuplexModel / DuplexSession give plugin authors a surface carrying only what such a model can actually do. DuplexRealtimeAdapter runs one inside an AgentSession.

Why an adapter:

  • It consumes everything the model emits. The adapter takes the whole continuous audio stream and decides what reaches the room, so output the model never transcribes — backchannels, breath, laughter — still plays. Nothing is discarded to fit a turn-shaped API.
  • After the adapter, the framework sees an ordinary realtime session. Turns, transcripts and tool calls arrive as GenerationCreatedEvents, so SpeechHandle, speech scheduling, drain() and agent_state need no duplex-specific path. The only changes outside the adapter are two capability flags: server_barge_in, for a model that stops its own output when the user speaks, and manual_response_creation, for whether generate_reply may ask a model to speak. Both default to current behaviour.
  • It leaves room for the duplex model. Turn boundaries, transcript timing and frame tagging may vary between providers, and several are optional; the adapter degrades to whatever a given model reports, and the plugin surface stays honest either way.

AgentSession(llm=...) and Agent(llm=...) accept a DuplexModel directly and wrap it internally.

TODO: client delegation — a duplex model handing its reasoning and tool work back to the agent, answered through the normal text pipeline.

longcw added 2 commits August 3, 2026 18:35
A full-duplex speech model listens and speaks at the same time. Its audio
streams continuously whether or not it is speaking, it decides for itself
when to talk, and it can be neither interrupted nor truncated. None of
that fits RealtimeModel, whose surface is built around the client driving
each response.

DuplexModel / DuplexSession give plugin authors a surface carrying no
method the model cannot perform. DuplexRealtimeAdapter runs one inside an
AgentSession by segmenting its continuous output into turns and presenting
them as an ordinary RealtimeSession, so the voice pipeline needs no
duplex-specific path.

An energy gate decides what reaches the room, since a model that emits
unconditionally would otherwise publish silence forever. Its floor is the
quietest frame of the recent past, which speech cannot drag upward, and
every duration is measured in audio rather than wall clock, so one set of
defaults ports across providers and frame sizes. A turn closes when the
model reports it ended, or when its transcript catches up with the audio
that carried sound; a liveness bound covers a model that can do neither.
Output the model never transcribes still plays, it simply produces no chat
item, which is what makes backchannels audible.

Two capabilities are added to RealtimeCapabilities, both defaulted so
existing realtime models are unaffected:

- server_barge_in, for a model that stops its own output when the user
  speaks. The framework then leaves playback alone on its speech-started
  event and only interrupts from an explicitly configured VAD or
  interruption detector.
- manual_response_creation, for whether generate_reply may ask the model
  to speak. When false the reply is skipped rather than dispatching a call
  that can only fail.

Also releases the transcription synchronizer's trailing word once a timed
span closes, rather than holding it for a delimiter that a server-driven
model only sends with its next turn.
A duplex model previously had to be wrapped in its adapter by hand before
it could be passed as an llm. Accept it directly and wrap it where it is
stored, so nothing downstream ever sees one.

Storage is the right place rather than AgentActivity: _update_models
rejects swapping to or from a RealtimeModel while running, because such a
model owns a live session. A DuplexModel wrapped any later would not match
that check and would slip through it. Annotating _llm makes the invariant
checkable rather than conventional.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant