Evaluate the aai voice-agent framework via host mode - #197
Open
alexkroman wants to merge 11 commits into
Open
Conversation
Additive integration: a shared WebSocketBridgeAssistantServer base (Twilio framing, 20ms pacing, buffer sync, latency/turn logging) plus an aai host-mode server that injects the domain prompt + tool schemas via config.host and relays tool_call -> tool_result so EVA's ToolExecutor mutates the scenario database. Existing five servers untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six TDD tasks: aai event models, the shared WebSocket bridge base + seam, the aai host session client, the server + framework registration, a full-session test against an in-process fake aai host, and docs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ported from the tau2-bench aai provider, with loguru swapped for eva's logger and the tick-loop-only event types dropped. Parsing never raises: unknown and malformed frames become AAIUnknownEvent so one bad frame cannot abort a conversation mid-evaluation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WebSocketBridgeAssistantServer owns everything the assistant-server contract requires -- Twilio framing, 20ms output pacing, time-aligned recording buffers at a fixed 24kHz, turn bookkeeping, latency and audit writes -- leaving a backend to supply only a VoiceBackendSession. Existing servers are untouched; this sits alongside them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Performs the ?host=1 handshake (system prompt, greeting, and tool schemas injected per session), streams PCM16 audio as binary frames, and relays tool_call -> tool_result so tools execute in EVA rather than aai's sandbox. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AAIAssistantServer builds the per-session agent definition -- system prompt via the base class, greeting from the run's initial message, and tool schemas in aai's flat ToolSchema shape -- then opens a host session. Selected with framework: aai. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drives both WebSocket hops in-process: a fake simulator sends Twilio-framed mu-law audio, the bridge speaks host mode to a fake aai server that requests a tool and replies with audio. Verifies the injected prompt and greeting, the tool round-trip through EVA, and 160-byte mu-law output framing -- with no paid APIs and no Node process. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds docs/aai_integration.md (prerequisites, config table, run command, architecture, troubleshooting), indexes it in docs/README.md, and registers AAI_WS_URL plus the aai framework option in .env.example so the config editor offers them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A deployed agent's WebSocket is deliberately unauthenticated, so the platform requires the owner's API key as a bearer token before honoring host-mode prompt and tool overrides — without that check, host mode would turn any deployed agent into an open LLM proxy billed to its owner. A local `aai dev` host gates on AAI_ALLOW_HOST instead and needs no key. AAIHostSession.connect() therefore takes an api_key and sends it as Authorization: Bearer on the upgrade. Header only, never a query parameter: a URL travels through proxy logs and Referer headers, and this token is the caller's whole platform credential. The key is read from s2s_params.api_key or AAI_API_KEY. RunConfig already requires api_key to be present for any S2S provider, so this gives that field a real meaning for aai rather than a placeholder. Also maps the two rejections a host-mode upgrade can hit onto actionable messages — 401 (no bearer token) and 403 (key does not own the slug) otherwise surface as an opaque InvalidStatus. Verified against a deployed agent: handshake completes, 59 tool schemas accepted, audio flows both ways. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The send loop's `current_time` is the event loop's monotonic clock, which shares no origin with wall time. It was going out on the wire as user_speech_stop's timestamp_ms, while assistant servers subtract that stamp from their own time.time() reading to compute model_response latency. Every latency therefore came out as the gap between the two clocks — ~1.8e12 ms — and was discarded as implausible, so the metric was never recorded at all. user_speech_start was already wall-clock (time.time()), so the two events were not even in the same domain as each other. The monotonic-to-wall conversion already existed one block above, but was scoped inside `if self.event_logger:`. Hoisting it fixes the wire value and keeps the existing semantics: the stamp is anchored to the last real audio chunk (~600 ms before detection), not to detection time. This also repairs gemini_live_server, whose bare `0 < latency_ms < 30_000` guard has been silently dropping every value for the same reason. Verified against a deployed aai agent: 9 model_response samples recorded across one conversation (2.9-3.6 s, one 11.7 s outlier) where the previous code recorded none, and correctly no sample for the model-initiated greeting turn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documents running against a deployed agent: the bearer-token requirement, api_key/AAI_API_KEY in the config table, and 401/403 plus the agent's own LLM failures in troubleshooting. Corrects three things that were wrong: - The run command carried AAI_ALLOW_HOST, which EVA never reads. It gates host mode server-side on the aai host process only. - The turn-taking section claimed the simulator's speech events carry wall-clock timestamps. They did not, which is what the preceding commit fixes; the note now explains the conversion and why it matters. - The test command omitted --extra dev, so it failed on a default `uv sync` — pytest lives in an optional extra. Co-Authored-By: Claude Opus 5 (1M context) <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.
Adds an assistant server so EVA can benchmark the aai voice-agent framework through aai's host mode, where EVA supplies the domain system prompt, greeting, and tool schemas per session and aai relays each tool call back to EVA for execution.
Tool execution has to happen inside EVA: the accuracy metrics diff
initial_scenario_db.jsonagainstfinal_scenario_db.json, so a backend that ran tools internally would score as if nothing happened.Structure
The reusable part is split from the aai-specific part, so adding another WebSocket voice backend means writing one session adapter plus a thin server subclass — no changes to the bridge:
assistant/ws_bridge_server.pyassistant/bridge_events.pyassistant/aai_session.pytool_resultrelayassistant/aai_events.pyassistant/aai_server.pyAll three domains (airline, itsm, medical_hr) work with no extra setup — prompts and tool schemas come from
configs/agents/*.yaml.EVA_MAX_CONCURRENT_CONVERSATIONSabove 1 is fine: host mode builds a fresh single-use runtime per connection.Two fixes found by running it end-to-end
Deployed-agent authentication. A deployed aai agent's WebSocket is deliberately unauthenticated, so the platform requires the owner's API key as a bearer token before honoring host-mode prompt and tool overrides — otherwise host mode would turn any deployed agent into an open LLM proxy billed to its owner.
connect()now sendsAuthorization: Bearer, header-only (a URL travels through proxy logs andRefererheaders).401and403get actionable messages instead of an opaqueInvalidStatus. A localaai devhost gates onAAI_ALLOW_HOSTand needs no key.model_responselatency was never recorded — for any backend. The user simulator's send loop usesasyncio.get_event_loop().time(), a monotonic clock, and that value was going out asuser_speech_stop'stimestamp_ms. Assistant servers subtract it from their owntime.time()reading, so every latency came out as the gap between the two clocks (~1.8e12 ms) and was discarded as implausible.user_speech_startwas already wall-clock, so the two events were not even in the same domain as each other.The conversion already existed one block above but was scoped inside
if self.event_logger:; hoisting it fixes the wire value while keeping the stamp anchored to the last real audio chunk (~600 ms before detection) rather than to detection time.This also repairs
gemini_live_server, whose bare0 < latency_ms < 30_000guard has been silently dropping every value for the same reason.elevenlabs_serverprefers ElevenLabs' own end-of-turn timestamp and only hit this on fallback.Verified against a deployed agent: 9
model_responsesamples across one conversation (2.9–3.6 s, one 11.7 s outlier) where the previous code recorded none — and correctly no sample for the model-initiated greeting turn.Tests
tests/unit/assistant/— wire events, session framing and handshake, bearer auth, prompt/tool construction, bridge pacing and turn bookkeepingtests/integration/test_aai_fake_backend.py— drives both WebSocket hops against an in-process fake aai host, so it needs no API keys and no Node processtests/unit/user_simulator/test_audio_bridge.py— pins that a monotonic timestamp never reaches the wire and that the two speech events stay in one clock domainFull suite: 1997 passed, 52 skipped, 3 xfailed.
ruff checkclean.Note: run tests with
uv run --extra dev python -m pytest—pytestlives in an optional extra thatuv syncskips by default, and bareuv run pytestsilently resolves to a pytest outside the project virtualenv.Known gaps
pipecat_metrics.jsonlholds latency entries only.model_response_latencyis unaffected; token-based cost reporting is unavailable for aai runs.audio_bridge.py; another rate raises at session start with a clear message rather than producing distorted audio.Setup, configuration, and troubleshooting are documented in
docs/aai_integration.md.🤖 Generated with Claude Code