Repository navigation
feat(gateway): voice agents - talk to a Bud agent over /v1/realtime and /ws - #16
Merged
Merged
Conversation
…nd /ws
A caller connects to `prompt:<agent>` and WaaV runs the agent as a voice
session: STT, turn detection, one budprompt turn per utterance through
budgateway /v1/responses (streamed, caller's credential, bud_channel
"voice"), then TTS. budapp publishes each voice agent as
`voice_agent:{prompt_id}:v{n}`; bud-auth hydrates it and keeps it fresh
from keyspace events.
- core/agent: the turn engine. Turns never overlap; a barge-in truncates
the reply to what was heard and the next turn sends bud_truncate.
Ordered fillers timed from the turn's start (covering the stream open),
tool phrases, greeting, idle hang-up, degradation message, think
stripping, speaking one field of structured output.
- Speech is input only where it may cut what plays: interruption.enabled
for an answer, greeting.interruptible for the greeting; min_words 0/1 is
any speech.
- /ws config.agent and the /v1/realtime GA facade (cascade), with caller
overrides limited to the agent's session_overrides; the session reports
the agent's own turn-taking (semantic_vad / server_vad / null and
interrupt_response).
- Fixes found live: <think> stripper UTF-8 slice panic; eleven_v3 refuses
previous_text/next_text and request stitching; the voice manager counts
delivered and unplayed audio for truncation.
- docs/openapi.yaml regenerated for the agent config and messages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI's stable moved to 1.99 (main last passed on 1.98.1), which fails every branch on three gates, none from the voice-agent change: - clippy double_must_use fired on every #[async_trait] method generated by async-trait 0.1.89; 0.1.92 no longer emits the redundant must_use. - Atomic::fetch_update is deprecated (renamed try_update). The four call sites keep it under #[allow(deprecated)]: the image builds on 1.96, where try_update does not exist. - rustfmt 1.99 places one return-type brace differently (aws_transcribe). - cargo audit: RUSTSEC-2026-0319, anymap2 unmaintained (no patched release), via liquid in tract-linalg (noise-filter); ignored with the other unmaintained crates in deny.toml and .cargo/audit.toml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Voice agents (Bud spec 025,
specs/025-voice-agents/in bud-runtime). A caller can talk to any Bud agent that has avoiceblock by connecting toprompt:<agent>:/v1/realtime(the OpenAI GA protocol), or/wswithconfig.agent.WaaV runs speech-to-text, then turn detection, then the agent's turn through budgateway
/v1/responses(streamed, with the caller's credential andbud_channel: "voice"), then text-to-speech. The agent's prompt, tools and governance stay in budprompt, unchanged.Bud side: BudEcosystem/bud-runtime#3125. budapp validates the voice block and publishes
voice_agent:{prompt_id}:v{n}to Valkey; budprompt stores the block and replays only what the caller actually heard.What changed
bud-auth
voice_agent.rs: parses thevoice_agent:*projection (VoiceAgentEntry).hydrate.rs,runtime.rs,snapshot.rs: a full sweep ofvoice_agent:*at boot and on reconnect, plus keyspace events applied without a restart. An update this build cannot parse takes the agent off the air instead of serving stale settings.prompt:<name>alias for both API keys and JWTs, not against its speech deployments, which the caller may not reach directly.gateway:
core/agent/(new), the turn engine:brain.rs: the streamed/v1/responsesclient, failure classification, and a bounded retry when a request never reached the agent.engine.rs: turns never overlap. Barge-in truncates the reply to what was heard, and the next turn carriesbud_truncate.<think>stripping; text transforms; speaking one field of structured output; panic recovery.accepts_speech(): the caller's speech is input only where it may cut what is playing. That'sinterruption.enabledfor an answer andgreeting.interruptiblefor the greeting. Otherwise the speech is dropped before it becomes anstt_result, as LiveKit'sdiscard_audio_if_uninterruptibledoes.gateway:
/wshandlers/ws/agent.rs: the agent loop. It runs a turn controller with the agent's start strategy (min_wordsof 0 or 1 means any speech), ignore phrases, the session limit and idle, and maps engine signals to/wsmessages (agent_response_*,assistant_transcript,agent_tool,agent_truncated,agent_error).bud_legs.rs: resolves the agent's STT and TTS deployments into legs and applies caller overrides only where the agent'ssession_overridesallows them. A refused override comes back asconfig_warning deployment_setting_not_applied.gateway:
/v1/realtimeopenai_realtime/cascade.rs: the GA face of an in-process agent session, covering session events, overrides (only before the session starts), manual turns, typed turns, responses and truncation.semantic_vadwith its eagerness,server_vadfor silence,nullfor manual, andinterrupt_response = interruption.enabled. Eagerness is refused on non-semantic agents.Fixes found while testing live
core/text/think.rs: the<think>stripper sliced inside a multi-byte UTF-8 character and panicked, which wedged sessions.core/tts/elevenlabs.rs:eleven_v3*rejectsprevious_text,next_textand request stitching with a 400, so every sentence after the first failed. These fields are now gated bysupports_context_continuity.core/voice_manager: tracks audio delivered and still unplayed, so a truncation measures what was actually heard.Testing
manual Option::zipincore/stt/iflytek/client.rs, whichmain's CI clippy passes.min_words1, the session's turn-detection report, eagerness refusal) were each run against a deliberately broken implementation; all 7 failed as they should.main, the only failing CI job is the secret-gated real-provider e2e.Live on Bud's pde-ditto, with real Deepgram STT/TTS and an agent on deepseek-v4-pro with an MCP tool, over both endpoints:
min_words1: a single word cuts the answer; with 2 it doesn't (control).Deploy note
A fresh pod downloads the Silero VAD (from GitHub) and SmartTurn (from Hugging Face) models at first use. Where GitHub is unreachable, every voice-agent session fails setup with "Failed to initialize Silero VAD". This predates the PR, but voice agents are the first feature that needs the models on every session. Baking them into the image is a good follow-up.
🤖 Generated with Claude Code