Speech-to-Speech Orchestration¶
Multi-agent orchestration using Gemini Live (or OpenAI Realtime) speech-to-speech, where the AI provider handles audio recognition, reasoning, and speech generation natively — no STT/LLM/TTS pipeline.
Overview¶
Traditional orchestration routes text events between Agent channels backed by an AIProvider. Each agent is an intelligence channel in the room, and VoiceChannel handles the audio conversion (STT on the way in, TTS on the way out).
Speech-to-speech orchestration uses the same Agent definitions — same role, description, scope, voice, system_prompt — but wires them into a RealtimeVoiceChannel instead. The AI reasoning happens inside the provider (Gemini Live), not in a separate channel. On handoff, the framework reconnects the provider session with the new agent's configuration, preserving conversation history via Gemini's session resumption.
Traditional (STT → LLM → TTS):
User speaks → VoiceChannel (STT) → text → Agent (AIChannel) → text → VoiceChannel (TTS)
Speech-to-speech:
User speaks → RealtimeVoiceChannel → Gemini Live (audio in → AI → audio out) → User hears
↑
Agent config (system_prompt, voice, tools)
swapped on handoff via session resumption
The developer writes nearly identical code for both modes. The only difference is whether Agent has a provider or not:
# Traditional (STT → LLM → TTS)
triage = Agent(
"agent-triage",
provider=GeminiAIProvider(config), # <-- has AI provider
role="Triage receptionist",
system_prompt="Greet callers warmly. Under 30 words.",
voice="en-US-Journey-F", # TTS voice ID
)
voice = VoiceChannel("voice", stt=deepgram, tts=elevenlabs)
# Speech-to-speech
triage = Agent(
"agent-triage", # <-- no provider (config-only)
role="Triage receptionist",
system_prompt="Greet callers warmly. Under 30 words.",
voice="Aoede", # Gemini native voice
)
voice = RealtimeVoiceChannel("voice", provider=GeminiLiveProvider(...), transport=...)
Both use the same pipeline.install() call. The framework detects the channel type and wires things differently under the hood.
How it works¶
Handoff via session resumption¶
Gemini Live does not support mid-session configuration changes. However, it supports session resumption with different configuration — you can disconnect and reconnect with a new system_instruction, voice, and tools while preserving the conversation history. The model parameter is the only thing that cannot change.
On handoff, the framework:
- Builds the new agent's config (system_prompt + identity block, voice, handoff tools)
- Disconnects the current Gemini session
- Reconnects with the new config + the session resumption handle
- Gemini remembers the full conversation but responds with the new agent's personality/voice
The reconnect takes ~200-500ms — a natural pause, similar to a phone transfer.
Wiring¶
When pipeline.install() detects that voice_channel_id points to a RealtimeVoiceChannel:
- Agents are NOT registered as channels — they have no provider, they're config-only
- A session starts with its room's active agent — system_prompt, voice, and tools are passed to
provider.connect(), read from the room's conversation state when the session opens. The tools are the channel's own, as they are when the session opens or the handoff lands (a channel reconfigured after the install keeps them under every agent), the agent's own (Agent(tools=...)), and the handoff tool. A name the channel's tools carry (its host tools, its human-input tools, and the ones it serves itself: Tool Search's, the skills') is the channel's: an agent tool of the same name is neither declared nor served, and a warning names it at the install; it stays undeclared if the channel later drops its own, since nothing of the agent's serves it. The channel's ownsystem_prompt,voiceandtoolsare left as they are - The handoff tool is served by the RealtimeVoiceChannel — Gemini calls it natively via its built-in tool calling. A call to one of the active agent's own tools is served by the
tool_handlerthe agent was given; a call to a channel tool and to an agent tool the agent has no handler for, by the channel's. The active agent'stool_policyholds on its session beside the channel's: a tool either denies is not declared, nor listed by Tool Search, and a call to it is refused with the policy's words. A handoff applies the next agent's policy to every session of the room, for its participant's role, and each call is judged by the agent the room talks to, the one that serves it. An allow-list policy hides the handoff tool too, as on text turns: allowhandoff_conversationfor an agent that hands off. An agent that carries what a realtime session never serves for an agent is refused at the install (ValueError, before anything is installed), each cause named: itsskills, a registry even empty (pass them toRealtimeVoiceChannel(skills=...)), itshuman_input_handler(pass it toRealtimeVoiceChannel(human_input_handler=...)),enable_planning, asandboxor anexternal_tool_handler; a realtime session serves the channel's skills and human-input tools only. The agent's owntoolsstay served - On handoff,
reconfigure_session()is called for the room's sessions — disconnects and reconnects with the new agent's config. Another room's sessions, and the next session of another room, keep their own room's agent
pipeline.install(kit, [triage, advisor], voice_channel_id="voice")
Internally:
1. Does NOT call kit.register_channel() for agents
2. Builds per-agent handoff tools (enum-constrained targets)
3. Serves the handoff and the agents' tools on RealtimeVoiceChannel
4. Starts each new session with its room's active agent
5. HandoffHandler.handle() → rtv.reconfigure_session(session, new_config)
for the sessions of the room that handed off
Agent identity in speech-to-speech¶
Agent identity (role, description, scope) is appended to the system_prompt passed to provider.connect(), just as it would be appended to the AIChannel's prompt in traditional mode. The AI sees the same identity block regardless of mode:
Greet callers warmly. Under 30 words.
--- Agent Identity ---
Role: Triage receptionist
Description: Routes callers to the right financial specialist
Scope: Financial advisory services only
Quick start¶
from roomkit import Agent, RealtimeVoiceChannel, RoomKit
from roomkit.orchestration import ConversationPipeline, PipelineStage
from roomkit.providers.gemini.realtime import GeminiLiveProvider
from roomkit.voice.backends.local import LocalAudioBackend
# 1. Define agents (no provider — config-only for speech-to-speech)
triage = Agent(
"agent-triage",
role="Triage receptionist",
description="Routes callers to the right specialist",
system_prompt="Greet callers. Ask what they need. Under 30 words.",
voice="Aoede",
)
advisor = Agent(
"agent-advisor",
role="Portfolio advisor",
description="Provides financial and investment advice",
system_prompt="Give concise financial advice. Under 50 words.",
voice="Charon",
)
# 2. Define pipeline (same as traditional orchestration)
pipeline = ConversationPipeline(
stages=[
PipelineStage(phase="triage", agent_id="agent-triage", next="advising"),
PipelineStage(phase="advising", agent_id="agent-advisor", next=None),
],
)
# 3. Create RealtimeVoiceChannel with Gemini Live provider
kit = RoomKit()
provider = GeminiLiveProvider(api_key="...")
transport = LocalAudioBackend(
input_sample_rate=24000,
output_sample_rate=24000,
)
voice = RealtimeVoiceChannel(
"voice",
provider=provider,
transport=transport,
)
kit.register_channel(voice)
# 4. One-liner: wires everything (detects RealtimeVoiceChannel automatically)
router, handler = pipeline.install(kit, [triage, advisor], voice_channel_id="voice")
# 5. Create room, start session — same as any realtime voice app
await kit.create_room(room_id="call-1")
await kit.attach_channel("call-1", "voice")
session = await voice.start_session("call-1", "caller-1", connection=None)
When the caller asks for investment advice, Gemini (as the triage agent) calls the handoff_conversation tool. The framework reconnects the Gemini session with the advisor's config — the caller hears the advisor's voice and personality without any gap in context.
Gemini Live constraints¶
| Aspect | Supported | Notes |
|---|---|---|
| Change system_instruction on reconnect | Yes | Via session resumption |
| Change voice on reconnect | Yes | Via session resumption |
| Change tools on reconnect | Yes | Via session resumption |
| Change model on reconnect | No | Must be same model |
| Mid-session config update (no reconnect) | No | No session.update API |
| Conversation history preserved | Yes | Session resumption handle |
| Reconnect latency | ~200-500ms | Natural handoff pause |
Comparison with traditional orchestration¶
| Aspect | Traditional (STT→LLM→TTS) | Speech-to-Speech |
|---|---|---|
| Agent definition | Agent(..., provider=AIProvider) |
Agent(...) (no provider) |
| Voice channel | VoiceChannel with STT + TTS |
RealtimeVoiceChannel |
| AI reasoning | In Agent (AIChannel) | In provider (Gemini Live) |
| Latency | Higher (STT + LLM + TTS) | Lower (native audio) |
| Voice quality | Depends on TTS provider | Native (Gemini voices) |
| Provider flexibility | Mix providers per agent | Single provider, all agents |
| Tool calling | Via AIChannel tool loop | Via provider native tools |
| Handoff mechanism | Route events to different Agent | Reconnect provider session |
install() call |
Identical | Identical |
Implementation details¶
reconfigure_session() on RealtimeVoiceChannel¶
async def reconfigure_session(
self,
session: VoiceSession,
*,
system_prompt: str | None = None,
voice: str | None = None,
tools: list[dict[str, Any]] | None = None,
) -> None:
"""Switch agent config by reconnecting with session resumption.
Disconnects the current provider session and reconnects with
new configuration. Gemini preserves conversation history via
the session resumption handle.
"""
It changes the session it is given, and nothing else (RFC §12.4): the
channel's configuration for future sessions changes through configure(),
never as a side effect of one session's reconfiguration.
reconfigure() on RealtimeVoiceProvider¶
async def reconfigure(
self,
session: VoiceSession,
*,
system_prompt: str | None = None,
voice: str | None = None,
tools: list[dict[str, Any]] | None = None,
temperature: float | None = None,
provider_config: dict[str, Any] | None = None,
) -> None:
"""Rebuild config and reconnect with session resumption."""
GeminiLiveProvider implements this by:
- Building a new
LiveConnectConfigfrom the provided parameters - Storing it as
_live_configs[session.id] - Calling
_reconnect(session)which uses the stored resumption handle
Config-only Agent¶
When Agent is created without a provider, it uses an internal _NullAIProvider placeholder. The agent is still an AIChannel instance (isinstance checks work), but calling generate() raises a clear error. This is intentional — config-only agents are never registered as channels.
# Config-only (speech-to-speech)
triage = Agent("agent-triage", role="Triage", system_prompt="...")
assert isinstance(triage, AIChannel) # True
# triage._provider.generate() would raise RuntimeError
# Full agent (traditional)
triage = Agent("agent-triage", provider=GeminiAIProvider(config), role="Triage", ...)
# triage._provider.generate() works normally
install() detection logic¶
def install(self, kit, agents, *, voice_channel_id=None, ...):
vc = kit._channels.get(voice_channel_id) if voice_channel_id else None
if isinstance(vc, RealtimeVoiceChannel):
# Speech-to-speech mode
self._wire_realtime(kit, agents, vc)
else:
# Traditional mode (existing behavior)
self._wire_handoff(agents, handler)
if voice_channel_id:
self._wire_voice_map(kit, agents, voice_channel_id)
In speech-to-speech mode, _wire_realtime():
- Builds each agent's config with identity block, and its handoff tool; its tools (the channel's as they are, the agent's, the handoff tool) are read when a session opens or a handoff lands
- Builds per-agent handoff tools (enum-constrained targets with descriptions)
- Serves
handoff_conversationand the agents' own tools on the RealtimeVoiceChannel - Gives the channel the config a new session of a room starts with: the room's active agent's
- A handoff calls
HandoffHandler.handle(), which triggersreconfigure_session()for that room's sessions
Examples¶
examples/orchestration_realtime_triage.py— Voice triage with Gemini Live speech-to-speech