From 8842f448b1cdd7824e9e37e222d3c934bf51a783 Mon Sep 17 00:00:00 2001 From: Callum Reid Date: Tue, 31 Mar 2026 19:33:24 -0700 Subject: [PATCH] feat: add Vapi framework support and fix LiveKit missing asyncio import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'vapi' as a detected framework alongside pipecat/livekit/generic - Detect Vapi from dependency manifests (vapi-python) and webhook patterns (assistant-request, end-of-call-report) in Python source - Add Vapi-specific injection rules: simulation ID extraction from assistantOverrides.variableValues["coval-simulation-id"], call ID caching, and setup_coval_tracing at module level - Fix LiveKit rules to explicitly require 'import asyncio' — the previous rules used asyncio.ensure_future() without the import, causing NameError at runtime when a SIP participant connected - Add server.py to ENTRY_POINT_NAMES for Vapi webhook projects - Add tests for Vapi detection and prompt rules --- src/__tests__/detect.test.ts | 12 ++++++++++++ src/__tests__/prompts.test.ts | 8 ++++++++ src/constants.ts | 4 +++- src/detect.ts | 5 ++++- src/prompts.ts | 19 +++++++++++++++++++ 5 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/__tests__/detect.test.ts b/src/__tests__/detect.test.ts index 4092525..c2e7f2d 100644 --- a/src/__tests__/detect.test.ts +++ b/src/__tests__/detect.test.ts @@ -63,6 +63,18 @@ describe('detectFramework', () => { expect(detectFramework(dir)).toBe('livekit') }) + it('detects vapi from requirements.txt', () => { + const dir = makeTempDir() + touch(dir, 'requirements.txt', 'vapi-python\nfastapi\n') + expect(detectFramework(dir)).toBe('vapi') + }) + + it('detects vapi from webhook patterns in Python code', () => { + const dir = makeTempDir() + touch(dir, 'server.py', '# vapi webhook handler for assistant-request\n') + expect(detectFramework(dir)).toBe('vapi') + }) + it('returns generic for unknown project', () => { const dir = makeTempDir() touch(dir, 'app.py', 'import flask\n') diff --git a/src/__tests__/prompts.test.ts b/src/__tests__/prompts.test.ts index 7618b6b..4cd38f4 100644 --- a/src/__tests__/prompts.test.ts +++ b/src/__tests__/prompts.test.ts @@ -19,6 +19,14 @@ describe('buildSystemPrompt', () => { expect(prompt).toContain('AgentSession') expect(prompt).toContain('instrument_session') expect(prompt).toContain('sip.h.X-Coval-Simulation-Id') + expect(prompt).toContain('import asyncio') + }) + + it('includes vapi-specific rules', () => { + const prompt = buildSystemPrompt('vapi') + expect(prompt).toContain('assistantOverrides') + expect(prompt).toContain('coval-simulation-id') + expect(prompt).toContain('end-of-call-report') }) it('includes generic rules', () => { diff --git a/src/constants.ts b/src/constants.ts index 0ba4b37..8599f17 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -26,6 +26,7 @@ export const CONTENT_TYPE_JSON = 'application/json' export const FRAMEWORKS = { PIPECAT: 'pipecat', LIVEKIT: 'livekit', + VAPI: 'vapi', GENERIC: 'generic', } as const @@ -35,6 +36,7 @@ export type Framework = (typeof FRAMEWORKS)[keyof typeof FRAMEWORKS] export const FRAMEWORK_LABELS: Record = { [FRAMEWORKS.PIPECAT]: 'Pipecat', [FRAMEWORKS.LIVEKIT]: 'LiveKit Agents', + [FRAMEWORKS.VAPI]: 'Vapi', [FRAMEWORKS.GENERIC]: 'Generic Python', } @@ -73,4 +75,4 @@ export const MAX_FILE_SIZE_BYTES = 50 * 1024 export const PROJECT_FILES = ['pyproject.toml', 'requirements.txt', 'Pipfile', 'setup.py'] as const /** Entry point filenames to look for, in priority order. */ -export const ENTRY_POINT_NAMES = ['agent.py', 'main.py', 'bot.py', 'app.py'] as const +export const ENTRY_POINT_NAMES = ['agent.py', 'main.py', 'bot.py', 'app.py', 'server.py'] as const diff --git a/src/detect.ts b/src/detect.ts index cdaace0..53ed2f0 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -28,15 +28,18 @@ export const detectFramework = (dir: string): Framework => { if (!content) continue if (/pipecat[-_]ai|"pipecat"/.test(content)) return FRAMEWORKS.PIPECAT if (/livekit[-_]agents|"livekit"/.test(content)) return FRAMEWORKS.LIVEKIT + if (/vapi[-_]python|"vapi"/.test(content)) return FRAMEWORKS.VAPI } - // Scan .py files for framework imports + // Scan .py files for framework imports and Vapi webhook patterns try { for (const f of readdirSync(dir).filter((f) => f.endsWith('.py'))) { const content = tryRead(join(dir, f)) if (!content) continue if (/from pipecat|import pipecat/.test(content)) return FRAMEWORKS.PIPECAT if (/from livekit|import livekit/.test(content)) return FRAMEWORKS.LIVEKIT + if (/vapi.*(webhook|assistant-request|end-of-call-report)/.test(content)) + return FRAMEWORKS.VAPI } } catch { // directory not readable diff --git a/src/prompts.ts b/src/prompts.ts index c73a77b..70f9c72 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -334,6 +334,7 @@ const FRAMEWORK_RULES: Record = { livekit: `## LiveKit Agents Framework Rules - Inject \`setup_coval_tracing()\` BEFORE \`AgentSession()\` or \`VoicePipelineAgent()\` construction +- Add \`import asyncio\` to the top-level imports if not already present - Extract the simulation ID from the SIP participant attributes: \`\`\`python async def _check_sim_id(participant): @@ -347,6 +348,24 @@ const FRAMEWORK_RULES: Record = { - After \`await session.start()\`, add \`instrument_session(session)\` - Import \`instrument_session\` from \`coval_tracing\` alongside \`setup_coval_tracing\` and \`set_simulation_id\``, + vapi: `## Vapi Webhook Framework Rules +- Add \`setup_coval_tracing()\` at module level, after imports and app creation +- Vapi delivers a SIP header \`X-Coval-Simulation-Id\` which appears in \`message.call.assistantOverrides.variableValues["coval-simulation-id"]\` (Vapi auto-lowercases X- headers) +- Extract the simulation ID from every webhook event as early as possible and cache it by call ID: + \`\`\`python + _call_sim_map: dict[str, str] = {} + + # Inside the webhook handler, before any event-specific logic: + if call_id and call_id not in _call_sim_map: + var_values = call.get("assistantOverrides", {}).get("variableValues", {}) + sim_id = var_values.get("coval-simulation-id") + if sim_id: + set_simulation_id(str(sim_id)) + _call_sim_map[call_id] = str(sim_id) + \`\`\` +- In the \`end-of-call-report\` handler, retrieve the cached simulation ID and use it +- Do NOT import \`instrument_session\` — Vapi is webhook-based, not SDK-based`, + generic: `## Generic Python Agent Rules - Add \`setup_coval_tracing()\` at module level, after imports - Add a TODO comment for where to call \`set_simulation_id()\` with the actual simulation ID