chore(vocero): remove the legacy ApiPilot integration (#3)

Vocero now retrieves AquaPortal content through authenticated MCP. This removes the obsolete ApiPilot integration from the API and browser while preserving the separately deployed speech pipeline.

Release notes:

- Removed ApiPilot endpoints, configuration, credential overrides, and HTTP/realtime tool execution.
- Removed browser ApiPilot catalog loading and prompt generation while retaining authenticated MCP discovery.
- Updated regression coverage and operating documentation for the retired routes and settings.

Reviewed-on: AquaPortal/s2s#3
This commit is contained in:
2026-10-08 11:23:41 -04:00
parent 0311d3f1ff
commit dfed03ac7c
39 changed files with 198 additions and 3314 deletions
+5 -4
View File
@@ -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
+23 -7
View File
@@ -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(<App />);
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(<App />);
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({}, "", "/");
@@ -282,17 +282,16 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
Herramientas MCP cargadas
</span>
<span className="text-[13.5px] text-muted compact:text-[12.5px]">
Herramientas publicadas por el perfil remoto del agente.
Herramientas disponibles para esta conversación.
</span>
</div>
</div>
<p className="block text-[13px] leading-[1.4] text-faint compact:text-[12px]" role="status">
{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."}
</p>
<div className="flex flex-col gap-2">
{state.tools.map((tool) => (
+1 -20
View File
@@ -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<string, string> {
return { "X-Vocero-Conversation": id, ...(turn === undefined ? {} : { "X-Vocero-Turn": String(turn) }) };
+3 -29
View File
@@ -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() ? `<<<CUSTOM_RAG_BEGIN>>>\n${text.trim()}\n<<<CUSTOM_RAG_END>>>` : "";
}
export const TOOL_MARKER = "<<<APIPILOT_TOOLS_LIST_BEGIN>>>";
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<<<APIPILOT_CALL:NAME=tool_name,ARGUMENTS={"parameter1":"value1"}>>>\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<<<APIPILOT_TOOLS_LIST_END>>>`;
}
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"
-6
View File
@@ -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<typeof configSchema>;
@@ -50,11 +49,6 @@ export interface FunctionTool {
description: string;
parameters: Record<string, unknown>;
}
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(),
+3 -33
View File
@@ -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)