diff --git a/AGENTS.md b/AGENTS.md index e363b87..668afd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ URLs, not filesystem paths. | `vocero-api/vocero_api/api/` | HTTP routes, route registration, and response validation helper | | `vocero-api/vocero_api/models/` | Pydantic request and response contracts | | `vocero-api/vocero_api/core/` | Environment parsing, logging, public URLs, and startup diagnostics | -| `vocero-api/vocero_api/providers/` | ApiPilot and caller/profile/report HTTP integrations | +| `vocero-api/vocero_api/providers/` | Authenticated MCP transport, backend login, and caller/profile/report HTTP integrations | | `vocero-api/vocero_api/services/` | Prompt/context construction, reasoning, and tool-context rendering | | `vocero-api/vocero_api/realtime/` | WebSocket relay, event handling, marker filtering, and audio forwarding | | `vocero-frontend/` | React, TypeScript, Tailwind, and Vite browser application | @@ -139,7 +139,7 @@ evidence; do not bundle them into tooling or structural refactors. migration at `3d42df5`. `vocero-api/tests/test_compatibility.py` compares HTTP response bytes, statuses, selected headers, outgoing requests, request schemas, and realtime frames. Do not regenerate captures merely to make a refactor pass. -- Known reasoning failures, ApiPilot authentication/cache quirks, and relay +- Known reasoning failures and relay behavior are documented in the compatibility guide. Preserve narrow lint/type exceptions that retain those behaviors until a deliberate fix is in scope. - The pytest warning filter is only for Starlette TestClient's exact deprecated diff --git a/README.md b/README.md index 48ba8b2..9054708 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ documentation, source identifiers, comments, and internal instructions use Engli | Component | Purpose | |---|---| | [vocero-frontend](vocero-frontend) | React, TypeScript, and Tailwind browser UI, served by Vite with hot reload | -| [vocero-api](vocero-api) | FastAPI backend, realtime WebSocket proxy, search and ApiPilot bridges | +| [vocero-api](vocero-api) | FastAPI backend, realtime WebSocket proxy, search and authenticated AquaPortal MCP | | [speech_to_speech](speech_to_speech) | Queue-driven VAD, speech recognition, language model, and speech synthesis pipeline | Start with [client setup and usage](docs/client/usage.md), then read the diff --git a/docs/client/architecture.md b/docs/client/architecture.md index b527f07..d3fb590 100644 --- a/docs/client/architecture.md +++ b/docs/client/architecture.md @@ -15,14 +15,14 @@ flowchart LR Server[Client FastAPI server] Speech[Realtime speech backend] Reasoning[OpenAI-compatible HTTP reasoning provider] - Tools[ApiPilot backend] + Tools[AquaPortal MCP backend] Browser <-->|Frontend and HMR| Vite Vite -->|HTTP configuration and proxies| Server Vite <-->|Speech WebSocket relay| Server Server <-->|Realtime events and audio| Speech Browser <-->|Optional direct connection| Speech Server -->|Optional transcript reasoning| Reasoning - Server -->|Tool discovery and execution| Tools + Server -->|Authenticated tool discovery and execution| Tools ``` ## Connection modes @@ -34,8 +34,8 @@ session/queue proxy, or the user can enter a direct URL in settings. See [usage](usage.md) for the configuration matrix. The local realtime proxy relays audio and events in both directions. It also -patches selected `session.update` instructions, handles ApiPilot marker-based -tool execution, filters internal text, and can suppress speculative/tool-marker +patches selected `session.update` instructions, handles authenticated MCP +tool execution when enabled, filters internal text, and suppresses MCP tool-marker audio. It is not an unconditional byte-for-byte tunnel. ## Source map @@ -47,7 +47,7 @@ audio. It is not an unconditional byte-for-byte tunnel. | [api](../../vocero-api/vocero_api/api) | HTTP route registration, handlers and compatible response validation | | [models](../../vocero-api/vocero_api/models) | Pydantic request and response contracts | | [core](../../vocero-api/vocero_api/core) | Existing environment parsing, logging, URL helpers and diagnostics | -| [providers](../../vocero-api/vocero_api/providers) | Caller context and ApiPilot HTTP integrations | +| [providers](../../vocero-api/vocero_api/providers) | Authenticated MCP transport, backend login, and caller HTTP integrations | | [services](../../vocero-api/vocero_api/services) | Context construction, reasoning and tool-context rendering | | [realtime](../../vocero-api/vocero_api/realtime) | WebSocket relay, events, markers and audio handling | | [start_ui.sh](../../start_ui.sh) | Dependency setup, ports, readiness, reload processes, and shutdown | @@ -74,8 +74,6 @@ audio. It is not an unconditional byte-for-byte tunnel. | `GET /api/mcp/tools` | Discover the selected conversation's authenticated MCP catalog | | `GET /api/user/phone` | Configured caller profile lookup | | `GET /api/user/reports` | Configured scheduled report lookup, subject to report-injection controls | -| `GET /api/apipilot/tools` | Tool discovery with optional authorization and cache refresh | -| `POST /api/apipilot/execute` | Validated tool execution returning a `success/data/error` envelope | | `POST /api/search` | Serper web search proxy | | `/api/session`, `/api/queue/*` | Load-balancer session and waiting-queue proxies | diff --git a/docs/client/context-injection.md b/docs/client/context-injection.md index 30106eb..6152948 100644 --- a/docs/client/context-injection.md +++ b/docs/client/context-injection.md @@ -27,10 +27,10 @@ episode, report and prior-conversation context. It also controls whether the browser appends caller profile text to its realtime instructions. It is not a master switch for every context source. Operator-supplied RAG text -and configured ApiPilot discovery work independently. `S2S_INJECT_TODAY` +and authenticated MCP orchestration work independently. `S2S_INJECT_TODAY` defaults to true; date and voice-output guards are built on paths that invoke instruction augmentation or HTTP context assembly. A WebSocket session with no -profile/episode/report/summary or ApiPilot block skips proxy augmentation. +profile/episode/report/summary block skips non-MCP proxy augmentation. Direct browser connections bypass server-side WebSocket injection entirely. | Setting | Default | Effect | @@ -51,7 +51,6 @@ Direct browser connections bypass server-side WebSocket injection entirely. | Scheduled reports | `S2S_REPORT_LIST`, profile gate and report gate | Newest N labels/dates by default; full mode includes scripts with a 12,000-character block limit | | Latest conversation | `S2S_LATEST_SUMMARY_TEXT`, or `S2S_LATEST_SUMMARY` with a phone number | Up to 1,600 characters; static text overrides HTTP fetch | | Custom RAG | Operator applies text through the RAG dialog | Up to 12,000 characters; appended in browser instructions and HTTP `user_context` | -| ApiPilot discovery | Configured `S2S_APIPILOT_TOOLS_URL` | Tool names/descriptions/auth requirements and call-marker rules, independent of the profile gate | ### Episode @@ -113,25 +112,25 @@ session. This context does not require an external retrieval endpoint. At local WebSocket connect, the server resolves enabled context and stores it in `session_ctx`. Each client `session.update` then passes through `_patch_client_session_update_with_profile()`. Other client frame types are not -modified by this helper. The broader relay still performs ApiPilot handling and -text/audio filtering. +modified by this helper. The non-MCP relay retains its general assistant-text +filtering and forwards audio without a tool-execution pass. The current augmentation function **keeps base instructions first**, then appends surviving voice-output guards, date context, episode, profile, reports, -and summary blocks. ApiPilot discovery is appended separately. If a block type -is already present in the base, deduplication may omit its server candidate. +and summary blocks. If a block type is already present in the base, deduplication +may omit its server candidate. This describes string assembly, not a guarantee of model attention or priority. Every HTTP reasoning request rebuilds enabled context. Its `user_context` -assembly orders voice guard, date, episode, profile, reports, summary, ApiPilot +assembly orders voice guard, date, episode, profile, reports, summary, and browser-provided context, then deduplicates recognized types and strips internal markers. Server instruction augmentation remains separate, so avoid assuming the WebSocket and HTTP prompts have identical ordering. Context markers support internal deduplication and diagnostics; ordinary profile/report/episode/history sentinels are stripped before final augmentation. -ApiPilot call/catalog markers have their own handling. Treat the source helpers -as the reference for the exact final payload. +MCP uses its separate conversation context and call-marker handling described +above. Treat the source helpers as the reference for the exact final payload. ## Failure behavior and inspection diff --git a/docs/client/development.md b/docs/client/development.md index b68a5d3..6a9fdcf 100644 --- a/docs/client/development.md +++ b/docs/client/development.md @@ -45,7 +45,7 @@ and the retained `server:app` shim; use `--app-dir vocero-api` from the root. ## Schema boundaries -`models/requests.py` retains the existing search, reasoning, and ApiPilot +`models/requests.py` retains the existing search and reasoning request models, including their defaults, coercion, and ignored extra fields. The queue beacon still accepts malformed JSON and non-object bodies as before; its Pydantic view is applied only after the original object check, and @@ -54,9 +54,10 @@ its Pydantic view is applied only after the original object check, and `models/responses.py` validates structure constructed by this server. Fields owned by external services remain `Any` where the old parser imposed no type constraint. Object models allow extra fields. Caller profiles can be arbitrary -JSON; load-balancer fields remain optional; execution results and error details -remain open. Existing provider parsing, authentication precedence, argument -filtering, environment parsing, and WebSocket frames are unchanged. +JSON; load-balancer fields remain optional. Existing provider parsing, +authentication precedence, environment parsing, and WebSocket frames are +preserved outside explicitly retired integrations. Authenticated conversation +and MCP contracts live in `models/mcp.py` and `models/content.py`. HTTP handlers call `validated_json_response`: Pydantic validates the payload, then `JSONResponse` serializes the original object. Returning a dumped model @@ -69,19 +70,23 @@ existing error shapes; there is no new common error envelope. ## Compatibility evidence `tests/fixtures/legacy-contracts.json` was captured from the original monolithic -server at commit `3d42df5`, before refactoring. Tests compare 82 HTTP scenarios, +server at commit `3d42df5`, before refactoring. Tests compare 66 HTTP scenarios, including exact response text, statuses, selected headers, and outgoing mock HTTP requests. They also compare request OpenAPI definitions and representative realtime frames. Additional tests cover response validation and permissive upstream values; the original local HTTP/WebSocket tests remain in the suite. Four captures belonging exclusively to the retired workflow reasoning service -were removed with that integration; all retained captures are unchanged. +were removed with that integration. ApiPilot retirement removed another 16 HTTP +captures, its two OpenAPI operations, and its request model. The four bootstrap +captures now omit the retired `apipilot` flag. One reasoning failure case was +renamed because removing ApiPilot eliminated its token access; it still exposes +the existing uninitialized profile failure. All other retained capture values +are unchanged. The captures deliberately preserve unknown fields, omitted fields, unusual JSON -values, queue beacon edge cases, permissive tool arguments, and known failures. -Examples include reasoning's existing uninitialized local variables in certain -configuration combinations, API-key-only tool precheck behavior, the catalog -cache's existing key, and relay termination after a suppressed invalid +values, queue beacon edge cases, and known failures. Examples include reasoning's +existing uninitialized profile in certain non-MCP configuration combinations +and relay termination after a suppressed invalid `session.update` error. Fix those in separately reviewed behavior changes; do not regenerate golden captures to make a refactor pass. @@ -90,3 +95,7 @@ provider responses, live speech models, or production configuration. No deployed services or real credentials are needed. The Docker check verifies the actual runtime image, API configuration, documentation, OpenAPI, and the backend's existing root 404; it removes its temporary container and image afterward. + +ApiPilot removal deliberately excludes `speech_to_speech/`. The MCP orchestration +test still exercises its existing pure marker sanitizer without importing model +dependencies, verifying that `VOCERO_MCP_CALL` reaches the Vocero relay unchanged. diff --git a/docs/client/glossary.md b/docs/client/glossary.md index d5498db..c93a1ff 100644 --- a/docs/client/glossary.md +++ b/docs/client/glossary.md @@ -22,8 +22,8 @@ names shown in a deployment are configuration examples, not definitions. | Tool executor | Code that receives a tool call, executes it and returns the result to the model | | Web search | The browser-supported native tool whose request goes through the server's `/api/search` proxy | | Search proxy | Same-origin server route holding a deployment search key and calling the external provider | -| ApiPilot | The dynamic-tool discovery/execution bridge, with both HTTP endpoints and model-text call markers | -| Call marker | Internal structured text such as `<<>>` used by the ApiPilot bridge; separate from native function-call events | +| AquaPortal MCP | Authenticated tool discovery and execution using the current conversation's backend session | +| Call marker | Internal structured text such as `<<>>` used by the MCP orchestrator; separate from native function-call events | | Tools panel | Controls for available browser tools and search-key configuration | | Settings | Connection, voice, audio and instruction controls persisted in browser storage | | Identity block | The header's product/user identity, separate from configuration dialogs | diff --git a/docs/client/reasoning.md b/docs/client/reasoning.md index 46d727a..83cb259 100644 --- a/docs/client/reasoning.md +++ b/docs/client/reasoning.md @@ -19,8 +19,9 @@ with `config.primaryPrompt || settings.instructions`. `/api/config` pins `DEFAULT_INSTRUCTIONS` is used. The current server does not fetch a remote agent profile to choose this base prompt. -The browser adds caller context when `injectProfile` is enabled, custom RAG text -when the operator has applied it, and an available ApiPilot catalog block. +The browser adds caller context when `injectProfile` is enabled and custom RAG +text when the operator has applied it. In MCP mode, the server constructs tool +instructions from the authenticated conversation's catalog and content selection. `speechInstructions()` adds the speech-rendering hint only in `OPENAI` mode. The WebSocket client packages these instructions in `session.update.session`; the local proxy may then add server-resolved context. Details belong in @@ -30,13 +31,14 @@ the local proxy may then add server-resolved context. Details belong in The realtime backend receives session instructions and enabled native tools, then processes each voice turn through STT, model generation and TTS. -Native function-call events are executed by the browser. ApiPilot also has a +Native function-call events are executed by the browser. Authenticated MCP has a separate marker-based path handled by the local proxy. These are distinct protocols, described in [tool calling](tool-calling.md). Using a direct browser URL bypasses local WebSocket proxy processing. Browser instructions still apply, but server-side session context and marker execution -require the local `/v1/realtime` route. +require the local `/v1/realtime` route. MCP in `PROXY` mode requires this relay; +configuration rejects a pinned direct URL or load-balancer bypass. ## OpenAI-compatible HTTP reasoning @@ -53,12 +55,14 @@ is a runtime default, not a recommendation about provider availability. `VoiceController.reason()` in [controller.ts](../../vocero-frontend/src/realtime/controller.ts) runs after a complete user transcript and posts a body with `transcript`, `instructions`, `language`, `user_context`, `phoneNumber`, and `episode_id`. -The server combines instructions and context into model messages, requests a -final answer with a 30-second timeout, and returns `answer`. ApiPilot can -append discovery instructions, execute a detected call marker, and request a -second response based on the tool result, including available `usage_pass2` -metadata. The endpoint does not forward the browser's native `session.tools` -catalog as OpenAI Chat Completions `tools`. +Without MCP, the server combines instructions and context into model messages, +requests a final answer with a 30-second timeout, and returns `answer`. +With MCP, the browser also sends conversation and turn headers. The server uses +that conversation's backend token, linked/current content, and bounded history +to run tool selections before returning `answer`. This path has a 180-second +total timeout and configured per-operation limits. Retired ApiPilot execution +metadata is no longer returned. The endpoint does not forward the browser's +native `session.tools` catalog as OpenAI Chat Completions `tools`. For an external answer, the browser cancels/suppresses the active native response and appends the external assistant text. It uses browser @@ -78,5 +82,6 @@ realtime backend instead. | Change the base agent prompt | `S2S_PRIMARY_PROMPT` or browser settings when unpinned | | Change browser context assembly | `buildInstructions()` in `vocero-frontend/src/lib/context.ts` | | Change HTTP provider messages | `reasoning()` in `vocero-api/vocero_api/services/reasoning.py` | +| Change MCP reasoning or tool context | `vocero-api/vocero_api/services/mcp_reasoning.py` and `vocero-api/vocero_api/services/mcp_tools.py` | | Change external-answer rendering | `VoiceController.reason()` and `RealtimeClient.renderExternalAnswer()` | | Change native model/tool generation | `speech_to_speech/LLM` and realtime handlers | diff --git a/docs/client/tool-calling.md b/docs/client/tool-calling.md index c7af703..fdf010c 100644 --- a/docs/client/tool-calling.md +++ b/docs/client/tool-calling.md @@ -2,7 +2,7 @@ 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. +`S2S_MCP_URL` is set. Without MCP, the native browser tools remain available. A spoken promise to fetch something is not evidence that a tool executed. ## Authenticated AquaPortal MCP @@ -172,69 +172,32 @@ result should be spoken. A `function_call_output` item alone does not start generation. See the [realtime API guide](../speech/realtime-api.md) for local model `` 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 with - `data.tools`, `data.count`, and `data.include_unauthorized`. -- `POST /api/apipilot/execute`: body `{"name":"tool_name","arguments":{}}` and - a `success/data/error` envelope. - -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: - -```text -<<>> -``` - -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 +ApiPilot discovery/execution endpoints, catalog injection, credential overrides, +and two-pass marker handling have been removed from Vocero. Its former HTTP +routes return 404 regardless of MCP configuration. The separate speech service +still has its existing legacy marker sanitizers; no ApiPilot requests can be +made through Vocero. See [usage](usage.md#authenticated-aquaportal-mcp) for +deployment configuration cleanup. + 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. +agent-profile JSON is an illustrative historical/example shape. Content tool +definitions and permissions belong to the AquaPortal MCP backend; Vocero's +provider enforces its allowlist. Do not restore obsolete endpoints solely to +match older documentation. ## Verify a tool turn 1. Confirm `/api/config` reports the intended feature and connection mode. -2. Inspect `session.update`: native `tools` and ApiPilot instruction text are - separate evidence. +2. For MCP, confirm conversation creation and authenticated `/api/mcp/tools` + discovery succeed. Inspect the relay's `session.update` for MCP instructions; + these are separate from native `session.tools` declarations. 3. For native calls, find `response.function_call_arguments.done`, then the browser `toolcall` and executor request. -4. For ApiPilot, find the discovery, call-marker detection and execution logs. +4. For MCP, find `mcp_tool_completed` for the expected user/tool and any + `vocero.tool.started` / `vocero.tool.failed` voice events. 5. Confirm the tool result reaches the model and follow-up text/audio reaches the browser. A spoken acknowledgement alone does not complete the flow. diff --git a/docs/client/troubleshooting.md b/docs/client/troubleshooting.md index 42e212e..4232684 100644 --- a/docs/client/troubleshooting.md +++ b/docs/client/troubleshooting.md @@ -12,7 +12,7 @@ Inspect the server configuration without printing secret environment files: curl -fsS http://localhost:8420/api/config | jq ``` -Confirm `s2sUrl`, `lb`, `reasoningType`, `injectProfile`, `apipilot`, `theme` +Confirm `s2sUrl`, `lb`, `reasoningType`, `injectProfile`, `mcp`, `theme` and `startSessionMode`. A direct WebSocket URL bypasses the local relay's server-side context injection and marker execution. For a local proxy, confirm the server can resolve and reach `S2S_PROXY_TARGET`. @@ -50,8 +50,10 @@ Follow the actual execution mechanism: JSON-string arguments. 3. Confirm the browser dispatched `toolcall` and the executor sent its request. 4. Confirm `function_call_output` and any required `response.create` were sent. -5. For ApiPilot, inspect discovery, marker detection, authorization/schema - validation, execution, and second-pass result injection instead. +5. For MCP, inspect conversation creation, `/api/mcp/tools` discovery, + `mcp_tool_completed` logs, and the returned tool data/follow-up response. + Verify the conversation belongs to the browser and backend authorization + succeeds; do not retry with shared credentials or legacy content endpoints. A spoken sentence such as “Voy a buscar el reporte” does not satisfy step 2. If the function-call event is absent, inspect model/backend tool generation @@ -59,7 +61,7 @@ before debugging a browser executor that has not been invoked. Earlier local debug notes recorded a reproduction where tools and required tool policy were sent but the model produced only spoken text. Those notes -refer to the retired MCP ranking path; they do not establish a current ApiPilot +refer to the retired MCP ranking path; they do not establish a current AquaPortal MCP failure. Both local investigation notes remain marked `OPEN`. Their useful lesson is to collect evidence at each boundary and reproduce against current code before claiming a root cause is resolved. @@ -114,7 +116,7 @@ Useful checks are: The historical investigation introduced a 90ms playback prebuffer and reduced MCP prompt reinjection. The old 150ms MCP fallback timer is historical, not a -current ApiPilot configuration knob. Verify the live worklet and current relay +current MCP configuration knob. Verify the live worklet and current relay before changing audio timing based on those observations. ## External reasoning and startup diff --git a/docs/client/usage.md b/docs/client/usage.md index 0a5fa35..7348622 100644 --- a/docs/client/usage.md +++ b/docs/client/usage.md @@ -2,7 +2,8 @@ The browser voice client connects to an existing OpenAI Realtime-compatible speech backend. The UI server can also proxy WebSocket traffic, search, -reasoning, caller context, ApiPilot tools and load-balancer requests. +reasoning, caller context, authenticated AquaPortal MCP tools and load-balancer +requests. ## Local startup @@ -126,8 +127,8 @@ translations. Web search needs `SERPER_API_KEY` on the server or a user-supplied key in the tools panel. The server proxies requests through `/api/search`; its own key is -not returned to the browser. Dynamic ApiPilot tools are configured through -`S2S_APIPILOT_TOOLS_URL`; see [tool calling](tool-calling.md). +not returned to the browser. AquaPortal content tools use authenticated MCP; +see [tool calling](tool-calling.md). Operator RAG text is applied through the RAG dialog and updates live instructions. Caller links may include `phoneNumber=%2B...`; episode links may @@ -141,7 +142,7 @@ enabled data sources and limits. Streamable HTTP endpoint, for example `https://backend.example.test/api/v1/mcp/`. Set `S2S_LOGIN_BY_PHONE` to that backend's `POST /api/v1/auth/phone-login` URL. The phone parameter remains an identity selector without proof of ownership. -MCP uses the returned opaque `sessionToken`, never an ApiPilot API key or service token. +MCP uses the returned opaque `sessionToken` for that user, with no service-token override. For voice reasoning (`S2S_REASONING_TYPE=PROXY`), set `S2S_PROXY_TARGET` to the speech service and leave `SPEECH_TO_SPEECH_URL` and `LOAD_BALANCER_URL` unset. @@ -153,10 +154,20 @@ or audio-format change is required. The backend must expose the eight-tool catalog and phone-login contract described in [tool calling](tool-calling.md#authenticated-aquaportal-mcp). Leave -`S2S_MCP_URL` unset to retain the existing ApiPilot and caller-context paths. +`S2S_MCP_URL` unset to use the existing caller-context paths without MCP tools. Setting it makes MCP the exclusive AquaPortal content path: legacy profile, -report, latest-summary, and episode preloads are skipped. Existing ApiPilot -and caller HTTP routes return 409 while MCP is enabled. +report, latest-summary, and episode preloads are skipped. Legacy caller HTTP +routes return 409 while MCP is enabled. + +ApiPilot has been retired. `/api/apipilot/tools` and `/api/apipilot/execute` +return 404, and `/api/config` no longer includes `apipilot`. Remove obsolete +`S2S_APIPILOT_TOOLS_URL`, `S2S_APIPILOT_TOOLS_EXECUTE_URL`, +`S2S_APIPILOT_SERVICE_TOKEN`, `S2S_APIPILOT_API_KEY`, `S2S_APIPILOT_TTL_SEC`, +and `S2S_APIPILOT_TIMEOUT_SEC` from deployment configuration; they are ignored. +Keep `S2S_LOGIN_BY_PHONE`, which is still required for MCP authentication. +Deploy the API and frontend together and refresh existing browser sessions. +The separately deployed `speech_to_speech/` service is unchanged by this removal; +its existing legacy marker sanitizers remain, with no speech restart required. | Setting | Default | Meaning | |---|---|---| diff --git a/vocero-api/CHANGELOG.md b/vocero-api/CHANGELOG.md index 4c62a47..60c17f7 100644 --- a/vocero-api/CHANGELOG.md +++ b/vocero-api/CHANGELOG.md @@ -46,6 +46,11 @@ the implementation at the recorded date; current operating instructions live in ### Changed +- Removed the ApiPilot API bridge, configuration, credential overrides, browser + catalog/prompt handling, and two-pass HTTP/realtime execution. AquaPortal tools + use authenticated MCP; the separate speech pipeline retains its existing + marker sanitizers. + - Use the AquaPortal admin favicon in the Vocero browser frontend. - Start `TALK` and `TALKFIRST` conversations as soon as configuration and caller diff --git a/vocero-api/README.md b/vocero-api/README.md index ece8538..5b50494 100644 --- a/vocero-api/README.md +++ b/vocero-api/README.md @@ -13,7 +13,8 @@ short_description: Voice chat over WebSocket against a HF speech-to-speech FastAPI APIs and WebSocket relay for the [React voice frontend](../vocero-frontend). This service handles configuration, caller context, upstream authentication, -search, reasoning, ApiPilot tools, and optional load-balancer allocation. +search, reasoning, authenticated AquaPortal MCP tools, and optional load-balancer +allocation. The root `start_ui.sh` now serves the [React frontend](../vocero-frontend) with Vite on public port `8420`, proxying backend requests here on loopback port diff --git a/vocero-api/scripts/test_docker_image.sh b/vocero-api/scripts/test_docker_image.sh index b41e7f0..5c4ef6e 100644 --- a/vocero-api/scripts/test_docker_image.sh +++ b/vocero-api/scripts/test_docker_image.sh @@ -38,7 +38,7 @@ with urllib.request.urlopen(base + "/api/config") as response: assert json.load(response) == { "search": False, "lb": False, "allowDirect": True, "s2sUrl": "", "primaryPrompt": "", "reasoningType": "PROXY", "injectProfile": False, - "debugPrompt": False, "startSessionMode": "", "theme": "", "apipilot": False, + "debugPrompt": False, "startSessionMode": "", "theme": "", } for path in ["/docs", "/docs/README.md", "/openapi.json"]: with urllib.request.urlopen(base + path) as response: diff --git a/vocero-api/tests/fixtures/legacy-contracts.json b/vocero-api/tests/fixtures/legacy-contracts.json index d7d4e68..8c305b6 100644 --- a/vocero-api/tests/fixtures/legacy-contracts.json +++ b/vocero-api/tests/fixtures/legacy-contracts.json @@ -2,7 +2,7 @@ "http": { "config-default": { "status": 200, - "body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\",\"apipilot\":false}", + "body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\"}", "headers": { "content-type": "application/json" }, @@ -10,7 +10,7 @@ }, "config-proxy": { "status": 200, - "body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"wss://voice.test/v1/realtime\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\",\"apipilot\":false}", + "body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"wss://voice.test/v1/realtime\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\"}", "headers": { "content-type": "application/json" }, @@ -18,7 +18,7 @@ }, "config-direct": { "status": 200, - "body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"wss://voice.test/realtime?token=fixture\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\",\"apipilot\":false}", + "body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"wss://voice.test/realtime?token=fixture\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\"}", "headers": { "content-type": "application/json" }, @@ -26,7 +26,7 @@ }, "config-lb": { "status": 200, - "body": "{\"search\":false,\"lb\":true,\"allowDirect\":false,\"s2sUrl\":\"\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\",\"apipilot\":false}", + "body": "{\"search\":false,\"lb\":true,\"allowDirect\":false,\"s2sUrl\":\"\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\"}", "headers": { "content-type": "application/json" }, @@ -736,7 +736,7 @@ }, "upstream": [] }, - "reasoning-OPENAI-legacy-unbound-token": { + "reasoning-openai-profile-disabled": { "status": 500, "body": "Internal Server Error", "headers": { @@ -775,279 +775,6 @@ } } ] - }, - "apipilot-disabled-GET": { - "status": 501, - "body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"APIPILOT_NOT_CONFIGURED\",\"message\":\"ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).\",\"status_code\":501,\"raw\":null}}", - "headers": { - "content-type": "application/json" - }, - "upstream": [] - }, - "apipilot-disabled-POST": { - "status": 501, - "body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"APIPILOT_NOT_CONFIGURED\",\"message\":\"ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).\",\"status_code\":501,\"raw\":null}}", - "headers": { - "content-type": "application/json" - }, - "upstream": [] - }, - "tools-query-validation": { - "status": 422, - "body": "{\"detail\":[{\"type\":\"bool_parsing\",\"loc\":[\"query\",\"refresh\"],\"msg\":\"Input should be a valid boolean, unable to interpret input\",\"input\":\"invalid\"}]}", - "headers": { - "content-type": "application/json" - }, - "upstream": [] - }, - "tools-list": { - "status": 200, - "body": "{\"success\":true,\"data\":{\"count\":1,\"include_unauthorized\":false,\"tools\":[{\"name\":\"fixture_tool\",\"description\":\"A fixture tool.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"count\":{\"type\":\"integer\"}},\"required\":[\"count\"]},\"requires_auth\":false}]},\"error\":null}", - "headers": { - "content-type": "application/json" - }, - "upstream": [ - { - "method": "GET", - "url": "http://tools.test/apipilot/tools?include_unauthorized=false", - "body": "", - "headers": { - "authorization": "Bearer fixture-token", - "x-demo-session": "fixture-session" - } - } - ] - }, - "tools-result": { - "status": 200, - "body": "{\"success\":true,\"data\":{\"count\":1,\"include_unauthorized\":false,\"tools\":[{\"name\":\"fixture_tool\",\"description\":\"A fixture tool.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"count\":{\"type\":\"integer\"}},\"required\":[\"count\"]},\"requires_auth\":false}]},\"error\":null}", - "headers": { - "content-type": "application/json" - }, - "upstream": [ - { - "method": "GET", - "url": "http://tools.test/apipilot/tools?include_unauthorized=false", - "body": "", - "headers": { - "authorization": "Bearer fixture-token", - "x-demo-session": "fixture-session" - } - } - ] - }, - "tools-envelope": { - "status": 200, - "body": "{\"success\":true,\"data\":{\"count\":1,\"include_unauthorized\":false,\"tools\":[{\"name\":\"fixture_tool\",\"description\":\"A fixture tool.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"count\":{\"type\":\"integer\"}},\"required\":[\"count\"]},\"requires_auth\":false}]},\"error\":null}", - "headers": { - "content-type": "application/json" - }, - "upstream": [ - { - "method": "GET", - "url": "http://tools.test/apipilot/tools?include_unauthorized=false", - "body": "", - "headers": { - "authorization": "Bearer fixture-token", - "x-demo-session": "fixture-session" - } - } - ] - }, - "tools-opaque": { - "status": 200, - "body": "{\"success\":true,\"data\":{\"count\":0,\"include_unauthorized\":false,\"tools\":[]},\"error\":null}", - "headers": { - "content-type": "application/json" - }, - "upstream": [ - { - "method": "GET", - "url": "http://tools.test/apipilot/tools?include_unauthorized=false", - "body": "", - "headers": { - "authorization": "Bearer fixture-token", - "x-demo-session": "fixture-session" - } - } - ] - }, - "tools-upstream-error": { - "status": 502, - "body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"APIPILOT_BAD_UPSTREAM\",\"message\":\"Backend ApiPilot tools devolvió HTTP 403\",\"status_code\":403}}", - "headers": { - "content-type": "application/json" - }, - "upstream": [ - { - "method": "GET", - "url": "http://tools.test/apipilot/tools?include_unauthorized=true", - "body": "", - "headers": {} - } - ] - }, - "execute-discovery-error-envelope": { - "status": 502, - "body": "{\"detail\":{\"code\":\"APIPILOT_BAD_UPSTREAM\",\"message\":\"Backend ApiPilot tools devolvió HTTP 503\",\"status_code\":503}}", - "headers": { - "content-type": "application/json" - }, - "upstream": [ - { - "method": "GET", - "url": "http://tools.test/apipilot/tools?include_unauthorized=true", - "body": "", - "headers": {} - } - ] - }, - "execute-unknown-tool": { - "status": 404, - "body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"TOOL_NOT_FOUND\",\"message\":\"Herramienta «unknown» no encontrada en el catálogo ApiPilot. Actualice la lista de herramientas y vuelva a intentarlo.\",\"status_code\":404,\"raw\":null}}", - "headers": { - "content-type": "application/json" - }, - "upstream": [ - { - "method": "GET", - "url": "http://tools.test/apipilot/tools?include_unauthorized=true", - "body": "", - "headers": {} - }, - { - "method": "GET", - "url": "http://tools.test/apipilot/tools?include_unauthorized=true", - "body": "", - "headers": {} - } - ] - }, - "execute-missing-argument": { - "status": 400, - "body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"INVALID_ARGUMENTS\",\"message\":\"Falta el parámetro requerido: count\",\"status_code\":400,\"raw\":{\"removed\":[]}}}", - "headers": { - "content-type": "application/json" - }, - "upstream": [ - { - "method": "GET", - "url": "http://tools.test/apipilot/tools?include_unauthorized=true", - "body": "", - "headers": {} - } - ] - }, - "execute-api-key-precheck": { - "status": 401, - "body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"AUTH_REQUIRED\",\"message\":\"La herramienta «fixture_tool» requiere autenticación. Inicie sesión o proporcione un token para continuar.\",\"status_code\":401,\"raw\":null}}", - "headers": { - "content-type": "application/json" - }, - "upstream": [ - { - "method": "GET", - "url": "http://tools.test/apipilot/tools?include_unauthorized=true", - "body": "", - "headers": { - "authorization": "Bearer fixture-key", - "x-demo-session": "fixture-key", - "x-api-key": "fixture-key" - } - } - ] - }, - "execute-envelope": { - "status": 200, - "body": "{\"success\":true,\"data\":{\"future\":[1,null]},\"error\":null,\"future_envelope\":true}", - "headers": { - "content-type": "application/json" - }, - "upstream": [ - { - "method": "GET", - "url": "http://tools.test/apipilot/tools?include_unauthorized=true", - "body": "", - "headers": {} - }, - { - "method": "POST", - "url": "http://tools.test/apipilot/execute", - "body": "{\"name\":\"fixture_tool\",\"arguments\":{\"count\":\"wrong-type-is-accepted\"}}", - "headers": { - "content-type": "application/json" - } - } - ] - }, - "execute-raw-list": { - "status": 200, - "body": "{\"success\":true,\"data\":[{\"future\":true}],\"error\":null}", - "headers": { - "content-type": "application/json" - }, - "upstream": [ - { - "method": "GET", - "url": "http://tools.test/apipilot/tools?include_unauthorized=true", - "body": "", - "headers": {} - }, - { - "method": "POST", - "url": "http://tools.test/apipilot/execute", - "body": "{\"name\":\"fixture_tool\",\"arguments\":{\"count\":\"wrong-type-is-accepted\"}}", - "headers": { - "content-type": "application/json" - } - } - ] - }, - "execute-minimal-envelope": { - "status": 200, - "body": "{\"success\":true}", - "headers": { - "content-type": "application/json" - }, - "upstream": [ - { - "method": "GET", - "url": "http://tools.test/apipilot/tools?include_unauthorized=true", - "body": "", - "headers": {} - }, - { - "method": "POST", - "url": "http://tools.test/apipilot/execute", - "body": "{\"name\":\"fixture_tool\",\"arguments\":{\"count\":\"wrong-type-is-accepted\"}}", - "headers": { - "content-type": "application/json" - } - } - ] - }, - "execute-error": { - "status": 409, - "body": "{\"success\":false,\"data\":null,\"error\":{\"status_code\":409,\"code\":\"FIXTURE\",\"message\":\"Fixture\",\"future\":true}}", - "headers": { - "content-type": "application/json" - }, - "upstream": [ - { - "method": "GET", - "url": "http://tools.test/apipilot/tools?include_unauthorized=true", - "body": "", - "headers": {} - }, - { - "method": "POST", - "url": "http://tools.test/apipilot/execute", - "body": "{\"name\":\"fixture_tool\",\"arguments\":{\"count\":\"wrong-type-is-accepted\"}}", - "headers": { - "content-type": "application/json" - } - } - ] } }, "openapi_requests": { @@ -1055,44 +782,6 @@ "GET /api/config": { "operationId": "config_api_config_get" }, - "GET /api/apipilot/tools": { - "operationId": "apipilot_list_tools_api_apipilot_tools_get", - "parameters": [ - { - "name": "include_unauthorized", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "default": true, - "title": "Include Unauthorized" - } - }, - { - "name": "refresh", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "default": false, - "title": "Refresh" - } - } - ] - }, - "POST /api/apipilot/execute": { - "operationId": "apipilot_execute_api_apipilot_execute_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApipilotExecuteRequest" - } - } - }, - "required": true - } - }, "POST /api/reasoning": { "operationId": "reasoning_api_reasoning_post", "requestBody": { @@ -1264,26 +953,6 @@ ], "title": "ReasoningRequest" }, - "ApipilotExecuteRequest": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "arguments": { - "additionalProperties": true, - "type": "object", - "title": "Arguments", - "default": {} - } - }, - "type": "object", - "required": [ - "name" - ], - "title": "ApipilotExecuteRequest", - "description": "Body for the ApiPilot execute proxy endpoint. Mirrors §2.2 request shape." - }, "HTTPValidationError": { "properties": { "detail": { diff --git a/vocero-api/tests/test_compatibility.py b/vocero-api/tests/test_compatibility.py index 9944bd2..3da669d 100644 --- a/vocero-api/tests/test_compatibility.py +++ b/vocero-api/tests/test_compatibility.py @@ -19,7 +19,6 @@ from fastapi.testclient import TestClient with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_value=False): from vocero_api import main as server from vocero_api.core import config as settings - from vocero_api.providers import apipilot from vocero_api.realtime import proxy from vocero_api.services import context @@ -42,21 +41,10 @@ BASE_CONFIG = { "S2S_LOGIN_BY_PHONE": "", "S2S_LATEST_SUMMARY": "", "S2S_LATEST_SUMMARY_TEXT": "", - "S2S_APIPILOT_TOOLS_URL": "", - "S2S_APIPILOT_TOOLS_EXECUTE_URL": "", - "S2S_APIPILOT_SERVICE_TOKEN": "", - "S2S_APIPILOT_API_KEY": "", "OPENAI_API_KEY": "", "S2S_REPORTS_QUICK_HINT_MAX": 5, "S2S_REPORTS_INJECT_MODE": "minimal", } -TOOL = { - "name": "fixture_tool", - "description": "A fixture tool.", - "parameters": {"type": "object", "properties": {"count": {"type": "integer"}}, "required": ["count"]}, - "requires_auth": False, - "future_definition_field": {"nested": True}, -} def cases(): @@ -268,7 +256,7 @@ def cases(): for name, payload in (("missing", {}), ("proxy", {"transcript": "hello"}), ("proxy-blank", {"transcript": " "})): yield f"reasoning-{name}", "POST", "/api/reasoning", {"json": payload}, {}, [] yield ( - "reasoning-OPENAI-legacy-unbound-token", + "reasoning-openai-profile-disabled", "POST", "/api/reasoning", {"json": {"transcript": "hello"}}, @@ -323,91 +311,6 @@ def cases(): ], ) - for method, path, kwargs in ( - ("GET", "/api/apipilot/tools", {}), - ("POST", "/api/apipilot/execute", {"json": {"name": "fixture_tool"}}), - ): - yield f"apipilot-disabled-{method}", method, path, kwargs, {}, [] - tool_config = {"S2S_APIPILOT_TOOLS_URL": "http://tools.test/apipilot"} - yield "tools-query-validation", "GET", "/api/apipilot/tools?refresh=invalid", {}, tool_config, [] - for name, payload in ( - ("list", [TOOL]), - ("result", {"result": [TOOL]}), - ("envelope", {"data": {"tools": [TOOL]}, "future": True}), - ("opaque", "anything"), - ): - yield ( - f"tools-{name}", - "GET", - "/api/apipilot/tools?include_unauthorized=false&refresh=true", - {"headers": {"Authorization": "Bearer fixture-token", "x-demo-session": "fixture-session"}}, - tool_config, - [(200, payload)], - ) - yield "tools-upstream-error", "GET", "/api/apipilot/tools", {}, tool_config, [(403, {"error": "fixture"})] - yield ( - "execute-discovery-error-envelope", - "POST", - "/api/apipilot/execute", - {"json": {"name": "fixture_tool"}}, - tool_config, - [(503, {})], - ) - yield ( - "execute-unknown-tool", - "POST", - "/api/apipilot/execute", - {"json": {"name": "unknown"}}, - tool_config, - [(200, [TOOL]), (200, [TOOL])], - ) - yield ( - "execute-missing-argument", - "POST", - "/api/apipilot/execute", - {"json": {"name": "fixture_tool"}}, - tool_config, - [(200, [TOOL])], - ) - yield ( - "execute-api-key-precheck", - "POST", - "/api/apipilot/execute", - {"json": {"name": "fixture_tool", "arguments": {"count": 1}}}, - { - **tool_config, - "S2S_APIPILOT_API_KEY": "fixture-key", - }, - [(200, [{**TOOL, "requires_auth": True}])], - ) - for name, payload in ( - ("envelope", {"success": True, "data": {"future": [1, None]}, "error": None, "future_envelope": True}), - ("raw-list", [{"future": True}]), - ("minimal-envelope", {"success": True}), - ( - "error", - { - "success": False, - "data": None, - "error": {"status_code": 409, "code": "FIXTURE", "message": "Fixture", "future": True}, - }, - ), - ): - yield ( - f"execute-{name}", - "POST", - "/api/apipilot/execute", - { - "json": { - "name": "fixture_tool", - "arguments": {"count": "wrong-type-is-accepted", "unknown": 1}, - "future": True, - } - }, - tool_config, - [(200, [TOOL]), (200, payload)], - ) - def capture_http(): captures = {} @@ -442,7 +345,6 @@ def capture_http(): with ( patch.multiple(settings, **{**BASE_CONFIG, **config}), - patch.object(apipilot, "_APIPILOT_CACHE", {}), patch.object(context, "_build_today_fixed_context_block", return_value="Fixture current date: 2026-01-04"), patch.object( httpx, @@ -482,7 +384,6 @@ def capture_openapi_requests(): for name in ( "SearchRequest", "ReasoningRequest", - "ApipilotExecuteRequest", "HTTPValidationError", "ValidationError", ) diff --git a/vocero-api/tests/test_mcp_orchestration.py b/vocero-api/tests/test_mcp_orchestration.py index 23beedf..9ddc20d 100644 --- a/vocero-api/tests/test_mcp_orchestration.py +++ b/vocero-api/tests/test_mcp_orchestration.py @@ -143,8 +143,8 @@ def test_http_chains_tools_retains_history_and_ignores_browser_identity(enabled, assert client.post("/api/reasoning", json=body).status_code == 401 assert client.get("/api/user/phone?phoneNumber=123").status_code == 409 assert client.get("/api/user/reports").status_code == 409 - assert client.get("/api/apipilot/tools").status_code == 409 - assert client.post("/api/apipilot/execute", json={"name": "get_report"}).status_code == 409 + assert client.get("/api/apipilot/tools").status_code == 404 + assert client.post("/api/apipilot/execute", json={"name": "get_report"}).status_code == 404 enabled.assert_awaited_once_with("+56000000001") diff --git a/vocero-api/tests/test_models.py b/vocero-api/tests/test_models.py index 5539cda..51b9767 100644 --- a/vocero-api/tests/test_models.py +++ b/vocero-api/tests/test_models.py @@ -5,14 +5,13 @@ from fastapi.responses import JSONResponse from pydantic import ValidationError from vocero_api.api.responses import validated_json_response -from vocero_api.models.requests import ApipilotExecuteRequest, QueueEndPayload, SearchRequest +from vocero_api.models.requests import QueueEndPayload, SearchRequest from vocero_api.models.responses import ( AllocationResponse, QueueCancellationResponse, ReasoningResponse, ReportsResponse, SearchResponse, - ToolExecutionResponse, UserProfileResponse, ) @@ -22,9 +21,7 @@ from vocero_api.models.responses import ( [ (AllocationResponse, {}), (AllocationResponse, {"state": [1, None], "connectUrl": "wss://host", "future": {"x": 1.0}}), - (ToolExecutionResponse, {"success": True, "unknown": {"nested": [None, 1.0, False]}}), - (ToolExecutionResponse, {"success": False, "data": 42, "error": "loose error", "future": []}), - (ReasoningResponse, {"answer": "á", "usage_pass2": [1, None], "future": True}), + (ReasoningResponse, {"answer": "á", "future": [1, None, True]}), (SearchResponse, {"query": "q", "answer": {"x": 2}, "results": [{"title": [], "snippet": 7, "url": None}]}), ( ReportsResponse, @@ -47,10 +44,8 @@ def test_owned_response_fields_are_actually_validated(payload): validated_json_response(payload, QueueCancellationResponse) -def test_request_models_keep_legacy_extra_field_and_argument_behavior(): +def test_request_models_keep_legacy_extra_field_and_beacon_behavior(): assert SearchRequest.model_validate({"query": "q", "future": True}).model_dump() == {"query": "q", "key": None} - arguments = {"arbitrary": {"nested": [1, None]}, "unknown": True} - assert ApipilotExecuteRequest.model_validate({"name": "tool", "arguments": arguments}).arguments == arguments for value in [None, "id", 42, False, [], {}, {"future": True}]: assert QueueEndPayload.model_validate({"queueId": value, "future": 1}).queueId == value assert QueueEndPayload.model_validate({}).queueId == "" diff --git a/vocero-api/tests/test_server.py b/vocero-api/tests/test_server.py index 4fe0a24..459b2aa 100644 --- a/vocero-api/tests/test_server.py +++ b/vocero-api/tests/test_server.py @@ -7,10 +7,9 @@ import json import os import threading import unittest -from unittest.mock import ANY, AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, patch import httpx -from fastapi import Request from fastapi.testclient import TestClient from websockets.sync.server import serve @@ -18,8 +17,7 @@ from websockets.sync.server import serve with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_value=False): from vocero_api import main as server from vocero_api.core import config as settings - from vocero_api.providers import apipilot, caller - from vocero_api.services import tool_context + from vocero_api.providers import caller GRANT = { @@ -182,34 +180,27 @@ class BackendContractTests(unittest.TestCase): self.assertEqual(lookup.headers["authorization"], "Bearer fixture-token") self.assertEqual(lookup.url.params["phoneNumber"], "+56000000000") - def test_caller_auth_headers_remain_independent(self): - request = Request( - { - "type": "http", - "headers": [ - (b"authorization", b"Bearer fixture-token"), - (b"x-demo-session", b"fixture-demo"), - ], - } - ) - self.assertEqual(apipilot._apipilot_parse_authorization(request), ("Bearer fixture-token", "fixture-demo")) + def test_apipilot_routes_and_contracts_are_retired(self): + self.assertNotIn("apipilot", self.client.get("/api/config").json()) + spec = self.client.get("/openapi.json").json() + for method, path in (("GET", "/api/apipilot/tools"), ("POST", "/api/apipilot/execute")): + self.assertNotIn(path, spec["paths"]) + response = self.client.request(method, path, json={"name": "get_report"}) + self.assertEqual(response.status_code, 404) + schemas = spec["components"]["schemas"] + for name in ("ApipilotExecuteRequest", "ToolCatalogResponse", "ToolExecutionResponse", "ToolInvocation"): + self.assertNotIn(name, schemas) + self.assertNotIn("apipilot", schemas["ConfigResponse"]["properties"]) + self.assertEqual(set(schemas["ReasoningResponse"]["properties"]), {"answer"}) + self.upstream.assert_not_called() class VoiceProxyTests(unittest.TestCase): - def test_apipilot_catalog_and_caller_defaults_reach_the_voice_session(self): + def test_caller_and_episode_context_reach_the_voice_session(self): closed = threading.Event() - catalog = AsyncMock( - return_value=[ - { - "name": "get_caller_briefing", - "description": "Retrieve the caller's local test briefing.", - "requires_auth": True, - "parameters": {"required": ["phone", "episode_id", "email"]}, - } - ] - ) login = AsyncMock(return_value=("fixture-token", {})) - profile = AsyncMock(return_value={"user": {"email": "caller@example.test"}}) + profile = AsyncMock(return_value={"user": {"name": "Local Caller", "email": "caller@example.test"}}) + episode = AsyncMock(return_value={"title": "Local episode", "text": "Local episode text"}) def voice(socket): try: @@ -226,13 +217,12 @@ class VoiceProxyTests(unittest.TestCase): target = f"ws://127.0.0.1:{upstream.socket.getsockname()[1]}/v1/realtime" with ( patch.object(settings, "S2S_PROXY_TARGET", target), - patch.object(settings, "S2S_INJECT_PROFILE", False), - patch.object(settings, "S2S_APIPILOT_TOOLS_URL", "http://catalog.test/tools"), + patch.object(settings, "S2S_INJECT_PROFILE", True), patch.object(settings, "S2S_LOGIN_BY_PHONE", "http://profile.test/login"), patch.object(settings, "VOCERO_USER_PHONE_AUTH_URL", "http://profile.test/user"), - patch.object(apipilot, "_apipilot_fetch_tools", catalog), patch.object(caller, "_login_by_phone", login), patch.object(caller, "_fetch_user_profile_for_phone", profile), + patch.object(caller, "_fetch_podcast_episode", episode), patch.object(httpx, "AsyncClient", side_effect=AssertionError("Unexpected HTTP request")), TestClient(server.app) as client, ): @@ -246,11 +236,9 @@ class VoiceProxyTests(unittest.TestCase): instructions = update["session"]["instructions"] for expected in ( "Local fixture", - tool_context.APIPILOT_PASS1_LIST_MARKER, - "name: get_caller_briefing", - 'phone = "+56000000000"', - 'episode_id = "fixture-episode"', - 'email = "caller@example.test"', + "Local Caller", + "Local episode text", + "caller@example.test", ): self.assertIn(expected, instructions) # Configuration injection must preserve ordinary audio forwarding. @@ -260,7 +248,7 @@ class VoiceProxyTests(unittest.TestCase): self.assertTrue(closed.wait(3), "Client disconnect must close the upstream WebSocket") login.assert_awaited_once_with("+56000000000") profile.assert_awaited_once_with("+56000000000", auth_token="fixture-token") - catalog.assert_awaited_once_with(auth_header="Bearer fixture-token", demo_session=ANY, refresh=False) + episode.assert_awaited_once_with("fixture-episode") def test_proxy_voice_frames_keepalive_and_client_disconnect(self): closed = threading.Event() diff --git a/vocero-api/vocero_api/api/router.py b/vocero-api/vocero_api/api/router.py index ac09286..86f01c0 100644 --- a/vocero-api/vocero_api/api/router.py +++ b/vocero-api/vocero_api/api/router.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from vocero_api.api.routes import apipilot, caller, config, conversations, search, sessions +from vocero_api.api.routes import caller, config, conversations, search, sessions from vocero_api.models.responses import ReasoningResponse from vocero_api.realtime.proxy import realtime_proxy from vocero_api.services.reasoning import reasoning @@ -10,7 +10,6 @@ from vocero_api.services.reasoning import reasoning router = APIRouter() router.include_router(config.router) router.include_router(conversations.router) -router.include_router(apipilot.router) router.add_api_route("/api/reasoning", reasoning, methods=["POST"], response_model=ReasoningResponse) router.add_api_websocket_route("/v1/realtime", realtime_proxy) router.include_router(caller.router) diff --git a/vocero-api/vocero_api/api/routes/apipilot.py b/vocero-api/vocero_api/api/routes/apipilot.py deleted file mode 100644 index 9d3b73a..0000000 --- a/vocero-api/vocero_api/api/routes/apipilot.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import JSONResponse - -from vocero_api.api.responses import validated_json_response -from vocero_api.core import config as settings -from vocero_api.core import logging as app_logging -from vocero_api.models import requests as models_requests -from vocero_api.models.responses import ToolCatalogResponse, ToolExecutionResponse -from vocero_api.providers import apipilot as providers_apipilot - -router = APIRouter() - - -@router.get("/api/apipilot/tools", response_model=ToolCatalogResponse) -async def apipilot_list_tools( - request: Request, - include_unauthorized: bool = True, - refresh: bool = False, -) -> JSONResponse: - """Public proxy for ApiPilot tool discovery (guide §2.1). - - Forwards Authorization / x-demo-session headers from the caller to the - upstream backend verbatim and returns the list of `ToolDefinition` records - exactly as documented: - - { "name": "...", "description": "...", "parameters": {...}, "requires_auth": true/false } - - A short in-process TTL cache is applied (default 5 min, configurable via - S2S_APIPILOT_TTL_SEC). The client may force a refresh via `?refresh=true` - (e.g. after a user just logged in and previously saw only requires_auth tools).""" - if settings.S2S_MCP_URL: - raise HTTPException(409, "Las herramientas de AquaPortal usan la sesión de conversación.") - if not settings.S2S_APIPILOT_TOOLS_URL: - return validated_json_response( - model=ToolCatalogResponse, - status_code=501, - content={ - "success": False, - "data": None, - "error": { - "code": "APIPILOT_NOT_CONFIGURED", - "message": "ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).", - "status_code": 501, - "raw": None, - }, - }, - ) - auth, demo = providers_apipilot._apipilot_parse_authorization(request) - try: - tools = await providers_apipilot._apipilot_fetch_tools( - auth_header=auth, - demo_session=demo, - include_unauthorized=bool(include_unauthorized), - force_refresh=bool(refresh), - ) - except HTTPException as exc: - # Normalize to envelope-style payload so downstream code is uniform. - detail = ( - exc.detail - if isinstance(exc.detail, dict) - else { - "code": "APIPILOT_ERROR", - "message": str(exc.detail) if exc.detail else "Error", - "status_code": exc.status_code, - "raw": None, - } - ) - return validated_json_response( - model=ToolCatalogResponse, - status_code=exc.status_code, - content={"success": False, "data": None, "error": detail}, - ) - app_logging.logger.info( - "[APIPILOT] /api/apipilot/tools OK tools=%d include_unauthorized=%s refresh=%s", - len(tools), - bool(include_unauthorized), - bool(refresh), - ) - return validated_json_response( - { - "success": True, - "data": { - "count": len(tools), - "include_unauthorized": bool(include_unauthorized), - "tools": tools, - }, - "error": None, - }, - ToolCatalogResponse, - ) - - -@router.post("/api/apipilot/execute", response_model=ToolExecutionResponse) -async def apipilot_execute(request: Request, body: models_requests.ApipilotExecuteRequest) -> JSONResponse: - """Public proxy for ApiPilot /execute (guide §2.2). - - Accepts `{name, arguments}`, runs the mandatory PRE-CHECK flow (fresh tool - discovery, `requires_auth` gate, strict JSON-Schema argument validation), - forwards the request to the upstream backend, and returns the envelope - `{success, data, error}`. - - Show only `error.message` to the end user. `error.code`, `error.status_code` - and `error.raw` are for internal auditing (guide §2.2, recommended UI errors).""" - if settings.S2S_MCP_URL: - raise HTTPException(409, "Las herramientas de AquaPortal usan la sesión de conversación.") - if not settings.S2S_APIPILOT_TOOLS_URL: - return validated_json_response( - model=ToolExecutionResponse, - status_code=501, - content={ - "success": False, - "data": None, - "error": { - "code": "APIPILOT_NOT_CONFIGURED", - "message": "ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).", - "status_code": 501, - "raw": None, - }, - }, - ) - auth, demo = providers_apipilot._apipilot_parse_authorization(request) - app_logging.logger.info( - "[APIPILOT-CALL-NAME] USE=API_ENDPOINT_EXECUTE name=%r argsKeys=%s authReceived=%s", - (body.name or ""), - sorted(list((body.arguments or {}).keys())) if isinstance(body.arguments, dict) else [], - ("Y" if auth or demo else "none"), - ) - envelope = await providers_apipilot._apipilot_execute_tool( - auth_header=auth, - demo_session=demo, - name=body.name, - arguments=body.arguments if isinstance(body.arguments, dict) else {}, - ) - status_code = 200 - if (not envelope.get("success")) and isinstance(envelope.get("error"), dict): - code = envelope["error"].get("status_code") - try: - code_int = int(code) - if 400 <= code_int < 599: - status_code = code_int - except Exception: - pass - app_logging.logger.info( - "[APIPILOT] /api/apipilot/execute name=%s success=%s status=%s", - body.name, - envelope.get("success"), - status_code, - ) - return validated_json_response(envelope, ToolExecutionResponse, status_code=status_code) diff --git a/vocero-api/vocero_api/api/routes/config.py b/vocero-api/vocero_api/api/routes/config.py index 0c0e57f..52b2d99 100644 --- a/vocero-api/vocero_api/api/routes/config.py +++ b/vocero-api/vocero_api/api/routes/config.py @@ -21,12 +21,7 @@ async def config(request: Request) -> JSONResponse: """Client bootstrap: whether web search is available, whether the deploy runs behind a load balancer (so the browser uses the /api/session proxy), and whether the user may instead set a direct - s2s server URL. The LB address itself is intentionally NOT included. - - 2026-09-24: new top-level `apipilot` flag. When True the deploy exposes the - new `/api/apipilot/tools` and `/api/apipilot/execute` proxy endpoints (see - docs/client/tool-calling.md) that the browser may call directly instead of - hardcoding upstream tool URLs / names.""" + s2s server URL. The LB address itself is intentionally NOT included.""" app_logging.logger.debug( "[INTERNAL] /api/config requested client=%s", request.client.host if request.client else "?" ) @@ -42,7 +37,6 @@ async def config(request: Request) -> JSONResponse: "debugPrompt": settings.S2S_DEBUG_PROMPT, "startSessionMode": settings.S2S_START_SESSION_MODE, "theme": settings.S2S_THEME, - "apipilot": bool(settings.S2S_APIPILOT_TOOLS_URL), } if settings.S2S_MCP_URL: if settings.S2S_REASONING_TYPE == "PROXY" and ( @@ -50,13 +44,12 @@ async def config(request: Request) -> JSONResponse: ): raise HTTPException(503, "MCP en modo PROXY requiere la conexión de voz a través de Vocero.") out["mcp"] = True - out["apipilot"] = False app_logging.logger.debug( - "[INTERNAL] /api/config returned promptLen=%d reasoning=%s startSessionMode=%s theme=%s apipilot=%s", + "[INTERNAL] /api/config returned promptLen=%d reasoning=%s startSessionMode=%s theme=%s mcp=%s", len(primary_prompt), settings.S2S_REASONING_TYPE, settings.S2S_START_SESSION_MODE or "-", settings.S2S_THEME or "-", - out["apipilot"], + bool(settings.S2S_MCP_URL), ) return validated_json_response(out, ConfigResponse) diff --git a/vocero-api/vocero_api/core/config.py b/vocero-api/vocero_api/core/config.py index 3f2a84d..6c87b6f 100644 --- a/vocero-api/vocero_api/core/config.py +++ b/vocero-api/vocero_api/core/config.py @@ -137,50 +137,6 @@ OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1") OPENAI_REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-4.1-mini").strip() OPENAI_ORGANIZATION = os.environ.get("OPENAI_ORGANIZATION", "").strip() OPENAI_PROJECT = os.environ.get("OPENAI_PROJECT", "").strip() -# ────────────────────────────────────────────────────────────────────────────── -# APIPILOT — Backend AquaPortal dynamic-tool bridge (docs/client/tool-calling.md) -# ────────────────────────────────────────────────────────────────────────────── -# Base URL for ApiPilot dynamic tool discovery and execution. When configured, -# the proxy sends GET /tools with include_unauthorized=true (guide §2.1) -# to discover tools, and POST /execute (§2.2) to invoke one. The trailing -# slash is optional; the proxy normalizes it with rstrip("/") below. -# -# Example: S2S_APIPILOT_TOOLS_URL=https://aquachile.azurianlab.com/api/v1/apipilot -S2S_APIPILOT_TOOLS_URL = os.environ.get("S2S_APIPILOT_TOOLS_URL", "").strip().rstrip("/") -# Explicit execution URL override (guide §2.2). When set, it takes precedence -# over S2S_APIPILOT_TOOLS_URL for POST execution calls. Remove trailing slashes -# and append "/execute" only if the URL does not already end with that suffix. -# Example 1 (base): S2S_APIPILOT_TOOLS_EXECUTE_URL=https://aquachile.azurianlab.com/api/v1/apipilot -# → POST https://aquachile.azurianlab.com/api/v1/apipilot/execute -# Example 2: S2S_APIPILOT_TOOLS_EXECUTE_URL=https://aquachile.azurianlab.com/api/v1/apipilot/tools -# → POST https://aquachile.azurianlab.com/api/v1/apipilot/tools/execute -S2S_APIPILOT_TOOLS_EXECUTE_URL = os.environ.get("S2S_APIPILOT_TOOLS_EXECUTE_URL", "").strip().rstrip("/") -# Optional fixed bearer token, used only when the caller supplies no session -# token (Authorization / x-demo-session). Useful for staging service tokens. -S2S_APIPILOT_SERVICE_TOKEN = os.environ.get("S2S_APIPILOT_SERVICE_TOKEN", "").strip() -# 2026-09-25 SUPER API KEY (TEMPORARY AQUAPORTAL AUTHENTICATION BYPASS). -# When set, send all three header forms for maximum compatibility: -# 1) X-API-Key: -# 2) Authorization: Bearer -# 3) x-demo-session: -# Highest priority; completely disables the unnecessary login_by_phone call. -# Clear the value to restore standard login. -S2S_APIPILOT_API_KEY = os.environ.get("S2S_APIPILOT_API_KEY", "").strip().strip("\"'") -# Tool discovery cache TTL in seconds. The guide recommends a five-minute -# client cache; the server uses the same default: 300 seconds. -try: - _APIPILOT_TTL_RAW = int((os.environ.get("S2S_APIPILOT_TTL_SEC", "") or "300").strip()) -except Exception: - _APIPILOT_TTL_RAW = 300 -APIPILOT_CACHE_TTL_SEC = max(15, min(3600, _APIPILOT_TTL_RAW)) -del _APIPILOT_TTL_RAW -# HTTP timeout for ApiPilot discovery and execution calls, in seconds. -try: - _APIPILOT_TO_RAW = float((os.environ.get("S2S_APIPILOT_TIMEOUT_SEC", "") or "15.0").strip()) -except Exception: - _APIPILOT_TO_RAW = 15.0 -APIPILOT_HTTP_TIMEOUT = max(1.0, min(120.0, _APIPILOT_TO_RAW)) -del _APIPILOT_TO_RAW # Speech-to-speech load balancer URL. When set, the browser POSTs /api/session diff --git a/vocero-api/vocero_api/core/diagnostics.py b/vocero-api/vocero_api/core/diagnostics.py index 832c5c0..9f5502a 100644 --- a/vocero-api/vocero_api/core/diagnostics.py +++ b/vocero-api/vocero_api/core/diagnostics.py @@ -3,23 +3,8 @@ from __future__ import annotations from vocero_api.core import config as settings from vocero_api.core import logging as app_logging - -# Resolve the execution URL for the startup banner using the same normalization -# as _apipilot_resolve_execute_url. -def _resolve_exec_url_for_startup_banner() -> str: - base = (settings.S2S_APIPILOT_TOOLS_EXECUTE_URL or settings.S2S_APIPILOT_TOOLS_URL).strip().rstrip("/") - if not base: - return "" - if base.lower().endswith("/execute"): - return base - return f"{base}/execute" - - -_STARTUP_APIPILOT_EXEC_URL = _resolve_exec_url_for_startup_banner() -del _resolve_exec_url_for_startup_banner - app_logging.logger.info( - "[STARTUP] Other externals — reasoning=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s injectReports=%s reportsHintMax=%d reportsInjectMode=%s injectToday=%s summaryApi=%s summaryText=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s theme=%s apipilotToolsUrl=%s apipilotExecuteUrlOverride=%s apipilotServiceToken=%s apipilotTtl=%ds apipilotTimeout=%.1fs apipilotReasoningPass2=%s apipilotApiKeyBypass=%s", + "[STARTUP] Other externals — reasoning=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s injectReports=%s reportsHintMax=%d reportsInjectMode=%s injectToday=%s summaryApi=%s summaryText=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s theme=%s", settings.S2S_REASONING_TYPE, bool(settings.SERPER_KEY), bool(settings.LOAD_BALANCER_URL), @@ -38,27 +23,7 @@ app_logging.logger.info( settings.S2S_DEBUG_PROMPT, settings.S2S_START_SESSION_MODE or "-", settings.S2S_THEME or "-", - settings.S2S_APIPILOT_TOOLS_URL or "-", - (settings.S2S_APIPILOT_TOOLS_EXECUTE_URL + f"(resolved:{_STARTUP_APIPILOT_EXEC_URL})") - if settings.S2S_APIPILOT_TOOLS_EXECUTE_URL - else "-", - "Y" if bool(settings.S2S_APIPILOT_SERVICE_TOKEN) else "-", - settings.APIPILOT_CACHE_TTL_SEC, - settings.APIPILOT_HTTP_TIMEOUT, - "Y" if (bool(settings.S2S_APIPILOT_TOOLS_URL) or bool(settings.S2S_APIPILOT_TOOLS_EXECUTE_URL)) else "-", - ( - "YES head=" - + ( - settings.S2S_APIPILOT_API_KEY[:6] + "..." - if len(settings.S2S_APIPILOT_API_KEY) > 6 - else settings.S2S_APIPILOT_API_KEY - ) - + " (3 FORMS!)" - ) - if settings.S2S_APIPILOT_API_KEY - else "-", ) -del _STARTUP_APIPILOT_EXEC_URL if settings.S2S_MCP_URL: app_logging.logger.info( diff --git a/vocero-api/vocero_api/models/requests.py b/vocero-api/vocero_api/models/requests.py index 35b8691..f832d24 100644 --- a/vocero-api/vocero_api/models/requests.py +++ b/vocero-api/vocero_api/models/requests.py @@ -29,13 +29,6 @@ class ReasoningRequest(BaseModel): episode_id: str | None = None -class ApipilotExecuteRequest(BaseModel): - """Body for the ApiPilot execute proxy endpoint. Mirrors §2.2 request shape.""" - - name: str - arguments: dict = {} - - class QueueEndPayload(BaseModel): """A view of a decoded beacon object, preserving the legacy loose value. diff --git a/vocero-api/vocero_api/models/responses.py b/vocero-api/vocero_api/models/responses.py index c60d358..ad20730 100644 --- a/vocero-api/vocero_api/models/responses.py +++ b/vocero-api/vocero_api/models/responses.py @@ -27,7 +27,6 @@ class ConfigResponse(OpenResponse): debugPrompt: bool startSessionMode: str theme: str - apipilot: bool class SearchResult(OpenResponse): @@ -62,42 +61,8 @@ class ReportsResponse(OpenResponse): note: str | None = None -class ToolInvocation(OpenResponse): - name: str - success: bool - result_length: int - - class ReasoningResponse(OpenResponse): answer: str - usage_pass2: Any = None - apipilot_invoked: ToolInvocation | None = None - - -class ToolDefinition(OpenResponse): - name: str - description: str - parameters: dict[str, Any] - requires_auth: bool - - -class ToolCatalog(OpenResponse): - count: int - include_unauthorized: bool - tools: list[ToolDefinition] - - -class ToolCatalogResponse(OpenResponse): - success: bool - data: ToolCatalog | None - error: Any - - -class ToolExecutionResponse(OpenResponse): - success: bool - # The upstream may omit these fields or return any JSON value for them. - data: Any = None - error: Any = None class AllocationResponse(OpenResponse): diff --git a/vocero-api/vocero_api/providers/apipilot.py b/vocero-api/vocero_api/providers/apipilot.py deleted file mode 100644 index 16c801f..0000000 --- a/vocero-api/vocero_api/providers/apipilot.py +++ /dev/null @@ -1,597 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -from collections.abc import Callable -from time import monotonic -from typing import Any, cast - -import httpx -from fastapi import HTTPException, Request - -from vocero_api.core import config as settings -from vocero_api.core import logging as app_logging - -# In-process cache and asynchronous lock: one caller refreshes while others -# read the stale entry. `_APIPILOT_CACHE[auth_fingerprint] = (fetched_at_monotonic, tools_list)`. -_APIPILOT_CACHE: dict[str, tuple[float, list[dict]]] = {} -_APIPILOT_CACHE_LOCK = asyncio.Lock() - -# Monotonic time helper for duration measurements unaffected by NTP drift. -_monotonic_now: Callable[[], float] = monotonic - - -def _apipilot_auth_fingerprint(auth_header: str | None, demo_session: str | None) -> str: - """Return a short string used as cache key scoped to the caller identity. - - We don't want to share cached tool-lists across users with different auth - tokens (requires_auth flags can be token-dependent via /tools?include_unauthorized=true - because the back-end may still attach per-role metadata).""" - parts: list[str] = [] - if auth_header: - # Don't cache the raw bearer. SHA-256 truncated 12 chars = safe fingerprint. - import hashlib - - parts.append("h:" + hashlib.sha256(auth_header.encode("utf-8")).hexdigest()[:12]) - if demo_session: - parts.append("d:" + demo_session) - return "|".join(parts) if parts else "anon" - - -def _apipilot_parse_authorization(request: Request | None) -> tuple[str | None, str | None]: - """Extract (Authorization header value, x-demo-session header value) from a FastAPI request. - - Returns a tuple of two optional strings. Both are forwarded to the upstream - ApiPilot backend verbatim when non-empty (see docs/client/tool-calling.md). - """ - if request is None: - return None, None - auth = None - demo = None - try: - raw_auth = request.headers.get("authorization") or request.headers.get("Authorization") - if isinstance(raw_auth, str) and raw_auth.strip(): - auth = raw_auth.strip() - raw_demo = request.headers.get("x-demo-session") or request.headers.get("X-Demo-Session") - if isinstance(raw_demo, str) and raw_demo.strip(): - demo = raw_demo.strip() - except Exception: - pass - return auth, demo - - -def _apipilot_resolve_execute_url() -> str: - """Resolve the final POST URL for ApiPilot tool execution. - - Priority (guide §2.2 and explicit environment override): - 1. When S2S_APIPILOT_TOOLS_EXECUTE_URL is set, append "/execute" if needed. - 2. Fallback S2S_APIPILOT_TOOLS_URL + "/execute". - If neither URL is configured, the caller (usually _apipilot_execute_tool) - must return an APIPILOT_NOT_CONFIGURED error envelope before calling this. - """ - base = settings.S2S_APIPILOT_TOOLS_EXECUTE_URL or settings.S2S_APIPILOT_TOOLS_URL - base = base.strip().rstrip("/") - if not base: - return "" - if base.lower().endswith("/execute"): - return base - return f"{base}/execute" - - -def _apipilot_strip_tool_for_llm(tool_def: dict) -> dict: - """Return a tool definition that is safe for LLM function-calling providers. - - Guide §4 forbids passing raw /tools output directly to the model because - requires_auth is outside the function-calling standard and some providers - reject it. Project the native `ToolDefinition` into the OpenAI-compatible - `{type: "function", function: {name, description, parameters}}`. - """ - if not isinstance(tool_def, dict): - return {} - name = tool_def.get("name") if isinstance(tool_def.get("name"), str) else "" - description = tool_def.get("description") if isinstance(tool_def.get("description"), str) else "" - parameters = tool_def.get("parameters") - if not isinstance(parameters, dict): - parameters = {"type": "object", "properties": {}, "required": []} - out: dict = { - "type": "function", - "function": { - "name": name, - "description": description, - "parameters": parameters, - }, - } - return out - - -async def _apipilot_fetch_tools( - *, - auth_header: str | None, - demo_session: str | None, - include_unauthorized: bool = True, - force_refresh: bool = False, - refresh: bool | None = None, -) -> list[dict]: - """Fetch ApiPilot tool definitions with a short in-process TTL cache. - - Implements §2.1 (GET /tools?include_unauthorized=true). The cache key is - scoped by caller auth fingerprint (see `_apipilot_auth_fingerprint`) so - per-token variations in `requires_auth` or list length don't leak between - identities. - - Returns a list of tool definitions (parsed JSON, may be empty). Raises - `HTTPException` only when `S2S_APIPILOT_TOOLS_URL` is set but the backend - replied with a non-2xx / non-JSON response. When env is empty → returns - `[]` silently (passthrough). - """ - # 2026-09-25 compatibility alias: WS connect uses `refresh`; when present, - # apply it to force_refresh. - if refresh is not None: - force_refresh = bool(refresh) or bool(force_refresh) - if not settings.S2S_APIPILOT_TOOLS_URL: - return [] - import time as _time_mod - - # 2026-09-25 fingerprint consistency: determine effective auth/demo values - # first (SUPER API KEY takes precedence), then calculate the cache fingerprint. - # An anonymous fingerprint with APIKEY headers would mismatch the cache and - # demo session, causing Aquachile user_type=null and a 403 response. - if settings.S2S_APIPILOT_API_KEY: - final_auth: str | None = "BYPASS-API-KEY" - _demo_used: str | None = settings.S2S_APIPILOT_API_KEY - _effective_auth_for_fp = f"Bearer {settings.S2S_APIPILOT_API_KEY}" - _effective_demo_for_fp = settings.S2S_APIPILOT_API_KEY - else: - final_auth = auth_header or ( - f"Bearer {settings.S2S_APIPILOT_SERVICE_TOKEN}" if settings.S2S_APIPILOT_SERVICE_TOKEN else None - ) - _demo_used = demo_session - _effective_auth_for_fp = ( - final_auth if isinstance(final_auth, str) and final_auth.strip() else (auth_header or "") - ) - _effective_demo_for_fp = _demo_used if isinstance(_demo_used, str) and _demo_used.strip() else "" - fp = _apipilot_auth_fingerprint(_effective_auth_for_fp, _effective_demo_for_fp) - now = _time_mod.monotonic() - cached = _APIPILOT_CACHE.get(fp) - if (not force_refresh) and cached and (now - cached[0]) < settings.APIPILOT_CACHE_TTL_SEC: - app_logging.logger.debug( - "[APIPILOT-DISCOVERY] cache HIT ttl=%.1fs fp=%s tools=%d", - settings.APIPILOT_CACHE_TTL_SEC - (now - cached[0]), - fp, - len(cached[1]), - ) - return cached[1] - # Prefer to serve stale (if any) rather than block many callers. - stale_tools = cached[1] if cached else None - async with _APIPILOT_CACHE_LOCK: - # Double-check after acquiring lock (another coroutine may have refreshed). - now2 = _time_mod.monotonic() - recheck = _APIPILOT_CACHE.get(fp) - if (not force_refresh) and recheck and (now2 - recheck[0]) < settings.APIPILOT_CACHE_TTL_SEC: - return recheck[1] - url = f"{settings.S2S_APIPILOT_TOOLS_URL}/tools" - params = {"include_unauthorized": "true" if include_unauthorized else "false"} - headers = {"accept": "application/json"} - # 2026-09-25 SUPER API KEY overrides other authentication and sends all - # three header forms simultaneously for maximum compatibility. - if settings.S2S_APIPILOT_API_KEY: - headers["X-API-Key"] = settings.S2S_APIPILOT_API_KEY - headers["Authorization"] = f"Bearer {settings.S2S_APIPILOT_API_KEY}" - headers["x-demo-session"] = settings.S2S_APIPILOT_API_KEY - else: - if final_auth: - headers["Authorization"] = final_auth - if _demo_used: - headers["x-demo-session"] = _demo_used - app_logging.logger.info( - "[APIPILOT-DISCOVERY] → GET %s?include_unauthorized=%s fp=%s auth=%s demo=%s", - url, - params["include_unauthorized"], - fp, - ("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if final_auth else "-")), - ("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if _demo_used else "-")), - ) - try: - async with httpx.AsyncClient(timeout=settings.APIPILOT_HTTP_TIMEOUT, follow_redirects=True) as http: - resp = await http.get(url, params=params, headers=headers) - except Exception as exc: - app_logging.logger.error("[APIPILOT-DISCOVERY] ✗ network error: %s", exc) - if stale_tools is not None: - app_logging.logger.warning( - "[APIPILOT-DISCOVERY] using STALE cached fallback tools=%d", len(stale_tools) - ) - return stale_tools - raise HTTPException( # noqa: B904 - status_code=502, - detail={ - "code": "APIPILOT_NETWORK_ERROR", - "message": f"Error al contactar ApiPilot tools backend: {exc}", - }, - ) - if resp.status_code >= 300: - app_logging.logger.error( - "[APIPILOT-DISCOVERY] ✗ backend HTTP %s body=%s", resp.status_code, resp.text[:400] - ) - if stale_tools is not None: - app_logging.logger.warning( - "[APIPILOT-DISCOVERY] using STALE cached fallback tools=%d", len(stale_tools) - ) - return stale_tools - raise HTTPException( - status_code=502, - detail={ - "code": "APIPILOT_BAD_UPSTREAM", - "message": f"Backend ApiPilot tools devolvió HTTP {resp.status_code}", - "status_code": resp.status_code, - }, - ) - try: - payload = resp.json() - except Exception: - app_logging.logger.error("[APIPILOT-DISCOVERY] ✗ non-JSON response: %s", resp.text[:500]) - if stale_tools is not None: - return stale_tools - raise HTTPException( # noqa: B904 - status_code=502, - detail={"code": "APIPILOT_BAD_JSON", "message": "La respuesta de ApiPilot tools no es JSON válido."}, - ) - # Backend might return: list OR {"result": [...]} OR {"tools": [...]} OR - # {"data": {"tools": [...]}}. Unwrap generic wrapper-style payloads. - tools: list[dict] = [] - if isinstance(payload, list): - tools = [t for t in payload if isinstance(t, dict)] - elif isinstance(payload, dict): - for key in ("tools", "result", "data"): - cand = payload.get(key) - if isinstance(cand, list): - tools = [t for t in cand if isinstance(t, dict)] - break - if isinstance(cand, dict): - sub = cand.get("tools") if isinstance(cand.get("tools"), list) else cand.get("result") - if isinstance(sub, list): - tools = [t for t in sub if isinstance(t, dict)] - break - # Validate minimal ToolDefinition contract §2.1: name + description + parameters. - sanitized: list[dict] = [] - for raw in tools: - name = raw.get("name") - if not isinstance(name, str) or not name.strip(): - continue - description = raw.get("description") if isinstance(raw.get("description"), str) else "" - parameters = raw.get("parameters") - if not isinstance(parameters, dict): - parameters = {"type": "object", "properties": {}, "required": []} - requires_auth = raw.get("requires_auth", False) - if not isinstance(requires_auth, bool): - # Some backends return 0/1; normalize to bool as required. - try: - requires_auth = bool(int(requires_auth)) - except Exception: - requires_auth = False - tool_def = { - "name": name.strip(), - "description": description, - "parameters": parameters, - "requires_auth": requires_auth, - } - sanitized.append(tool_def) - _APIPILOT_CACHE[fp] = (now2, sanitized) - app_logging.logger.info( - "[APIPILOT-DISCOVERY] ← OK tools=%s fp=%s status=%s upstreamFmt=%s cachedTools=%d", - len(sanitized), - fp, - resp.status_code, - "list" if isinstance(payload, list) else "object", - len(sanitized), - ) - if settings.S2S_DEBUG_PROMPT and sanitized: - app_logging.logger.debug( - "[APIPILOT-DISCOVERY] raw first tool preview: %s", - json.dumps(sanitized[0], ensure_ascii=False)[:400], - ) - return sanitized - - -def _apipilot_validate_arguments(arguments: dict | None, tool_def: dict) -> tuple[dict, list[str]]: - """Validate / execute arguments strictly against the tool's JSON-Schema parameters. - - Implements §2.2 PRE-CHECK #3: - • Remove unknown keys only when parameters.properties is nonempty. - • AquaPortal tool.get_user_briefing has parameters.properties={} and - required=[]: no explicit schema, so arguments remain flexible. The - backend resolves phone from the token; preserve supplied arguments. - • Validate required parameters and return errors for missing values. - • Never invent parameters or add keys. - - Returns `(filtered_args, list_of_validation_errors_in_spanish)`. - If the returned errors list is non-empty, MUST block the execution (see - the `_apipilot_execute_tool` caller). - """ - errors: list[str] = [] - schema = cast(dict[str, Any], tool_def.get("parameters")) if isinstance(tool_def.get("parameters"), dict) else {} - properties = cast(dict[str, Any], schema.get("properties")) if isinstance(schema.get("properties"), dict) else {} - required = cast(list[Any], schema.get("required")) if isinstance(schema.get("required"), list) else [] - required = [x for x in required if isinstance(x, str)] - args = arguments if isinstance(arguments, dict) else {} - - # 2026-09-25 empty-schema flexibility: preserve all arguments when no - # properties are explicitly declared, as with AquaPortal get_user_briefing. - schema_is_open: bool = (not isinstance(properties, dict)) or len(properties) == 0 - requires_auth_flag = bool(tool_def.get("requires_auth")) - # 2026-09-25 auth-only tools: requires_auth=true, empty properties and - # required=[], with a valid-session description, resolve user data from - # the bearer JWT. Send arguments={} to avoid conflicts between a phone - # argument and the token's user, which would produce a 403 response. - auth_only_tool = False - if schema_is_open and requires_auth_flag and len(required) == 0: - _d = tool_def.get("description") if isinstance(tool_def.get("description"), str) else "" - if (not _d) or "valid session token" in _d.lower() or "authenticated" in _d.lower(): - auth_only_tool = True - if auth_only_tool: - cleaned = {} - app_logging.logger.info( - "[APIPILOT-VALIDATE] tool=%s requires only an authenticated session → arguments forced to {} (avoid phone-argument/user-token conflicts)", - tool_def.get("name") or "?", - ) - elif schema_is_open: - cleaned = dict(args) - app_logging.logger.info( - "[APIPILOT-VALIDATE] tool=%s open schema (empty parameters.properties) → preserve all arguments N=%d keys=%s", - tool_def.get("name") or "?", - len(cleaned), - sorted(list(cleaned.keys())), - ) - else: - cleaned = {} - for key in args.keys(): - if key in properties: - cleaned[key] = args[key] - else: - app_logging.logger.warning( - "[APIPILOT-VALIDATE] tool=%s removing unknown arg=%s", - tool_def.get("name") or "?", - key, - ) - # Check required fields only for a closed schema; open required=[] is vacuous. - if not schema_is_open: - for req in required: - if req not in cleaned: - errors.append(f"Falta el parámetro requerido: {req}") - return cleaned, errors - - -async def _apipilot_execute_tool( - *, - auth_header: str | None, - demo_session: str | None, - name: str, - arguments: dict | None, -) -> dict: - """Run a single ApiPilot tool via POST /execute. - - Implements the full PRE-CHECK flow (§2.2) BEFORE sending the HTTP - request: - 1. Fresh discovery (TTL cache; force_refresh=True if a tool is missing). - 2. requires_auth: return HTTP 401 when authentication is required but absent. - 3. Strict JSON-Schema validation: filter unknown parameters and check required. - - Always return the standard `{success, data, error}` envelope (guide §2.2). - Pre-check failures populate error.{code,message,status_code}, with success=false. - """ - name_clean = (name or "").strip() if isinstance(name, str) else "" - app_logging.logger.info( - "[APIPILOT-CALL-NAME] USE=EXECUTE_ENTRYPOINT name=%r argsKeys=%s authHead=%.20s demo=%.20s", - name_clean, - sorted(list((arguments or {}).keys())) if isinstance(arguments, dict) else [], - str(auth_header or "")[:20], - str(demo_session or "")[:20], - ) - if not settings.S2S_APIPILOT_TOOLS_URL: - return { - "success": False, - "data": None, - "error": { - "code": "APIPILOT_NOT_CONFIGURED", - "message": "ApiPilot no está configurado en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).", - "status_code": 501, - "raw": None, - }, - } - if not name_clean: - return { - "success": False, - "data": None, - "error": { - "code": "TOOL_NAME_REQUIRED", - "message": "Falta el campo obligatorio: name (nombre de la herramienta).", - "status_code": 400, - "raw": None, - }, - } - tools = await _apipilot_fetch_tools(auth_header=auth_header, demo_session=demo_session, force_refresh=False) - tool_def = next((t for t in tools if t.get("name") == name_clean), None) - if tool_def is None: - # Unknown tool: refresh discovery and retry once. - app_logging.logger.info("[APIPILOT-EXEC] tool=%s not found → forced re-discovery", name_clean) - tools = await _apipilot_fetch_tools(auth_header=auth_header, demo_session=demo_session, force_refresh=True) - tool_def = next((t for t in tools if t.get("name") == name_clean), None) - if tool_def is None: - return { - "success": False, - "data": None, - "error": { - "code": "TOOL_NOT_FOUND", - "message": ( - f"Herramienta «{name_clean}» no encontrada en el catálogo ApiPilot. " - "Actualice la lista de herramientas y vuelva a intentarlo." - ), - "status_code": 404, - "raw": None, - }, - } - # ── PRE-CHECK 2: requires_auth ──────────────────────────────────────────── - requires_auth = bool(tool_def.get("requires_auth")) - has_token = bool(auth_header) or bool(demo_session) or bool(settings.S2S_APIPILOT_SERVICE_TOKEN) - if requires_auth and not has_token: - app_logging.logger.warning("[APIPILOT-EXEC] tool=%s requires_auth=true blocked (no token)", name_clean) - return { - "success": False, - "data": None, - "error": { - "code": "AUTH_REQUIRED", - "message": ( - f"La herramienta «{name_clean}» requiere autenticación. " - "Inicie sesión o proporcione un token para continuar." - ), - "status_code": 401, - "raw": None, - }, - } - # Pre-check 3: strict JSON-Schema argument validation. - cleaned_args, errors = _apipilot_validate_arguments(arguments, tool_def) - if errors: - return { - "success": False, - "data": None, - "error": { - "code": "INVALID_ARGUMENTS", - "message": " / ".join(errors), - "status_code": 400, - "raw": { - "removed": sorted(list(set(arguments) - set(cleaned_args))) if isinstance(arguments, dict) else [] - }, - }, - } - # ── HTTP: POST /execute ─────────────────────────────────────────────────── - url = _apipilot_resolve_execute_url() - if not url: - return { - "success": False, - "data": None, - "error": { - "code": "APIPILOT_NOT_CONFIGURED", - "message": "ApiPilot no está configurado en este despliegue (S2S_APIPILOT_TOOLS_URL y S2S_APIPILOT_TOOLS_EXECUTE_URL están vacíos).", - "status_code": 501, - "raw": None, - }, - } - headers = {"Content-Type": "application/json", "accept": "application/json,text/plain,*/*"} - # 2026-09-25 SUPER API KEY (highest priority): bypass AquaPortal authentication. - # Send all three header forms simultaneously for maximum compatibility: - # - X-API-Key: - # - Authorization: Bearer - # - x-demo-session: - # Ignore service tokens and login_by_phone when the API KEY is set. - if settings.S2S_APIPILOT_API_KEY: - headers["X-API-Key"] = settings.S2S_APIPILOT_API_KEY - headers["Authorization"] = f"Bearer {settings.S2S_APIPILOT_API_KEY}" - headers["x-demo-session"] = settings.S2S_APIPILOT_API_KEY - final_auth: str | None = f"Bearer {settings.S2S_APIPILOT_API_KEY}" - else: - # Authentication priority: service-token override, then caller auth_header - # from phone login/discovery/pass-2 fallback, then no authentication. - if settings.S2S_APIPILOT_SERVICE_TOKEN: - _svc = settings.S2S_APIPILOT_SERVICE_TOKEN.strip() - final_auth = (f"Bearer {_svc}" if (not _svc.lower().startswith("bearer ")) else _svc) if _svc else None - else: - final_auth = auth_header if isinstance(auth_header, str) and auth_header.strip() else None - if final_auth: - headers["Authorization"] = final_auth - if demo_session: - headers["x-demo-session"] = demo_session - payload = {"name": name_clean, "arguments": cleaned_args} - removed = sorted(list(set(arguments) - set(cleaned_args))) if isinstance(arguments, dict) else [] - app_logging.logger.info( - "[APIPILOT-EXEC] → POST %s name=%s argsKeys=%s removedKeys=%s auth=%s demo=%s", - url, - name_clean, - sorted(cleaned_args.keys()), - removed, - ("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if final_auth else "-")), - ("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if demo_session else "-")), - ) - try: - async with httpx.AsyncClient(timeout=settings.APIPILOT_HTTP_TIMEOUT, follow_redirects=True) as http: - resp = await http.post(url, json=payload, headers=headers) - except Exception as exc: - app_logging.logger.error("[APIPILOT-EXEC] ✗ network error: %s", exc) - return { - "success": False, - "data": None, - "error": { - "code": "EXECUTION_ERROR", - "message": f"Error de red al ejecutar «{name_clean}»: {exc}", - "status_code": 502, - "raw": None, - }, - } - # Parse a standard envelope; construct a matching error for non-JSON/5xx responses. - raw_text = resp.text - # Detailed diagnostics are always enabled for HTTP >= 400: request payload, - # unmasked demo session, response status and bounded raw response body. - if resp.status_code >= 400: - try: - payload_dump = __import__("json").dumps(payload, ensure_ascii=False) - except Exception: # noqa: BLE001 - payload_dump = str(payload) - app_logging.logger.warning( - "[APIPILOT-EXEC][DIAGNOSTIC 4xx] HTTP=%d url=%s name=%s demoSession=%.60s AUTH=%s requestPAYLOAD=%s |RAW RESPONSE BODY (max 1800 chars)=|%s|", - resp.status_code, - url, - name_clean, - (demo_session if isinstance(demo_session, str) else "?"), - ("YES len=" + str(len(final_auth)) if isinstance(final_auth, str) and final_auth.strip() else "-"), - payload_dump, - (raw_text or "")[:1800], - ) - try: - parsed = resp.json() - except Exception: - app_logging.logger.error("[APIPILOT-EXEC] ✗ non-JSON body HTTP %s: %s", resp.status_code, raw_text[:500]) - return { - "success": False, - "data": None, - "error": { - "code": "EXECUTION_ERROR", - "message": f"La respuesta de ejecución «{name_clean}» no es JSON válido (HTTP {resp.status_code}).", - "status_code": resp.status_code, - "raw": raw_text[:2000] if raw_text else None, - }, - } - # Return standard {success,data,error} envelopes unchanged. - if isinstance(parsed, dict) and "success" in parsed and isinstance(parsed.get("success"), bool): - envelope_ok = parsed["success"] and parsed.get("error") in (None, {}) - app_logging.logger.info( - "[APIPILOT-EXEC] ← %s name=%s success=%s http=%s dataKeys=%s", - "OK" if envelope_ok else "FAIL", - name_clean, - bool(parsed.get("success")), - resp.status_code, - sorted(list(cast(dict, parsed.get("data")).keys())) if isinstance(parsed.get("data"), dict) else "-", - ) - if (not envelope_ok) and isinstance(parsed.get("error"), dict): - err = parsed["error"] - app_logging.logger.warning( - "[APIPILOT-EXEC] ERROR: code=%s status=%s msg=%s", - err.get("code"), - err.get("status_code"), - (str(err.get("message"))[:200] if err.get("message") is not None else "-"), - ) - return parsed - # Wrap raw backend data in the required envelope (guide §2.2) for stable - # downstream behavior when an upstream response omits the wrapper. - app_logging.logger.warning( - "[APIPILOT-EXEC] backend omitted standard envelope → wrapping response http=%s", resp.status_code - ) - return { - "success": 200 <= resp.status_code < 300, - "data": parsed if (200 <= resp.status_code < 300) else None, - "error": None - if (200 <= resp.status_code < 300) - else { - "code": "EXECUTION_ERROR", - "message": (f"Ejecución «{name_clean}» devolvió HTTP {resp.status_code} sin envelope estándar."), - "status_code": resp.status_code, - "raw": (raw_text[:2000] if raw_text else None), - }, - } diff --git a/vocero-api/vocero_api/realtime/events.py b/vocero-api/vocero_api/realtime/events.py index e4a57ff..992a052 100644 --- a/vocero-api/vocero_api/realtime/events.py +++ b/vocero-api/vocero_api/realtime/events.py @@ -6,7 +6,6 @@ from typing import Any, cast from vocero_api.core import logging as app_logging from vocero_api.services import context as services_context -from vocero_api.services import tool_context as services_tool_context async def _report_server_debug(hypothesis_id: str, location: str, msg: str, data: dict | None = None) -> None: @@ -324,10 +323,6 @@ def _sanitize_realtime_event_raw(raw_message: str, session_ctx: dict[str, Any] | nonlocal changed if isinstance(v, str) and v.strip(): cleaned = _sanitize_agent_output(v, session_ctx) - # Layer 8 removes internal ApiPilot CALL markers before UI/TTS. - # The pass-2 accumulator retains the original transcript so - # execution can still be triggered by its marker. - cleaned = services_tool_context._sanitize_text_strip_apipilot_markers(cleaned) if cleaned != v: changed = True return cleaned diff --git a/vocero-api/vocero_api/realtime/proxy.py b/vocero-api/vocero_api/realtime/proxy.py index b402032..5cabbd7 100644 --- a/vocero-api/vocero_api/realtime/proxy.py +++ b/vocero-api/vocero_api/realtime/proxy.py @@ -13,12 +13,10 @@ from websockets.typing import Subprotocol from vocero_api.core import config as settings from vocero_api.core import logging as app_logging from vocero_api.core import urls as core_urls -from vocero_api.providers import apipilot as providers_apipilot from vocero_api.providers import caller as providers_caller from vocero_api.realtime import events as realtime_events from vocero_api.realtime.mcp_proxy import realtime_mcp_proxy from vocero_api.services import context as services_context -from vocero_api.services import tool_context as services_tool_context async def realtime_proxy(ws: WebSocket) -> None: @@ -46,102 +44,20 @@ async def realtime_proxy(ws: WebSocket) -> None: injected_profile_context = "" injected_reports_context = "" injected_summary_context = "" - injected_apipilot_pass1 = "" - apipilot_tools_pass1: list = [] inject_phone = "" inject_episode_id = "" client_query = {} login_token: str | None = None - login_response_obj: dict | None = None profile_obj: dict | None = None - needs_apipilot = bool(settings.S2S_APIPILOT_TOOLS_URL) or bool(settings.S2S_APIPILOT_TOOLS_EXECUTE_URL) try: client_query = dict(parse_qsl(ws.url.query, keep_blank_values=True)) except Exception: # noqa: BLE001 client_query = {} - # ApiPilot works independently of profile injection. When enabled, extract - # phone/episode query parameters, resolve the bearer for authenticated tools, - # and load the profile for email defaults when its endpoint is configured. - if needs_apipilot: - inject_phone = (client_query.get("phoneNumber") or "").strip() or inject_phone - inject_episode_id = (client_query.get("episode_id") or "").strip() or inject_episode_id - _needs_login_apipilot = bool(settings.S2S_LOGIN_BY_PHONE.strip()) - _needs_profile_apipilot = bool(settings.VOCERO_USER_PHONE_AUTH_URL.strip()) - # Always log initial WS state to diagnose missing phone, token or login - # configuration that could leave the AquaPortal user anonymous. - _s_init = { - "needs_apipilot": needs_apipilot, - "phoneLen": len(inject_phone or ""), - "phoneHead": ( - ("+" + inject_phone.lstrip("+")[:3] + "...") - if (inject_phone and len(inject_phone) > 6) - else (inject_phone or "") - ), - "loginTokenLen": (len(login_token) if isinstance(login_token, str) and login_token.strip() else 0), - "loginByPhone": ("YES" if _needs_login_apipilot else "NO"), - "profileUrl": ("YES" if _needs_profile_apipilot else "NO"), - "S2S_INJECT_PROFILE": settings.S2S_INJECT_PROFILE, - } - app_logging.logger.info( - "[INJECT-APIPILOT][WS connect] INITIAL STATE: " + ", ".join(f"{k}={v}" for k, v in sorted(_s_init.items())), - ) - # The API-key bypass makes login_by_phone unnecessary. - if ( - not settings.S2S_APIPILOT_API_KEY - and _needs_login_apipilot - and inject_phone - and ( - not isinstance(login_token, str) - or not login_token.strip() - or (isinstance(login_token, str) and 0 < len(login_token.strip()) < 80) - ) - ): - try: - login_token, _lresp = await providers_caller._login_by_phone(inject_phone) - if isinstance(_lresp, dict) and _lresp: - login_response_obj = _lresp - app_logging.logger.info( - "[INJECT-APIPILOT][WS connect] login_by_phone for authenticated ApiPilot execution phoneLen=%d token=%s", - len(inject_phone or ""), - f"YES len={len(login_token or '')}" - if isinstance(login_token, str) and login_token - else "NONE (403!)", - ) - except Exception as _exc_login: # noqa: BLE001 - app_logging.logger.info( - "[INJECT-APIPILOT][WS connect] login_by_phone failed (anonymous ApiPilot user, likely 403) phoneLen=%d err=%r", - len(inject_phone or ""), - _exc_login, - ) - if _needs_profile_apipilot and inject_phone and (not isinstance(profile_obj, dict) or not profile_obj): - try: - profile_obj = await providers_caller._fetch_user_profile_for_phone(inject_phone, auth_token=login_token) - app_logging.logger.info( - "[INJECT-APIPILOT][WS connect] profile fetch for email defaults phoneLen=%d profile=%s", - len(inject_phone or ""), - ("FOUND user=%s" % (((profile_obj or {}).get("user") or {}).get("name") or "?")) - if isinstance(profile_obj, dict) - else "NONE", - ) - except Exception as _exc_p: # noqa: BLE001 - app_logging.logger.info( - "[INJECT-APIPILOT][WS connect] profile fetch failed phoneLen=%d err=%r", - len(inject_phone or ""), - _exc_p, - ) - - if not needs_apipilot: - app_logging.logger.info( - "[INJECT-APIPILOT] ⏭️ WS connect: APIPILOT tools injection DISABLED (S2S_APIPILOT_TOOLS_URL='%s' S2S_APIPILOT_TOOLS_EXECUTE_URL='%s'). " - "No ApiPilot fetch or session.update patch in this tunnel.", - settings.S2S_APIPILOT_TOOLS_URL, - settings.S2S_APIPILOT_TOOLS_EXECUTE_URL, - ) if not settings.S2S_INJECT_PROFILE: app_logging.logger.info( "[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (S2S_INJECT_PROFILE=false). " - "No profile/episode fetch; tunnel stays pure passthrough for injects EXCEPT apipilot." + "No profile/episode fetch; tunnel stays pure passthrough for context injection." ) else: needs_phone_authed = bool(settings.VOCERO_USER_PHONE_AUTH_URL.strip()) @@ -177,7 +93,7 @@ async def realtime_proxy(ws: WebSocket) -> None: # 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set). # Obtains a bearer token used on subsequent profile/reports/summary fetches. if needs_login and inject_phone: - login_token, login_response_obj = await providers_caller._login_by_phone(inject_phone) + login_token, _ = await providers_caller._login_by_phone(inject_phone) # 0.5) Podcast episode fetch (independent of phone — uses only episode_id from query) if inject_episode_id: if settings.S2S_DEBUG_PROMPT: @@ -259,121 +175,12 @@ async def realtime_proxy(ws: WebSocket) -> None: app_logging.logger.info( "[INJECT-SUMMARY] ⏭️ WS connect: latest summary skipped (context empty / upstream returned empty summary / fetch failed)." ) - # 3) Fetch the ApiPilot pass-1 tool list independently of S2S_INJECT_PROFILE, - # even without login/phone. Use the resolved token or anonymous fingerprint, - # matching the reasoning endpoint's server cache. - if needs_apipilot: - apipilot_auth_header = f"Bearer {login_token}" if isinstance(login_token, str) and login_token.strip() else None - # Phone login resolves the AquaPortal JWT for execution. The configured - # S2S_APIPILOT_SERVICE_TOKEN overrides it. - if settings.S2S_APIPILOT_SERVICE_TOKEN: - _svc = settings.S2S_APIPILOT_SERVICE_TOKEN.strip() - if _svc: - apipilot_auth_header = f"Bearer {_svc}" if not _svc.lower().startswith("bearer ") else _svc - demo_session_key = client_query.get("x-demo-session") if isinstance(client_query, dict) else None - if not demo_session_key: - demo_session_key = f"anon-ws-{client_ip}-{id(ws)}" - try: - # Always log discovery, matching reasoning pass 1. - app_logging.logger.info( - "[INJECT-APIPILOT] WS connect: fetching tools (auth=%s demoSession=%.40s...) upstream=%s...", - "yes" if apipilot_auth_header else "anon", - (demo_session_key[:40] if isinstance(demo_session_key, str) else "?"), - settings.S2S_APIPILOT_TOOLS_URL or "", - ) - if settings.S2S_DEBUG_PROMPT: - app_logging.logger.info( - "[DEBUG-APIPILOT][WS connect] tools fetch detail toolsURL=%s executeURL=%s overrideExec=%s", - settings.S2S_APIPILOT_TOOLS_URL, - settings.S2S_APIPILOT_TOOLS_EXECUTE_URL, - providers_apipilot._apipilot_resolve_execute_url(), - ) - _t0 = providers_apipilot._monotonic_now() - apipilot_tools_pass1 = ( - await providers_apipilot._apipilot_fetch_tools( - auth_header=apipilot_auth_header, - demo_session=demo_session_key if isinstance(demo_session_key, str) else None, - refresh=False, - ) - or [] - ) - _el = providers_apipilot._monotonic_now() - _t0 - # Supply known phone/episode defaults to pass 1 so the model does - # not leave required arguments empty (observed ARGUMENTS=>>> bug). - _apipilot_defaults_p1: dict[str, str] = {} - if isinstance(inject_phone, str) and inject_phone.strip(): - _apipilot_defaults_p1["phone"] = inject_phone.strip() - if isinstance(inject_episode_id, str) and inject_episode_id.strip(): - _apipilot_defaults_p1["episode_id"] = inject_episode_id.strip() - # Also supply email when the resolved profile has a valid value. - if isinstance(profile_obj, dict) and profile_obj: - try: - u = profile_obj.get("user") if isinstance(profile_obj.get("user"), dict) else None - if isinstance(u, dict): - email = cast(str, u.get("email")) if isinstance(u.get("email"), str) else "" - if email.strip(): - _apipilot_defaults_p1["email"] = email.strip() - except Exception: # noqa: BLE001 - pass - session_ctx_apipilot_defaults = dict(_apipilot_defaults_p1) - injected_apipilot_pass1 = ( - services_tool_context._resolve_apipilot_reasoning_context_block( - tools_list=apipilot_tools_pass1, - context_defaults=_apipilot_defaults_p1, - ) - or "" - ) - if injected_apipilot_pass1.strip(): - app_logging.logger.info( - "[APIPILOT] REASONING PASS-1 (WS realtime): tool list ready N=%d t=%.0fms auth=%s defaults=%s", - len(apipilot_tools_pass1), - _el, - "Y" if isinstance(apipilot_auth_header, str) else "anon", - list(_apipilot_defaults_p1.keys()), - ) - app_logging.logger.info( - "[INJECT-APIPILOT] ✅ WS connect: APIPILOT TOOLS list PASS-1 READY Ntools=%d wrappedLen=%d defaultsPhone=%s -> will patch session.update frames in this tunnel.", - len(apipilot_tools_pass1), - len(injected_apipilot_pass1), - ("yes" if _apipilot_defaults_p1.get("phone") else "no"), - ) - else: - app_logging.logger.info( - "[INJECT-APIPILOT] ⏭️ WS connect: apipilot fetch returned empty tools list; PASS-1 injection skipped for this tunnel.", - ) - except Exception as exc: # noqa: BLE001 - app_logging.logger.info( - "[INJECT-APIPILOT] ⛔ WS connect: apipilot tools fetch failed. Skipping PASS-1 injection for tunnel. err=%r", - exc, - ) - injected_apipilot_pass1 = "" - apipilot_tools_pass1 = [] - session_ctx_apipilot_defaults = {} - # Preserve the exact discovery authentication and demo-session values. - # AquaPortal binds authorized tools to GET /tools' x-demo-session; a new - # ws-pass2 value would appear anonymous and produce a 403 response. - _ws_apipilot_auth_for_execute = apipilot_auth_header - _ws_apipilot_demo_for_execute = demo_session_key if isinstance(demo_session_key, str) else None - # Preserve API-key identity in every propagation variable, matching the - # discovery cache fingerprint and HTTP headers even during pass-2 fallback. - if settings.S2S_APIPILOT_API_KEY: - _ws_apipilot_auth_for_execute = f"Bearer {settings.S2S_APIPILOT_API_KEY}" - _ws_apipilot_demo_for_execute = settings.S2S_APIPILOT_API_KEY - else: - # With ApiPilot unconfigured, keep defaults as an empty dict rather than None. - session_ctx_apipilot_defaults = {} - injected_apipilot_pass1 = "" - apipilot_tools_pass1 = [] - _ws_apipilot_auth_for_execute = None - _ws_apipilot_demo_for_execute = None app_logging.logger.info( - "[INJECT-PROFILE] WS connect FINAL: injectEpisode=%s injectProfile=%s injectReports=%s injectSummary=%s injectApipilotPass1=%s(Ntools=%d) login=%s episodeIdLen=%d phoneLen=%d episodeCtxLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d apipilotCtxLen=%d target=%s", + "[INJECT-PROFILE] WS connect FINAL: injectEpisode=%s injectProfile=%s injectReports=%s injectSummary=%s login=%s episodeIdLen=%d phoneLen=%d episodeCtxLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d target=%s", bool(injected_episode_context), bool(injected_profile_context), bool(injected_reports_context), bool(injected_summary_context), - bool(injected_apipilot_pass1.strip()), - len(apipilot_tools_pass1), ("token-present" if login_token else "skipped"), len(inject_episode_id), len(inject_phone), @@ -381,7 +188,6 @@ async def realtime_proxy(ws: WebSocket) -> None: len(injected_profile_context), len(injected_reports_context), len(injected_summary_context), - len(injected_apipilot_pass1), upstream_url, ) if settings.S2S_DEBUG_PROMPT and injected_episode_context.strip(): @@ -415,11 +221,6 @@ async def realtime_proxy(ws: WebSocket) -> None: "injected_profile_context": injected_profile_context, "injected_reports_context": injected_reports_context, "injected_summary_context": injected_summary_context, - "injected_apipilot_pass1": injected_apipilot_pass1, - "inject_phone": inject_phone, - "inject_episode_id": inject_episode_id, - "login_token": login_token, - "login_response": login_response_obj, # 2026-09-11 proper-name anglicization guard: keep the full # upstream profile object so: # (a) _build_voice_rules_head_lock_block can emit rule #5 @@ -429,24 +230,6 @@ async def realtime_proxy(ws: WebSocket) -> None: # Alessandro with word-boundary regex even when the # prompt lock fails on GPTQ. "profile_obj": profile_obj, - "apipilot_tools_pass1": apipilot_tools_pass1, - # Accumulate transcripts per response_id and prevent pass-2 - # injection loops after ApiPilot execution. - "_ws_apipilot_asst_transcript_acc": {}, # response_id -> str full transcript - "_ws_apipilot_pass2_active": False, # True during the second response; prevents infinite marker loops. - "_ws_apipilot_pass2_count": 0, # Session execution count, limited to three attempts. - # Defaults prepared by pass 1 fill missing pass-2 arguments. - "apipilot_defaults": (session_ctx_apipilot_defaults if needs_apipilot else {}), - # Pass-2 phone fallback also works when defaults are empty but - # the query string supplied phoneNumber. - "inject_phone": (inject_phone if isinstance(inject_phone, str) else ""), # noqa: F601 - # Bearer for authenticated execution, resolved by phone login - # independently of S2S_INJECT_PROFILE. - "login_token": (login_token if isinstance(login_token, str) else None), # noqa: F601 - # Execute with the exact auth/demo values used for WS discovery; - # AquaPortal binds the authorized tool list to that demo session. - "apipilot_exec_auth": _ws_apipilot_auth_for_execute, - "apipilot_exec_demo": _ws_apipilot_demo_for_execute, } client_to_upstream = asyncio.create_task(_client_to_upstream(ws, upstream, client_ip, session_ctx)) upstream_to_client = asyncio.create_task(_upstream_to_client(ws, upstream, client_ip, session_ctx)) @@ -542,7 +325,6 @@ async def _client_to_upstream( ctx_profile_context = session_ctx.get("injected_profile_context") if isinstance(session_ctx, dict) else "" ctx_reports_context = session_ctx.get("injected_reports_context") if isinstance(session_ctx, dict) else "" ctx_summary_context = session_ctx.get("injected_summary_context") if isinstance(session_ctx, dict) else "" - ctx_apipilot_pass1 = session_ctx.get("injected_apipilot_pass1") if isinstance(session_ctx, dict) else "" ctx_profile_obj = session_ctx.get("profile_obj") if isinstance(session_ctx, dict) else None forward_text = services_context._patch_client_session_update_with_profile( message["text"], @@ -551,7 +333,6 @@ async def _client_to_upstream( ctx_summary_context or "", ctx_episode_context or "", profile_obj=ctx_profile_obj, - apipilot_context_text_pass1=ctx_apipilot_pass1 or "", ) await upstream.send(forward_text) elif message.get("bytes") is not None: @@ -576,38 +357,6 @@ async def _upstream_to_client( ) -> None: async for message in upstream: if isinstance(message, bytes): - # Drop audio only for the current response_id marked as containing - # a CALL marker: Qwen3 may already have spoken it. Matching any ID - # would also discard valid pass-2 audio. - drop_now = False - matched_rid = "" - drop_ids = session_ctx.get("_ws_drop_audio_for_response_ids") - if isinstance(drop_ids, dict) and drop_ids: - rid_ctx = session_ctx.get("_ws_current_response_id") - if isinstance(rid_ctx, str) and rid_ctx and drop_ids.get(rid_ctx): - drop_now = True - matched_rid = rid_ctx - elif (not isinstance(rid_ctx, str) or not rid_ctx) and len(drop_ids) == 1: - # Fallback when no current ID exists but exactly one response - # remains in the dictionary. - only_rid = next(iter(drop_ids.keys())) - if drop_ids.get(only_rid): - drop_now = True - matched_rid = only_rid - if drop_now: - app_logging.logger.info( - "[APIPILOT-AUDIO-DROP] FRAME DROP bytes=%dB matchedResponse=%s (CALL marker in this response → leaked Qwen3 audio DISCARDED)", - len(message), - (matched_rid or session_ctx.get("_ws_current_response_id") or "-"), - ) - continue - else: - app_logging.logger.debug( - "[APIPILOT-AUDIO-ALLOW] FRAME PASS bytes=%dB responseCtx=%s dropIds=%s", - len(message), - (session_ctx.get("_ws_current_response_id") or "-"), - (sorted(list(drop_ids.keys())) if isinstance(drop_ids, dict) else "none"), - ) # #region debug-point B:upstream-bytes-forward await realtime_events._report_server_debug( "B", @@ -623,535 +372,7 @@ async def _upstream_to_client( # #endregion await ws.send_bytes(message) else: - # Extract the original message before sanitation removes pass-2 markers. - pre_sanitize_summary = realtime_events._realtime_event_summary(message) - pre_event_type = pre_sanitize_summary.get("type") if isinstance(pre_sanitize_summary, dict) else None - pre_response_id: str | None = None - if isinstance(pre_sanitize_summary, dict): - if isinstance(pre_sanitize_summary.get("response_id"), str): - pre_response_id = pre_sanitize_summary["response_id"] - elif isinstance(pre_sanitize_summary.get("id"), str): - pre_response_id = pre_sanitize_summary["id"] - # Track the current response_id for the binary-audio gate. - if isinstance(pre_response_id, str) and pre_response_id: - session_ctx["_ws_current_response_id"] = pre_response_id - # Detect CALL markers in every text event, including before response.done. - # Mark this response_id to drop its audio and sanitize later events. - if ( - isinstance(pre_sanitize_summary, dict) - and isinstance(pre_event_type, str) - and pre_event_type not in {"response.done"} - ): - quick_text_parts: list[str] = [] - for _k in ( - "text", - "transcript", - "output_text", - "audio_text", - "output_transcript", - "final_transcript", - "output_audio_text", - "content", - "message", - "utterance", - "delta", - "_raw_preview", - ): - _v = pre_sanitize_summary.get(_k) - if isinstance(_v, str) and _v.strip(): - quick_text_parts.append(_v) - for _cname in ("item", "part", "output", "audio"): - _sub = pre_sanitize_summary.get(_cname) - if isinstance(_sub, dict): - for _k2 in ("text", "transcript", "output_text"): - _v2 = _sub.get(_k2) - if isinstance(_v2, str) and _v2.strip(): - quick_text_parts.append(_v2) - quick_text = (" ".join(quick_text_parts)).strip() - if quick_text: - _detected_name, _detected_args = services_tool_context._apipilot_parse_call_marker(quick_text) - if isinstance(_detected_name, str) and _detected_name: - drop_ids2 = session_ctx.get("_ws_drop_audio_for_response_ids") - if not isinstance(drop_ids2, dict): - drop_ids2 = {} - session_ctx["_ws_drop_audio_for_response_ids"] = drop_ids2 - _flag_rid = ( - pre_response_id - if isinstance(pre_response_id, str) and pre_response_id - else f"auto-{id(session_ctx)}" - ) - drop_ids2[_flag_rid] = True - # Remove older IDs when more than ten remain. - if len(drop_ids2) > 10: - try: - for _old_k in list(drop_ids2.keys())[:-10]: - drop_ids2.pop(_old_k, None) - except Exception: - pass - app_logging.logger.info( - "[APIPILOT-SANITIZE] CALL marker in event=%s response=%s name=%s → AUDIO DROP GATE enabled (discard potential leaked Qwen3 audio)", - pre_event_type, - _flag_rid, - _detected_name, - ) - # 1a) Accumulate original TTS transcripts for pass-2 marker parsing. - if ( - isinstance(pre_sanitize_summary, dict) - and isinstance(pre_event_type, str) - and pre_event_type in {"response.audio_transcript.done", "response.output_audio_transcript.done"} - ): - pre_agent_text = ( - cast(str, pre_sanitize_summary.get("text")) - if isinstance(pre_sanitize_summary.get("text"), str) - else "" - ).strip() - rid_key = ( - pre_response_id - if isinstance(pre_response_id, str) and pre_response_id - else f"auto-{id(session_ctx)}" - ) - if rid_key and pre_agent_text: - acc = session_ctx.get("_ws_apipilot_asst_transcript_acc") - if not isinstance(acc, dict): - acc = {} - session_ctx["_ws_apipilot_asst_transcript_acc"] = acc - prev = acc.get(rid_key, "") - if prev: - acc[rid_key] = f"{prev} {pre_agent_text}".strip() - else: - acc[rid_key] = pre_agent_text - # 1b) Also accumulate assistant conversation.item.created text, - # which can contain markers outside TTS transcripts. - elif ( - isinstance(pre_sanitize_summary, dict) - and isinstance(pre_event_type, str) - and pre_event_type == "conversation.item.created" - and pre_sanitize_summary.get("role") == "assistant" - ): - pre_conv_text = ( - cast(str, pre_sanitize_summary.get("text")) - if isinstance(pre_sanitize_summary.get("text"), str) - else "" - ).strip() - rid_key2 = ( - pre_response_id - if isinstance(pre_response_id, str) and pre_response_id - else f"auto-{id(session_ctx)}" - ) - if rid_key2 and pre_conv_text: - acc2 = session_ctx.get("_ws_apipilot_asst_transcript_acc") - if not isinstance(acc2, dict): - acc2 = {} - session_ctx["_ws_apipilot_asst_transcript_acc"] = acc2 - prev2 = acc2.get(rid_key2, "") - if prev2: - acc2[rid_key2] = f"{prev2} {pre_conv_text}".strip() - else: - acc2[rid_key2] = pre_conv_text - # 2) Parse the original marker at response.done to trigger pass 2. - if ( - isinstance(pre_sanitize_summary, dict) - and isinstance(pre_event_type, str) - and pre_event_type == "response.done" - ): - rid_val: str = ( - pre_response_id - if isinstance(pre_response_id, str) - else ( - cast(str, pre_sanitize_summary.get("id")) - if isinstance(pre_sanitize_summary.get("id"), str) - else f"auto-{id(session_ctx)}" - ) - ) - full_text = "" - acc3 = session_ctx.get("_ws_apipilot_asst_transcript_acc") - if rid_val and isinstance(acc3, dict): - full_text = (acc3.pop(rid_val, "") if isinstance(acc3, dict) else "") or "" - # Use the sole remaining accumulator when an upstream changes - # response_id between events. - if ( - (not isinstance(full_text, str) or not full_text.strip()) - and isinstance(acc3, dict) - and len(acc3) == 1 - ): - _only_key = next(iter(acc3.keys())) - full_text = (acc3.pop(_only_key, "") if isinstance(acc3, dict) else "") or "" - marker_name, marker_args = None, None - if isinstance(full_text, str) and full_text.strip(): - _p1_defaults = ( - session_ctx.get("apipilot_defaults") - if isinstance(session_ctx.get("apipilot_defaults"), dict) - else {} - ) - marker_name, marker_args = services_tool_context._apipilot_parse_call_marker( - full_text, defaults=_p1_defaults - ) - app_logging.logger.info( - "[APIPILOT-CALL-NAME] USE=WS_RESPONSE_DONE_DETECTION markerFound=%s name=%r argsKeys=%s", - ("Y" if isinstance(marker_name, str) and marker_name else "N"), - (marker_name or ""), - list((marker_args or {}).keys()), - ) - # Always log pass-2 marker detection diagnostics. - try: - _preview = full_text.replace("\n", " ") - if len(_preview) > 500: - _preview = _preview[:500] + "…" - except Exception: - _preview = str(full_text)[:500] - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 (WS realtime): response.done DIAGNOSTIC fullLen=%d markerFound=%s markerName=%s argsKeys=%s defaultsAutoFill=%s acc3Remaining=%d fullTextPreview=%r", - len(full_text or ""), - ("Y" if isinstance(marker_name, str) and marker_name else "N"), - (marker_name if isinstance(marker_name, str) and marker_name else ""), - (list(marker_args.keys()) if isinstance(marker_args, dict) else []), - (list(_p1_defaults.keys()) if isinstance(_p1_defaults, dict) and _p1_defaults else "none"), - (len(acc3) if isinstance(acc3, dict) else -1), - _preview, - ) - elif isinstance(full_text, str): - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 (WS realtime): response.done DIAGNOSTIC fullLen=0 EMPTY markerFound=N response_id=%r acc3Remaining=%d", - rid_val, - (len(acc3) if isinstance(acc3, dict) else -1), - ) - else: - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 (WS realtime): response.done DIAGNOSTIC fullText is not str type=%r acc3=%r", - type(full_text).__name__, - (list(acc3.keys()) if isinstance(acc3, dict) else "?"), - ) - needs_pass2 = ( - isinstance(marker_name, str) - and bool(marker_name) - and not bool(session_ctx.get("_ws_apipilot_pass2_active")) - and (int(session_ctx.get("_ws_apipilot_pass2_count") or 0) < 3) - ) - if needs_pass2: - # Clear active audio-drop gates before pass 2: its response - # contains actual user data and must be played normally. - drop_ids_clean = session_ctx.get("_ws_drop_audio_for_response_ids") - if isinstance(drop_ids_clean, dict) and drop_ids_clean: - drop_ids_clean.clear() - app_logging.logger.info( - "[APIPILOT-AUDIO-DROP] PASS-2 start: clear audio-drop gate → forward pass-2 briefing audio normally" - ) - args = marker_args if isinstance(marker_args, dict) else {} - # Fill missing arguments or replace fictional prompt example - # values with actual defaults. When defaults are empty, use - # session_ctx["inject_phone"] from the query string. - _p1_defaults_use = ( - session_ctx.get("apipilot_defaults") - if isinstance(session_ctx.get("apipilot_defaults"), dict) - else {} - ) - _fallback_inject_phone = ( - session_ctx.get("inject_phone") if isinstance(session_ctx.get("inject_phone"), str) else "" - ) - # Known fictional example values appearing in prompts. - _FAKE_PHONE_PLACEHOLDERS: set[str] = { - "+56912345678", # Prompt example from rule 7. - "56912345678", # Variant without +. - "+5612345678", - "+1234567890", - "+12345678", - "+56XXXXXXX", - "+56xxxxxxx", - "", - } - - def _is_phone_fake(v: str) -> bool: - c = str(v or "").strip() - if c in _FAKE_PHONE_PLACEHOLDERS: # noqa: B023 - return True - if len(c) < 7: - return True - if c.endswith("00000"): - return True - if "123456" in c or "xxxxxx" in c.lower() or "xxxx" in c.lower(): - return True - if c.startswith("+56") and len(c) <= 9: - return True - return False - - # Apply defaults and fallback to all available keys. - if isinstance(_p1_defaults_use, dict) and _p1_defaults_use: - for k, default_v in _p1_defaults_use.items(): - if default_v in (None, "", []): - continue - if k not in args or args[k] in (None, "", []): - args[k] = default_v - elif k == "phone" and isinstance(args.get("phone"), str): - if _is_phone_fake(str(args.get("phone"))): - args[k] = default_v - # Replace fictional phone values even when defaults lacks phone. - if ( - isinstance(args.get("phone"), str) - and _is_phone_fake(str(args.get("phone"))) - and isinstance(_fallback_inject_phone, str) - and _fallback_inject_phone.strip() - ): - args["phone"] = _fallback_inject_phone.strip() - # Refresh _p1_defaults for subsequent logging. - _p1_defaults = _p1_defaults_use - session_ctx["_ws_apipilot_pass2_count"] = int(session_ctx.get("_ws_apipilot_pass2_count") or 0) + 1 - session_ctx["_ws_apipilot_pass2_active"] = True - # Reuse exact discovery auth/demo values: AquaPortal binds - # authorized tools to the x-demo-session used for GET /tools. - _exec_auth = ( - session_ctx.get("apipilot_exec_auth") - if isinstance(session_ctx.get("apipilot_exec_auth"), str) - else None - ) - _exec_demo = ( - session_ctx.get("apipilot_exec_demo") - if isinstance(session_ctx.get("apipilot_exec_demo"), str) - else None - ) - # Compatibility fallback for missing values: - if not isinstance(_exec_auth, str) or not _exec_auth.strip(): - login_tok = session_ctx.get("login_token") - _exec_auth = f"Bearer {login_tok}" if isinstance(login_tok, str) and login_tok.strip() else None - if not isinstance(_exec_demo, str) or not _exec_demo.strip(): - _exec_demo = f"ws-pass2-{client_ip}-{id(session_ctx)}" - # The SUPER API KEY overrides even SERVICE_TOKEN. Force bearer - # and demo-session values to match the discovery bypass identity. - if settings.S2S_APIPILOT_API_KEY: - _exec_auth = f"Bearer {settings.S2S_APIPILOT_API_KEY}" - _exec_demo = settings.S2S_APIPILOT_API_KEY - # Apply the service-token override only without a SUPER API KEY. - elif settings.S2S_APIPILOT_SERVICE_TOKEN: - _svc = settings.S2S_APIPILOT_SERVICE_TOKEN.strip() - if _svc: - _exec_auth = f"Bearer {_svc}" if not _svc.lower().startswith("bearer ") else _svc - # For authenticated tools with an empty/short bearer, force - # phone login again so AquaPortal receives a valid user token. - # The length heuristic treats fewer than 80 characters as short. - _phone_lfb = ( - session_ctx.get("inject_phone") if isinstance(session_ctx.get("inject_phone"), str) else "" - ) or (inject_phone if isinstance(inject_phone, str) else "") # type: ignore[name-defined] # noqa: F821 - preserve the legacy fallback failure. - _phone_lfb = _phone_lfb.strip() if isinstance(_phone_lfb, str) else "" - _current_bearer_token = "" - if isinstance(_exec_auth, str) and _exec_auth.lower().startswith("bearer "): - _current_bearer_token = _exec_auth[len("bearer ") :].strip() - _is_jwt_like = ( - bool(_current_bearer_token) - and len(_current_bearer_token) >= 120 - and _current_bearer_token.count(".") >= 2 - ) - # Resolve requires_auth from recent session/cache tools; default true. - _tools_recent: list[dict] = ( - cast(list[dict], session_ctx.get("apipilot_tools_pass1")) - if isinstance(session_ctx.get("apipilot_tools_pass1"), list) - else [] - ) - if not _tools_recent: - try: - _cached = ( - providers_apipilot._APIPILOT_CACHE.get("tools") - if providers_apipilot._APIPILOT_CACHE - else None - ) - if isinstance(_cached, tuple) and _cached and isinstance(_cached[0], list): - _tools_recent = _cached[0] - except Exception: # noqa: BLE001 - _tools_recent = [] - _mtool = next( - (t for t in _tools_recent if isinstance(t, dict) and t.get("name") == marker_name), None - ) - _requires_auth = True if (_mtool is None) else bool(_mtool.get("requires_auth")) - if ( - (not settings.S2S_APIPILOT_API_KEY) - and settings.S2S_LOGIN_BY_PHONE - and _phone_lfb - and _requires_auth - and (not _is_jwt_like) - ): - try: - _l_tok, _l_obj = await providers_caller._login_by_phone(_phone_lfb) - if isinstance(_l_tok, str) and _l_tok.strip(): - _exec_auth = f"Bearer {_l_tok.strip()}" - _tok_len = len(_l_tok.strip()) - _is_new_jwt = _tok_len >= 120 and _l_tok.strip().count(".") >= 2 - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 (WS realtime): FORCED PASS-2 LOGIN (tool requires_auth) phoneLen=%d token=%s%s", - len(_phone_lfb), - f"YES len={_tok_len}", - (" JWT?" if _is_new_jwt else (" SHORT?" if _tok_len < 80 else " OK")), - ) - else: - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 (WS realtime): FORCED PASS-2 LOGIN token EXTRACTION FAILED phoneLen=%d (tool requires_auth → likely 403)", - len(_phone_lfb), - ) - except Exception as _lfb_exc: # noqa: BLE001 - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 (WS realtime): FORCED PASS-2 LOGIN failed phoneLen=%d err=%r", - len(_phone_lfb), - _lfb_exc, - ) - bearer = _exec_auth - demo_key = _exec_demo - try: - app_logging.logger.info( - "[APIPILOT-CALL-NAME] USE=WS_REALTIME_PASS2_TRIGGER name=%r argsKeys=%s argsPhone=%r", - marker_name, - list(args.keys()) if args else [], - (args.get("phone") if isinstance(args.get("phone"), str) else ""), - ) - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 (WS realtime): trigger marker name=%s argsKeys=%s argsPhone=%s auth=%s defaultsAutoFill=%s demoSessionKey=%.40s", - marker_name, - list(args.keys()) if args else [], - (args.get("phone") if isinstance(args.get("phone"), str) else ""), - "Y" if bearer else "anon", - list(_p1_defaults.keys()) if isinstance(_p1_defaults, dict) and _p1_defaults else "none", - demo_key if isinstance(demo_key, str) else "?", - ) - t0 = providers_apipilot._monotonic_now() - app_logging.logger.info( - "[APIPILOT-CALL-NAME] USE=WS_REALTIME_PASS2_EXEC_HELPER name=%r → forward a _apipilot_execute_tool", - marker_name, - ) - env_exec = providers_apipilot._apipilot_execute_tool( - auth_header=bearer, - demo_session=demo_key, - name=cast(str, marker_name), - arguments=args, - ) - if hasattr(asyncio, "create_task"): - envelope = await asyncio.wait_for(env_exec, timeout=60.0) - else: - envelope = await env_exec - t1 = providers_apipilot._monotonic_now() - t0 - ok = bool(isinstance(envelope, dict) and envelope.get("success")) - data = envelope.get("data") if isinstance(envelope, dict) else None - err_obj = ( - envelope.get("error") - if isinstance(envelope, dict) and isinstance(envelope.get("error"), dict) - else None - ) - if isinstance(data, str): - tool_text = data - elif isinstance(data, (dict, list)): - try: - tool_text = json.dumps(data, indent=2, ensure_ascii=False) - except Exception: # noqa: BLE001 - tool_text = str(data) - else: - tool_text = "" - if not ok: - code = ( - err_obj.get("code") - if isinstance(err_obj, dict) and err_obj.get("code") is not None - else (envelope.get("code") if isinstance(envelope, dict) else None) - ) - msg = ( - err_obj.get("message") - if isinstance(err_obj, dict) and isinstance(err_obj.get("message"), str) - else "" - ) - prefix = "" - if code is not None or msg: - prefix = f"[{code if code is not None else 'ERROR'}] {msg}".strip() + "\n" - tool_text = f"{prefix}{tool_text}".strip() - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 (WS realtime): execute DONE name=%s success=%s len=%d t=%.0fms", - marker_name, - "ok" if ok else "error", - len(tool_text), - t1, - ) - call_result_dict: dict[str, Any] = { - "name": marker_name, - "text": tool_text or "", - "error": (not ok), - } - # Use the light internal sanitizer for pass 2. The user-output - # sanitizer would remove the RESULT wrapper and prevent the - # upstream model from receiving the briefing result. - len_raw_tool_text = len(call_result_dict.get("text") or "") - if isinstance(call_result_dict.get("text"), str): - call_result_dict["text"] = services_tool_context._sanitize_pass2_internal_context( - call_result_dict["text"] - ) - len_sanitized_tool_text = len(call_result_dict.get("text") or "") - blk_pass2 = ( - services_tool_context._resolve_apipilot_reasoning_context_block( - None, - call_result=call_result_dict, - ) - or "" - ) - if not blk_pass2.strip(): - blk_pass2 = (tool_text or "").strip() - len_raw_wrapper = len(blk_pass2 or "") - # Light sanitation preserves the pass-2 RESULT wrapper. - if blk_pass2.strip(): - blk_pass2 = services_tool_context._sanitize_pass2_internal_context(blk_pass2) - len_sanitized_wrapper = len(blk_pass2 or "") - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 (WS realtime): block SIZES len_raw_tool_text=%d len_sanitized_tool_text=%d len_raw_wrapper=%d len_sanitized_wrapper=%d success=%s", - len_raw_tool_text, - len_sanitized_tool_text, - len_raw_wrapper, - len_sanitized_wrapper, - ("ok" if ok else "error"), - ) - if blk_pass2.strip(): - # Reset _ws_current_response_id before pass-2 injection so - # valid briefing audio cannot match the previous CALL response. - session_ctx["_ws_current_response_id"] = None - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 (WS realtime): inject PASS-2 block len=%d -> send conversation.item.create + response.create upstream", - len(blk_pass2), - ) - try: - await upstream.send( - json.dumps( - { - "type": "conversation.item.create", - "item": { - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": blk_pass2}], - }, - }, - ensure_ascii=False, - ) - ) - await upstream.send(json.dumps({"type": "response.create"}, ensure_ascii=False)) - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 (WS realtime): injection OK. Awaiting model response (pass2_active=Y until next response.done)", - ) - except Exception as exc2: # noqa: BLE001 - session_ctx["_ws_apipilot_pass2_active"] = False - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 (WS realtime): inject FAIL upstream send err=%r. pass2_active=N.", - exc2, - ) - else: - session_ctx["_ws_apipilot_pass2_active"] = False - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 (WS realtime): EMPTY result block -> skip injection. pass2_active=N.", - ) - except Exception as exc: # noqa: BLE001 - session_ctx["_ws_apipilot_pass2_active"] = False - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 (WS realtime): ABORT marker=%s err=%r. pass2_active=N (safety).", - marker_name, - exc, - ) - elif isinstance(session_ctx.get("_ws_apipilot_pass2_active"), bool) and bool( - session_ctx.get("_ws_apipilot_pass2_active") - ): - session_ctx["_ws_apipilot_pass2_active"] = False - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 (WS realtime): response.done PASS-2 FINALE -> pass2_active=N safety counter=%s", - int(session_ctx.get("_ws_apipilot_pass2_count") or 0), - ) - # Layer 7 post-processing: after marker handling, safely remove - # internal markers before forwarding text to the client. + # Remove internal context markers before forwarding text to the client. message, was_sanitized = realtime_events._sanitize_realtime_event_raw(message, session_ctx) # #region debug-point B:upstream-text-forward summary = realtime_events._realtime_event_summary(message) @@ -1184,8 +405,7 @@ async def _upstream_to_client( # Agent output, including all event variants. agent_text = "" if event_type in {"response.audio_transcript.done", "response.output_audio_transcript.done"}: - # Pass-2 accumulation already retained the original text. - # This sanitized agent_text is only for UI and logs. + # Use the sanitized agent text for UI and logs. agent_text = (summary.get("text") or "").strip() if agent_text: app_logging.logger.info( diff --git a/vocero-api/vocero_api/services/context.py b/vocero-api/vocero_api/services/context.py index 3bb603f..8835b25 100644 --- a/vocero-api/vocero_api/services/context.py +++ b/vocero-api/vocero_api/services/context.py @@ -6,7 +6,6 @@ from typing import Any, cast from vocero_api.core import config as settings from vocero_api.core import logging as app_logging -from vocero_api.services import tool_context as services_tool_context VOCERO_USER_CONTEXT_HINT = " Caller profile resolved from the page phoneNumber: " REPORTS_LIST_HINT = ( @@ -334,8 +333,6 @@ _CONTEXT_DEDUPE_MARKERS: tuple = ( ("summary_full", SUMMARY_FULL_MARKER), ("today_fixed", TODAY_BLOCK_MARKER), ("reports_minimal", MINIMAL_REPORTS_MARKER), - ("apipilot_tools_list", services_tool_context.APIPILOT_PASS1_LIST_MARKER), - ("apipilot_result", services_tool_context.APIPILOT_PASS2_RESULT_MARKER), ) @@ -971,25 +968,19 @@ def _patch_client_session_update_with_profile( summary_context_text: str | None = None, episode_context_text: str | None = None, profile_obj: Any = None, - apipilot_context_text_pass1: str | None = None, ) -> str: """If raw_text is a client session.update JSON and S2S_INJECT_PROFILE is true, append episode_context_text + user_context_text + (optional) reports_context_text + - (optional) summary_context_text + (optional) apipilot_context_text_pass1 to session.instructions. + (optional) summary_context_text to session.instructions. Returns the modified JSON string, or the original string unchanged if: - it is not session.update JSON (the VAST majority of frames -> silent passthrough, NO LOG) - - episode_context_text AND user_context_text AND reports_context_text AND summary_context_text AND apipilot_context_text_pass1 are all empty - - S2S_INJECT_PROFILE is False (but if we have apipilot_context_text_pass1 -> PATCH even if S2S_INJECT_PROFILE=false, tools injection is independent.) + - episode_context_text AND user_context_text AND reports_context_text AND summary_context_text are all empty + - S2S_INJECT_PROFILE is False - parse fails 2026-09-11: NEW keyword profile_obj. Forwarded to _augment_instructions_with_profile so the prompt lock can emit the proper-name anti-anglicization rule for this caller (e.g. Alessandro never Alexander). - - 2026-09-25: NEW keyword apipilot_context_text_pass1. Injected IN ADDITION so WS - Realtime LLM sees the tools list match description / marker rules (same as the - /api/reasoning HTTP endpoint). Independent gate: even S2S_INJECT_PROFILE=false, - if apipilot_context_text_pass1 has content -> PATCH instructions. """ # Step 1: only consider session.update frames. All other frame types # (response.create, conversation.item.*, audio_* etc.) pass through @@ -1005,10 +996,9 @@ def _patch_client_session_update_with_profile( # From here on: we know this is a real session.update (typically emitted # just once or twice per realtime session). We can log safely — 1 line, # not 20 repeated lines. - has_apipilot_pass1 = bool((apipilot_context_text_pass1 or "").strip()) - if not settings.S2S_INJECT_PROFILE and not has_apipilot_pass1: + if not settings.S2S_INJECT_PROFILE: app_logging.logger.info( - "[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (S2S_INJECT_PROFILE=false and no apipilot list pass1). " + "[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (S2S_INJECT_PROFILE=false). " "WS stays pure passthrough." ) return raw_text @@ -1016,9 +1006,9 @@ def _patch_client_session_update_with_profile( has_profile = bool((user_context_text or "").strip()) has_reports = bool((reports_context_text or "").strip()) has_summary = bool((summary_context_text or "").strip()) - if not (has_episode or has_profile or has_reports or has_summary or has_apipilot_pass1): + if not (has_episode or has_profile or has_reports or has_summary): app_logging.logger.info( - "[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (no episode, no profile context, no reports list, no latest summary, no apipilot tools list pass1). " + "[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (no episode, no profile context, no reports list, no latest summary). " "Probably phoneNumber/episode_id query param missing / fetch failed upstream / " "endpoints or S2S_LATEST_SUMMARY / S2S_REPORT_LIST returned empty result." ) @@ -1038,12 +1028,6 @@ def _patch_client_session_update_with_profile( "[INJECT-REPORTS] profile missing, but REPORTS LIST block loaded. " "Will inject reports alone into session.update." ) - if has_apipilot_pass1 and not has_profile and not has_episode and not has_reports and not has_summary: - app_logging.logger.info( - "[INJECT-APIPILOT] profile/episode/reports/summary missing, but APIPILOT TOOLS list PASS-1 block loaded (%d chars). " - "Will inject tools list alone into session.update.", - len(apipilot_context_text_pass1) if isinstance(apipilot_context_text_pass1, str) else 0, - ) top_session = payload.get("session") if not isinstance(top_session, dict): return raw_text @@ -1064,22 +1048,10 @@ def _patch_client_session_update_with_profile( len(current_instructions), current_instructions, ) - # Append ApiPilot pass-1 tools and marker rules after profile/report/episode/ - # summary context, matching /api/reasoning. If a second client session.update - # overwrites instructions without the tool-list marker, append the list again. - already_has_apipilot_pass1 = ( - isinstance(current_instructions, str) - and services_tool_context.APIPILOT_PASS1_LIST_MARKER in current_instructions - ) - if has_apipilot_pass1 and not already_has_apipilot_pass1: - augmented = (augmented + "\n\n" if augmented.strip() else "") + ( - apipilot_context_text_pass1 if isinstance(apipilot_context_text_pass1, str) else "" - ) - # Do not skip a tool-list repair even if profile augmentation was unchanged. - if augmented == current_instructions and not (has_apipilot_pass1 and not already_has_apipilot_pass1): + if augmented == current_instructions: app_logging.logger.info( "[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (augmented == current; " - "episode/profile already injected or context empty or apipilot same)" + "episode/profile already injected or context empty)" ) if settings.S2S_DEBUG_PROMPT and augmented.strip(): app_logging.logger.info( @@ -1091,7 +1063,7 @@ def _patch_client_session_update_with_profile( top_session["instructions"] = augmented app_logging.logger.info( "[INJECT-PROFILE] ✅ WS session.update PATCHED -> upstream instructions. " - "origLen=%d newLen=%d injectedLen=%d episode=%s profile=%s reports=%s summary=%s apipilotPass1=%s(len=%d, wasAlreadyPresent=%s)", + "origLen=%d newLen=%d injectedLen=%d episode=%s profile=%s reports=%s summary=%s", len(current_instructions), len(augmented), (len(augmented) - len(current_instructions)), @@ -1099,9 +1071,6 @@ def _patch_client_session_update_with_profile( has_profile, has_reports, has_summary, - has_apipilot_pass1, - (len(apipilot_context_text_pass1) if isinstance(apipilot_context_text_pass1, str) else 0), - ("true" if already_has_apipilot_pass1 else "false"), ) if settings.S2S_DEBUG_PROMPT: app_logging.logger.info( @@ -1121,25 +1090,6 @@ def _patch_client_session_update_with_profile( len(reports_context_text.strip()), reports_context_text.strip(), ) - if ( - settings.S2S_DEBUG_PROMPT - and isinstance(apipilot_context_text_pass1, str) - and apipilot_context_text_pass1.strip() - ): - app_logging.logger.info( - "[DEBUG-APIPILOT][WS session.update] FULL ApiPilot tools list PASS-1 block (len=%d):\n%s\n", - len(apipilot_context_text_pass1.strip()), - _strip_internal_markers(apipilot_context_text_pass1), - ) - # Also log the FINAL upstream instructions when S2S_DEBUG_PROMPT is on and - # this is a session.update (so we see the EXACT text reaching the model even - # when S2S_INJECT_PROFILE is off and no patch happened.) - if (not settings.S2S_INJECT_PROFILE) and settings.S2S_DEBUG_PROMPT and current_instructions.strip(): - app_logging.logger.info( - "[DEBUG-PROMPT][WS session.update] FINAL upstream instructions (injectProfile=false, len=%d):\n%s\n", - len(current_instructions), - current_instructions, - ) try: return json.dumps(payload) except Exception: diff --git a/vocero-api/vocero_api/services/mcp_tools.py b/vocero-api/vocero_api/services/mcp_tools.py index 48abcca..b45ad7d 100644 --- a/vocero-api/vocero_api/services/mcp_tools.py +++ b/vocero-api/vocero_api/services/mcp_tools.py @@ -25,7 +25,7 @@ _CALL = re.compile(r"<<>>", def selection(text: str) -> tuple[str, dict[str, Any]] | None: - """Use a marker that survives the speech pipeline's ApiPilot sanitizer.""" + """Parse the internal tool selection marker returned by the speech model.""" if "VOCERO_MCP" not in text.upper() and not text.lstrip().startswith("<<<"): return None match = _CALL.fullmatch(text.strip()) diff --git a/vocero-api/vocero_api/services/reasoning.py b/vocero-api/vocero_api/services/reasoning.py index 996fd02..e65674d 100644 --- a/vocero-api/vocero_api/services/reasoning.py +++ b/vocero-api/vocero_api/services/reasoning.py @@ -11,11 +11,9 @@ from vocero_api.core import config as settings from vocero_api.core import logging as app_logging from vocero_api.models import requests as models_requests from vocero_api.models.responses import ReasoningResponse -from vocero_api.providers import apipilot as providers_apipilot from vocero_api.providers import caller as providers_caller from vocero_api.services import context as services_context from vocero_api.services import mcp_reasoning -from vocero_api.services import tool_context as services_tool_context def _openai_headers() -> dict[str, str]: @@ -182,45 +180,6 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) -> app_logging.logger.info( "[INJECT-SUMMARY] ⏭️ REASONING: latest summary skipped (context empty / upstream returned empty summary / fetch failed.)" ) - # Load dynamic ApiPilot tools before building context, independently of - # S2S_INJECT_PROFILE. - needs_apipilot = bool(settings.S2S_APIPILOT_TOOLS_URL) or bool(settings.S2S_APIPILOT_TOOLS_EXECUTE_URL) - apipilot_tools_pass1: list[dict] = [] - apipilot_auth_header: str | None = None - if isinstance(login_token, str) and login_token.strip(): - tk = login_token.strip() - if tk.lower().startswith("bearer "): - apipilot_auth_header = tk - else: - apipilot_auth_header = f"Bearer {tk}" - # S2S_LOGIN_BY_PHONE resolves an AquaPortal JWT, used for requires_auth=true - # execution. S2S_APIPILOT_SERVICE_TOKEN overrides it when configured. - if settings.S2S_APIPILOT_SERVICE_TOKEN: - _svc = settings.S2S_APIPILOT_SERVICE_TOKEN.strip() - if _svc: - apipilot_auth_header = f"Bearer {_svc}" if not _svc.lower().startswith("bearer ") else _svc - if needs_apipilot: - try: - apipilot_t0 = providers_apipilot._monotonic_now() - apipilot_tools_pass1 = await providers_apipilot._apipilot_fetch_tools( - auth_header=apipilot_auth_header, - demo_session=None, - force_refresh=False, - ) - app_logging.logger.info( - "[APIPILOT] REASONING PASS-1: tool list ready N=%d t=%dms auth=%s", - len(apipilot_tools_pass1), - int((providers_apipilot._monotonic_now() - apipilot_t0) * 1000), - "Y" if apipilot_auth_header else "-", - ) - except Exception as exc: - app_logging.logger.warning("[APIPILOT] REASONING: tool-list fetch failed, ignoring: %s", exc) - apipilot_tools_pass1 = [] - inject_apipilot_text_pass1 = services_tool_context._resolve_apipilot_reasoning_context_block( - apipilot_tools_pass1, - call_result=None, - ) - user_context_parts: list[str] = [] # ─── USER FIX BUG 3: TODAY fixed context block → PREPEND to user_context too # (the same anti-hallucination block gets prefixed in the SYSTEM instructions @@ -242,8 +201,6 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) -> user_context_parts.append(inject_reports_text) if inject_summary_text: user_context_parts.append(inject_summary_text) - if inject_apipilot_text_pass1: - user_context_parts.append(inject_apipilot_text_pass1) original_user_context = (req.user_context or "").strip() if original_user_context: user_context_parts.append(original_user_context) @@ -334,18 +291,6 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) -> len(inject_profile_text), services_context._strip_internal_markers(inject_profile_text), ) - if settings.S2S_DEBUG_PROMPT and inject_apipilot_text_pass1.strip(): - app_logging.logger.info( - "[DEBUG-APIPILOT][REASONING augment] FULL ApiPilot tools list block (len=%d):\n%s\n", - len(inject_apipilot_text_pass1), - services_context._strip_internal_markers(inject_apipilot_text_pass1), - ) - if needs_apipilot and inject_apipilot_text_pass1.strip(): - app_logging.logger.info( - "[INJECT-APIPILOT] REASONING PASS-1: inject tool list (len=%d, Ntools=%d) into instructions and user_context.", - len(inject_apipilot_text_pass1), - len(apipilot_tools_pass1), - ) effective_instructions = services_context._augment_instructions_with_profile( req.instructions or "", inject_profile_text, @@ -354,28 +299,8 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) -> inject_episode_text, profile_obj=profile_obj, ) - if inject_apipilot_text_pass1.strip(): - effective_instructions = ( - effective_instructions + "\n\n" if effective_instructions else "" - ) + inject_apipilot_text_pass1 else: effective_instructions = (req.instructions or "").strip() - if inject_apipilot_text_pass1.strip(): - app_logging.logger.info( - "[INJECT-APIPILOT] REASONING PASS-1: inject tool list (len=%d, Ntools=%d) into instructions (inject_profile=false).", - len(inject_apipilot_text_pass1), - len(apipilot_tools_pass1), - ) - effective_instructions = ( - effective_instructions + "\n\n" if effective_instructions else "" - ) + inject_apipilot_text_pass1 - if settings.S2S_DEBUG_PROMPT and inject_apipilot_text_pass1.strip(): - app_logging.logger.info( - "[DEBUG-APIPILOT][REASONING augment] FULL ApiPilot tools list block (len=%d):\n%s\n", - len(inject_apipilot_text_pass1), - services_context._strip_internal_markers(inject_apipilot_text_pass1), - ) - # Call the OpenAI-compatible Chat Completions provider. if not settings.OPENAI_API_KEY: raise HTTPException(status_code=503, detail="OPENAI_API_KEY no está configurado.") @@ -385,8 +310,6 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) -> system_parts.append(instructions) if effective_user_context.strip(): system_parts.append(f"User context:\n{effective_user_context.strip()}") - if inject_apipilot_text_pass1: - system_parts.append(inject_apipilot_text_pass1) if req.language.strip(): system_parts.append( "Reply only with the final assistant answer text. " @@ -474,183 +397,4 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) -> ) raise HTTPException(status_code=502, detail="El servicio de razonamiento de OpenAI no devolvió una respuesta.") app_logging.logger.info("[Vocero REASONING] engine=OPENAI transcript=%s answer=%s", transcript[:300], answer[:500]) - # Capture the ApiPilot marker, execute its tool and run OpenAI pass 2. - tool_name_p1_oai, tool_args_p1_oai = services_tool_context._apipilot_parse_call_marker(answer) - if tool_name_p1_oai: - app_logging.logger.info( - "[APIPILOT-CALL-NAME] USE=OPENAI_PASS2_TRIGGER name=%r argsKeys=%s", - tool_name_p1_oai, - sorted(list((tool_args_p1_oai or {}).keys())), - ) - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 trigger OPENAI marker name=%s args=%s", - tool_name_p1_oai, - json.dumps(tool_args_p1_oai or {}, ensure_ascii=True)[:300], - ) - app_logging.logger.info( - "[APIPILOT-CALL-NAME] USE=OPENAI_PASS2_EXEC_HELPER name=%r → forward to _apipilot_execute_tool", - tool_name_p1_oai, - ) - tool_env_oai_p1 = await providers_apipilot._apipilot_execute_tool( - auth_header=apipilot_auth_header, - demo_session=None, - name=tool_name_p1_oai, - arguments=tool_args_p1_oai or {}, - ) - is_tool_err_oai = not bool(tool_env_oai_p1.get("success")) - tool_data_oai = tool_env_oai_p1.get("data") - if isinstance(tool_data_oai, str): - tool_text_oai = tool_data_oai - elif tool_data_oai is None: - tool_text_oai = "" - else: - try: - tool_text_oai = json.dumps(tool_data_oai, ensure_ascii=False, indent=2) - except Exception: - tool_text_oai = str(tool_data_oai) - if is_tool_err_oai and isinstance(tool_env_oai_p1.get("error"), dict): - err_oai = tool_env_oai_p1["error"] - err_oai_head = f"[{err_oai.get('code')}] {err_oai.get('message')}" - tool_text_oai = (err_oai_head + ("\n" + tool_text_oai if tool_text_oai.strip() else "")).strip() - inject_apipilot_pass2_oai = services_tool_context._resolve_apipilot_reasoning_context_block( - [], - call_result={ - "name": tool_name_p1_oai, - "text": tool_text_oai, - "error": is_tool_err_oai, - }, - ) - app_logging.logger.info( - "[APIPILOT] REASONING PASS-2 OPENAI execute name=%s success=%s resultLen=%d → second chat/completions call", - tool_name_p1_oai, - str(not is_tool_err_oai).lower(), - len(tool_text_oai), - ) - # Build pass 2 with the same instructions/context structure, replacing - # inject_apipilot_text_pass1 with the result block inject_apipilot_pass2_oai. - user_ctx_oai_2: list[str] = [] - if voice_lock_block.strip(): - user_ctx_oai_2.append(voice_lock_block) - if today_block.strip(): - user_ctx_oai_2.append(today_block) - if inject_episode_text: - user_ctx_oai_2.append(inject_episode_text) - if inject_profile_text: - user_ctx_oai_2.append(inject_profile_text) - if inject_reports_text: - user_ctx_oai_2.append(inject_reports_text) - if inject_summary_text: - user_ctx_oai_2.append(inject_summary_text) - if inject_apipilot_pass2_oai: - user_ctx_oai_2.append(inject_apipilot_pass2_oai) - if original_user_context: - user_ctx_oai_2.append(original_user_context) - user_ctx_oai_2 = services_context._dedup_context_blocks_by_type(user_ctx_oai_2) - stripped_oai_2: list[str] = [] - for p in user_ctx_oai_2: - sp = services_context._strip_internal_markers(p) - if sp: - stripped_oai_2.append(sp) - user_ctx_oai_2 = stripped_oai_2 - eff_user_ctx_oai_2 = "\n\n".join(user_ctx_oai_2) - if settings.S2S_INJECT_PROFILE: - eff_inst_oai_2 = services_context._augment_instructions_with_profile( - req.instructions or "", - inject_profile_text, - inject_reports_text, - inject_summary_text, - inject_episode_text, - profile_obj=profile_obj, - ) - else: - eff_inst_oai_2 = (req.instructions or "").strip() - sys_parts_oai_2: list[str] = [] - instr_oai_2 = eff_inst_oai_2 or ((req.instructions or "").strip()) - if instr_oai_2: - sys_parts_oai_2.append(instr_oai_2) - if eff_user_ctx_oai_2.strip(): - sys_parts_oai_2.append(f"User context:\n{eff_user_ctx_oai_2.strip()}") - if inject_apipilot_pass2_oai: - sys_parts_oai_2.append(inject_apipilot_pass2_oai) - if req.language.strip(): - sys_parts_oai_2.append( - "Reply only with the final assistant answer text. " - f"Keep the answer natural and concise in this language: {req.language.strip()}." - ) - else: - sys_parts_oai_2.append( - "Reply only with the final assistant answer text. Keep the answer natural and concise." - ) - system_msg_oai_2 = "\n\n".join(sys_parts_oai_2) - body_oai_2 = { - "model": settings.OPENAI_REASONING_MODEL, - "messages": [ - {"role": "system", "content": system_msg_oai_2}, - {"role": "user", "content": transcript}, - ], - } - if settings.S2S_DEBUG_PROMPT: - app_logging.logger.info( - "[DEBUG-PROMPT][REASONING → OPENAI PASS-2] FULL SYSTEM message (len=%d):\n%s\n", - len(system_msg_oai_2), - system_msg_oai_2, - ) - try: - async with httpx.AsyncClient(timeout=30.0) as http_oai2: - resp_oai_2 = await http_oai2.post( - f"{settings.OPENAI_BASE_URL}/chat/completions", - headers=_openai_headers(), - json=body_oai_2, - ) - except httpx.RequestError as exc: - app_logging.logger.warning("[EXT][OPENAI PASS-2] unreachable: %r", exc) - raise HTTPException( # noqa: B904 - status_code=502, - detail="No se puede conectar con el servicio de razonamiento de OpenAI (segunda pasada).", - ) - if resp_oai_2.status_code != 200: - app_logging.logger.warning( - "[EXT][OPENAI PASS-2] failed %s: %s", resp_oai_2.status_code, resp_oai_2.text[:400] - ) - raise HTTPException( - status_code=502, - detail=f"La segunda pasada del razonamiento de OpenAI falló ({resp_oai_2.status_code}).", - ) - try: - payload_oai_2 = resp_oai_2.json() - except Exception: - app_logging.logger.warning("[EXT][OPENAI PASS-2] invalid JSON: %s", resp_oai_2.text[:400]) - raise HTTPException( # noqa: B904 - status_code=502, - detail="El servicio de razonamiento de OpenAI devolvió un JSON no válido (segunda pasada).", - ) - final_oai_ans = "" - choices_oai_2 = payload_oai_2.get("choices") - if isinstance(choices_oai_2, list) and choices_oai_2: - msg_oai_2 = choices_oai_2[0].get("message") if isinstance(choices_oai_2[0], dict) else None - if isinstance(msg_oai_2, dict): - content_oai_2 = msg_oai_2.get("content") - if isinstance(content_oai_2, str): - final_oai_ans = content_oai_2.strip() - usage_oai_2 = payload_oai_2.get("usage") - if not final_oai_ans: - # Last-resort fallback: raw tool-result text. - app_logging.logger.warning("[EXT][OPENAI PASS-2] empty answer, falling back to raw tool-result text") - final_oai_ans = (tool_text_oai or answer).strip() - final_out_oai: dict = {"answer": final_oai_ans} - if usage_oai_2: - final_out_oai["usage_pass2"] = usage_oai_2 - final_out_oai["apipilot_invoked"] = { - "name": tool_name_p1_oai, - "success": not is_tool_err_oai, - "result_length": len(tool_text_oai), - } - app_logging.logger.info( - "[Vocero REASONING] engine=OPENAI PASS-2 transcript=%s finalAnswer=%s apipilot=%s", - transcript[:300], - final_oai_ans[:500], - json.dumps(final_out_oai["apipilot_invoked"], ensure_ascii=True), - ) - return validated_json_response(final_out_oai, ReasoningResponse) - # Without a marker, return the original pass-1 answer. return validated_json_response({"answer": answer}, ReasoningResponse) diff --git a/vocero-api/vocero_api/services/tool_context.py b/vocero-api/vocero_api/services/tool_context.py deleted file mode 100644 index 3011332..0000000 --- a/vocero-api/vocero_api/services/tool_context.py +++ /dev/null @@ -1,629 +0,0 @@ -from __future__ import annotations - -import json -import re -from typing import cast - -from vocero_api.core import logging as app_logging - -# --------------------------------------------------------------------------- -# APIPILOT — Reasoning engine integration (two passes) -# --------------------------------------------------------------------------- -# Deduplication/start/end markers used by _classify_context_block to avoid -# injecting a block already supplied by the client or found in base_instructions. -APIPILOT_PASS1_LIST_MARKER = "<<>>" -APIPILOT_PASS2_RESULT_MARKER = "<<]{1,3}", - re.IGNORECASE | re.DOTALL, -) -# 2026-09-25: observed inverted marker (incorrect LLM syntax): -# >>>APIPILOT_CALL:NAME=tool,ARGUMENTS=json<<< -_APIPILOT_CALL_MARKER_INVERTED_RE = re.compile( - r"[>]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[<]{1,3}", - re.IGNORECASE | re.DOTALL, -) - - -def _apipilot_parse_call_marker(answer_text: str, *, defaults: dict | None = None) -> tuple[str | None, dict | None]: - """Parse the mandatory CALL marker emitted by the LLM in pass 1. - - Returns (tool_name, arguments_dict), or (None, None) when no marker exists. - - Supports: - (1) ARGUMENTS={"key":"val"}, valid JSON. - (2) ARGUMENTS={}, a valid empty object. - (3) ARGUMENTS= without braces, observed on realtime WebSocket sessions. - (4) key=val,key2=val2 strings through a manual parsing fallback. - (5) After parsing, fill missing required arguments from defaults such as - phone and episode_id. - """ - if not isinstance(answer_text, str) or not answer_text.strip(): - app_logging.logger.info("[APIPILOT-CALL-NAME] USE=PARSE_EMPTY_INPUT name=None markerFound=N") - return None, None - m = _APIPILOT_CALL_MARKER_RE.search(answer_text.strip()) - inverted = False - if not m: - m = _APIPILOT_CALL_MARKER_INVERTED_RE.search(answer_text.strip()) - inverted = bool(m) - if not m: - app_logging.logger.info( - "[APIPILOT-CALL-NAME] USE=PARSE_NO_MATCH name=None markerFound=N textHead=%r", answer_text.strip()[:80] - ) - return None, None - name_raw = m.group(1).strip() - args_raw = (m.group(2) if m.lastindex and m.lastindex >= 2 and isinstance(m.group(2), str) else "").strip() - if not name_raw: - app_logging.logger.info( - "[APIPILOT-CALL-NAME] USE=PARSE_EMPTY_NAME name=None markerFound=Y argsRawHead=%r inverted=%s", - args_raw[:60], - ("Y" if inverted else "N"), - ) - return None, None - app_logging.logger.info( - "[APIPILOT-CALL-NAME] USE=PARSE_MARKER_OK name=%r argsRawLen=%d markerFound=Y inverted=%s", - name_raw, - len(args_raw), - ("Y" if inverted else "N"), - ) - args: dict = {} - # Parse args_raw into a dict, tolerating common formatting variations. - if args_raw == "" or args_raw in {"{}", "null", "None"}: - args = {} - else: - # Attempt 1: standard JSON, with or without braces. - _candidate = args_raw - try: - args = json.loads(_candidate) - except Exception: - try: - # Attempt 2: supply missing outer braces. - args = json.loads("{" + _candidate + "}") - except Exception: - try: - # Attempt 3: replace single quotes with double quotes. - args = json.loads(_candidate.replace("'", '"')) - except Exception: - try: - # Attempt 4: supply braces and replace single quotes. - args = json.loads("{" + _candidate.replace("'", '"') + "}") - except Exception: - # Manual fallback 5: equals signs and commas. - # Example: args_raw="phone=+56,name=Mario" → dict. - args = {} - for piece in args_raw.split(","): - kv = piece.strip() - if not kv or "=" not in kv: - continue - k, v = kv.split("=", 1) - k = k.strip().strip('"').strip("'") - v = v.strip().strip('"').strip("'") - if k: - args[k] = v - if not isinstance(args, dict): - args = {} - # Stage 2: fill missing arguments from defaults (phone, etc.). - if isinstance(defaults, dict) and defaults: - for dk, dv in defaults.items(): - if dk not in args or args[dk] in (None, "", []): - if dv not in (None, "", []): - args[dk] = dv - app_logging.logger.info( - "[APIPILOT-CALL-NAME] USE=PARSE_RETURN_FINAL name=%r finalArgsKeys=%s defaultsApplied=%s", - name_raw, - sorted(list(args.keys())), - ("Y" if isinstance(defaults, dict) and defaults else "N"), - ) - return name_raw, args - - -def _resolve_apipilot_reasoning_context_block( - tools_list: list[dict] | None, - *, - call_result: dict | None = None, - context_defaults: dict | None = None, -) -> str: - """Build the ApiPilot context block for the reasoning prompt. - - With call_result=None, pass 1 supplies the tool list and marker syntax - the model must emit when a description matches. - - With call_result={'name':str, 'text':str, 'error':bool}, pass 2 injects the - execution result and requests a final spoken/text response for the user. - - The optional context_defaults dict adds known default values to pass 1. - The model must use these exact values for required arguments rather than - inventing them. Usually contains {"phone": "+56..."} from inject_phone. - """ - # Pass 2: the tool result is already available. - if isinstance(call_result, dict) and call_result.get("name"): - name = call_result.get("name") or "?" - text = (call_result.get("text") if isinstance(call_result.get("text"), str) else "") or "" - is_err = bool(call_result.get("error")) - status_label = "ERROR" if is_err else "OK" - title = f"<<>>" - trailer = ( - "Now reinterpret THE RESULT TEXT (above) in clear, concise natural language, " - "exclusively in the user's language (language field; Spanish by default). " - "Do not mention the internal tool or technical details. " - "Respond ONLY with the final content intended for the user." - ) - return ( - f"{APIPILOT_PASS2_RESULT_MARKER}PASS2>>>\n" - "### Invoked ApiPilot tool result\n" - f"{title}\n" - f"\n```tool-output\n" - f"{text.rstrip() if text.strip() else '(empty output)'}\n" - f"```\n\n" - f"{trailer}\n" - ) - # Pass 1: available tools and marker rules. - tools = tools_list if isinstance(tools_list, list) else [] - # Limit to 100 tools, although the guide specifies at most 50 for the demo. - tools_cap = tools[:100] - if not tools_cap: - return "" - entries: list[str] = [] - for t in tools_cap: - if not isinstance(t, dict): - continue - name = t.get("name") if isinstance(t.get("name"), str) else "" - if not name: - continue - desc = cast(str, t.get("description")) if isinstance(t.get("description"), str) else "" - params = t.get("parameters") if isinstance(t.get("parameters"), dict) else None - requires_auth = bool(t.get("requires_auth")) - required_fields: list[str] = [] - if isinstance(params, dict) and isinstance(params.get("required"), list): - required_fields = [str(x) for x in cast(list, params.get("required")) if isinstance(x, str)] - # Limit descriptions to about 500 characters to control prompt size. - desc_cut = desc[:500].rstrip() - if len(desc) > 500: - desc_cut += "…" - snippet = f"- name: {name}\n requires_auth: {('true' if requires_auth else 'false')}" - if required_fields: - snippet += f"\n required_parameters: [{', '.join(required_fields[:40])}]" - if desc_cut: - snippet += f"\n description: {desc_cut}" - entries.append(snippet) - if not entries: - return "" - rule = ( - f"{APIPILOT_PASS1_LIST_MARKER}\n" - "### Available dynamic tools (ApiPilot AquaPortal)\n" - "\n" - "Each tool has a `name` field (a unique identifier: do NOT modify it, " - "use camelCase, or translate it) and a `description` describing its use case.\n" - "\n" - "MANDATORY EXECUTION RULES:\n" - "1. If the USER REQUEST (transcript) clearly matches the `description` of " - "EXACTLY one tool below, emit ONLY one line as the FIRST line of YOUR RESPONSE, " - "with this exact syntax:\n" - ' <<>>\n' - "2. ARGUMENTS={…} MUST be valid JSON (double quotes, commas, no comments). " - "Include ONLY fields listed in `required_parameters:`, if present.\n" - "3. Add NO text after the marker line: no explanations or previews. The engine " - "will execute the tool and supply its result in a second call.\n" - "4. If NO description matches, emit NO marker and respond normally in Spanish.\n" - "5. If multiple descriptions match, choose the most specific one and use the marker.\n" - "6. 🔴 EXCLUSIVE RESPONSE MODE (HIGHEST priority, mandatory): when using a CALL " - "marker, YOUR ENTIRE RESPONSE MUST CONTAIN ONLY THE MARKER LINE. " - "Write NOTHING BEFORE it: no 'Claro que sí', 'Voy a revisar', " - "'Voy a generar tu briefing', greeting, or transition. " - "Write NOTHING AFTER it: no comments, previews, or explanations. " - "Exactly one line containing the marker. Additional text prevents the engine " - "from recognizing the marker and executing the tool.\n" - "8. INCORRECT examples (NEVER do this):\n" - " 'Claro que sí, ahora ejecuto'\n" - " 'Voy a generar tu briefing ejecutivo personal.'\n" - " 'Claro que sí.\\n<<>>'\n" - " '<<>>\\nAhora proceso el resultado.'\n" - "9. 🔴 TTS ANTI-LEAK RULES (mandatory, HIGHEST audio priority):\n" - " <<>> IS AN INTERNAL SYSTEM SIGNAL and MUST NEVER be spoken,\n" - " read aloud, or synthesized by TTS. When emitting the marker:\n" - " - INTERNAL REASONING (text/transcript) MUST contain the exact marker line.\n" - " - FINAL TTS AUDIO heard by the user MUST contain ONLY this placeholder:\n" - ' "Voy a consultar tus datos, un momento por favor."\n' - " - NEVER speak any of these technical terms in final TTS audio:\n" - " APIPILOT, CALL, ARGUMENTS, tool, herramienta, ejecutar, function, function call,\n" - " marcador, marker, even to explain or describe the call.\n" - "\n" - ) - # 2026-09-25 known defaults: inject values already resolved from the URL or - # profile, such as phoneNumber, so tool arguments use those exact values. - if isinstance(context_defaults, dict) and context_defaults: - defaults_lines = [] - for dk, dv in context_defaults.items(): - if dv in (None, "", []): - continue - if isinstance(dv, (dict, list)): - try: - dv_repr = json.dumps(dv, ensure_ascii=False) - except Exception: - dv_repr = str(dv) - else: - dv_repr = str(dv) - defaults_lines.append(f'- {dk} = "{dv_repr}"') - if defaults_lines: - rule += ( - "KNOWN DEFAULT VALUES (use these exact values in ARGUMENTS={…} when " - "the corresponding tool lists these names in required_parameters; " - "do not invent, translate, or normalize them):\n" - f"{chr(10).join(defaults_lines)}\n" - "\n" - ) - rule += f"Tool list (name / description):\n{chr(10).join(entries)}\n<<>>" - return rule - - -def _sanitize_pass2_internal_context(text: str) -> str: - """Lightly sanitize the pass-2 block injected into the upstream model. - - Unlike _sanitize_text_strip_apipilot_markers, preserve the pass-2 wrapper: - the model needs it to recognize tool data. Limit sanitation to filesystem - path leaks, fragmented marker tokens, and contextual phone leaks. - """ - if not isinstance(text, str) or not text.strip(): - return text - # Block 3: Unix/Windows/TRAE path leaks, matching the user-output sanitizer. - import re as _re_pass2 - - text = _re_pass2.sub( - r"(?i)(?:/[A-Za-z0-9_.\-@~]+){2,}/(?:\.trae[^\s\"']*|workspaceStorage[^\s\"']*|User[^\s\"']*|home[^\s\"']*)", - " ", - text, - ) - text = _re_pass2.sub(r"(?i)(?:[A-Z]:)?[\\/](?:Users|home|root|tmp|var|mnt|opt|srv)[\\/][^\s\"']{5,}", " ", text) - text = _re_pass2.sub(r"(?i)(?:\.trae-server|\.trae[^\s\"']{0,40}|workspaceStorage[0-9a-f]+)", " ", text) - text = _re_pass2.sub(r"\s{2,}", " ", text) - # Block 4: fragmented marker leaks, tier 4. - tier4_out_parts: list[str] = [] - for _line in text.splitlines(keepends=True): - _orig = _line - _low = _line.lower() - if "apipilot" in _low and ( - ("call" in _low and "name=" in _low) or ("arguments=" in _low) or ("result" in _low and "pass" in _low) - ): - if "<<<" in _line or ">>>" in _line: - _line = _re_pass2.sub( - r"[<]{1,3}\s*APIPILOT_(?:CALL|RESULT)[^<>\n]{0,500}[>]{1,3}", - " ", - _line, - flags=_re_pass2.IGNORECASE, - ) - if "apipilot" in _line.lower(): - _line = _re_pass2.sub( - r"(?i)\bAPIPILOT(?:_CALL|_RESULT)?\b", - " ", - _line, - ) - _line = _re_pass2.sub( - r"(?i)\b(?:NAME|ARGUMENTS|STATUS|REV|PASS2)\s*[:=]", - " ", - _line, - ) - tier4_out_parts.append(_line if _line.strip() else _orig) - text = "".join(tier4_out_parts) - text = _re_pass2.sub(r"\s{2,}", " ", text) - return text.strip() if isinstance(text, str) else text - - -def _sanitize_text_strip_apipilot_markers( - text: str, - *, - placeholder: str | None = None, -) -> str: - """Remove all internal ApiPilot markers from user-visible chat and TTS text. - - Replace pass-1 CALL markers with a Spanish placeholder. Silently remove - pass-2 RESULT markers, their heading, tool-output block and instruction - trailer. These server-only signals must never reach the user. - - Args: - text: Text to sanitize. - placeholder: Optional replacement; defaults to the Spanish UI/voice message. - """ - if not isinstance(text, str) or not text: - return text - if placeholder is None: - placeholder = "Voy a consultar tus datos, un momento por favor." - - # ================================================================ - # Block 1: silently remove pass-2 RESULT markers and wrappers from TTS/UI. - # ================================================================ - # (1a) Full pass-2 wrapper first: marker, heading, status, output, trailer. - pass2_full_block_pattern = re.compile( - r"[ \t]*(?:\r?\n|\r)?[ \t]*" - r"<<>>[ \t]*(?:\r?\n|\r)+" - r"###[ \t]+(?:Resultado de la herramienta ApiPilot invocada|Invoked ApiPilot tool result)[ \t]*(?:\r?\n|\r)+" - r"<<>>[ \t]*(?:\r?\n|\r)*" - r"(?:\r?\n|\r)?" - r"```tool-output[ \t]*(?:\r?\n|\r)?" - r"(?P.*?)" - r"(?:\r?\n|\r)?```[ \t]*(?:\r?\n|\r){0,3}" - r"(?:" - r"(?:Ahora debes reinterpretar EL TEXTO RESULTADO|Now reinterpret THE RESULT TEXT)[\s\S]{0,1200}?" - r"(?:\r?\n|\r){0,2}" - r")?" - r"[ \t]*", - re.IGNORECASE | re.DOTALL, - ) - text = pass2_full_block_pattern.sub("", text) - - # (1b) Standalone PASS2 markers when the wrapper is incomplete or fragmented. - pass2_marker_pattern = re.compile( - r"[ \t]*(?:\r?\n|\r)?[ \t]*<<>>[ \t]*(?:\r?\n|\r)?[ \t]*", - re.IGNORECASE, - ) - text = pass2_marker_pattern.sub("", text) - - # (1c) Residual Markdown heading after the marker has been removed. - pass2_title_pattern = re.compile( - r"[ \t]*(?:\r?\n|\r)?[ \t]*###[ \t]+(?:Resultado de la herramienta ApiPilot invocada|Invoked ApiPilot tool result)[ \t]*(?:\r?\n|\r)?[ \t]*", - re.IGNORECASE, - ) - text = pass2_title_pattern.sub("", text) - - # (1d) Standalone tool-output blocks without a PASS2 wrapper. - pass2_codeblock_pattern = re.compile( - r"```tool-output[ \t]*(?:\r?\n|\r)?.*?(?:\r?\n|\r)?```", - re.IGNORECASE | re.DOTALL, - ) - text = pass2_codeblock_pattern.sub("", text) - - # (1e) Residual instruction trailers missed by the full-wrapper pattern. - pass2_trailer_pattern = re.compile( - r"(?:Ahora debes reinterpretar EL TEXTO RESULTADO[\s\S]{0,1200}?contenido final que le quieres transmitir al usuario|Now reinterpret THE RESULT TEXT[\s\S]{0,1200}?final content intended for the user)\.", - re.IGNORECASE | re.DOTALL, - ) - text = pass2_trailer_pattern.sub("", text) - # Broader fallback also handles trailers truncated before their closing text. - pass2_trailer_fallback = re.compile( - r"(?:Ahora debes reinterpretar[\s\S]{0,1500}?(?:al usuario\.|$)|Now reinterpret THE RESULT TEXT[\s\S]{0,1500}?(?:for the user\.|$))", - re.IGNORECASE | re.DOTALL, - ) - text = pass2_trailer_fallback.sub("", text) - # Handle residual middle fragments after the trailer's opening was removed. - pass2_trailer_broad = re.compile( - r"(?:en lenguaje natural[\s\S]{0,800}?contenido final que le quieres transmitir al usuario|in clear, concise natural language[\s\S]{0,800}?final content intended for the user)\.?", - re.IGNORECASE | re.DOTALL, - ) - text = pass2_trailer_broad.sub("", text) - - # ================================================================ - # Block 2: pass-1 CALL marker replacement with a Spanish placeholder. - # ================================================================ - marker_full_pattern = re.compile( - r"<<>>", - re.IGNORECASE | re.DOTALL, - ) - line_wrapped_pattern = re.compile( - r"[ \t]*(?:\r?\n|\r)?[ \t]*<<>>[ \t]*(?:\r?\n|\r)?[ \t]*", - re.IGNORECASE | re.DOTALL, - ) - - def _replacement_full(_m: re.Match) -> str: - """Replace standalone or inline markers with the spoken placeholder.""" - return placeholder - - text = line_wrapped_pattern.sub(_replacement_full, text) - text = marker_full_pattern.sub(_replacement_full, text) - - # Remove truncated CALL markers such as <<\n]{0,500}?)?>>*", - re.IGNORECASE, - ) - text = trunc_call_pattern.sub(_replacement_full, text) - - # Make closing brackets optional to remove RESULT markers truncated by TTS, - # such as <<>>. - trunc_result_pattern = re.compile( - r"<<?>?>?", - re.IGNORECASE, - ) - text = trunc_result_pattern.sub("", text) - # Remove remaining APIPILOT_RESULT fragments after partial sanitation. - trunc_result_residual = re.compile( - r"<{0,3}APIPILOT_RESULT(?::[^\s<]{0,800})?<{0,3}", - re.IGNORECASE, - ) - text = trunc_result_residual.sub("", text) - - # Collapse whitespace left by pass-2 block removal. - text = re.sub(r"[ \t]{2,}", " ", text) - text = re.sub(r"\n{3,}", "\n\n", text) - # Remove residual trailer fragments and empty parentheses before TTS. - text = re.sub(r"\s*\((?:arriba|above)\)\s*", " ", text, flags=re.IGNORECASE) - text = re.sub(r"\(\s*\)", "", text) - # ================================================================ - # Block 3: remove filesystem paths and host information leaked into TTS. - # Match long absolute Unix/Windows/mixed paths and known workspace tokens. - # ================================================================ - # (3a) Absolute Unix paths and nested paths with multiple separators. - unix_path_pattern = re.compile( - r"\b(?:/(?:home|tmp|var|root|opt|mnt|media|srv|proc|sys|etc|usr|Users|trae)[/\w@.+\-:#$%^&()[\]{}!~=,]*){2,}[A-Za-z0-9_./\-+@#$%^&()[\]{}!~=,:]*\.(?:txt|md|log|json|csv|pdf|doc|docx|py|js|ts|html|css|sh|yaml|yml|env|conf|cfg|ini|mp3|wav|ogg|png|jpg|jpeg|gif)\b" - r"|" - # Broad fallback for long absolute Unix paths under known directories, - # including paths without extensions; avoid short URLs such as /api/v1. - r"(?:^|\s+|\(|[,.;:!?])(/(?:home|tmp|var|root|opt|mnt|media|srv|proc|sys|etc|usr|Users|trae)[/A-Za-z0-9_\-+@#$%^&()[\]{}!~=,.]{10,})(?=$|\s|\)|[,.;:!?])", - re.IGNORECASE, - ) - text = unix_path_pattern.sub("", text) - # (3b) Windows paths with single/double backslashes and mixed separators. - win_path_pattern = re.compile( - r"(?:^|\s|\(|[,.;:])" - r"([A-Za-z]:[\\/]" - r"|(?:[\\/]{1,2}(?:home|Users|Windows|Program|Program\sFiles|ProgramData|Users|trae|AppData)[\\/])" - r"|(?:[\\/]{1,2}\.(?:trae|trae-server|vscode|config|cache)[\\/])" - r")" - r"[A-Za-z0-9_@.+\-:#$%^&()[\]{}!~=,\\/\s]{10,}" - r"(?:\.(?:txt|md|log|json|csv|pdf|doc|docx|py|js|ts|html|css|sh|yaml|yml|env|conf|cfg|ini|mp3|wav|ogg|png|jpg|jpeg|gif))?" - r"(?=$|\s|\)|[,.;:!?])", - re.IGNORECASE, - ) - text = win_path_pattern.sub("", text) - # (3c) TRAE workspace tokens, including fragments without complete paths. - trae_leak_tokens = re.compile( - r"\b(?:workspaceStorage|long-text|\.trae-server|\.trae|ckg_server|ai-agent)\b[^\s()'\"`,.]{0,80}", - re.IGNORECASE, - ) - text = trae_leak_tokens.sub("", text) - # (3d) Remove long tokens containing mixed separators or many path segments. - mixed_path_pattern = re.compile( - r"(?=\S{0,200})" - r"(?=\S*(?:[/\\].*){5,})" - r"(?:\S{10,})", - re.IGNORECASE, - ) - - def _mixed_safe(m: re.Match[str]) -> str: - s = m.group(0) - has_slash = "/" in s - has_back = "\\" in s - if (has_slash and has_back) or s.count("/") + s.count("\\") >= 6: - return "" - return s - - text = mixed_path_pattern.sub(_mixed_safe, text) - # ================================================================ - # Block 4: remove fragmented APIPILOT CALL text from TTS. The upstream - # pipeline (start_pipeline_rag / Qwen3-TTS) can remove <<< >>> and read: - # "APIPILOT CALL name get_user_briefing arguments phone +56912345678" - # The bracketed patterns cannot catch this, so use contextual fallback tiers. - # ================================================================ - # Tier 1: full normalized form without braces, through the sentence ending. - call_normalized_full = re.compile( - r"(?:^|\s+|\(|[,.;:!?\"\'])\s*" - r"(?:" - r"APIPILOT[\s_\-]+CALL\s+(?:marker\s+)?(?:de\s+)?(?:señal\s+)?(?:herramienta\s+)?(?:funci[oó]n\s+)?" - r"|marcador\s+APIPILOT(?:\s+CALL)?\s*" - r"|señal\s+de\s+llamada\s+APIPILOT\s*" - r"|llamada\s+(?:a\s+)?(?:la\s+)?(?:herramienta|funci[oó]n)\s+APIPILOT\s*" - r")" - r"(?:\s*(?:con\s+)?(?:identificador|ID|name|nombre)\s*[:=]?\s*[A-Za-z0-9_.-]+)?" - r"(?:\s*(?:y\s+)?(?:con\s+)?(?:argumentos|ARGUMENTS|params|par[áa]metros)\s*[:=]?\s*(?:\{[^{}]{0,800}\}|[\"'][^\"']{0,200}[\"']|[^\s.!,?;]{3,100}(?:\s*[,:]\s*[^\s.!,?;]{3,100})*\s*)?)?" - r"(?:\s*(?:phone|tel[ée]fono|correo|email)\s*[:=]?\s*[+0-9A-Za-z._@\-]{6,50})?" - r"(?=$|\s+|\)|[,.;:!?\"\'])", - re.IGNORECASE, - ) - text = call_normalized_full.sub(placeholder, text) - # Tier 2: shortened forms with APIPILOT_CALL or adjacent APIPILOT and CALL. - apipilot_call_token = re.compile( - r"(?:^|\s|\(|[,.;:!?\"\'])\s*" - r"APIPILOT(?:[\s_\-]+CALL|_CALL|\s+llamada|\s+marker)" - r"(?:[^.!,?;\n]{0,600})?" - r"(?=$|\s|\)|[,.;:!?\"\'])", - re.IGNORECASE, - ) - text = apipilot_call_token.sub(placeholder, text) - # Tier 3: standalone service tokens in call/name/argument contexts. - service_token_context = re.compile( - r"(?:" - r"ARGUMENTS\s*[:=]\s*(?:\{[^{}]{0,800}\}|[^\s,.;!?]{3,100})" - r"|\barguments\s+(?:de\s+)?(?:la\s+)?(?:llamada|herramienta|funci[oó]n)\b[^\n.]{0,200}" - r")", - re.IGNORECASE, - ) - text = service_token_context.sub("", text) - - # Tier 4: remove sentences containing APIPILOT with technical call context. - def _apipilot_sentence(m: re.Match[str]) -> str: - sentence = m.group(0) - lowered = sentence.lower() - # Preserve a sentence already containing the Spanish CALL placeholder, - # including any natural text surrounding it. - if "voy a consultar tus datos" in lowered: - return sentence - ctx_kw = ( - "name", - "argument", - "arg", - "herramienta", - "tool", - "funci", - "phone", - "tel", - "+56", - "+44", - "marker", - "call", - "llamada", - "señal", - "marcador", - ) - if any(k in lowered for k in ctx_kw) and "apipilot" in lowered: - return "" - return sentence - - catchall_sentence = re.compile( - r"[^.?!;\n]{0,600}APIPILOT[^.?!;\n]{0,600}[.?!;\n]{0,1}", - re.IGNORECASE, - ) - text = catchall_sentence.sub(_apipilot_sentence, text) - # Tier 5: replace any remaining APIPILOT CALL tokens, even without context. - final_apipilot_leak = re.compile( - r"\bAPIPILOT[\s_\-]+CALL\b|\bAPIPILOT_CALL\b", - re.IGNORECASE, - ) - text = final_apipilot_leak.sub(placeholder, text) - # Tier 6: remove residual fake/real phone numbers from internal call text. - # Match compact and spaced forms to tolerate TTS normalization. - fake_phone_pattern = re.compile( - r"(" - # Compact forms without spaces: - r"\+56912345678" - r"|\+1234567890" - r"|\+447432477433" - r"|56912345678" - r"|1234567890" - r"|447432477433" - # Spaced forms with punctuation: - r"|\+?\s*56\s*[\s\-()]?9\s*[\s\-()]?1\s*[\s\-()]?2\s*[\s\-()]?3\s*[\s\-()]?4\s*[\s\-()]?5\s*[\s\-()]?6\s*[\s\-()]?7\s*[\s\-()]?8" - r"|\+?\s*1\s*[\s\-()]?2\s*[\s\-()]?3\s*[\s\-()]?4\s*[\s\-()]?5\s*[\s\-()]?6\s*[\s\-()]?7\s*[\s\-()]?8\s*[\s\-()]?9\s*[\s\-()]?0" - r"|\+?\s*44\s*[\s\-()]?7\s*[\s\-()]?4\s*[\s\-()]?3\s*[\s\-()]?2\s*[\s\-()]?4\s*[\s\-()]?7\s*[\s\-()]?7\s*[\s\-()]?4\s*[\s\-()]?3\s*[\s\-()]?3" - # Generic labeled forms (English or Spanish phone labels): - r"|phone\s*[:=]\s*[+()\-\s]*[0-9][+()\-\s0-9]{6,}" - r"|tel(?:éfono|efono)?\s*[:=]\s*[+()\-\s]*[0-9][+()\-\s0-9]{6,}" - r")", - re.IGNORECASE, - ) - # Strip numbers only from lines with technical call context or the Spanish - # placeholder, indicating that the number belongs to a replaced CALL marker. - lines = text.splitlines(keepends=True) - _phone_ctx = ( - "apipilot", - "call", - "arguments", - "marker", - "herramienta", - "funcion", - "función", - "ejecutar", - "llamada", - "marcador", - "señal", - "voy a consultar tus datos", - ) - - def _phone_line_safe(line: str) -> str: - lowered = line.lower() - has_ctx = any(c in lowered for c in _phone_ctx) - if has_ctx: - # Remove phone numbers in this line (strip also surrounding space) - return fake_phone_pattern.sub(" ", line) - return line - - text = "".join(_phone_line_safe(line) for line in lines) - # Collapse whitespace left by marker and path removal. - text = re.sub(r"[ \t]{2,}", " ", text) - text = re.sub(r"\n{3,}", "\n\n", text) - return text.strip() diff --git a/vocero-frontend/README.md b/vocero-frontend/README.md index cfb55ab..1a83fd9 100644 --- a/vocero-frontend/README.md +++ b/vocero-frontend/README.md @@ -144,10 +144,11 @@ Markdown replies, JSON/TXT exports, optional browser wake word, TALK/TALKFIRST startup modes, and optional backend features are retained. Camera controls and quick actions remain absent, matching their disabled state in the old UI. -Native speech stays at the current project's 16 kHz PCM rate. The backend -continues to own VAD, transcription, model inference, synthesis, upstream -credentials, and ApiPilot execution. Browser `SpeechSynthesis` is used only -when the configured external reasoning service returns an answer. +Native speech stays at the current project's 16 kHz PCM rate. The speech service +owns VAD, transcription, model inference, and synthesis. The Vocero API owns +AquaPortal credentials and authenticated MCP discovery/execution. Browser +`SpeechSynthesis` is used only when the configured external reasoning service +returns an answer. Session startup waits for audio initialization and `session.created`, then sends `session.update` before microphone audio or the initial greeting. The speech diff --git a/vocero-frontend/src/App.test.tsx b/vocero-frontend/src/App.test.tsx index e367e52..b26e87c 100644 --- a/vocero-frontend/src/App.test.tsx +++ b/vocero-frontend/src/App.test.tsx @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import App from "./App"; import { VoiceController } from "./realtime/controller"; -const bootstrap = { theme: "white", s2sUrl: "ws://localhost:8420/v1/realtime", apipilot: true }; +const bootstrap = { theme: "white", s2sUrl: "ws://localhost:8420/v1/realtime" }; beforeEach(() => { window.history.replaceState({}, "", "/?debug=true&phoneNumber=1234"); vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(function (this: HTMLDialogElement) { @@ -29,9 +29,13 @@ beforeEach(() => { truncated: true, note: "Lista abreviada", }, - "/api/apipilot/tools": { - success: true, - data: { tools: [{ name: "get_reports", description: "Consulta tus reportes", parameters: {} }] }, + "/api/conversations": { + conversationId: "conversation-ana", + expiresIn: 3600, + profile: { id: "ana", name: "Ana Vargas" }, + }, + "/api/mcp/tools": { + tools: [{ name: "list_reports", description: "Consulta tus reportes", parameters: {}, requires_auth: true }], }, }; return Promise.resolve(new Response(JSON.stringify(data[path] ?? {}))); @@ -150,7 +154,7 @@ describe("React application", () => { await user.click(screen.getByRole("button", { name: "Vaciar" })); expect(screen.getByRole("textbox")).toHaveValue(""); }); - it("shows nested profile fields and discovers remote tools after bootstrap", async () => { + it("shows nested profile fields", async () => { const user = userEvent.setup(); render(); await user.click(await screen.findByRole("button", { name: "Ver datos de Ana Vargas" })); @@ -159,11 +163,23 @@ describe("React application", () => { "mailto:ana@example.test", ); await user.click(screen.getByRole("button", { name: "Cerrar" })); - await user.click(screen.getByRole("button", { name: "Herramientas" })); + }); + it("discovers and displays MCP tools for the authenticated conversation", async () => { + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ ...bootstrap, mcp: true }))); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole("button", { name: "Herramientas" })); await waitFor(() => { - expect(screen.getByText("get_reports")).toBeInTheDocument(); + expect(screen.getByText("list_reports")).toBeInTheDocument(); }); expect(screen.getByText("Consulta tus reportes")).toBeInTheDocument(); + expect(vi.mocked(fetch).mock.calls.map(([path]) => path)).toEqual([ + "/api/config", + "/api/conversations", + "/api/mcp/tools", + ]); + const discovery = vi.mocked(fetch).mock.calls.find(([path]) => path === "/api/mcp/tools"); + expect(new Headers(discovery?.[1]?.headers).get("X-Vocero-Conversation")).toBe("conversation-ana"); }); it("keeps debug controls out of the normal user screen", async () => { window.history.replaceState({}, "", "/"); diff --git a/vocero-frontend/src/components/SettingsDialogs.tsx b/vocero-frontend/src/components/SettingsDialogs.tsx index c6fb07e..d80524f 100644 --- a/vocero-frontend/src/components/SettingsDialogs.tsx +++ b/vocero-frontend/src/components/SettingsDialogs.tsx @@ -282,17 +282,16 @@ export function ToolsDialog({ state, controller, onClose }: Props) { Herramientas MCP cargadas - Herramientas publicadas por el perfil remoto del agente. + Herramientas disponibles para esta conversación.

{state.toolsLoading ? "Cargando herramientas…" - : state.toolsError || - (state.tools.length - ? `${String(state.tools.length)} herramientas disponibles.` - : "No hay herramientas remotas disponibles.")} + : state.tools.length + ? `${String(state.tools.length)} herramientas disponibles.` + : "No hay herramientas remotas disponibles."}

{state.tools.map((tool) => ( diff --git a/vocero-frontend/src/lib/api.ts b/vocero-frontend/src/lib/api.ts index 1b39d1a..fd67c64 100644 --- a/vocero-frontend/src/lib/api.ts +++ b/vocero-frontend/src/lib/api.ts @@ -1,13 +1,6 @@ import { z } from "zod"; -import { - configSchema, - conversationSchema, - mcpToolsSchema, - recordSchema, - reportsSchema, - toolsEnvelopeSchema, -} from "./contracts"; +import { configSchema, conversationSchema, mcpToolsSchema, recordSchema, reportsSchema } from "./contracts"; import type { Caller } from "./settings"; export class ApiError extends Error { @@ -108,18 +101,6 @@ export function getReports(phone: string, signal: AbortSignal) { const query = phone ? `?${new URLSearchParams({ phoneNumber: phone })}` : ""; return optionalRequest(`/api/user/reports${query}`, reportsSchema, signal); } -export async function getTools(signal: AbortSignal, refresh = false) { - const result = await request( - `/api/apipilot/tools?include_unauthorized=true&refresh=${String(refresh)}`, - toolsEnvelopeSchema, - { signal }, - 10000, - ); - if (!result.success || !result.data) { - throw new Error("No se pudieron cargar las herramientas."); - } - return result.data.tools; -} export function conversationHeaders(id: string, turn?: number): Record { return { "X-Vocero-Conversation": id, ...(turn === undefined ? {} : { "X-Vocero-Turn": String(turn) }) }; diff --git a/vocero-frontend/src/lib/context.ts b/vocero-frontend/src/lib/context.ts index 0a641d9..cf9b75f 100644 --- a/vocero-frontend/src/lib/context.ts +++ b/vocero-frontend/src/lib/context.ts @@ -1,4 +1,4 @@ -import { recordSchema, type DynamicTool, type FunctionTool, type Profile, type ServerConfig } from "./contracts"; +import { recordSchema, type FunctionTool, type Profile, type ServerConfig } from "./contracts"; import type { Settings } from "./settings"; export function profileName(profile: Profile | null): string { @@ -43,35 +43,9 @@ export function profileContext(profile: Profile | null) { export function ragContext(text: string) { return text.trim() ? `<<>>\n${text.trim()}\n<<>>` : ""; } -export const TOOL_MARKER = "<<>>"; -export function toolContext(tools: DynamicTool[]) { - if (!tools.length) { - return ""; - } - const entries = tools.slice(0, 100).map((tool) => { - const required = Array.isArray(tool.parameters.required) - ? tool.parameters.required.filter((key): key is string => typeof key === "string") - : []; - return `- name: ${tool.name}\n requires_auth: ${String(tool.requires_auth)}${required.length ? `\n required_parameters: [${required.slice(0, 40).join(", ")}]` : ""}\n description: ${tool.description.slice(0, 500)}`; - }); - return `${TOOL_MARKER}\n### Available dynamic tools (ApiPilot AquaPortal)\nEach tool name is a unique identifier: DO NOT change, camelCase or translate it.\nMANDATORY EXECUTION RULES:\n1. If the user request matches a tool description, output ONLY one line as the FIRST line of your response:\n<<>>\n2. ARGUMENTS must be valid JSON. Include ONLY fields listed in required_parameters, if any.\n3. Do NOT add text after the marker. The engine executes the tool and supplies its result in a second call.\n4. If no description matches, respond normally in Spanish without a marker.\n5. If multiple descriptions match, choose the most specific.\n${entries.join("\n")}\n<<>>`; -} -export function buildInstructions( - config: ServerConfig, - settings: Settings, - profile: Profile | null, - rag: string, - tools: DynamicTool[], -) { +export function buildInstructions(config: ServerConfig, settings: Settings, profile: Profile | null, rag: string) { const base = config.primaryPrompt || settings.instructions; - return [ - base, - config.injectProfile ? profileContext(profile) : "", - ragContext(rag), - config.mcp || base.includes(TOOL_MARKER) ? "" : toolContext(tools), - ] - .filter(Boolean) - .join("\n\n"); + return [base, config.injectProfile ? profileContext(profile) : "", ragContext(rag)].filter(Boolean).join("\n\n"); } export function speechInstructions(instructions: string, config: ServerConfig) { return config.reasoningType === "OPENAI" diff --git a/vocero-frontend/src/lib/contracts.ts b/vocero-frontend/src/lib/contracts.ts index 8e46d34..0648885 100644 --- a/vocero-frontend/src/lib/contracts.ts +++ b/vocero-frontend/src/lib/contracts.ts @@ -14,7 +14,6 @@ export const configSchema = z.object({ debugPrompt: z.boolean().default(false), startSessionMode: z.string().default(""), theme: z.string().default(""), - apipilot: z.boolean().default(false), mcp: z.boolean().default(false), }); export type ServerConfig = z.infer; @@ -50,11 +49,6 @@ export interface FunctionTool { description: string; parameters: Record; } -export const toolsEnvelopeSchema = z.object({ - success: z.boolean(), - data: z.object({ tools: z.array(toolSchema), count: z.number().optional() }).nullish(), - error: z.unknown().optional(), -}); export const conversationSchema = z.object({ conversationId: z.string().min(1), expiresIn: z.number(), diff --git a/vocero-frontend/src/realtime/controller.ts b/vocero-frontend/src/realtime/controller.ts index 80f2f7f..76eacf5 100644 --- a/vocero-frontend/src/realtime/controller.ts +++ b/vocero-frontend/src/realtime/controller.ts @@ -11,7 +11,6 @@ import { getMcpTools, getProfile, getReports, - getTools, interruptConversation, post, reasoningSchema, @@ -53,7 +52,6 @@ export interface AppState { reports: Reports | null; tools: DynamicTool[]; toolsLoading: boolean; - toolsError: string; status: ConnectionStatus; error: string; notice: string; @@ -102,7 +100,6 @@ export class VoiceController { reports: null, tools: [], toolsLoading: false, - toolsError: "", status: "idle", error: "", notice: "", @@ -194,12 +191,7 @@ export class VoiceController { } }) .catch(() => undefined); - const tools = profile.then(async () => { - if (config.apipilot && !signal.aborted) { - await this.refreshTools(signal, !!this.state.profile); - } - }); - await Promise.all([profile, reports, tools]); + await Promise.all([profile, reports]); } catch (error) { if (!signal.aborted) { this.update({ loading: false, configError: `No se pudo cargar la configuración. ${errorMessage(error)}` }); @@ -212,7 +204,7 @@ export class VoiceController { const version = ++this.conversationVersion; let createdId: string | undefined; this.releaseConversation(); - this.update({ contextLoading: true, profile: null, reports: null, tools: [], toolsLoading: true, toolsError: "" }); + this.update({ contextLoading: true, profile: null, reports: null, tools: [], toolsLoading: true }); try { if (!this.caller.phoneNumber) { throw new Error("Abre Vocero desde un enlace que incluya tu número de teléfono."); @@ -247,30 +239,8 @@ export class VoiceController { this.initialization?.abort(); void this.stop(); } - async refreshTools(signal: AbortSignal, refresh = false) { - this.update({ toolsLoading: true, toolsError: "" }); - try { - const tools = this.state.config.mcp - ? await getMcpTools(this.conversationId ?? "", signal) - : await getTools(signal, refresh); - if (!signal.aborted) { - this.update({ tools, toolsLoading: false }); - this.pushSettings(); - } - } catch (error) { - if (!signal.aborted) { - this.update({ toolsError: errorMessage(error), toolsLoading: false }); - } - } - } private instructions() { - return buildInstructions( - this.state.config, - this.state.settings, - this.state.profile, - this.state.rag, - this.state.tools, - ); + return buildInstructions(this.state.config, this.state.settings, this.state.profile, this.state.rag); } private tools() { return this.state.settings.webSearch && (this.state.config.search || this.state.settings.searchKey)