Skip to content

Evaluate the aai voice-agent framework via host mode - #197

Open
alexkroman wants to merge 11 commits into
ServiceNow:mainfrom
alexkroman:feat/aai-assistant-server
Open

Evaluate the aai voice-agent framework via host mode#197
alexkroman wants to merge 11 commits into
ServiceNow:mainfrom
alexkroman:feat/aai-assistant-server

Conversation

@alexkroman

Copy link
Copy Markdown

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.json against final_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:

Module Responsibility
assistant/ws_bridge_server.py Shared bridge: Twilio framing, 20 ms pacing, recording buffers, turn bookkeeping, metrics
assistant/bridge_events.py Backend-agnostic event vocabulary and the session protocol
assistant/aai_session.py aai host handshake, audio frames, tool_result relay
assistant/aai_events.py aai wire-event models, tolerant parser
assistant/aai_server.py Prompt and tool-schema construction

All three domains (airline, itsm, medical_hr) work with no extra setup — prompts and tool schemas come from configs/agents/*.yaml. EVA_MAX_CONCURRENT_CONVERSATIONS above 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 sends Authorization: Bearer, header-only (a URL travels through proxy logs and Referer headers). 401 and 403 get actionable messages instead of an opaque InvalidStatus. A local aai dev host gates on AAI_ALLOW_HOST and needs no key.

model_response latency was never recorded — for any backend. The user simulator's send loop uses asyncio.get_event_loop().time(), a monotonic clock, and that value was going out as user_speech_stop's timestamp_ms. Assistant servers subtract it from their own time.time() reading, so every latency came out as the gap between the two clocks (~1.8e12 ms) and was discarded as implausible. user_speech_start was 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 bare 0 < latency_ms < 30_000 guard has been silently dropping every value for the same reason. elevenlabs_server prefers ElevenLabs' own end-of-turn timestamp and only hit this on fallback.

Verified against a deployed agent: 9 model_response samples 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 bookkeeping
  • tests/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 process
  • tests/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 domain

Full suite: 1997 passed, 52 skipped, 3 xfailed. ruff check clean.

Note: run tests with uv run --extra dev python -m pytestpytest lives in an optional extra that uv sync skips by default, and bare uv run pytest silently resolves to a pytest outside the project virtualenv.

Known gaps

  • No token usage. aai emits no usage events, so pipecat_metrics.jsonl holds latency entries only. model_response_latency is unaffected; token-based cost reporting is unavailable for aai runs.
  • Backend output must be 24 kHz. Only a 24 kHz → μ-law converter exists in 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

alexkroman-assembly and others added 11 commits July 29, 2026 00:00
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>
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.

2 participants