Vocero needs to query AquaPortal on behalf of the caller selected by the existing phone link. This adds server-owned conversations and MCP tool execution for voice and HTTP reasoning while leaving the speech pipeline unchanged. Release notes: - Added browser-bound conversations that keep backend credentials server-side and isolate callers. - Connected the eight-tool AquaPortal catalog through authenticated Streamable HTTP with full input schemas and structured results. - Added bounded multi-step tool execution with cancellation, expiry, and stale-result protection. - Updated browser bootstrap, tool progress, reasoning headers, and conversation cleanup. - Disabled legacy unscoped content preloads in MCP mode and documented configuration and deployment constraints. - Preserved disabled-mode contracts and added local SDK, WebSocket, and browser regression coverage. Reviewed-on: AquaPortal/s2s#1
9.2 KiB
Tool calling
The application supports native realtime function calling and server-side
tool selection from model text. AquaPortal content uses authenticated MCP when
S2S_MCP_URL is set; otherwise the legacy ApiPilot bridge remains available.
A spoken promise to fetch something is not evidence that a tool executed.
Authenticated AquaPortal MCP
Vocero uses the official MCP Python SDK with Streamable HTTP. Each discovery
or execution initializes a client with that conversation's opaque backend token
as Authorization: Bearer .... JSON and SSE responses are supported. Neither
browser values nor model arguments choose the authenticated user. There is no
service-token override, anonymous retry, redirect following, or public REST
fallback on this path. AquaPortal checks current permissions on every request,
including its administrator and assigned report-manager access rules.
The backend phone login must return sessionToken plus user.id and user.name.
The only allowed tool names are:
| Tool | Content |
|---|---|
list_reports |
Accessible published reports with name, center, and date filters |
get_report |
One report or an explicit list of ambiguous selections |
list_insights |
Published findings, priorities, recommendations, and sources |
get_metric_history |
Dated metric values, units, centers, cages, and source reports |
list_podcasts |
Daily and report-associated episodes |
get_podcast |
Selected episode summary, highlights, and available media |
read_podcast_transcript |
Paginated transcript or report-script passages |
get_my_briefing |
Compact overview with actual source dates |
Discovery preserves the complete input schema, including nested request,
optional filters, references, and pagination. The executor validates arguments
against that schema and supplies structuredContent to reasoning when present,
falling back to MCP text content. It preserves get_report's result union and
list next_offset values. Tool errors are data for the model to explain; expired
authentication stops the conversation. Tool text is labeled as reference data,
never as instructions or authorization.
Both reasoning paths can discover reports, retrieve details, and follow up with another tool before answering. The HTTP loop retains bounded completed-turn history. The realtime relay injects results into the speech service's existing conversation. Attempts are bounded per user turn (six by default), identical calls within a turn are rejected, and tool results are limited to 64,000 characters individually and 96,000 per turn. Each MCP operation has a timeout; HTTP reasoning also has a 180-second total timeout.
Selection uses an internal text marker, not a new speech protocol event:
<<<VOCERO_MCP_CALL:NAME=get_report,ARGUMENTS={"request":{"report_id":"example-id"}}>>>
The distinct marker is necessary because the current speech pipeline removes
APIPILOT_CALL before emitting assistant text. The relay buffers undecided
text/audio, handles transcripts emitted before response.created, executes
the MCP selection, and requests another response. Normal speech continues using
the existing PCM16 protocol. This leaves speech_to_speech/ unchanged, although
the speech service may spend time synthesizing audio that the relay suppresses.
Malformed selections fail with a user-facing error rather than permissive parsing.
New speech, cancellation, disconnect, and expiry invalidate pending work.
Generation checks reject late results even if the transport does not stop in
time. The browser receives vocero.tool.started / vocero.tool.failed status
events, without backend credentials or raw tool arguments.
Automatic current-episode context from episode_id is not wired yet. The model
must resolve a podcast with the catalog rather than fetching an unauthenticated
episode URL. See usage for configuration,
conversation endpoints, single-worker constraints, and the retained unsafe
phone selector.
Native realtime function calls
The browser sends tool declarations inside the session object:
{
"type": "session.update",
"session": {
"type": "realtime",
"tools": [
{
"type": "function",
"name": "web_search",
"description": "Search the web for information.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]
}
}
],
"tool_choice": "auto"
}
}
Keep configuration inside session, not at the event root. The backend has a
legacy flat-payload compatibility path, but new clients should use the nested
shape. The current browser's activeToolDefs() declares web_search when
enabled and a deploy/user search key is available. It does not load the old
remote agent-profile MCP catalog into native session tools.
For a native call, the backend emits:
{
"type": "response.function_call_arguments.done",
"call_id": "call_example",
"name": "web_search",
"arguments": "{\"query\":\"example query\"}"
}
arguments is a JSON-encoded string. The
WebSocket client validates the event and
dispatches tool; controller.ts
parses the arguments and runs the supported executor.
Web search uses POST /api/search, keeping the deployment's Serper key on the
server. Tool output returns to the model as:
{
"type": "conversation.item.create",
"item": {
"type": "function_call_output",
"call_id": "call_example",
"output": "Tool result text"
}
}
The client requests follow-up generation with response.create when the
result should be spoken. A function_call_output item alone does not start
generation. See the realtime API guide for local
model <code> extraction and native remote-provider tool calls.
ApiPilot discovery and execution
When MCP is disabled, S2S_APIPILOT_TOOLS_URL enables the legacy dynamic-tool bridge. The backend
discovers tools through its /tools route and executes through /execute.
S2S_APIPILOT_TOOLS_EXECUTE_URL can override the execution target. The same-origin UI
server exposes:
GET /api/apipilot/tools?include_unauthorized=true: discovery envelope withdata.tools,data.count, anddata.include_unauthorized.POST /api/apipilot/execute: body{"name":"tool_name","arguments":{}}and asuccess/data/errorenvelope.
Tool definitions contain name, description, JSON Schema parameters, and
requires_auth. Execution looks up the current discovery cache, refreshes once
if the tool is missing, and applies an authorization gate and argument
validation before forwarding. Caller Authorization and
x-demo-session can be forwarded; configured service credentials and
login-by-phone provide additional server paths. Display error.message to the
user; keep error.code, error.status_code, and error.raw for diagnostics.
The discovery cache defaults to 300 seconds server-side
(S2S_APIPILOT_TTL_SEC) and can be refreshed with refresh=true. The browser
also keeps a per-user catalog cache. ApiPilot configuration is independent of
S2S_INJECT_PROFILE.
Marker-based two-pass reasoning
The browser and proxy add an ApiPilot catalog as instruction text, including tool names, descriptions, authentication requirements and required parameters. The model is instructed to emit a call marker when it chooses a matching tool:
<<<APIPILOT_CALL:NAME=tool_name,ARGUMENTS={"parameter":"value"}>>>
Tool names are exact identifiers and must not be translated. The local realtime proxy accumulates assistant text, detects the marker, validates and executes the ApiPilot call, then injects the result as context and requests a new response. The HTTP reasoning route has analogous discovery/execution and second-answer handling. Internal marker text and its speculative audio are filtered from user output where those handlers run.
This marker is not response.function_call_arguments.done, and ApiPilot prompt
discovery is not the same as declaring a native session.tools array. Direct
browser connections bypass the local relay's marker executor even if the
browser includes catalog instructions.
Retired documentation assumptions
The former HOWITWORKS.md described /api/mcp/tool, remote
S2S_AGENT_PROFILE loading, and Spanish/Italian-to-English client ranking.
Those APIs/rankers are absent from the current client/server path. The retained
agent-profile JSON is an illustrative historical/example shape. Add or modify
current ApiPilot tool definitions in the provider; do not restore obsolete
endpoints solely to match older documentation.
Verify a tool turn
- Confirm
/api/configreports the intended feature and connection mode. - Inspect
session.update: nativetoolsand ApiPilot instruction text are separate evidence. - For native calls, find
response.function_call_arguments.done, then the browsertoolcalland executor request. - For ApiPilot, find the discovery, call-marker detection and execution logs.
- Confirm the tool result reaches the model and follow-up text/audio reaches the browser. A spoken acknowledgement alone does not complete the flow.