Update files
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import net from "node:net";
|
||||
|
||||
function parseArgs(argv) {
|
||||
let socketPath = null;
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--socket") {
|
||||
socketPath = argv[index + 1] ?? null;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!socketPath || !socketPath.trim()) {
|
||||
throw new Error("Missing required --socket <path>");
|
||||
}
|
||||
|
||||
return { socketPath: socketPath.trim() };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { socketPath } = parseArgs(process.argv.slice(2));
|
||||
const socket = net.createConnection(socketPath);
|
||||
|
||||
socket.on("error", (error) => {
|
||||
process.stderr.write(`MCP stdio-socket bridge error: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
process.stdin.on("error", (error) => {
|
||||
process.stderr.write(`MCP stdio-socket bridge stdin error: ${error.message}\n`);
|
||||
socket.destroy();
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
process.stdout.on("error", (error) => {
|
||||
process.stderr.write(`MCP stdio-socket bridge stdout error: ${error.message}\n`);
|
||||
socket.destroy();
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once("connect", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
|
||||
process.stdin.pipe(socket);
|
||||
socket.pipe(process.stdout);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
socket.once("close", resolve);
|
||||
process.stdin.once("end", () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import pino from "pino";
|
||||
|
||||
import { DaemonClient } from "../src/server/test-utils/daemon-client.js";
|
||||
import { OpenAITTS } from "../src/server/speech/providers/openai/tts.js";
|
||||
|
||||
type RoundTripResult = {
|
||||
voiceAgentId: string;
|
||||
speakToolCalls: number;
|
||||
audioChunks: number;
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function streamToBuffer(stream: AsyncIterable<unknown>): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as ArrayBufferLike));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
async function synthesizeInput(text: string, logger: pino.Logger): Promise<Buffer> {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("OPENAI_API_KEY is required for voice-main-daemon-e2e");
|
||||
}
|
||||
|
||||
const tts = new OpenAITTS(
|
||||
{
|
||||
apiKey,
|
||||
responseFormat: "pcm",
|
||||
voice: "alloy",
|
||||
},
|
||||
logger
|
||||
);
|
||||
|
||||
const generated = await tts.synthesizeSpeech(text);
|
||||
return streamToBuffer(generated.stream as AsyncIterable<unknown>);
|
||||
}
|
||||
|
||||
async function runVoiceRoundTrip(params: {
|
||||
daemonUrl: string;
|
||||
voiceAgentId: string;
|
||||
speechText: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<RoundTripResult> {
|
||||
const client = new DaemonClient({ url: `${params.daemonUrl}/ws` });
|
||||
await client.connect();
|
||||
client.subscribeAgentUpdates({ subscriptionId: `voice-e2e-${randomUUID()}` });
|
||||
|
||||
const mode = await client.setVoiceMode(true, params.voiceAgentId);
|
||||
if (!mode.accepted) {
|
||||
throw new Error(`set_voice_mode rejected: ${mode.error ?? "unknown error"}`);
|
||||
}
|
||||
if (!mode.voiceAgentId) {
|
||||
throw new Error("set_voice_mode returned null voiceAgentId");
|
||||
}
|
||||
|
||||
const activeVoiceAgentId = mode.voiceAgentId;
|
||||
const pcm = await synthesizeInput(params.speechText, pino({ level: "warn" }));
|
||||
|
||||
let audioChunks = 0;
|
||||
let speakToolCalls = 0;
|
||||
let sawLastAudio = false;
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
const offAudio = client.on("audio_output", (msg) => {
|
||||
if (msg.type !== "audio_output") return;
|
||||
audioChunks += 1;
|
||||
if (msg.payload.isLastChunk) {
|
||||
sawLastAudio = true;
|
||||
offAudio();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const offStream = client.on("agent_stream", (msg) => {
|
||||
if (msg.type !== "agent_stream") return;
|
||||
if (msg.payload.agentId !== activeVoiceAgentId) return;
|
||||
if (msg.payload.event.type !== "timeline") return;
|
||||
const item = msg.payload.event.item;
|
||||
if (item.type !== "tool_call") return;
|
||||
if (typeof item.name === "string" && item.name.toLowerCase().includes("speak")) {
|
||||
speakToolCalls += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const chunkBytes = 4800;
|
||||
for (let offset = 0; offset < pcm.length; offset += chunkBytes) {
|
||||
const chunk = pcm.subarray(offset, Math.min(pcm.length, offset + chunkBytes));
|
||||
const isLast = offset + chunkBytes >= pcm.length;
|
||||
await client.sendVoiceAudioChunk(
|
||||
chunk.toString("base64"),
|
||||
"audio/pcm;rate=24000;bits=16",
|
||||
isLast
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
done,
|
||||
(async () => {
|
||||
await sleep(params.timeoutMs);
|
||||
throw new Error("Timed out waiting for audio_output");
|
||||
})(),
|
||||
]);
|
||||
} finally {
|
||||
offStream();
|
||||
await client.setVoiceMode(false).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (!sawLastAudio) {
|
||||
throw new Error("No final audio chunk observed");
|
||||
}
|
||||
if (audioChunks === 0) {
|
||||
throw new Error("No audio chunks observed");
|
||||
}
|
||||
if (speakToolCalls === 0) {
|
||||
throw new Error("No speak tool call observed in agent stream");
|
||||
}
|
||||
|
||||
return {
|
||||
voiceAgentId: activeVoiceAgentId,
|
||||
speakToolCalls,
|
||||
audioChunks,
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const daemonUrl = process.env.PASEO_DAEMON_URL ?? "ws://localhost:6767".replace(/^ws/, "http");
|
||||
const timeoutMs = Number(process.env.PASEO_VOICE_E2E_TIMEOUT_MS ?? "120000");
|
||||
const voiceAgentId = randomUUID();
|
||||
|
||||
const newAgentResult = await runVoiceRoundTrip({
|
||||
daemonUrl,
|
||||
voiceAgentId,
|
||||
speechText:
|
||||
"Use the speak tool and say exactly: new voice agent round trip successful.",
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
const resumedResult = await runVoiceRoundTrip({
|
||||
daemonUrl,
|
||||
voiceAgentId: newAgentResult.voiceAgentId,
|
||||
speechText:
|
||||
"Use the speak tool and say exactly: resumed voice agent round trip successful.",
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
daemonUrl,
|
||||
newAgentResult,
|
||||
resumedResult,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import pino from "pino";
|
||||
|
||||
import { AgentManager } from "../src/server/agent/agent-manager.js";
|
||||
import { AgentStorage } from "../src/server/agent/agent-storage.js";
|
||||
import { buildProviderRegistry, shutdownProviders } from "../src/server/agent/provider-registry.js";
|
||||
import type {
|
||||
AgentProvider,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
} from "../src/server/agent/agent-sdk-types.js";
|
||||
import { buildVoiceAgentMcpServerConfig } from "../src/server/session.js";
|
||||
import { createAgentMcpServer } from "../src/server/agent/mcp-server.js";
|
||||
import { createVoiceMcpSocketBridgeManager } from "../src/server/voice-mcp-bridge.js";
|
||||
|
||||
type CliOptions = {
|
||||
provider: AgentProvider;
|
||||
model?: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
let provider: AgentProvider = "claude";
|
||||
let model: string | undefined;
|
||||
let timeoutMs = 120_000;
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--provider" && argv[i + 1]) {
|
||||
provider = argv[i + 1] as AgentProvider;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--model" && argv[i + 1]) {
|
||||
model = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--timeout-ms" && argv[i + 1]) {
|
||||
timeoutMs = Number(argv[i + 1]) || timeoutMs;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { provider, model, timeoutMs };
|
||||
}
|
||||
|
||||
async function streamWithTimeout(
|
||||
iterator: AsyncGenerator<AgentStreamEvent>,
|
||||
timeoutMs: number,
|
||||
onEvent: (event: AgentStreamEvent) => Promise<void> | void
|
||||
): Promise<void> {
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
});
|
||||
|
||||
const consume = (async () => {
|
||||
for await (const event of iterator) {
|
||||
await onEvent(event);
|
||||
}
|
||||
})();
|
||||
|
||||
await Promise.race([consume, timeout]);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const logger = pino({ level: "info" });
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const serverDir = path.resolve(scriptDir, "..");
|
||||
const tmpRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-voice-mcp-smoke-"));
|
||||
const paseoHome = path.join(tmpRoot, ".paseo");
|
||||
const storageDir = path.join(paseoHome, "agents");
|
||||
const voiceBridgeRuntimeDir = path.join(tmpRoot, "voice-mcp");
|
||||
const voiceWorkspace = path.join(tmpRoot, "voice-agent-workspace");
|
||||
await mkdir(voiceWorkspace, { recursive: true });
|
||||
|
||||
const providerRegistry = buildProviderRegistry(logger);
|
||||
const providerDefinition = providerRegistry[opts.provider];
|
||||
if (!providerDefinition) {
|
||||
throw new Error(`Unsupported provider: ${opts.provider}`);
|
||||
}
|
||||
|
||||
const agentStorage = new AgentStorage(storageDir, logger);
|
||||
const agentManager = new AgentManager({
|
||||
logger,
|
||||
registry: agentStorage,
|
||||
clients: {
|
||||
[opts.provider]: providerDefinition.createClient(logger),
|
||||
},
|
||||
});
|
||||
|
||||
const speakCalls: string[] = [];
|
||||
const resolveSpeakHandler = (callerAgentId: string) => {
|
||||
return async ({ text }: { text: string; callerAgentId: string }) => {
|
||||
speakCalls.push(text);
|
||||
logger.info({ callerAgentId, text }, "Smoke speak handler invoked");
|
||||
};
|
||||
};
|
||||
|
||||
const voiceBridgeManager = createVoiceMcpSocketBridgeManager({
|
||||
runtimeDir: voiceBridgeRuntimeDir,
|
||||
logger,
|
||||
createAgentMcpServerForCaller: async (callerAgentId) => {
|
||||
return createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
paseoHome,
|
||||
callerAgentId,
|
||||
enableVoiceTools: false,
|
||||
resolveSpeakHandler,
|
||||
resolveCallerContext: () => ({
|
||||
childAgentDefaultLabels: { ui: "true" },
|
||||
allowCustomCwd: true,
|
||||
enableVoiceTools: true,
|
||||
}),
|
||||
logger,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const agentId = randomUUID();
|
||||
const socketPath = await voiceBridgeManager.ensureBridgeForCaller(agentId);
|
||||
const bridgeScriptPath = path.resolve(serverDir, "scripts/mcp-stdio-socket-bridge-cli.mjs");
|
||||
const config: AgentSessionConfig = {
|
||||
provider: opts.provider,
|
||||
cwd: voiceWorkspace,
|
||||
modeId: "default",
|
||||
...(opts.model ? { model: opts.model } : {}),
|
||||
mcpServers: {
|
||||
paseo: buildVoiceAgentMcpServerConfig({
|
||||
command: process.execPath,
|
||||
baseArgs: [bridgeScriptPath],
|
||||
socketPath,
|
||||
env: {
|
||||
PASEO_HOME: paseoHome,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = [
|
||||
"You must call the MCP tool named speak.",
|
||||
"Call it exactly once with this exact text: BRIDGE_SMOKE_OK",
|
||||
"Do not call any other tools.",
|
||||
"After the tool call, end your response.",
|
||||
].join("\n");
|
||||
|
||||
let sawSpeakTimeline = false;
|
||||
let sawTurnFailed = false;
|
||||
let failureError: string | null = null;
|
||||
|
||||
try {
|
||||
const created = await agentManager.createAgent(config, agentId, {
|
||||
labels: { surface: "voice-smoke", ui: "true" },
|
||||
});
|
||||
logger.info({ provider: opts.provider, agentId: created.id }, "Created smoke agent");
|
||||
|
||||
const iterator = agentManager.streamAgent(created.id, prompt);
|
||||
await streamWithTimeout(iterator, opts.timeoutMs, async (event) => {
|
||||
if (event.type === "permission_requested") {
|
||||
await agentManager.respondToPermission(created.id, event.request.id, { behavior: "allow" });
|
||||
}
|
||||
if (event.type === "turn_failed") {
|
||||
sawTurnFailed = true;
|
||||
failureError = event.error;
|
||||
}
|
||||
if (event.type === "timeline" && event.item.type === "tool_call") {
|
||||
logger.info({ name: event.item.name, callId: event.item.callId }, "Timeline tool call");
|
||||
if (typeof event.item.name === "string" && event.item.name.toLowerCase().includes("speak")) {
|
||||
sawSpeakTimeline = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: !sawTurnFailed && sawSpeakTimeline && speakCalls.length > 0,
|
||||
provider: opts.provider,
|
||||
agentId,
|
||||
sawSpeakTimeline,
|
||||
speakCalls,
|
||||
sawTurnFailed,
|
||||
failureError,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
await agentManager.closeAgent(agentId).catch(() => undefined);
|
||||
await agentManager.flush().catch(() => undefined);
|
||||
await voiceBridgeManager.stop().catch(() => undefined);
|
||||
await shutdownProviders(logger).catch(() => undefined);
|
||||
await rm(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import pino from "pino";
|
||||
|
||||
import { createTestPaseoDaemon } from "../src/server/test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../src/server/test-utils/daemon-client.js";
|
||||
import { OpenAITTS } from "../src/server/speech/providers/openai/tts.js";
|
||||
|
||||
async function streamToBuffer(stream: AsyncIterable<unknown>): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as ArrayBufferLike));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("OPENAI_API_KEY is required");
|
||||
}
|
||||
|
||||
const logger = pino({ level: process.env.PASEO_LOG_LEVEL ?? "info" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
logger,
|
||||
agentClients: {},
|
||||
openai: { apiKey },
|
||||
speech: {
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
},
|
||||
},
|
||||
voiceLlmProvider: "claude",
|
||||
voiceLlmProviderExplicit: true,
|
||||
});
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
});
|
||||
|
||||
const cleanup = async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close().catch(() => undefined);
|
||||
};
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
client.subscribeAgentUpdates({ subscriptionId: "voice-debug" });
|
||||
|
||||
const voiceAgentId = randomUUID();
|
||||
const mode = await client.setVoiceMode(true, voiceAgentId);
|
||||
console.log("set_voice_mode_response", mode);
|
||||
|
||||
const offTranscript = client.on("transcription_result", (msg) => {
|
||||
if (msg.type !== "transcription_result") return;
|
||||
console.log("transcription_result", {
|
||||
text: msg.payload.text,
|
||||
isLowConfidence: msg.payload.isLowConfidence,
|
||||
});
|
||||
});
|
||||
|
||||
const offActivity = client.on("activity_log", (msg) => {
|
||||
if (msg.type !== "activity_log") return;
|
||||
if (msg.payload.type === "transcript" || msg.payload.type === "error" || msg.payload.type === "assistant") {
|
||||
console.log("activity_log", {
|
||||
type: msg.payload.type,
|
||||
content: msg.payload.content,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const offStream = client.on("agent_stream", (msg) => {
|
||||
if (msg.type !== "agent_stream") return;
|
||||
if (msg.payload.event.type !== "timeline") return;
|
||||
const item = msg.payload.event.item;
|
||||
if (item.type !== "tool_call") return;
|
||||
console.log("agent_stream:tool_call", {
|
||||
agentId: msg.payload.agentId,
|
||||
name: item.name,
|
||||
status: item.status,
|
||||
});
|
||||
});
|
||||
|
||||
let audioChunkCount = 0;
|
||||
const firstAudio = new Promise<void>((resolve) => {
|
||||
const offAudio = client.on("audio_output", (msg) => {
|
||||
if (msg.type !== "audio_output") return;
|
||||
audioChunkCount += 1;
|
||||
console.log("audio_output", {
|
||||
id: msg.payload.id,
|
||||
groupId: msg.payload.groupId,
|
||||
chunkIndex: msg.payload.chunkIndex,
|
||||
isLastChunk: msg.payload.isLastChunk,
|
||||
format: msg.payload.format,
|
||||
});
|
||||
if (audioChunkCount === 1) {
|
||||
offAudio();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
const lastAudio = new Promise<void>((resolve) => {
|
||||
const offAudio = client.on("audio_output", (msg) => {
|
||||
if (msg.type !== "audio_output") return;
|
||||
if (msg.payload.isLastChunk) {
|
||||
offAudio();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const tts = new OpenAITTS(
|
||||
{
|
||||
apiKey,
|
||||
responseFormat: "pcm",
|
||||
voice: "alloy",
|
||||
},
|
||||
logger
|
||||
);
|
||||
const generated = await tts.synthesizeSpeech(
|
||||
"Use the speak tool and say exactly round trip successful."
|
||||
);
|
||||
const pcm = await streamToBuffer(generated.stream as AsyncIterable<unknown>);
|
||||
|
||||
const chunkBytes = 4800;
|
||||
for (let offset = 0; offset < pcm.length; offset += chunkBytes) {
|
||||
const chunk = pcm.subarray(offset, Math.min(pcm.length, offset + chunkBytes));
|
||||
const isLast = offset + chunkBytes >= pcm.length;
|
||||
await client.sendVoiceAudioChunk(
|
||||
chunk.toString("base64"),
|
||||
"audio/pcm;rate=24000;bits=16",
|
||||
isLast
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
firstAudio,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Timed out waiting for first audio_output")), 120000)
|
||||
),
|
||||
]);
|
||||
await Promise.race([
|
||||
lastAudio,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Timed out waiting for final audio_output")), 120000)
|
||||
),
|
||||
]);
|
||||
|
||||
console.log("success", { audioChunkCount });
|
||||
await client.setVoiceMode(false);
|
||||
|
||||
offTranscript();
|
||||
offActivity();
|
||||
offStream();
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user