Repository navigation
bug(voice): GET /voice/realtime/tools has no server route — the Realtime session registers zero tools and the 404 is swallowed #17734
Description
Activity
Pairs with #17736 — same file, same surface, one PR.
Both land in
autobot-backend/api/realtime_session.pyand both concern what the Realtime tool path tells the truth about:- bug(voice): GET /voice/realtime/tools has no server route — the Realtime session registers zero tools and the 404 is swallowed #17734 — the browser asks
GET /voice/realtime/toolswhich no router serves, so the model is told about zero tools whilePOST /tools/calldispatches whatever it is asked to. Discovery is missing. - security(api): anonymous POST /voice/realtime/tools/call dispatched arbitrary MCP tools, and the audit record cannot name the caller #17736 —
POST /tools/callpasses nouser_id, so the audit record of an invocation cannot name the caller. Attribution is missing.
Two halves of one contract: what may be called, and who called it. A PR that adds the discovery route without threading the caller through the dispatch would touch the same file twice and leave the log still anonymous, so they go together under the owner's combine-issues rule (2026-09-28: no single-issue PRs).
Whichever is picked up first carries the other. Per the same-file rule, one agent owns both.
- bug(voice): GET /voice/realtime/tools has no server route — the Realtime session registers zero tools and the 404 is swallowed #17734 — the browser asks
Premise check — stale: the route exists, is authenticated, and is mounted. Read-only at
origin/mainab05ba3aef.The issue states "
GET /voice/realtime/toolshas no server route — the Realtime session registers zero tools and the 404 is swallowed." On currentmain:- the route exists —
autobot-backend/api/voice.py:56@realtime_router.get("/realtime/tools")→voice_realtime_list_tools, and the companion:89@realtime_router.post("/realtime/tools/call"); - it is authenticated —
:63current_user: dict = Depends(get_current_user); - and the router is mounted, which is the half that usually fails silently —
initialization/router_registry/core_routers.py:118importsrealtime_routerand:440registers it under/voice….
So the client half at
autobot-frontend/src/composables/useRealtimeVoice.ts:112now reaches a real server route.What this does not settle: the issue's second complaint — the 404 is swallowed — is about the client's error handling, not the route. A
if (!res.ok)that returns quietly is still a swallow when the endpoint fails for some other reason (401 after a session expires, 500 from the MCP listing). If that half is wanted, it is a separate and much smaller criterion than the one this issue is titled for; if it is not, this closes.And a note on how this was found, because it is a limitation of the sweep that reached it: no commit subject carries
(#17734). The pre-filter that scans commit subjects reported this issue clean, and it is fixed. A subject scan finds fixes that cite the issue; only a premise check finds fixes that do not. Worth stating wherever that pre-filter is written down — a clean pre-filter is not evidence that nothing was done.- the route exists —
Premise stale, issue NOT closable — 1 of 4 ACs met at
origin/mainab05ba3aef(a4's read-only premise check, per-AC verification by the coordinating session):- AC1 ✔ the route exists and is mounted:
api/voice.py:56@realtime_router.get("/realtime/tools"), authenticated at:63Depends(get_current_user), mounted atinitialization/router_registry/core_routers.py:118/:440under/voice. No commit subject carries(#17734)— a subject-scan pre-filter reports this issue as untouched; only the premise check found the route. - AC2 ✘ partially:
useRealtimeVoice.test.ts:186asserts thesession.updatetool shape, but:208"skips session.update when tool list is empty" treats an empty list as a state — the test would not fail if the list arrived empty. No backend test exercises the route's response shape. - AC3 ✘
useRealtimeVoice.ts:110-125_fetchToolsreturns[]on!res.okand on exception (logger.warnthenreturn []) — "no tools configured" and "the request failed" are still the same value. - AC4 ✘ no test pins that the registered toolset is non-empty when the bridge has tools.
Remaining work is frontend-only (
_fetchTools+ two tests) plus one backend shape test; unchanged milestone.- AC1 ✔ the route exists and is mounted:
B6 spec — two voice surfaces whose failure looks like nothing to do
Batch B6 = #17004 → #17734's client half, one risk class, one review pass. Full spec at
~/b6-spec-2026-09-28.md.#17004 — and the fix is a design choice, not a one-liner
useVoiceOutput.ts:665-666opensnew WebSocket(url)with no credential. The mechanical constraint: the browserWebSocketconstructor cannot set headers, and this app's credential is a Bearer token in anAuthorizationheader (utils/fetchWithAuth.ts:25,:35-36). So the token cannot be attached the way every REST call attaches it, and the fix has to pick a mechanism: a short-lived ticket as a query parameter, theSec-WebSocket-Protocolsubprotocol, or a required first-message handshake.Whichever is chosen, match
open_authenticated_ws— the mechanism the workflow socket already uses (services/workflow_automation/ws_endpoint.py). A second WebSocket auth scheme in this repo is the #17693 shape in a new place.Tests: client half in
useVoiceOutput's own__tests__; server half besideapi/ws_auth_17009_test.py, the established home for "this socket refuses an unauthenticated connection". Mutation: revert to the barenew WebSocket(url)→ the server test shows a refusal and the client test goes red. Negative control: an authenticated connection still succeeds, or the fix has just broken voice output.#17734's client half — and the test that exists today is the half-assertion
AC1 is met (route exists, mounted, authenticated).
_fetchTools(useRealtimeVoice.ts:110-125) returns[]on both!res.okand an exception, so the endpoint failed and there are no tools are the same value.Two assertions, and the second is pinned by nothing today:
- a non-2xx and a thrown exception each produce a failure, not
[]; - a successful fetch of a non-empty toolset produces that toolset.
useRealtimeVoice.test.ts:208treats an empty list as a state — it passes for a real empty toolset and for a failed fetch, so it must be split, not extended. Mutations: return500→ assertion 1 red; return200 []→ assertion 2 red. A single test that accepts[]passes both, which is exactly why this AC is 1/4 and not 2/4.Do not close #17734 on this batch alone — the route half is already done, and
:208's half-assertion is part of the remainder.- a non-2xx and a thrown exception each produce a failure, not
#7343 shipped and closed on 2026-06-01. Its client half calls an endpoint the backend does not serve, and the failure is swallowed, so voice mode has been registering zero tools ever since.
The two halves
autobot-frontend/src/composables/useRealtimeVoice.ts:110-125:The result feeds
session.update, which is how the model is told which tools exist.Server side,
api.realtime_sessionis the only router mounted at/voice/realtime(initialization/router_registry/feature_routers.py:697), and it declares noGET /tools. Its routes are/session,/providers(GET, PATCH),/session/{id}/end,/session/{id}/response_done,/tools/calland/sessions. The/toolslist route lives inapi.mcp_registry, which is mounted at/mcp(core_routers.py:473) — a different path.So the GET 404s,
_fetchToolsreturns[], andsession.updateadvertises an empty toolset.Why it went unnoticed
if (!res.ok)logs atwarnand returns an empty list, which is indistinguishable from "this deployment has no MCP tools configured". The feature degrades to exactly its own no-op, and the only signal is a log line in a path nobody reads unless voice is being debugged.Meanwhile
POST /voice/realtime/tools/calldoes exist and dispatches by name against the registry built from the configured MCP servers. So dispatch works and discovery does not: a model that already knows a tool name can invoke it, and the browser can never tell it one.Not a routing typo — pick the direction deliberately
Either is defensible and they are not equivalent:
GET /toolstoapi.realtime_session, returning the bridge's registry in Realtime function-tool shape. Keeps the voice contract on one prefix and lets the response be shaped for the transport, which is whatsession.updateneeds./mcp/toolsand shape it client-side. Fewer server routes, but the browser then owns a translation that belongs to the bridge, and/mcp/toolsanswers a different question (registry contents, not what this session may call).(1) matches how
/tools/callis already split fromapi.mcp_registry.Acceptance criteria
GET /voice/realtime/toolseither exists and returns the session's callable tools, or the client calls the route that does — one of the two, stated in the PR.session.updateconsumes, asserted in a test that would fail if the tool list arrived empty._fetchToolsdistinguishes "no tools configured" from "the request failed" — an empty list from a 404 is a defect, not a state.Refs #7343
Refs #16375 — found while gating
api.realtime_session, whosePOST /tools/callwas reachable anonymously.