Skip to content

bug(voice): GET /voice/realtime/tools has no server route — the Realtime session registers zero tools and the 404 is swallowed #17734

Description

@mrveiss

#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:

const res = await fetchWithAuth(`${getApiBase()}/voice/realtime/tools`, { method: 'GET' })
if (!res.ok) {
  logger.warn('Failed to fetch realtime tools: %s', res.status)
  return []
}

The result feeds session.update, which is how the model is told which tools exist.

Server side, api.realtime_session is the only router mounted at /voice/realtime (initialization/router_registry/feature_routers.py:697), and it declares no GET /tools. Its routes are /session, /providers (GET, PATCH), /session/{id}/end, /session/{id}/response_done, /tools/call and /sessions. The /tools list route lives in api.mcp_registry, which is mounted at /mcp (core_routers.py:473) — a different path.

So the GET 404s, _fetchTools returns [], and session.update advertises an empty toolset.

Why it went unnoticed

if (!res.ok) logs at warn and 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/call does 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:

  1. Add GET /tools to api.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 what session.update needs.
  2. Point the client at /mcp/tools and shape it client-side. Fewer server routes, but the browser then owns a translation that belongs to the bridge, and /mcp/tools answers a different question (registry contents, not what this session may call).

(1) matches how /tools/call is already split from api.mcp_registry.

Acceptance criteria

  • GET /voice/realtime/tools either exists and returns the session's callable tools, or the client calls the route that does — one of the two, stated in the PR.
  • The response shape is the one session.update consumes, asserted in a test that would fail if the tool list arrived empty.
  • _fetchTools distinguishes "no tools configured" from "the request failed" — an empty list from a 404 is a defect, not a state.
  • A test pins that the registered toolset is non-empty when the bridge has tools, so this cannot silently return to zero.

Refs #7343
Refs #16375 — found while gating api.realtime_session, whose POST /tools/call was reachable anonymously.

Activity

  1. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Pairs with #17736 — same file, same surface, one PR.

    Both land in autobot-backend/api/realtime_session.py and both concern what the Realtime tool path tells the truth about:

    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.

  2. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Premise check — stale: the route exists, is authenticated, and is mounted. Read-only at origin/main ab05ba3aef.

    The issue states "GET /voice/realtime/tools has no server route — the Realtime session registers zero tools and the 404 is swallowed." On current main:

    • 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 — :63 current_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:118 imports realtime_router and :440 registers it under /voice….

    So the client half at autobot-frontend/src/composables/useRealtimeVoice.ts:112 now 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.

  3. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Premise stale, issue NOT closable — 1 of 4 ACs met at origin/main ab05ba3aef (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 :63 Depends(get_current_user), mounted at initialization/router_registry/core_routers.py:118 / :440 under /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:186 asserts the session.update tool 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 _fetchTools returns [] on !res.ok and on exception (logger.warn then return []) — "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.

  4. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    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-666 opens new WebSocket(url) with no credential. The mechanical constraint: the browser WebSocket constructor cannot set headers, and this app's credential is a Bearer token in an Authorization header (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, the Sec-WebSocket-Protocol subprotocol, 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 beside api/ws_auth_17009_test.py, the established home for "this socket refuses an unauthenticated connection". Mutation: revert to the bare new 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.ok and 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:

    1. a non-2xx and a thrown exception each produce a failure, not [];
    2. a successful fetch of a non-empty toolset produces that toolset.

    useRealtimeVoice.test.ts:208 treats 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: return 500 → assertion 1 red; return 200 [] → 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions