Skip to content
40 changes: 0 additions & 40 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions src/__tests__/detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,20 @@ describe('findEntryPoint', () => {
touch(dir, 'bot.py')
expect(findEntryPoint(dir)).toBe('bot.py')
})

it('prefers bot.py over agent.py for pipecat framework', () => {
const dir = makeTempDir()
touch(dir, 'agent.py')
touch(dir, 'bot.py')
expect(findEntryPoint(dir, 'pipecat')).toBe('bot.py')
})

it('keeps agent.py priority for livekit framework', () => {
const dir = makeTempDir()
touch(dir, 'agent.py')
touch(dir, 'bot.py')
expect(findEntryPoint(dir, 'livekit')).toBe('agent.py')
})
})

describe('detectFramework edge cases', () => {
Expand Down Expand Up @@ -166,6 +180,18 @@ describe('detect (full pipeline)', () => {
expect(result!.additionalFiles['pyproject.toml']).toBeDefined()
})

it('detects a Pipecat project and prefers bot.py over agent.py', () => {
const dir = makeTempDir()
touch(dir, 'requirements.txt', 'pipecat-ai\n')
touch(dir, 'agent.py', 'from pipecat.pipeline import Pipeline\n')
touch(dir, 'bot.py', 'from pipecat.pipeline import Pipeline\n')

const result = detect(dir)
expect(result).not.toBeNull()
expect(result!.framework).toBe('pipecat')
expect(result!.entryPointPath).toBe('bot.py')
})

it('returns null without a project file', () => {
const dir = makeTempDir()
touch(dir, 'agent.py')
Expand Down
12 changes: 11 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,15 @@ export const MAX_FILE_SIZE_BYTES = 50 * 1024
/** Files that indicate a Python project, checked in priority order. */
export const PROJECT_FILES = ['pyproject.toml', 'requirements.txt', 'Pipfile', 'setup.py'] as const

/** Entry point filenames to look for, in priority order. */
/** Entry point filenames to look for, in priority order (default / LiveKit). */
export const ENTRY_POINT_NAMES = ['agent.py', 'main.py', 'bot.py', 'app.py'] as const

/** Entry point filenames for Pipecat projects — bot.py takes priority (Pipecat Cloud convention). */
export const PIPECAT_ENTRY_POINT_NAMES = ['bot.py', 'main.py', 'agent.py', 'app.py'] as const

/** OTel packages required by coval_tracing.py. */
export const OTEL_PACKAGES = [
'opentelemetry-api>=1.0.0',
'opentelemetry-sdk>=1.0.0',
'opentelemetry-exporter-otlp-proto-http>=1.0.0',
] as const
16 changes: 11 additions & 5 deletions src/detect.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { FRAMEWORKS, PROJECT_FILES, ENTRY_POINT_NAMES } from './constants.js'
import {
FRAMEWORKS,
PROJECT_FILES,
ENTRY_POINT_NAMES,
PIPECAT_ENTRY_POINT_NAMES,
} from './constants.js'
import type { Framework, DetectionResult } from './types.js'

/** Safely read a file, returning null on error. */
Expand Down Expand Up @@ -45,9 +50,10 @@ export const detectFramework = (dir: string): Framework => {
return FRAMEWORKS.GENERIC
}

/** Find the most likely entry point file. */
export const findEntryPoint = (dir: string): string | null => {
for (const name of ENTRY_POINT_NAMES) {
/** Find the most likely entry point file. Uses framework-specific priority when provided. */
export const findEntryPoint = (dir: string, framework?: Framework): string | null => {
const names = framework === FRAMEWORKS.PIPECAT ? PIPECAT_ENTRY_POINT_NAMES : ENTRY_POINT_NAMES
for (const name of names) {
if (existsSync(join(dir, name))) return name
}

Expand Down Expand Up @@ -80,7 +86,7 @@ export const detect = (dir: string): DetectionResult | null => {
if (!projectFile) return null

const framework = detectFramework(dir)
const entryPointPath = findEntryPoint(dir)
const entryPointPath = findEntryPoint(dir, framework)
if (!entryPointPath) return null

return {
Expand Down
48 changes: 47 additions & 1 deletion src/files.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { readFileSync, writeFileSync, copyFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { createTwoFilesPatch } from 'diff'
import chalk from 'chalk'
import * as p from '@clack/prompts'
import { MAX_FILE_SIZE_BYTES } from './constants.js'
import { MAX_FILE_SIZE_BYTES, OTEL_PACKAGES } from './constants.js'

export const readFile = (path: string): string => {
const content = readFileSync(path, 'utf-8')
Expand Down Expand Up @@ -42,3 +43,48 @@ export const writeFile = (path: string, content: string): void => {
}

export const fileExists = (path: string): boolean => existsSync(path)

/**
* Add missing OTel packages to the project's dependency file.
* Handles requirements.txt (plain append) and pyproject.toml (injects into dependencies array).
* Returns the list of packages that were added, or an empty array if none were missing.
*/
/**
* Add missing OTel packages to the project's dependency file.
* Prefers requirements.txt when it exists (used by pip/Docker), falling back
* to pyproject.toml. Returns the list of packages added, or [] if none.
*/
Comment thread
callumreid marked this conversation as resolved.
export const addOtelDeps = (dir: string, projectFile: string): readonly string[] => {
// Always prefer requirements.txt when present — pip/Docker deployments use it
// regardless of whether pyproject.toml was detected as the primary project file.
const reqTxt = join(dir, 'requirements.txt')
const targetFile = existsSync(reqTxt) ? 'requirements.txt' : projectFile
const filePath = join(dir, targetFile)
const content = readFileSync(filePath, 'utf-8')

const missing = OTEL_PACKAGES.filter((pkg) => {
const name = pkg.split('>=')[0]
return !content.includes(name)
})
Comment thread
callumreid marked this conversation as resolved.

if (missing.length === 0) return []

if (targetFile === 'requirements.txt') {
writeFileSync(filePath, content.trimEnd() + '\n' + missing.join('\n') + '\n', 'utf-8')
} else if (targetFile === 'pyproject.toml') {
// Match the closing ] that sits on its own line to avoid matching ] inside
// package extras like pipecat-ai[daily,openai]>=0.0.60
const updated = content.replace(
/(\[project\][\s\S]*?dependencies\s*=\s*\[)([\s\S]*?)(\n\])/,
(_, open: string, inner: string, close: string) => {
// Ensure the last existing dep has a trailing comma before appending
const innerNormalized = inner.trimEnd().endsWith(',') ? inner : inner.trimEnd() + ','
const additions = missing.map((p) => ` "${p}",`).join('\n')
return `${open}${innerNormalized}\n${additions}${close}`
},
)
if (updated !== content) writeFileSync(filePath, updated, 'utf-8')
}

return missing
}
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import chalk from 'chalk'
import { getApiKey, verifyApiKey } from './auth.js'
import { detect } from './detect.js'
import { callWizardLLM, getLLMConfig } from './llm.js'
import { readFile, backupFile, showDiff, writeFile, fileExists } from './files.js'
import { readFile, backupFile, showDiff, writeFile, fileExists, addOtelDeps } from './files.js'
import { sendTestSpan } from './validate.js'
import { FRAMEWORK_LABELS, VERIFY_RESULTS, COVAL_TRACING_FILE } from './constants.js'

Expand Down Expand Up @@ -116,6 +116,11 @@ const main = async () => {
p.log.success(`${isCreate ? 'Created' : 'Updated'} ${COVAL_TRACING_FILE}`)
p.log.success(`Modified ${detection.entryPointPath}`)

const addedDeps = addOtelDeps(targetDir, detection.projectFile)
if (addedDeps.length > 0) {
p.log.success(`Added OTel packages to ${detection.projectFile}: ${addedDeps.join(', ')}`)
}
Comment thread
callumreid marked this conversation as resolved.

// ── Validate ──────────────────────────────────────────────────────────
let shouldValidate = true
if (!autoYes) {
Expand Down
15 changes: 9 additions & 6 deletions src/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,27 +323,30 @@ def create_tool_call_span(name: str = "", call_id: str = "", arguments: str = ""
const FRAMEWORK_RULES: Record<Framework, string> = {
pipecat: `## Pipecat Framework Rules
- Inject \`setup_coval_tracing()\` BEFORE \`PipelineTask()\`, \`PipelineRunner()\`, or \`Pipeline()\` construction
- Extract the simulation ID from \`args.body\` in the \`on_dialin_connected\` handler if present:
- Extract the simulation ID from the SIP headers in \`args.body\` at the top of \`bot()\`, right after \`setup_coval_tracing()\`:
\`\`\`python
sim_id = (args.body or {}).get("dialin_settings", {}).get("custom_context", {}).get("coval_simulation_id")
sip_headers = (body or {}).get("dialin_settings", {}).get("sip_headers", {})
sim_id = sip_headers.get("X-Coval-Simulation-Id") or sip_headers.get("x-coval-simulation-id")
if sim_id:
set_simulation_id(sim_id)
\`\`\`
- If there is NO \`on_dialin_connected\` handler, add a TODO comment for where to call \`set_simulation_id()\`
Place this immediately after \`body = getattr(args, "body", None) or {}\` (or after \`setup_coval_tracing()\` if body is extracted later). Do NOT put it inside \`on_dialin_connected\`.
- If there is NO \`body\` variable extracted from \`args\`, add the body extraction then the sim ID lines above
- Add \`enable_metrics=True\` and \`enable_tracing=True\` to PipelineTask if not already present`,

livekit: `## LiveKit Agents Framework Rules
- Inject \`setup_coval_tracing()\` BEFORE \`AgentSession()\` or \`VoicePipelineAgent()\` construction
- Extract the simulation ID from the SIP participant attributes:
\`\`\`python
async def _check_sim_id(participant):
def _check_sim_id(participant):
sim_id = participant.attributes.get("sip.h.X-Coval-Simulation-Id")
if sim_id:
set_simulation_id(sim_id)

ctx.room.on("participant_connected", lambda p: asyncio.ensure_future(_check_sim_id(p)))
ctx.room.on("participant_attributes_changed", lambda old, p: asyncio.ensure_future(_check_sim_id(p)))
ctx.room.on("participant_connected", _check_sim_id)
ctx.room.on("participant_attributes_changed", lambda old, p: _check_sim_id(p))
\`\`\`
- Do NOT use \`asyncio.ensure_future\` — \`set_simulation_id\` is synchronous, so \`_check_sim_id\` must be a plain \`def\`
- After \`await session.start()\`, add \`instrument_session(session)\`
- Import \`instrument_session\` from \`coval_tracing\` alongside \`setup_coval_tracing\` and \`set_simulation_id\``,

Expand Down
Loading