diff --git a/.changeset/breezy-groups-rest.md b/.changeset/breezy-groups-rest.md new file mode 100644 index 000000000..f72e6e43b --- /dev/null +++ b/.changeset/breezy-groups-rest.md @@ -0,0 +1,5 @@ +--- +"braintrust": minor +--- + +feat: Add `@earendil-works/pi-coding-agent` instrumentation diff --git a/e2e/config/pr-comment-scenarios.json b/e2e/config/pr-comment-scenarios.json index 0b8107e88..2e178fe20 100644 --- a/e2e/config/pr-comment-scenarios.json +++ b/e2e/config/pr-comment-scenarios.json @@ -21,6 +21,12 @@ } ] }, + { + "scenarioDirName": "pi-coding-agent-instrumentation", + "label": "Pi Coding Agent Instrumentation", + "metadataScenario": "pi-coding-agent-instrumentation", + "variants": [{ "variantKey": "pi-coding-agent-v079", "label": "v0.79" }] + }, { "scenarioDirName": "openai-agents-instrumentation", "label": "OpenAI Agents Instrumentation", diff --git a/e2e/helpers/normalize.ts b/e2e/helpers/normalize.ts index 0db1a800d..2b621647c 100644 --- a/e2e/helpers/normalize.ts +++ b/e2e/helpers/normalize.ts @@ -94,6 +94,8 @@ const NODE_INTERNAL_FRAME_REGEX = /node:[^)\n]+:\d+:\d+/g; const TEMP_SCENARIO_PATH_REGEX = /\/e2e\/\.bt-tmp\/[^/\s)]+\/scenarios\/([^/\s)]+)\/?/g; const TEMP_HELPER_PATH_REGEX = /\/e2e\/\.bt-tmp\/[^/\s)]+\/helpers\/?/g; +const TEMP_SCENARIO_DEPENDENCY_PATH_REGEX = + /\/e2e\/\.bt-tmp\/scenario-deps\/([^/\s)]+)-locked-[0-9a-f]{8,}(?=\/|$)/gi; const PROVIDER_HELPER_CALLER_REGEX = /^\/e2e\/helpers\/.+-scenario\.mjs$/; const ANTHROPIC_MESSAGE_STREAM_PATH_REGEX = /([/\\]node_modules[/\\]\.pnpm[/\\]@anthropic-ai\+sdk@[^/\\\s)]+[/\\]node_modules[/\\]@anthropic-ai[/\\]sdk[/\\])(?:src[/\\]lib[/\\]MessageStream\.ts|lib[/\\]MessageStream\.js)/g; @@ -151,6 +153,10 @@ function normalizeStackLikeString(value: string): string { "/e2e/scenarios/$1/", ); normalized = normalized.replace(TEMP_HELPER_PATH_REGEX, "/e2e/helpers/"); + normalized = normalized.replace( + TEMP_SCENARIO_DEPENDENCY_PATH_REGEX, + "/e2e/.bt-tmp/scenario-deps/$1-locked-", + ); normalized = normalized.replace( STACK_FRAME_REPO_PATH_REGEX, diff --git a/e2e/scenarios/pi-coding-agent-instrumentation/__cassettes__/pi-coding-agent-v079.cassette.json b/e2e/scenarios/pi-coding-agent-instrumentation/__cassettes__/pi-coding-agent-v079.cassette.json new file mode 100644 index 000000000..1a7447c70 --- /dev/null +++ b/e2e/scenarios/pi-coding-agent-instrumentation/__cassettes__/pi-coding-agent-v079.cassette.json @@ -0,0 +1,266 @@ +{ + "entries": [ + { + "callIndex": 0, + "id": "5625e915bcb72d05", + "matchKey": "POST api.anthropic.com/v1/messages", + "recordedAt": "2026-06-15T13:41:55.141Z", + "request": { + "body": { + "kind": "json", + "value": { + "max_tokens": 64000, + "messages": [ + { + "content": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "Use the bash tool to run `printf pi_tool_ok` exactly once, then reply with exactly PI_CODING_AGENT_OK and include the command output.", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "claude-haiku-4-5", + "stream": true, + "system": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.\n\nAvailable tools:\n- bash: Execute bash commands (ls, grep, find, etc.)\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.\n\nGuidelines:\n- Use bash for file operations like ls, rg, find\n- Be concise in your responses\n- Show file paths clearly when working with files\n\nPi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):\n- Main documentation: /Users/lucaforstner/conductor/workspaces/braintrust-sdk-javascript/baghdad/e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-0c3eb72dc7a20ce8/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/README.md\n- Additional docs: /Users/lucaforstner/conductor/workspaces/braintrust-sdk-javascript/baghdad/e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-0c3eb72dc7a20ce8/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/docs\n- Examples: /Users/lucaforstner/conductor/workspaces/braintrust-sdk-javascript/baghdad/e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-0c3eb72dc7a20ce8/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/examples (extensions, custom tools, SDK)\n- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory\n- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md)\n- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing\n- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)\n\n\n\nProject-specific instructions and guidelines:\n\n\n# Braintrust JavaScript SDK Monorepo\n\nTypeScript SDKs and integrations for Braintrust. Uses `pnpm` workspaces.\n\n## Repository Structure\n\n```text\n.\n├── js/ # Main `braintrust` package\n├── integrations/ # Integration packages (@braintrust/*)\n├── e2e/ # End-to-end scenario tests (mock server + subprocess isolation)\n├── docs/ # Docs and reference material\n└── internal/ # Internal test fixtures and golden projects\n```\n\n## Setup\n\n```bash\nmise install # Install toolchain and dependencies\n```\n\n## Build\n\n```bash\npnpm run build # Build all workspace packages (from repo root)\n```\n\n## Testing\n\nUses Vitest. Prefer running the **narrowest relevant test** rather than the full suite.\n\n**From `js/` directory:**\n\n```bash\npnpm test # Core vitest suite (excludes wrappers)\npnpm test -- -t \"test name\" # Filter by test name\npnpm run test:checks # Hermetic tests (core + vitest wrapper)\n```\n\n**E2E tests (`e2e/`):**\n\nEach scenario runs the SDK in a subprocess against a mock Braintrust server and snapshots the results. No API keys required for replay; recording needs provider keys.\n\n```bash\npnpm run test:e2e # Run all e2e scenarios (from repo root)\npnpm run test:e2e:update # Update e2e snapshots without re-recording cassettes\npnpm run test:e2e:record # Re-record provider cassettes and update snapshots\n```\n\nWhen adding or modifying e2e tests, run the relevant e2e verification twice before stopping so flakes are caught proactively. After running `pnpm run test:e2e:update` or `pnpm run test:e2e:record`, always run the normal e2e tests afterward to verify there is no snapshot drift or unstable output.\n\nSpan-tree snapshots are paired: `*.span-tree.json` is the structural contract, and `*.span-tree.txt` is the human-readable ASCII tree generated from the same normalized spans. Both files are asserted and should be updated together through `pnpm run test:e2e:update` or `pnpm run test:e2e:record`; do not hand-edit only one side of the pair.\n\n**From repo root:**\n\n```bash\npnpm run test # Run all workspace tests via turbo\n```\n\n## Linting & Formatting\n\nRun from the repo root. **Always run `fix:formatting` before committing** — there is a pre-commit hook that will reject unformatted code.\n\n```bash\npnpm run formatting # Check formatting (prettier)\npnpm run lint # Run eslint checks\npnpm run fix:formatting # Auto-fix formatting\npnpm run fix:lint # Auto-fix eslint issues\n```\n\n\n\n\n\nCurrent date: 2026-06-15\nCurrent working directory: /Users/lucaforstner/conductor/workspaces/braintrust-sdk-javascript/baghdad/e2e/.bt-tmp/run-omEvbT/scenarios/pi-coding-agent-instrumentation", + "type": "text" + } + ], + "thinking": { + "type": "disabled" + }, + "tools": [ + { + "cache_control": { + "type": "ephemeral" + }, + "description": "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.", + "eager_input_streaming": true, + "input_schema": { + "properties": { + "command": { + "description": "Bash command to execute", + "type": "string" + }, + "timeout": { + "description": "Timeout in seconds (optional, no default timeout)", + "type": "number" + } + }, + "required": ["command"], + "type": "object" + }, + "name": "bash" + } + ] + } + }, + "headers": {}, + "method": "POST", + "url": "https://api.anthropic.com/v1/messages" + }, + "response": { + "body": { + "chunks": [ + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01LqYsv4F2obg4yhc9uihdq4\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2225,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":57,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}}", + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01Q4LnHM8Ehq3ZVe5cUVnNPE\",\"name\":\"bash\",\"input\":{},\"caller\":{\"type\":\"direct\"}}}", + "event: ping\ndata: {\"type\":\"ping\"}", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"command\\\": \\\"printf pi_tool_ok\"}}", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"}\"}}", + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}", + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":2225,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":57}}", + "event: message_stop\ndata: {\"type\":\"message_stop\"}" + ], + "kind": "sse" + }, + "headers": { + "anthropic-organization-id": "27796668-7351-40ac-acc4-024aee8995a5", + "anthropic-ratelimit-input-tokens-limit": "4000000", + "anthropic-ratelimit-input-tokens-remaining": "3999000", + "anthropic-ratelimit-input-tokens-reset": "2026-06-15T13:41:54Z", + "anthropic-ratelimit-output-tokens-limit": "800000", + "anthropic-ratelimit-output-tokens-remaining": "800000", + "anthropic-ratelimit-output-tokens-reset": "2026-06-15T13:41:54Z", + "anthropic-ratelimit-requests-limit": "20000", + "anthropic-ratelimit-requests-remaining": "19999", + "anthropic-ratelimit-requests-reset": "2026-06-15T13:41:54Z", + "anthropic-ratelimit-tokens-limit": "4800000", + "anthropic-ratelimit-tokens-remaining": "4799000", + "anthropic-ratelimit-tokens-reset": "2026-06-15T13:41:54Z", + "cache-control": "no-cache", + "cf-cache-status": "DYNAMIC", + "cf-ray": "a0c1feb71f2d23b5-VIE", + "connection": "keep-alive", + "content-encoding": "gzip", + "content-security-policy": "default-src 'none'; frame-ancestors 'none'", + "content-type": "text/event-stream; charset=utf-8", + "date": "Mon, 15 Jun 2026 13:41:55 GMT", + "request-id": "req_011Cc59CR6Yq9vuRBV88XUTo", + "server": "cloudflare", + "set-cookie": "[REDACTED]", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "traceresponse": "00-2dea5198e9d2ddabf43804824f83afe4-d1a93703197df856-01", + "transfer-encoding": "chunked", + "vary": "Accept-Encoding", + "x-robots-tag": "none" + }, + "status": 200, + "statusText": "OK" + } + }, + { + "callIndex": 1, + "id": "0c74acf5b76aad36", + "matchKey": "POST api.anthropic.com/v1/messages", + "recordedAt": "2026-06-15T13:41:56.111Z", + "request": { + "body": { + "kind": "json", + "value": { + "max_tokens": 64000, + "messages": [ + { + "content": [ + { + "text": "Use the bash tool to run `printf pi_tool_ok` exactly once, then reply with exactly PI_CODING_AGENT_OK and include the command output.", + "type": "text" + } + ], + "role": "user" + }, + { + "content": [ + { + "id": "toolu_01Q4LnHM8Ehq3ZVe5cUVnNPE", + "input": { + "command": "printf pi_tool_ok" + }, + "name": "bash", + "type": "tool_use" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "cache_control": { + "type": "ephemeral" + }, + "content": "pi_tool_ok", + "is_error": false, + "tool_use_id": "toolu_01Q4LnHM8Ehq3ZVe5cUVnNPE", + "type": "tool_result" + } + ], + "role": "user" + } + ], + "model": "claude-haiku-4-5", + "stream": true, + "system": [ + { + "cache_control": { + "type": "ephemeral" + }, + "text": "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.\n\nAvailable tools:\n- bash: Execute bash commands (ls, grep, find, etc.)\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.\n\nGuidelines:\n- Use bash for file operations like ls, rg, find\n- Be concise in your responses\n- Show file paths clearly when working with files\n\nPi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):\n- Main documentation: /Users/lucaforstner/conductor/workspaces/braintrust-sdk-javascript/baghdad/e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-0c3eb72dc7a20ce8/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/README.md\n- Additional docs: /Users/lucaforstner/conductor/workspaces/braintrust-sdk-javascript/baghdad/e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-0c3eb72dc7a20ce8/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/docs\n- Examples: /Users/lucaforstner/conductor/workspaces/braintrust-sdk-javascript/baghdad/e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-0c3eb72dc7a20ce8/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/examples (extensions, custom tools, SDK)\n- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory\n- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md)\n- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing\n- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)\n\n\n\nProject-specific instructions and guidelines:\n\n\n# Braintrust JavaScript SDK Monorepo\n\nTypeScript SDKs and integrations for Braintrust. Uses `pnpm` workspaces.\n\n## Repository Structure\n\n```text\n.\n├── js/ # Main `braintrust` package\n├── integrations/ # Integration packages (@braintrust/*)\n├── e2e/ # End-to-end scenario tests (mock server + subprocess isolation)\n├── docs/ # Docs and reference material\n└── internal/ # Internal test fixtures and golden projects\n```\n\n## Setup\n\n```bash\nmise install # Install toolchain and dependencies\n```\n\n## Build\n\n```bash\npnpm run build # Build all workspace packages (from repo root)\n```\n\n## Testing\n\nUses Vitest. Prefer running the **narrowest relevant test** rather than the full suite.\n\n**From `js/` directory:**\n\n```bash\npnpm test # Core vitest suite (excludes wrappers)\npnpm test -- -t \"test name\" # Filter by test name\npnpm run test:checks # Hermetic tests (core + vitest wrapper)\n```\n\n**E2E tests (`e2e/`):**\n\nEach scenario runs the SDK in a subprocess against a mock Braintrust server and snapshots the results. No API keys required for replay; recording needs provider keys.\n\n```bash\npnpm run test:e2e # Run all e2e scenarios (from repo root)\npnpm run test:e2e:update # Update e2e snapshots without re-recording cassettes\npnpm run test:e2e:record # Re-record provider cassettes and update snapshots\n```\n\nWhen adding or modifying e2e tests, run the relevant e2e verification twice before stopping so flakes are caught proactively. After running `pnpm run test:e2e:update` or `pnpm run test:e2e:record`, always run the normal e2e tests afterward to verify there is no snapshot drift or unstable output.\n\nSpan-tree snapshots are paired: `*.span-tree.json` is the structural contract, and `*.span-tree.txt` is the human-readable ASCII tree generated from the same normalized spans. Both files are asserted and should be updated together through `pnpm run test:e2e:update` or `pnpm run test:e2e:record`; do not hand-edit only one side of the pair.\n\n**From repo root:**\n\n```bash\npnpm run test # Run all workspace tests via turbo\n```\n\n## Linting & Formatting\n\nRun from the repo root. **Always run `fix:formatting` before committing** — there is a pre-commit hook that will reject unformatted code.\n\n```bash\npnpm run formatting # Check formatting (prettier)\npnpm run lint # Run eslint checks\npnpm run fix:formatting # Auto-fix formatting\npnpm run fix:lint # Auto-fix eslint issues\n```\n\n\n\n\n\nCurrent date: 2026-06-15\nCurrent working directory: /Users/lucaforstner/conductor/workspaces/braintrust-sdk-javascript/baghdad/e2e/.bt-tmp/run-omEvbT/scenarios/pi-coding-agent-instrumentation", + "type": "text" + } + ], + "thinking": { + "type": "disabled" + }, + "tools": [ + { + "cache_control": { + "type": "ephemeral" + }, + "description": "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.", + "eager_input_streaming": true, + "input_schema": { + "properties": { + "command": { + "description": "Bash command to execute", + "type": "string" + }, + "timeout": { + "description": "Timeout in seconds (optional, no default timeout)", + "type": "number" + } + }, + "required": ["command"], + "type": "object" + }, + "name": "bash" + } + ] + } + }, + "headers": {}, + "method": "POST", + "url": "https://api.anthropic.com/v1/messages" + }, + "response": { + "body": { + "chunks": [ + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01AX1cFbRF4q8BvTWBegpRgy\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2299,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}}", + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}", + "event: ping\ndata: {\"type\":\"ping\"}", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"PI\"}}", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"_CODING_AGENT_OK\\n\\nCommand output: `pi_tool_ok`\"}}", + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}", + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":2299,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":23}}", + "event: message_stop\ndata: {\"type\":\"message_stop\"}" + ], + "kind": "sse" + }, + "headers": { + "anthropic-organization-id": "27796668-7351-40ac-acc4-024aee8995a5", + "anthropic-ratelimit-input-tokens-limit": "4000000", + "anthropic-ratelimit-input-tokens-remaining": "3999000", + "anthropic-ratelimit-input-tokens-reset": "2026-06-15T13:41:55Z", + "anthropic-ratelimit-output-tokens-limit": "800000", + "anthropic-ratelimit-output-tokens-remaining": "800000", + "anthropic-ratelimit-output-tokens-reset": "2026-06-15T13:41:55Z", + "anthropic-ratelimit-requests-limit": "20000", + "anthropic-ratelimit-requests-remaining": "19999", + "anthropic-ratelimit-requests-reset": "2026-06-15T13:41:55Z", + "anthropic-ratelimit-tokens-limit": "4800000", + "anthropic-ratelimit-tokens-remaining": "4799000", + "anthropic-ratelimit-tokens-reset": "2026-06-15T13:41:55Z", + "cache-control": "no-cache", + "cf-cache-status": "DYNAMIC", + "cf-ray": "a0c1febc68d023b5-VIE", + "connection": "keep-alive", + "content-encoding": "gzip", + "content-security-policy": "default-src 'none'; frame-ancestors 'none'", + "content-type": "text/event-stream; charset=utf-8", + "date": "Mon, 15 Jun 2026 13:41:55 GMT", + "request-id": "req_011Cc59CVKoHopGJNCHdryA6", + "server": "cloudflare", + "set-cookie": "[REDACTED]", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "traceresponse": "00-0b14267e48bbd02df788256b562248a6-517917da69453693-01", + "transfer-encoding": "chunked", + "vary": "Accept-Encoding", + "x-robots-tag": "none" + }, + "status": 200, + "statusText": "OK" + } + } + ], + "meta": { + "createdAt": "2026-06-15T13:36:47.679Z" + } +} diff --git a/e2e/scenarios/pi-coding-agent-instrumentation/__snapshots__/pi-coding-agent-v079-auto-hook.span-tree.json b/e2e/scenarios/pi-coding-agent-instrumentation/__snapshots__/pi-coding-agent-v079-auto-hook.span-tree.json new file mode 100644 index 000000000..b08553edc --- /dev/null +++ b/e2e/scenarios/pi-coding-agent-instrumentation/__snapshots__/pi-coding-agent-v079-auto-hook.span-tree.json @@ -0,0 +1,146 @@ +{ + "span_tree": [ + { + "name": "pi-coding-agent-root", + "type": "task", + "children": [ + { + "name": "pi-coding-agent-prompt-operation", + "children": [ + { + "name": "AgentSession.prompt", + "type": "task", + "children": [ + { + "name": "anthropic.messages.create", + "type": "llm", + "children": [], + "input": [ + { + "content": "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.\n\nAvailable tools:\n- bash: Execute bash commands (ls, grep, find, etc.)\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.\n\nGuidelines:\n- Use bash for file operations like ls, rg, find\n- Be concise in your responses\n- Show file paths clearly when working with files\n\nPi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):\n- Main documentation: /e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/README.md\n- Additional docs: /e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/docs\n- Examples: /e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/examples (extensions, custom tools, SDK)\n- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory\n- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md)\n- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing\n- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)\n\n\n\nProject-specific instructions and guidelines:\n\n/AGENTS.md\">\n# Braintrust JavaScript SDK Monorepo\n\nTypeScript SDKs and integrations for Braintrust. Uses `pnpm` workspaces.\n\n## Repository Structure\n\n```text\n.\n├── js/ # Main `braintrust` package\n├── integrations/ # Integration packages (@braintrust/*)\n├── e2e/ # End-to-end scenario tests (mock server + subprocess isolation)\n├── docs/ # Docs and reference material\n└── internal/ # Internal test fixtures and golden projects\n```\n\n## Setup\n\n```bash\nmise install # Install toolchain and dependencies\n```\n\n## Build\n\n```bash\npnpm run build # Build all workspace packages (from repo root)\n```\n\n## Testing\n\nUses Vitest. Prefer running the **narrowest relevant test** rather than the full suite.\n\n**From `js/` directory:**\n\n```bash\npnpm test # Core vitest suite (excludes wrappers)\npnpm test -- -t \"test name\" # Filter by test name\npnpm run test:checks # Hermetic tests (core + vitest wrapper)\n```\n\n**E2E tests (`e2e/`):**\n\nEach scenario runs the SDK in a subprocess against a mock Braintrust server and snapshots the results. No API keys required for replay; recording needs provider keys.\n\n```bash\npnpm run test:e2e # Run all e2e scenarios (from repo root)\npnpm run test:e2e:update # Update e2e snapshots without re-recording cassettes\npnpm run test:e2e:record # Re-record provider cassettes and update snapshots\n```\n\nWhen adding or modifying e2e tests, run the relevant e2e verification twice before stopping so flakes are caught proactively. After running `pnpm run test:e2e:update` or `pnpm run test:e2e:record`, always run the normal e2e tests afterward to verify there is no snapshot drift or unstable output.\n\nSpan-tree snapshots are paired: `*.span-tree.json` is the structural contract, and `*.span-tree.txt` is the human-readable ASCII tree generated from the same normalized spans. Both files are asserted and should be updated together through `pnpm run test:e2e:update` or `pnpm run test:e2e:record`; do not hand-edit only one side of the pair.\n\n**From repo root:**\n\n```bash\npnpm run test # Run all workspace tests via turbo\n```\n\n## Linting & Formatting\n\nRun from the repo root. **Always run `fix:formatting` before committing** — there is a pre-commit hook that will reject unformatted code.\n\n```bash\npnpm run formatting # Check formatting (prettier)\npnpm run lint # Run eslint checks\npnpm run fix:formatting # Auto-fix formatting\npnpm run fix:lint # Auto-fix eslint issues\n```\n\n\n\n\n\nCurrent date: \nCurrent working directory: /e2e/scenarios/pi-coding-agent-instrumentation/", + "role": "system" + }, + { + "content": [ + { + "text": "Use the bash tool to run `printf pi_tool_ok` exactly once, then reply with exactly PI_CODING_AGENT_OK and include the command output.", + "type": "text" + } + ], + "role": "user" + }, + { + "content": null, + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"command\":\"printf pi_tool_ok\"}", + "name": "bash" + }, + "id": "", + "type": "function" + } + ] + }, + { + "content": [ + { + "text": "pi_tool_ok", + "type": "text" + } + ], + "role": "tool", + "tool_call_id": "" + } + ], + "output": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "PI_CODING_AGENT_OK\n\nCommand output: `pi_tool_ok`", + "role": "assistant" + } + } + ], + "metadata": { + "model": "claude-haiku-4-5", + "pi_coding_agent.api": "anthropic-messages", + "pi_coding_agent.model": "claude-haiku-4-5", + "pi_coding_agent.operation": "agent.streamFn", + "pi_coding_agent.stop_reason": "stop", + "provider": "anthropic" + }, + "metrics": { + "completion_tokens": 23, + "duration": 0, + "prompt_cache_creation_tokens": 0, + "prompt_cached_tokens": 0, + "prompt_tokens": 2299, + "time_to_first_token": 0, + "tokens": 2322 + } + }, + { + "name": "bash", + "type": "tool", + "children": [], + "input": { + "command": "printf pi_tool_ok" + }, + "output": { + "content": [ + { + "text": "pi_tool_ok", + "type": "text" + } + ] + }, + "metadata": { + "gen_ai.tool.call.id": "", + "gen_ai.tool.name": "bash", + "pi_coding_agent.tool.name": "bash" + } + } + ], + "input": "Use the bash tool to run `printf pi_tool_ok` exactly once, then reply with exactly PI_CODING_AGENT_OK and include the command output.", + "output": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "PI_CODING_AGENT_OK\n\nCommand output: `pi_tool_ok`", + "role": "assistant" + } + } + ], + "metadata": { + "model": "claude-haiku-4-5", + "pi_coding_agent.api": "anthropic-messages", + "pi_coding_agent.model": "claude-haiku-4-5", + "pi_coding_agent.operation": "AgentSession.prompt", + "pi_coding_agent.source": "rpc", + "provider": "anthropic" + }, + "metrics": { + "completion_tokens": 80, + "duration": 0, + "prompt_cache_creation_tokens": 0, + "prompt_cached_tokens": 0, + "prompt_tokens": 4524, + "tokens": 4604 + } + } + ], + "metadata": { + "operation": "prompt" + } + } + ], + "metadata": { + "scenario": "pi-coding-agent-instrumentation" + } + } + ] +} diff --git a/e2e/scenarios/pi-coding-agent-instrumentation/__snapshots__/pi-coding-agent-v079-auto-hook.span-tree.txt b/e2e/scenarios/pi-coding-agent-instrumentation/__snapshots__/pi-coding-agent-v079-auto-hook.span-tree.txt new file mode 100644 index 000000000..3540d5d48 --- /dev/null +++ b/e2e/scenarios/pi-coding-agent-instrumentation/__snapshots__/pi-coding-agent-v079-auto-hook.span-tree.txt @@ -0,0 +1,121 @@ +span_tree: +└── pi-coding-agent-root [task] + metadata: { + "scenario": "pi-coding-agent-instrumentation" + } + └── pi-coding-agent-prompt-operation + metadata: { + "operation": "prompt" + } + └── AgentSession.prompt [task] + input: "Use the bash tool to run `printf pi_tool_ok` exactly once, then reply with exactly PI_CODING_AGENT_OK and include the command output." + output: [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "PI_CODING_AGENT_OK\n\nCommand output: `pi_tool_ok`", + "role": "assistant" + } + } + ] + metadata: { + "model": "claude-haiku-4-5", + "pi_coding_agent.api": "anthropic-messages", + "pi_coding_agent.model": "claude-haiku-4-5", + "pi_coding_agent.operation": "AgentSession.prompt", + "pi_coding_agent.source": "rpc", + "provider": "anthropic" + } + metrics: { + "completion_tokens": 80, + "duration": 0, + "prompt_cache_creation_tokens": 0, + "prompt_cached_tokens": 0, + "prompt_tokens": 4524, + "tokens": 4604 + } + ├── anthropic.messages.create [llm] + │ input: [ + │ { + │ "content": "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.\n\nAvailable tools:\n- bash: Execute bash commands (ls, grep, find, etc.)\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.\n\nGuidelines:\n- Use bash for file operations like ls, rg, find\n- Be concise in your responses\n- Show file paths clearly when working with files\n\nPi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):\n- Main documentation: /e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/README.md\n- Additional docs: /e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/docs\n- Examples: /e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/examples (extensions, custom tools, SDK)\n- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory\n- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md)\n- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing\n- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)\n\n\n\nProject-specific instructions and guidelines:\n\n/AGENTS.md\">\n# Braintrust JavaScript SDK Monorepo\n\nTypeScript SDKs and integrations for Braintrust. Uses `pnpm` workspaces.\n\n## Repository Structure\n\n```text\n.\n├── js/ # Main `braintrust` package\n├── integrations/ # Integration packages (@braintrust/*)\n├── e2e/ # End-to-end scenario tests (mock server + subprocess isolation)\n├── docs/ # Docs and reference material\n└── internal/ # Internal test fixtures and golden projects\n```\n\n## Setup\n\n```bash\nmise install # Install toolchain and dependencies\n```\n\n## Build\n\n```bash\npnpm run build # Build all workspace packages (from repo root)\n```\n\n## Testing\n\nUses Vitest. Prefer running the **narrowest relevant test** rather than the full suite.\n\n**From `js/` directory:**\n\n```bash\npnpm test # Core vitest suite (excludes wrappers)\npnpm test -- -t \"test name\" # Filter by test name\npnpm run test:checks # Hermetic tests (core + vitest wrapper)\n```\n\n**E2E tests (`e2e/`):**\n\nEach scenario runs the SDK in a subprocess against a mock Braintrust server and snapshots the results. No API keys required for replay; recording needs provider keys.\n\n```bash\npnpm run test:e2e # Run all e2e scenarios (from repo root)\npnpm run test:e2e:update # Update e2e snapshots without re-recording cassettes\npnpm run test:e2e:record # Re-record provider cassettes and update snapshots\n```\n\nWhen adding or modifying e2e tests, run the relevant e2e verification twice before stopping so flakes are caught proactively. After running `pnpm run test:e2e:update` or `pnpm run test:e2e:record`, always run the normal e2e tests afterward to verify there is no snapshot drift or unstable output.\n\nSpan-tree snapshots are paired: `*.span-tree.json` is the structural contract, and `*.span-tree.txt` is the human-readable ASCII tree generated from the same normalized spans. Both files are asserted and should be updated together through `pnpm run test:e2e:update` or `pnpm run test:e2e:record`; do not hand-edit only one side of the pair.\n\n**From repo root:**\n\n```bash\npnpm run test # Run all workspace tests via turbo\n```\n\n## Linting & Formatting\n\nRun from the repo root. **Always run `fix:formatting` before committing** — there is a pre-commit hook that will reject unformatted code.\n\n```bash\npnpm run formatting # Check formatting (prettier)\npnpm run lint # Run eslint checks\npnpm run fix:formatting # Auto-fix formatting\npnpm run fix:lint # Auto-fix eslint issues\n```\n\n\n\n\n\nCurrent date: \nCurrent working directory: /e2e/scenarios/pi-coding-agent-instrumentation/", + │ "role": "system" + │ }, + │ { + │ "content": [ + │ { + │ "text": "Use the bash tool to run `printf pi_tool_ok` exactly once, then reply with exactly PI_CODING_AGENT_OK and include the command output.", + │ "type": "text" + │ } + │ ], + │ "role": "user" + │ }, + │ { + │ "content": null, + │ "role": "assistant", + │ "tool_calls": [ + │ { + │ "function": { + │ "arguments": "{\"command\":\"printf pi_tool_ok\"}", + │ "name": "bash" + │ }, + │ "id": "", + │ "type": "function" + │ } + │ ] + │ }, + │ { + │ "content": [ + │ { + │ "text": "pi_tool_ok", + │ "type": "text" + │ } + │ ], + │ "role": "tool", + │ "tool_call_id": "" + │ } + │ ] + │ output: [ + │ { + │ "finish_reason": "stop", + │ "index": 0, + │ "message": { + │ "content": "PI_CODING_AGENT_OK\n\nCommand output: `pi_tool_ok`", + │ "role": "assistant" + │ } + │ } + │ ] + │ metadata: { + │ "model": "claude-haiku-4-5", + │ "pi_coding_agent.api": "anthropic-messages", + │ "pi_coding_agent.model": "claude-haiku-4-5", + │ "pi_coding_agent.operation": "agent.streamFn", + │ "pi_coding_agent.stop_reason": "stop", + │ "provider": "anthropic" + │ } + │ metrics: { + │ "completion_tokens": 23, + │ "duration": 0, + │ "prompt_cache_creation_tokens": 0, + │ "prompt_cached_tokens": 0, + │ "prompt_tokens": 2299, + │ "time_to_first_token": 0, + │ "tokens": 2322 + │ } + └── bash [tool] + input: { + "command": "printf pi_tool_ok" + } + output: { + "content": [ + { + "text": "pi_tool_ok", + "type": "text" + } + ] + } + metadata: { + "gen_ai.tool.call.id": "", + "gen_ai.tool.name": "bash", + "pi_coding_agent.tool.name": "bash" + } diff --git a/e2e/scenarios/pi-coding-agent-instrumentation/__snapshots__/pi-coding-agent-v079-wrapped.span-tree.json b/e2e/scenarios/pi-coding-agent-instrumentation/__snapshots__/pi-coding-agent-v079-wrapped.span-tree.json new file mode 100644 index 000000000..b08553edc --- /dev/null +++ b/e2e/scenarios/pi-coding-agent-instrumentation/__snapshots__/pi-coding-agent-v079-wrapped.span-tree.json @@ -0,0 +1,146 @@ +{ + "span_tree": [ + { + "name": "pi-coding-agent-root", + "type": "task", + "children": [ + { + "name": "pi-coding-agent-prompt-operation", + "children": [ + { + "name": "AgentSession.prompt", + "type": "task", + "children": [ + { + "name": "anthropic.messages.create", + "type": "llm", + "children": [], + "input": [ + { + "content": "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.\n\nAvailable tools:\n- bash: Execute bash commands (ls, grep, find, etc.)\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.\n\nGuidelines:\n- Use bash for file operations like ls, rg, find\n- Be concise in your responses\n- Show file paths clearly when working with files\n\nPi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):\n- Main documentation: /e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/README.md\n- Additional docs: /e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/docs\n- Examples: /e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/examples (extensions, custom tools, SDK)\n- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory\n- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md)\n- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing\n- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)\n\n\n\nProject-specific instructions and guidelines:\n\n/AGENTS.md\">\n# Braintrust JavaScript SDK Monorepo\n\nTypeScript SDKs and integrations for Braintrust. Uses `pnpm` workspaces.\n\n## Repository Structure\n\n```text\n.\n├── js/ # Main `braintrust` package\n├── integrations/ # Integration packages (@braintrust/*)\n├── e2e/ # End-to-end scenario tests (mock server + subprocess isolation)\n├── docs/ # Docs and reference material\n└── internal/ # Internal test fixtures and golden projects\n```\n\n## Setup\n\n```bash\nmise install # Install toolchain and dependencies\n```\n\n## Build\n\n```bash\npnpm run build # Build all workspace packages (from repo root)\n```\n\n## Testing\n\nUses Vitest. Prefer running the **narrowest relevant test** rather than the full suite.\n\n**From `js/` directory:**\n\n```bash\npnpm test # Core vitest suite (excludes wrappers)\npnpm test -- -t \"test name\" # Filter by test name\npnpm run test:checks # Hermetic tests (core + vitest wrapper)\n```\n\n**E2E tests (`e2e/`):**\n\nEach scenario runs the SDK in a subprocess against a mock Braintrust server and snapshots the results. No API keys required for replay; recording needs provider keys.\n\n```bash\npnpm run test:e2e # Run all e2e scenarios (from repo root)\npnpm run test:e2e:update # Update e2e snapshots without re-recording cassettes\npnpm run test:e2e:record # Re-record provider cassettes and update snapshots\n```\n\nWhen adding or modifying e2e tests, run the relevant e2e verification twice before stopping so flakes are caught proactively. After running `pnpm run test:e2e:update` or `pnpm run test:e2e:record`, always run the normal e2e tests afterward to verify there is no snapshot drift or unstable output.\n\nSpan-tree snapshots are paired: `*.span-tree.json` is the structural contract, and `*.span-tree.txt` is the human-readable ASCII tree generated from the same normalized spans. Both files are asserted and should be updated together through `pnpm run test:e2e:update` or `pnpm run test:e2e:record`; do not hand-edit only one side of the pair.\n\n**From repo root:**\n\n```bash\npnpm run test # Run all workspace tests via turbo\n```\n\n## Linting & Formatting\n\nRun from the repo root. **Always run `fix:formatting` before committing** — there is a pre-commit hook that will reject unformatted code.\n\n```bash\npnpm run formatting # Check formatting (prettier)\npnpm run lint # Run eslint checks\npnpm run fix:formatting # Auto-fix formatting\npnpm run fix:lint # Auto-fix eslint issues\n```\n\n\n\n\n\nCurrent date: \nCurrent working directory: /e2e/scenarios/pi-coding-agent-instrumentation/", + "role": "system" + }, + { + "content": [ + { + "text": "Use the bash tool to run `printf pi_tool_ok` exactly once, then reply with exactly PI_CODING_AGENT_OK and include the command output.", + "type": "text" + } + ], + "role": "user" + }, + { + "content": null, + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"command\":\"printf pi_tool_ok\"}", + "name": "bash" + }, + "id": "", + "type": "function" + } + ] + }, + { + "content": [ + { + "text": "pi_tool_ok", + "type": "text" + } + ], + "role": "tool", + "tool_call_id": "" + } + ], + "output": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "PI_CODING_AGENT_OK\n\nCommand output: `pi_tool_ok`", + "role": "assistant" + } + } + ], + "metadata": { + "model": "claude-haiku-4-5", + "pi_coding_agent.api": "anthropic-messages", + "pi_coding_agent.model": "claude-haiku-4-5", + "pi_coding_agent.operation": "agent.streamFn", + "pi_coding_agent.stop_reason": "stop", + "provider": "anthropic" + }, + "metrics": { + "completion_tokens": 23, + "duration": 0, + "prompt_cache_creation_tokens": 0, + "prompt_cached_tokens": 0, + "prompt_tokens": 2299, + "time_to_first_token": 0, + "tokens": 2322 + } + }, + { + "name": "bash", + "type": "tool", + "children": [], + "input": { + "command": "printf pi_tool_ok" + }, + "output": { + "content": [ + { + "text": "pi_tool_ok", + "type": "text" + } + ] + }, + "metadata": { + "gen_ai.tool.call.id": "", + "gen_ai.tool.name": "bash", + "pi_coding_agent.tool.name": "bash" + } + } + ], + "input": "Use the bash tool to run `printf pi_tool_ok` exactly once, then reply with exactly PI_CODING_AGENT_OK and include the command output.", + "output": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "PI_CODING_AGENT_OK\n\nCommand output: `pi_tool_ok`", + "role": "assistant" + } + } + ], + "metadata": { + "model": "claude-haiku-4-5", + "pi_coding_agent.api": "anthropic-messages", + "pi_coding_agent.model": "claude-haiku-4-5", + "pi_coding_agent.operation": "AgentSession.prompt", + "pi_coding_agent.source": "rpc", + "provider": "anthropic" + }, + "metrics": { + "completion_tokens": 80, + "duration": 0, + "prompt_cache_creation_tokens": 0, + "prompt_cached_tokens": 0, + "prompt_tokens": 4524, + "tokens": 4604 + } + } + ], + "metadata": { + "operation": "prompt" + } + } + ], + "metadata": { + "scenario": "pi-coding-agent-instrumentation" + } + } + ] +} diff --git a/e2e/scenarios/pi-coding-agent-instrumentation/__snapshots__/pi-coding-agent-v079-wrapped.span-tree.txt b/e2e/scenarios/pi-coding-agent-instrumentation/__snapshots__/pi-coding-agent-v079-wrapped.span-tree.txt new file mode 100644 index 000000000..3540d5d48 --- /dev/null +++ b/e2e/scenarios/pi-coding-agent-instrumentation/__snapshots__/pi-coding-agent-v079-wrapped.span-tree.txt @@ -0,0 +1,121 @@ +span_tree: +└── pi-coding-agent-root [task] + metadata: { + "scenario": "pi-coding-agent-instrumentation" + } + └── pi-coding-agent-prompt-operation + metadata: { + "operation": "prompt" + } + └── AgentSession.prompt [task] + input: "Use the bash tool to run `printf pi_tool_ok` exactly once, then reply with exactly PI_CODING_AGENT_OK and include the command output." + output: [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "PI_CODING_AGENT_OK\n\nCommand output: `pi_tool_ok`", + "role": "assistant" + } + } + ] + metadata: { + "model": "claude-haiku-4-5", + "pi_coding_agent.api": "anthropic-messages", + "pi_coding_agent.model": "claude-haiku-4-5", + "pi_coding_agent.operation": "AgentSession.prompt", + "pi_coding_agent.source": "rpc", + "provider": "anthropic" + } + metrics: { + "completion_tokens": 80, + "duration": 0, + "prompt_cache_creation_tokens": 0, + "prompt_cached_tokens": 0, + "prompt_tokens": 4524, + "tokens": 4604 + } + ├── anthropic.messages.create [llm] + │ input: [ + │ { + │ "content": "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.\n\nAvailable tools:\n- bash: Execute bash commands (ls, grep, find, etc.)\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.\n\nGuidelines:\n- Use bash for file operations like ls, rg, find\n- Be concise in your responses\n- Show file paths clearly when working with files\n\nPi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):\n- Main documentation: /e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/README.md\n- Additional docs: /e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/docs\n- Examples: /e2e/.bt-tmp/scenario-deps/pi-coding-agent-instrumentation-locked-/node_modules/.pnpm/@earendil-works+pi-coding-agent@0.79.1_ws@8.21.0_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent/examples (extensions, custom tools, SDK)\n- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory\n- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md)\n- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing\n- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)\n\n\n\nProject-specific instructions and guidelines:\n\n/AGENTS.md\">\n# Braintrust JavaScript SDK Monorepo\n\nTypeScript SDKs and integrations for Braintrust. Uses `pnpm` workspaces.\n\n## Repository Structure\n\n```text\n.\n├── js/ # Main `braintrust` package\n├── integrations/ # Integration packages (@braintrust/*)\n├── e2e/ # End-to-end scenario tests (mock server + subprocess isolation)\n├── docs/ # Docs and reference material\n└── internal/ # Internal test fixtures and golden projects\n```\n\n## Setup\n\n```bash\nmise install # Install toolchain and dependencies\n```\n\n## Build\n\n```bash\npnpm run build # Build all workspace packages (from repo root)\n```\n\n## Testing\n\nUses Vitest. Prefer running the **narrowest relevant test** rather than the full suite.\n\n**From `js/` directory:**\n\n```bash\npnpm test # Core vitest suite (excludes wrappers)\npnpm test -- -t \"test name\" # Filter by test name\npnpm run test:checks # Hermetic tests (core + vitest wrapper)\n```\n\n**E2E tests (`e2e/`):**\n\nEach scenario runs the SDK in a subprocess against a mock Braintrust server and snapshots the results. No API keys required for replay; recording needs provider keys.\n\n```bash\npnpm run test:e2e # Run all e2e scenarios (from repo root)\npnpm run test:e2e:update # Update e2e snapshots without re-recording cassettes\npnpm run test:e2e:record # Re-record provider cassettes and update snapshots\n```\n\nWhen adding or modifying e2e tests, run the relevant e2e verification twice before stopping so flakes are caught proactively. After running `pnpm run test:e2e:update` or `pnpm run test:e2e:record`, always run the normal e2e tests afterward to verify there is no snapshot drift or unstable output.\n\nSpan-tree snapshots are paired: `*.span-tree.json` is the structural contract, and `*.span-tree.txt` is the human-readable ASCII tree generated from the same normalized spans. Both files are asserted and should be updated together through `pnpm run test:e2e:update` or `pnpm run test:e2e:record`; do not hand-edit only one side of the pair.\n\n**From repo root:**\n\n```bash\npnpm run test # Run all workspace tests via turbo\n```\n\n## Linting & Formatting\n\nRun from the repo root. **Always run `fix:formatting` before committing** — there is a pre-commit hook that will reject unformatted code.\n\n```bash\npnpm run formatting # Check formatting (prettier)\npnpm run lint # Run eslint checks\npnpm run fix:formatting # Auto-fix formatting\npnpm run fix:lint # Auto-fix eslint issues\n```\n\n\n\n\n\nCurrent date: \nCurrent working directory: /e2e/scenarios/pi-coding-agent-instrumentation/", + │ "role": "system" + │ }, + │ { + │ "content": [ + │ { + │ "text": "Use the bash tool to run `printf pi_tool_ok` exactly once, then reply with exactly PI_CODING_AGENT_OK and include the command output.", + │ "type": "text" + │ } + │ ], + │ "role": "user" + │ }, + │ { + │ "content": null, + │ "role": "assistant", + │ "tool_calls": [ + │ { + │ "function": { + │ "arguments": "{\"command\":\"printf pi_tool_ok\"}", + │ "name": "bash" + │ }, + │ "id": "", + │ "type": "function" + │ } + │ ] + │ }, + │ { + │ "content": [ + │ { + │ "text": "pi_tool_ok", + │ "type": "text" + │ } + │ ], + │ "role": "tool", + │ "tool_call_id": "" + │ } + │ ] + │ output: [ + │ { + │ "finish_reason": "stop", + │ "index": 0, + │ "message": { + │ "content": "PI_CODING_AGENT_OK\n\nCommand output: `pi_tool_ok`", + │ "role": "assistant" + │ } + │ } + │ ] + │ metadata: { + │ "model": "claude-haiku-4-5", + │ "pi_coding_agent.api": "anthropic-messages", + │ "pi_coding_agent.model": "claude-haiku-4-5", + │ "pi_coding_agent.operation": "agent.streamFn", + │ "pi_coding_agent.stop_reason": "stop", + │ "provider": "anthropic" + │ } + │ metrics: { + │ "completion_tokens": 23, + │ "duration": 0, + │ "prompt_cache_creation_tokens": 0, + │ "prompt_cached_tokens": 0, + │ "prompt_tokens": 2299, + │ "time_to_first_token": 0, + │ "tokens": 2322 + │ } + └── bash [tool] + input: { + "command": "printf pi_tool_ok" + } + output: { + "content": [ + { + "text": "pi_tool_ok", + "type": "text" + } + ] + } + metadata: { + "gen_ai.tool.call.id": "", + "gen_ai.tool.name": "bash", + "pi_coding_agent.tool.name": "bash" + } diff --git a/e2e/scenarios/pi-coding-agent-instrumentation/assertions.ts b/e2e/scenarios/pi-coding-agent-instrumentation/assertions.ts new file mode 100644 index 000000000..c51445e47 --- /dev/null +++ b/e2e/scenarios/pi-coding-agent-instrumentation/assertions.ts @@ -0,0 +1,236 @@ +import { beforeAll, describe, expect, test } from "vitest"; +import type { CapturedLogEvent } from "../../helpers/mock-braintrust-server"; +import { resolveFileSnapshotPath } from "../../helpers/file-snapshot"; +import { + effectiveScenarioTimeoutMs, + withScenarioHarness, + type ScenarioRunContext, +} from "../../helpers/scenario-harness"; +import { findChildSpans, findLatestSpan } from "../../helpers/trace-selectors"; +import { + matchSpanTreeSnapshot, + spanTreeFields, + type SpanTreeEntry, + type SpanTreeFields, +} from "../../helpers/span-tree"; +import { ROOT_NAME, SCENARIO_NAME } from "./scenario.impl.mjs"; + +type RunPiCodingAgentScenario = (harness: { + runNodeScenarioDir: (options: { + entry: string; + nodeArgs: string[]; + runContext?: ScenarioRunContext; + scenarioDir: string; + timeoutMs: number; + }) => Promise; + runScenarioDir: (options: { + entry: string; + runContext?: ScenarioRunContext; + scenarioDir: string; + timeoutMs: number; + }) => Promise; +}) => Promise; + +const METADATA_KEYS = [ + "provider", + "model", + "operation", + "scenario", + "gen_ai.tool.call.id", + "gen_ai.tool.name", + "pi_coding_agent.api", + "pi_coding_agent.model", + "pi_coding_agent.operation", + "pi_coding_agent.source", + "pi_coding_agent.stop_reason", + "pi_coding_agent.tool.name", +] as const; + +function normalizeToolCallIds(value: unknown): unknown { + if (typeof value === "string" && value.startsWith("toolu_")) { + return ""; + } + + if (Array.isArray(value)) { + return value.map((entry) => normalizeToolCallIds(entry)); + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + normalizeToolCallIds(entry), + ]), + ); + } + + return value; +} + +function snapshotFields(event: CapturedLogEvent): SpanTreeFields { + const fields = spanTreeFields(event); + const metadata = + fields.metadata && + typeof fields.metadata === "object" && + !Array.isArray(fields.metadata) + ? Object.fromEntries( + Object.entries(fields.metadata).filter(([key]) => + METADATA_KEYS.includes(key as (typeof METADATA_KEYS)[number]), + ), + ) + : undefined; + + return { + ...fields, + input: normalizeToolCallIds(fields.input), + output: normalizeToolCallIds(fields.output), + metadata: normalizeToolCallIds(metadata), + }; +} + +function findPiTask(events: CapturedLogEvent[]) { + const operation = findLatestSpan(events, "pi-coding-agent-prompt-operation"); + return findChildSpans(events, "AgentSession.prompt", operation?.span.id).at( + -1, + ); +} + +function summarize(events: CapturedLogEvent[]): SpanTreeEntry[] { + const operation = findLatestSpan(events, "pi-coding-agent-prompt-operation"); + const task = findPiTask(events); + const llm = findChildSpans( + events, + "anthropic.messages.create", + task?.span.id, + ).at(-1); + const tool = findChildSpans(events, "bash", task?.span.id).at(-1); + + return [ + findLatestSpan(events, ROOT_NAME), + operation, + task, + llm, + tool, + ].flatMap((event) => + event + ? [ + { + event, + fields: snapshotFields(event), + }, + ] + : [], + ); +} + +export function definePiCodingAgentInstrumentationAssertions(options: { + name: string; + runScenario: RunPiCodingAgentScenario; + snapshotName: string; + testFileUrl: string; + timeoutMs: number; +}): void { + const snapshotPath = resolveFileSnapshotPath( + options.testFileUrl, + `${options.snapshotName}.span-tree.json`, + ); + const timeoutMs = effectiveScenarioTimeoutMs(options.timeoutMs); + const testConfig = { timeout: timeoutMs }; + + describe(options.name, () => { + let events: CapturedLogEvent[] = []; + let setupError: string | undefined; + + beforeAll(async () => { + try { + await withScenarioHarness(async (harness) => { + await options.runScenario(harness); + events = harness.events(); + }); + } catch (error) { + setupError = error instanceof Error ? error.message : String(error); + } + }, timeoutMs); + + test("captures the root trace", testConfig, () => { + expect(setupError).toBeUndefined(); + const root = findLatestSpan(events, ROOT_NAME); + + expect(root).toBeDefined(); + expect(root?.row.metadata).toMatchObject({ scenario: SCENARIO_NAME }); + }); + + test( + "captures prompt task with nested LLM and tool spans", + testConfig, + () => { + expect(setupError).toBeUndefined(); + const operation = findLatestSpan( + events, + "pi-coding-agent-prompt-operation", + ); + const task = findPiTask(events); + const anthropicSpans = events.filter( + (event) => event.span.name === "anthropic.messages.create", + ); + const llmSpans = findChildSpans( + events, + "anthropic.messages.create", + task?.span.id, + ); + const llm = findChildSpans( + events, + "anthropic.messages.create", + task?.span.id, + ).at(-1); + const tool = findChildSpans(events, "bash", task?.span.id).at(-1); + + expect(operation).toBeDefined(); + expect(task).toBeDefined(); + expect(task?.span.parentIds).toEqual([operation?.span.id ?? ""]); + expect(task?.span.type).toBe("task"); + expect(task?.row.metadata).toMatchObject({ + "pi_coding_agent.operation": "AgentSession.prompt", + provider: "anthropic", + }); + + expect(llm).toBeDefined(); + expect(anthropicSpans).toHaveLength(2); + expect(llmSpans).toHaveLength(2); + expect(llm?.span.type).toBe("llm"); + expect(llm?.row.metadata).toMatchObject({ + "pi_coding_agent.api": "anthropic-messages", + provider: "anthropic", + }); + expect(String(llm?.row.metadata?.model)).toContain("claude-haiku-4-5"); + expect(llm?.input).toEqual(expect.any(Array)); + expect(llm?.output).toBeDefined(); + expect(llm?.metrics).toEqual( + expect.objectContaining({ + completion_tokens: expect.any(Number), + prompt_tokens: expect.any(Number), + tokens: expect.any(Number), + }), + ); + + expect(tool).toBeDefined(); + expect(tool?.span.type).toBe("tool"); + expect(tool?.input).toMatchObject({ + command: expect.stringContaining("printf pi_tool_ok"), + }); + expect(tool?.row.metadata).toMatchObject({ + "gen_ai.tool.name": "bash", + }); + expect(tool?.row.metadata?.["gen_ai.tool.call.id"]).toEqual( + expect.any(String), + ); + expect(JSON.stringify(tool?.output)).toContain("pi_tool_ok"); + }, + ); + + test("matches the shared span tree snapshot", testConfig, async () => { + expect(setupError).toBeUndefined(); + await matchSpanTreeSnapshot(summarize(events), snapshotPath); + }); + }); +} diff --git a/e2e/scenarios/pi-coding-agent-instrumentation/cassette-filter.mjs b/e2e/scenarios/pi-coding-agent-instrumentation/cassette-filter.mjs new file mode 100644 index 000000000..7890b9d5c --- /dev/null +++ b/e2e/scenarios/pi-coding-agent-instrumentation/cassette-filter.mjs @@ -0,0 +1,14 @@ +// @ts-check +/** @type {import("@braintrust/seinfeld").FilterSpec} */ +export const filter = [ + "default", + { + normalizeRequest(req) { + const url = new URL(req.url); + if (req.method === "POST" && url.hostname === "api.anthropic.com") { + return { ...req, body: { kind: "empty" } }; + } + return req; + }, + }, +]; diff --git a/e2e/scenarios/pi-coding-agent-instrumentation/package.json b/e2e/scenarios/pi-coding-agent-instrumentation/package.json new file mode 100644 index 000000000..17e0abdc1 --- /dev/null +++ b/e2e/scenarios/pi-coding-agent-instrumentation/package.json @@ -0,0 +1,14 @@ +{ + "name": "@braintrust/e2e-pi-coding-agent-instrumentation", + "private": true, + "braintrustScenario": { + "canary": { + "dependencies": { + "pi-coding-agent-v079": "@earendil-works/pi-coding-agent@latest" + } + } + }, + "dependencies": { + "pi-coding-agent-v079": "npm:@earendil-works/pi-coding-agent@0.79.1" + } +} diff --git a/e2e/scenarios/pi-coding-agent-instrumentation/pnpm-lock.yaml b/e2e/scenarios/pi-coding-agent-instrumentation/pnpm-lock.yaml new file mode 100644 index 000000000..4d37005d8 --- /dev/null +++ b/e2e/scenarios/pi-coding-agent-instrumentation/pnpm-lock.yaml @@ -0,0 +1,1298 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + pi-coding-agent-v079: + specifier: npm:@earendil-works/pi-coding-agent@0.79.1 + version: '@earendil-works/pi-coding-agent@0.79.1(ws@8.21.0)(zod@4.4.3)' + +packages: + + '@anthropic-ai/sdk@0.91.1': + resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.20': + resolution: {integrity: sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.46': + resolution: {integrity: sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.48': + resolution: {integrity: sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.53': + resolution: {integrity: sha512-ZfdhIOR41q8TcWEnUac+gCOb+O2LBWdHLmjedXpXz4IEFW2ppNuFcm6p0sMTavpM+zD5TYfpH5Gp7guRyqSgsQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.52': + resolution: {integrity: sha512-9hu2oR0qH7Fst5Tzdx+UWxm+w5zCXtErTLtOOW5hwwQc170CLwOeniRxyFY6s9mHfGEfC5zFukNBdKBwJR8mhQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.55': + resolution: {integrity: sha512-zMGLa/dhESVqmCD7mmIFFKSwSFrJGScvCXcjvBZEVOOMauFS5JRQvLTMukFpMEFWiV6dTAlsen2ATDBulLPtbg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.46': + resolution: {integrity: sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.52': + resolution: {integrity: sha512-nb2/n4o/HQf+FVpVbZe9vCTFngmuDoIsltMgLAtjixaKzvzhB4J8WSDFyWgnErgLHk55ctWH+I4PU+LIHhyffg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.52': + resolution: {integrity: sha512-lKj6aRSGbqLmpYmM24bY7a1Xmfcq2vkE3hv8CSPYfc1yCu0BPu/XEJ1L4Fm61MsU6ULLNSG8UGsffNoFUBjESA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/eventstream-handler-node@3.972.21': + resolution: {integrity: sha512-mVC0hOmwGJmNFezZ+wM8Sqfap/LjsMavEf2Evl0YWrLAcrdZOEdjnY8nRvgakVViWJSGm2eJxLuPVHGdeV06kA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.17': + resolution: {integrity: sha512-tdbnXbw73ww62ABWP0G0Z/euvFowEEvAoi/zG4NaZo7HJFpfGho/Z65HyVzkJLT1cMsUregr4pTyxljlarT0wA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.28': + resolution: {integrity: sha512-SCW06Zjugn86pq7+dxGnFcyWJuEWHT753HTU/Vj/OzVxP+NoShwdAr4ynxAcvWL883OgRVbSqW3ohnjIxwXjjw==} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.997.20': + resolution: {integrity: sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.34': + resolution: {integrity: sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1048.0': + resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1066.0': + resolution: {integrity: sha512-UqEUJq7dqa44hneLDUcX7UJy95cg8YqEWyakRpvIPnrNS3Mq+UlQHgCDGu5pvwAPtlIW4qcYbvW6reG6++FyvA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.12': + resolution: {integrity: sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.7': + resolution: {integrity: sha512-M0D6oIpohdNHjc7udzTHEQyot0+0iuA36jc2I9Hps+f/GtKi2HO/pyijQnCnNcwZqLB5+rtn81z3eZK/GyjAmA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.29': + resolution: {integrity: sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@earendil-works/pi-agent-core@0.79.4': + resolution: {integrity: sha512-xkaZ3yK2XbP9HYdHrrdj/6HqZPM0o/mwbjMSU4RTJyR3HjDG0ZrPz76Hg6s0W+G4u6PpJr1mGx/srCG+3eQA8A==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-ai@0.79.4': + resolution: {integrity: sha512-Z1j+YP+6ZyPBKDUoc5m0GO/o1hPK17fWeErtDgegCTpm2dcKzuFvL/7GTqHeJkVkfpeXRwO37xOfgozQbK6EUw==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-coding-agent@0.79.1': + resolution: {integrity: sha512-dLnje4U5H3/ZytJpvhjhPINeDT/yvx85e4OH/ziMQRLpPlfNP12/peY9jRQd4W11Xth2+y2xGAFwS+NeVf2ZwA==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-tui@0.79.4': + resolution: {integrity: sha512-/ZhfFiHSBMH7AbDrBQIN+UWlJnl9tSEpLYICRGGMzmNfyCqX+30NYacIhyOEaD8R5rS6wJZysAOPU0yNwigbXw==} + engines: {node: '>=22.19.0'} + + '@google/genai@1.52.0': + resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@mariozechner/clipboard-darwin-arm64@0.3.9': + resolution: {integrity: sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@mariozechner/clipboard-darwin-universal@0.3.9': + resolution: {integrity: sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==} + engines: {node: '>= 10'} + os: [darwin] + + '@mariozechner/clipboard-darwin-x64@0.3.9': + resolution: {integrity: sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': + resolution: {integrity: sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': + resolution: {integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': + resolution: {integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': + resolution: {integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@mariozechner/clipboard-linux-x64-musl@0.3.9': + resolution: {integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': + resolution: {integrity: sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': + resolution: {integrity: sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@mariozechner/clipboard@0.3.9': + resolution: {integrity: sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==} + engines: {node: '>= 10'} + + '@mistralai/mistralai@2.2.1': + resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} + + '@nodable/entities@2.2.0': + resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + + '@silvia-odwyer/photon-node@0.3.4': + resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} + + '@smithy/core@3.24.7': + resolution: {integrity: sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.3.9': + resolution: {integrity: sha512-ZlfJ/4Fa3jYb+3eaohPfG9utX9HmdhFNcFtpoGAhUhdynAOmGXtmigbi7eEiONKM+ykHw8RwKuDEb85Lx7t7fA==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.4.7': + resolution: {integrity: sha512-NslaM2ir0N2hisDmzXLstPaVINZheh8SokyOC++kzFPloZucL2R7Y7bS57mSzx/1Fc/fqmn7twjkeezTTrV0EA==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/node-http-handler@4.7.3': + resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.7.8': + resolution: {integrity: sha512-f+DbsWUwSbtMu1a/j8Y93KiU1SRg9nyzfjereqn1BJ33QOTUXxdlYvVXMhAYl1vuR1Kmna5aIJe09KSIfyFNYw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.4.7': + resolution: {integrity: sha512-LwQZazFayImv+IOm0S0enoLeUJwmAlhGC5O6YCcLWezyu08dF46GOxPOq35OpBIHkgd7OvNvBStIFwVNyrvoBw==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.4': + resolution: {integrity: sha512-B2S9+UGm1+/pHkcx3ZoLVX1a+pmSk8rqxRR+ZsNqZaJ5q9FWX9AFGQVM4qG5+OBeQUZVy99HY8HqW8gK/wgXzQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@types/node@25.9.3': + resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + anynum@1.0.0: + resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + + fast-xml-parser@5.7.3: + resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} + hasBin: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + gaxios@7.1.5: + resolution: {integrity: sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + google-auth-library@10.7.0: + resolution: {integrity: sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + + hosted-git-info@9.0.3: + resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} + engines: {node: ^20.17.0 || >=22.9.0} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + openai@6.26.0: + resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + protobufjs@7.6.4: + resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} + engines: {node: '>=12.0.0'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + strnum@2.4.0: + resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} + + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typebox@1.1.38: + resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici@8.3.0: + resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==} + engines: {node: '>=22.19.0'} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@anthropic-ai/sdk@0.91.1(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.4.3 + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.12 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.12 + '@aws-sdk/util-locate-window': 3.965.7 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.12 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.12 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/credential-provider-node': 3.972.55 + '@aws-sdk/eventstream-handler-node': 3.972.21 + '@aws-sdk/middleware-eventstream': 3.972.17 + '@aws-sdk/middleware-websocket': 3.972.28 + '@aws-sdk/token-providers': 3.1048.0 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/fetch-http-handler': 5.4.7 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/core@3.974.20': + dependencies: + '@aws-sdk/types': 3.973.12 + '@aws-sdk/xml-builder': 3.972.29 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.7 + '@smithy/signature-v4': 5.4.7 + '@smithy/types': 4.14.4 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.46': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.48': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/fetch-http-handler': 5.4.7 + '@smithy/node-http-handler': 4.7.8 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.53': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/credential-provider-env': 3.972.46 + '@aws-sdk/credential-provider-http': 3.972.48 + '@aws-sdk/credential-provider-login': 3.972.52 + '@aws-sdk/credential-provider-process': 3.972.46 + '@aws-sdk/credential-provider-sso': 3.972.52 + '@aws-sdk/credential-provider-web-identity': 3.972.52 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/credential-provider-imds': 4.3.9 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.52': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.55': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.46 + '@aws-sdk/credential-provider-http': 3.972.48 + '@aws-sdk/credential-provider-ini': 3.972.53 + '@aws-sdk/credential-provider-process': 3.972.46 + '@aws-sdk/credential-provider-sso': 3.972.52 + '@aws-sdk/credential-provider-web-identity': 3.972.52 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/credential-provider-imds': 4.3.9 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.46': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.52': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/token-providers': 3.1066.0 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.52': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/eventstream-handler-node@3.972.21': + dependencies: + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.17': + dependencies: + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.28': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/fetch-http-handler': 5.4.7 + '@smithy/signature-v4': 5.4.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.20': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/signature-v4-multi-region': 3.996.34 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/fetch-http-handler': 5.4.7 + '@smithy/node-http-handler': 4.7.8 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.34': + dependencies: + '@aws-sdk/types': 3.973.12 + '@smithy/signature-v4': 5.4.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1048.0': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1066.0': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.12': + dependencies: + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.7': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.29': + dependencies: + '@smithy/types': 4.14.4 + fast-xml-parser: 5.7.3 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + + '@babel/runtime@7.29.7': {} + + '@earendil-works/pi-agent-core@0.79.4(ws@8.21.0)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-ai': 0.79.4(ws@8.21.0)(zod@4.4.3) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.79.4(ws@8.21.0)(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0 + '@mistralai/mistralai': 2.2.1 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.21.0)(zod@4.4.3) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-coding-agent@0.79.1(ws@8.21.0)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-agent-core': 0.79.4(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.79.4(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-tui': 0.79.4 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + typebox: 1.1.38 + undici: 8.3.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-tui@0.79.4': + dependencies: + get-east-asian-width: 1.6.0 + marked: 15.0.12 + + '@google/genai@1.52.0': + dependencies: + google-auth-library: 10.7.0 + p-retry: 4.6.2 + protobufjs: 7.6.4 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@mariozechner/clipboard-darwin-arm64@0.3.9': + optional: true + + '@mariozechner/clipboard-darwin-universal@0.3.9': + optional: true + + '@mariozechner/clipboard-darwin-x64@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-x64-musl@0.3.9': + optional: true + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': + optional: true + + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': + optional: true + + '@mariozechner/clipboard@0.3.9': + optionalDependencies: + '@mariozechner/clipboard-darwin-arm64': 0.3.9 + '@mariozechner/clipboard-darwin-universal': 0.3.9 + '@mariozechner/clipboard-darwin-x64': 0.3.9 + '@mariozechner/clipboard-linux-arm64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-arm64-musl': 0.3.9 + '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-musl': 0.3.9 + '@mariozechner/clipboard-win32-arm64-msvc': 0.3.9 + '@mariozechner/clipboard-win32-x64-msvc': 0.3.9 + optional: true + + '@mistralai/mistralai@2.2.1': + dependencies: + ws: 8.21.0 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@nodable/entities@2.2.0': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + + '@silvia-odwyer/photon-node@0.3.4': {} + + '@smithy/core@3.24.7': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.3.9': + dependencies: + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.4.7': + dependencies: + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.3': + dependencies: + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.8': + dependencies: + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@smithy/signature-v4@5.4.7': + dependencies: + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@smithy/types@4.14.4': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@types/node@25.9.3': + dependencies: + undici-types: 7.24.6 + + '@types/retry@0.12.0': {} + + agent-base@7.1.4: {} + + anynum@1.0.0: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + bignumber.js@9.3.1: {} + + bowser@2.14.1: {} + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + buffer-equal-constant-time@1.0.1: {} + + chalk@5.6.2: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + data-uri-to-buffer@4.0.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + diff@8.0.4: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + extend@3.0.2: {} + + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 + + fast-xml-parser@5.7.3: + dependencies: + '@nodable/entities': 2.2.0 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.4.0 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + gaxios@7.1.5: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.5 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + get-east-asian-width@1.6.0: {} + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + google-auth-library@10.7.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.5 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + graceful-fs@4.2.11: {} + + highlight.js@10.7.3: {} + + hosted-git-info@9.0.3: + dependencies: + lru-cache: 11.5.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ignore@7.0.5: {} + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + long@5.3.2: {} + + lru-cache@11.5.1: {} + + marked@15.0.12: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minipass@7.1.3: {} + + ms@2.1.3: {} + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + openai@6.26.0(ws@8.21.0)(zod@4.4.3): + optionalDependencies: + ws: 8.21.0 + zod: 4.4.3 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + partial-json@0.1.7: {} + + path-expression-matcher@1.5.0: {} + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.1 + minipass: 7.1.3 + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + protobufjs@7.6.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 25.9.3 + long: 5.3.2 + + retry@0.12.0: {} + + retry@0.13.1: {} + + safe-buffer@5.2.1: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + strnum@2.4.0: + dependencies: + anynum: 1.0.0 + + ts-algebra@2.0.0: {} + + tslib@2.8.1: {} + + typebox@1.1.38: {} + + undici-types@7.24.6: {} + + undici@8.3.0: {} + + web-streams-polyfill@3.3.3: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + ws@8.21.0: {} + + xml-naming@0.1.0: {} + + yaml@2.9.0: {} + + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/e2e/scenarios/pi-coding-agent-instrumentation/scenario.impl.mjs b/e2e/scenarios/pi-coding-agent-instrumentation/scenario.impl.mjs new file mode 100644 index 000000000..aa6f28014 --- /dev/null +++ b/e2e/scenarios/pi-coding-agent-instrumentation/scenario.impl.mjs @@ -0,0 +1,73 @@ +import { wrapPiCodingAgentSDK } from "braintrust"; +import { + runOperation, + runTracedScenario, +} from "../../helpers/provider-runtime.mjs"; + +export const ROOT_NAME = "pi-coding-agent-root"; +export const SCENARIO_NAME = "pi-coding-agent-instrumentation"; + +async function runPiCodingAgentScenario({ decorateSDK, sdk }) { + const instrumentedSDK = decorateSDK ? decorateSDK(sdk) : sdk; + const { AuthStorage, ModelRegistry, SessionManager, createAgentSession } = + instrumentedSDK; + + const authStorage = AuthStorage.inMemory(); + authStorage.setRuntimeApiKey("anthropic", process.env.ANTHROPIC_API_KEY); + const modelRegistry = ModelRegistry.inMemory(authStorage); + modelRegistry.registerProvider("anthropic", { + baseUrl: process.env.ANTHROPIC_BASE_URL, + }); + const model = modelRegistry.find("anthropic", "claude-haiku-4-5"); + if (!model) { + throw new Error("Expected Pi Coding Agent Anthropic model"); + } + + let session; + await runTracedScenario({ + callback: async () => { + await runOperation( + "pi-coding-agent-prompt-operation", + "prompt", + async () => { + const result = await createAgentSession({ + authStorage, + cwd: process.cwd(), + model, + modelRegistry, + sessionManager: SessionManager.inMemory(process.cwd()), + thinkingLevel: "off", + tools: ["bash"], + }); + session = result.session; + await session.prompt( + "Use the bash tool to run `printf pi_tool_ok` exactly once, then reply with exactly PI_CODING_AGENT_OK and include the command output.", + { expandPromptTemplates: false, source: "rpc" }, + ); + }, + ); + }, + flushCount: 2, + flushDelayMs: 250, + metadata: { + scenario: SCENARIO_NAME, + }, + projectNameBase: "e2e-pi-coding-agent-instrumentation", + rootName: ROOT_NAME, + }); + + session?.dispose?.(); +} + +export async function runWrappedPiCodingAgentInstrumentation(sdk) { + await runPiCodingAgentScenario({ + decorateSDK: wrapPiCodingAgentSDK, + sdk, + }); +} + +export async function runAutoPiCodingAgentInstrumentation(sdk) { + await runPiCodingAgentScenario({ + sdk, + }); +} diff --git a/e2e/scenarios/pi-coding-agent-instrumentation/scenario.pi-coding-agent-v079-wrapped.mjs b/e2e/scenarios/pi-coding-agent-instrumentation/scenario.pi-coding-agent-v079-wrapped.mjs new file mode 100644 index 000000000..b3a2ddbe7 --- /dev/null +++ b/e2e/scenarios/pi-coding-agent-instrumentation/scenario.pi-coding-agent-v079-wrapped.mjs @@ -0,0 +1,5 @@ +import * as piCodingAgent from "pi-coding-agent-v079"; +import { runMain } from "../../helpers/provider-runtime.mjs"; +import { runWrappedPiCodingAgentInstrumentation } from "./scenario.impl.mjs"; + +runMain(async () => runWrappedPiCodingAgentInstrumentation(piCodingAgent)); diff --git a/e2e/scenarios/pi-coding-agent-instrumentation/scenario.pi-coding-agent-v079.mjs b/e2e/scenarios/pi-coding-agent-instrumentation/scenario.pi-coding-agent-v079.mjs new file mode 100644 index 000000000..90d1ac809 --- /dev/null +++ b/e2e/scenarios/pi-coding-agent-instrumentation/scenario.pi-coding-agent-v079.mjs @@ -0,0 +1,5 @@ +import * as piCodingAgent from "pi-coding-agent-v079"; +import { runMain } from "../../helpers/provider-runtime.mjs"; +import { runAutoPiCodingAgentInstrumentation } from "./scenario.impl.mjs"; + +runMain(async () => runAutoPiCodingAgentInstrumentation(piCodingAgent)); diff --git a/e2e/scenarios/pi-coding-agent-instrumentation/scenario.test.ts b/e2e/scenarios/pi-coding-agent-instrumentation/scenario.test.ts new file mode 100644 index 000000000..6e8069d1f --- /dev/null +++ b/e2e/scenarios/pi-coding-agent-instrumentation/scenario.test.ts @@ -0,0 +1,66 @@ +import { describe } from "vitest"; +import { + prepareScenarioDir, + readInstalledPackageVersion, + resolveScenarioDir, +} from "../../helpers/scenario-harness"; +import { definePiCodingAgentInstrumentationAssertions } from "./assertions"; + +const originalScenarioDir = resolveScenarioDir(import.meta.url); +const scenarioDir = await prepareScenarioDir({ + scenarioDir: originalScenarioDir, +}); +const TIMEOUT_MS = 240_000; +const piCodingAgentScenario = { + autoEntry: "scenario.pi-coding-agent-v079.mjs", + autoSnapshotName: "pi-coding-agent-v079-auto-hook", + dependencyName: "pi-coding-agent-v079", + version: await readInstalledPackageVersion( + scenarioDir, + "pi-coding-agent-v079", + ), + wrapperEntry: "scenario.pi-coding-agent-v079-wrapped.mjs", + wrapperSnapshotName: "pi-coding-agent-v079-wrapped", + variantKey: "pi-coding-agent-v079", +}; + +describe("wrapped instrumentation", () => { + definePiCodingAgentInstrumentationAssertions({ + name: `pi coding agent ${piCodingAgentScenario.version}`, + runScenario: async ({ runNodeScenarioDir }) => { + await runNodeScenarioDir({ + entry: piCodingAgentScenario.wrapperEntry, + runContext: { + variantKey: piCodingAgentScenario.variantKey, + originalScenarioDir, + }, + scenarioDir, + timeoutMs: TIMEOUT_MS, + }); + }, + snapshotName: piCodingAgentScenario.wrapperSnapshotName, + testFileUrl: import.meta.url, + timeoutMs: TIMEOUT_MS, + }); +}); + +describe("auto-hook instrumentation", () => { + definePiCodingAgentInstrumentationAssertions({ + name: `pi coding agent ${piCodingAgentScenario.version}`, + runScenario: async ({ runNodeScenarioDir }) => { + await runNodeScenarioDir({ + entry: piCodingAgentScenario.autoEntry, + nodeArgs: ["--import", "braintrust/hook.mjs"], + runContext: { + variantKey: piCodingAgentScenario.variantKey, + originalScenarioDir, + }, + scenarioDir, + timeoutMs: TIMEOUT_MS, + }); + }, + snapshotName: piCodingAgentScenario.autoSnapshotName, + testFileUrl: import.meta.url, + timeoutMs: TIMEOUT_MS, + }); +}); diff --git a/js/src/auto-instrumentations/configs/all.ts b/js/src/auto-instrumentations/configs/all.ts index fb245e41f..ad179de34 100644 --- a/js/src/auto-instrumentations/configs/all.ts +++ b/js/src/auto-instrumentations/configs/all.ts @@ -23,6 +23,7 @@ import { openaiConfigs } from "./openai"; import { openAICodexConfigs } from "./openai-codex"; import { openRouterConfigs } from "./openrouter"; import { openRouterAgentConfigs } from "./openrouter-agent"; +import { piCodingAgentConfigs } from "./pi-coding-agent"; interface InstrumentationConfigGroup { integrations: readonly (keyof InstrumentationIntegrationsConfig)[]; @@ -76,6 +77,10 @@ const defaultInstrumentationConfigGroups: readonly InstrumentationConfigGroup[] integrations: ["gitHubCopilot"], configs: gitHubCopilotConfigs, }, + { + integrations: ["piCodingAgent"], + configs: piCodingAgentConfigs, + }, { integrations: ["flue"], configs: flueConfigs, diff --git a/js/src/auto-instrumentations/configs/pi-coding-agent.test.ts b/js/src/auto-instrumentations/configs/pi-coding-agent.test.ts new file mode 100644 index 000000000..b35b470cc --- /dev/null +++ b/js/src/auto-instrumentations/configs/pi-coding-agent.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { readDisabledInstrumentationEnvConfig } from "../../instrumentation/config"; +import { getDefaultInstrumentationConfigs } from "./all"; +import { piCodingAgentConfigs } from "./pi-coding-agent"; + +const piCodingAgentChannelName = "AgentSession.prompt"; + +describe("piCodingAgentConfigs", () => { + it("targets the Pi Coding Agent library AgentSession.prompt entrypoint", () => { + expect(piCodingAgentConfigs).toHaveLength(1); + expect(piCodingAgentConfigs[0]).toMatchObject({ + channelName: piCodingAgentChannelName, + module: { + name: "@earendil-works/pi-coding-agent", + versionRange: ">=0.79.0 <0.80.0", + filePath: "dist/core/agent-session.js", + }, + functionQuery: { + className: "AgentSession", + methodName: "prompt", + kind: "Async", + }, + }); + expect(piCodingAgentConfigs[0].module.filePath).not.toContain("cli"); + }); + + it("is included by default and disabled by Pi Coding Agent env aliases", () => { + expect( + getDefaultInstrumentationConfigs().some( + (config) => config.channelName === piCodingAgentChannelName, + ), + ).toBe(true); + + for (const alias of [ + "pi-coding-agent", + "pi-coding-agent-sdk", + "picodingagent", + "picodingagentsdk", + "@earendil-works/pi-coding-agent", + ]) { + const disabledConfig = + readDisabledInstrumentationEnvConfig(alias).integrations; + + expect(disabledConfig).toMatchObject({ piCodingAgent: false }); + expect( + getDefaultInstrumentationConfigs({ + disabledIntegrationConfig: disabledConfig, + }).some((config) => config.channelName === piCodingAgentChannelName), + ).toBe(false); + } + }); +}); diff --git a/js/src/auto-instrumentations/configs/pi-coding-agent.ts b/js/src/auto-instrumentations/configs/pi-coding-agent.ts new file mode 100644 index 000000000..488c9cb1b --- /dev/null +++ b/js/src/auto-instrumentations/configs/pi-coding-agent.ts @@ -0,0 +1,20 @@ +import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import { piCodingAgentChannels } from "../../instrumentation/plugins/pi-coding-agent-channels"; + +const piCodingAgentVersionRange = ">=0.79.0 <0.80.0"; + +export const piCodingAgentConfigs: InstrumentationConfig[] = [ + { + channelName: piCodingAgentChannels.prompt.channelName, + module: { + name: "@earendil-works/pi-coding-agent", + versionRange: piCodingAgentVersionRange, + filePath: "dist/core/agent-session.js", + }, + functionQuery: { + className: "AgentSession", + methodName: "prompt", + kind: "Async", + }, + }, +]; diff --git a/js/src/auto-instrumentations/index.ts b/js/src/auto-instrumentations/index.ts index 06d551824..e316649c0 100644 --- a/js/src/auto-instrumentations/index.ts +++ b/js/src/auto-instrumentations/index.ts @@ -46,6 +46,7 @@ export { groqConfigs } from "./configs/groq"; export { genkitConfigs } from "./configs/genkit"; export { gitHubCopilotConfigs } from "./configs/github-copilot"; export { langchainConfigs } from "./configs/langchain"; +export { piCodingAgentConfigs } from "./configs/pi-coding-agent"; // Re-export orchestrion configuration types // Note: ModuleMetadata and FunctionQuery are properties of InstrumentationConfig, diff --git a/js/src/exports.ts b/js/src/exports.ts index ee9f7a205..5f50e01a5 100644 --- a/js/src/exports.ts +++ b/js/src/exports.ts @@ -183,6 +183,7 @@ export type { MastraObservabilityExporter } from "./wrappers/mastra"; export { wrapClaudeAgentSDK } from "./wrappers/claude-agent-sdk/claude-agent-sdk"; export { wrapOpenAICodexSDK } from "./wrappers/openai-codex"; export { wrapCursorSDK } from "./wrappers/cursor-sdk"; +export { wrapPiCodingAgentSDK } from "./wrappers/pi-coding-agent"; export { wrapGoogleGenAI } from "./wrappers/google-genai"; export { wrapGoogleADK } from "./wrappers/google-adk"; export { wrapGenkit } from "./wrappers/genkit"; diff --git a/js/src/instrumentation/auto-instrumentation-suppression.test.ts b/js/src/instrumentation/auto-instrumentation-suppression.test.ts new file mode 100644 index 000000000..d4561deb7 --- /dev/null +++ b/js/src/instrumentation/auto-instrumentation-suppression.test.ts @@ -0,0 +1,51 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { configureNode } from "../node/config"; +import { + enterAutoInstrumentationAllowed, + isAutoInstrumentationSuppressed, + runWithAutoInstrumentationSuppressed, +} from "./auto-instrumentation-suppression"; + +describe("auto instrumentation suppression context", () => { + beforeAll(() => { + configureNode(); + }); + + it("suppresses auto instrumentation until a Pi tool context allows it", async () => { + expect(isAutoInstrumentationSuppressed()).toBe(false); + + await runWithAutoInstrumentationSuppressed(async () => { + expect(isAutoInstrumentationSuppressed()).toBe(true); + await Promise.resolve(); + expect(isAutoInstrumentationSuppressed()).toBe(true); + + const restoreToolContext = enterAutoInstrumentationAllowed(); + expect(isAutoInstrumentationSuppressed()).toBe(false); + + await runWithAutoInstrumentationSuppressed(async () => { + expect(isAutoInstrumentationSuppressed()).toBe(true); + await Promise.resolve(); + expect(isAutoInstrumentationSuppressed()).toBe(true); + }); + + expect(isAutoInstrumentationSuppressed()).toBe(false); + restoreToolContext(); + expect(isAutoInstrumentationSuppressed()).toBe(true); + }); + + expect(isAutoInstrumentationSuppressed()).toBe(false); + }); + + it("keeps instrumentation allowed until every active allow frame exits", async () => { + await runWithAutoInstrumentationSuppressed(async () => { + const restoreFirstTool = enterAutoInstrumentationAllowed(); + const restoreSecondTool = enterAutoInstrumentationAllowed(); + + expect(isAutoInstrumentationSuppressed()).toBe(false); + restoreFirstTool(); + expect(isAutoInstrumentationSuppressed()).toBe(false); + restoreSecondTool(); + expect(isAutoInstrumentationSuppressed()).toBe(true); + }); + }); +}); diff --git a/js/src/instrumentation/auto-instrumentation-suppression.ts b/js/src/instrumentation/auto-instrumentation-suppression.ts new file mode 100644 index 000000000..9d4c84633 --- /dev/null +++ b/js/src/instrumentation/auto-instrumentation-suppression.ts @@ -0,0 +1,85 @@ +import iso, { + type IsoAsyncLocalStorage, + type IsoTracingChannel, +} from "../isomorph"; + +type AutoInstrumentationSuppressionFrame = { + id: symbol; + mode: "allow" | "suppress"; +}; + +type AutoInstrumentationSuppressionState = { + frames: AutoInstrumentationSuppressionFrame[]; +}; + +let autoInstrumentationSuppressionStore: + | IsoAsyncLocalStorage + | undefined; + +function suppressionStore() { + autoInstrumentationSuppressionStore ??= iso.newAsyncLocalStorage< + AutoInstrumentationSuppressionState | undefined + >(); + return autoInstrumentationSuppressionStore; +} + +function currentFrames(): AutoInstrumentationSuppressionFrame[] { + return suppressionStore().getStore()?.frames ?? []; +} + +export function isAutoInstrumentationSuppressed(): boolean { + const frames = currentFrames(); + return frames[frames.length - 1]?.mode === "suppress"; +} + +export function runWithAutoInstrumentationSuppressed(callback: () => R): R { + const frame = { + id: Symbol("braintrust.auto-instrumentation-suppress"), + mode: "suppress" as const, + }; + return suppressionStore().run( + { frames: [...currentFrames(), frame] }, + callback, + ); +} + +export function bindAutoInstrumentationSuppressionToStart( + tracingChannel: Pick, "start">, +): (() => void) | undefined { + const startChannel = tracingChannel.start; + if (!startChannel) { + return undefined; + } + + const store = suppressionStore(); + startChannel.bindStore(store, () => ({ + frames: [ + ...currentFrames(), + { + id: Symbol("braintrust.auto-instrumentation-suppress"), + mode: "suppress" as const, + }, + ], + })); + + return () => { + startChannel.unbindStore(store); + }; +} + +export function enterAutoInstrumentationAllowed(): () => void { + const frame = { + id: Symbol("braintrust.auto-instrumentation-allow"), + mode: "allow" as const, + }; + suppressionStore().enterWith({ + frames: [...currentFrames(), frame], + }); + + return () => { + const frames = currentFrames().filter( + (candidate) => candidate.id !== frame.id, + ); + suppressionStore().enterWith(frames.length > 0 ? { frames } : undefined); + }; +} diff --git a/js/src/instrumentation/braintrust-plugin.test.ts b/js/src/instrumentation/braintrust-plugin.test.ts index 39f5bfb1b..34ac282af 100644 --- a/js/src/instrumentation/braintrust-plugin.test.ts +++ b/js/src/instrumentation/braintrust-plugin.test.ts @@ -15,6 +15,7 @@ import { CoherePlugin } from "./plugins/cohere-plugin"; import { GroqPlugin } from "./plugins/groq-plugin"; import { GitHubCopilotPlugin } from "./plugins/github-copilot-plugin"; import { LangChainPlugin } from "./plugins/langchain-plugin"; +import { PiCodingAgentPlugin } from "./plugins/pi-coding-agent-plugin"; function createPluginClassMock() { return vi.fn(function MockPlugin(this: { @@ -96,6 +97,10 @@ vi.mock("./plugins/langchain-plugin", () => ({ LangChainPlugin: createPluginClassMock(), })); +vi.mock("./plugins/pi-coding-agent-plugin", () => ({ + PiCodingAgentPlugin: createPluginClassMock(), +})); + describe("BraintrustPlugin", () => { beforeEach(() => { vi.clearAllMocks(); @@ -557,6 +562,7 @@ describe("BraintrustPlugin", () => { groq: false, gitHubCopilot: false, langchain: false, + piCodingAgent: false, }, }); plugin.enable(); @@ -576,6 +582,18 @@ describe("BraintrustPlugin", () => { expect(GroqPlugin).not.toHaveBeenCalled(); expect(GitHubCopilotPlugin).not.toHaveBeenCalled(); expect(LangChainPlugin).not.toHaveBeenCalled(); + expect(PiCodingAgentPlugin).not.toHaveBeenCalled(); + }); + + it("should not create Pi Coding Agent plugin when piCodingAgent: false", () => { + const plugin = new BraintrustPlugin({ + integrations: { piCodingAgent: false }, + }); + plugin.enable(); + + expect(PiCodingAgentPlugin).not.toHaveBeenCalled(); + expect(OpenAIPlugin).toHaveBeenCalledTimes(1); + expect(AnthropicPlugin).toHaveBeenCalledTimes(1); }); it("should allow selective enabling of plugins", () => { @@ -731,6 +749,8 @@ describe("BraintrustPlugin", () => { const mistralMock = vi.mocked(MistralPlugin).mock.results[0].value; const cohereMock = vi.mocked(CoherePlugin).mock.results[0].value; const groqMock = vi.mocked(GroqPlugin).mock.results[0].value; + const piCodingAgentMock = + vi.mocked(PiCodingAgentPlugin).mock.results[0].value; const langChainMock = vi.mocked(LangChainPlugin).mock.results[0].value; expect(openaiMock.enable).toHaveBeenCalledTimes(1); @@ -746,6 +766,7 @@ describe("BraintrustPlugin", () => { expect(mistralMock.enable).toHaveBeenCalledTimes(1); expect(cohereMock.enable).toHaveBeenCalledTimes(1); expect(groqMock.enable).toHaveBeenCalledTimes(1); + expect(piCodingAgentMock.enable).toHaveBeenCalledTimes(1); expect(langChainMock.enable).toHaveBeenCalledTimes(1); }); @@ -772,6 +793,8 @@ describe("BraintrustPlugin", () => { const mistralMock = vi.mocked(MistralPlugin).mock.results[0].value; const cohereMock = vi.mocked(CoherePlugin).mock.results[0].value; const groqMock = vi.mocked(GroqPlugin).mock.results[0].value; + const piCodingAgentMock = + vi.mocked(PiCodingAgentPlugin).mock.results[0].value; const langChainMock = vi.mocked(LangChainPlugin).mock.results[0].value; plugin.disable(); @@ -789,6 +812,7 @@ describe("BraintrustPlugin", () => { expect(mistralMock.disable).toHaveBeenCalledTimes(1); expect(cohereMock.disable).toHaveBeenCalledTimes(1); expect(groqMock.disable).toHaveBeenCalledTimes(1); + expect(piCodingAgentMock.disable).toHaveBeenCalledTimes(1); expect(langChainMock.disable).toHaveBeenCalledTimes(1); }); @@ -836,6 +860,7 @@ describe("BraintrustPlugin", () => { expect(MistralPlugin).not.toHaveBeenCalled(); expect(CoherePlugin).not.toHaveBeenCalled(); expect(GroqPlugin).not.toHaveBeenCalled(); + expect(PiCodingAgentPlugin).not.toHaveBeenCalled(); }); it("should allow re-enabling after disable", () => { @@ -860,6 +885,7 @@ describe("BraintrustPlugin", () => { expect(MistralPlugin).toHaveBeenCalledTimes(1); expect(CoherePlugin).toHaveBeenCalledTimes(1); expect(GroqPlugin).toHaveBeenCalledTimes(1); + expect(PiCodingAgentPlugin).toHaveBeenCalledTimes(1); expect(LangChainPlugin).toHaveBeenCalledTimes(1); }); diff --git a/js/src/instrumentation/braintrust-plugin.ts b/js/src/instrumentation/braintrust-plugin.ts index 59580aff2..36e7e83f0 100644 --- a/js/src/instrumentation/braintrust-plugin.ts +++ b/js/src/instrumentation/braintrust-plugin.ts @@ -18,6 +18,7 @@ import { GenkitPlugin } from "./plugins/genkit-plugin"; import { GitHubCopilotPlugin } from "./plugins/github-copilot-plugin"; import { FluePlugin } from "./plugins/flue-plugin"; import { LangChainPlugin } from "./plugins/langchain-plugin"; +import { PiCodingAgentPlugin } from "./plugins/pi-coding-agent-plugin"; import type { InstrumentationIntegrationsConfig } from "./config"; export interface BraintrustPluginConfig { @@ -69,6 +70,7 @@ export class BraintrustPlugin extends BasePlugin { private gitHubCopilotPlugin: GitHubCopilotPlugin | null = null; private fluePlugin: FluePlugin | null = null; private langChainPlugin: LangChainPlugin | null = null; + private piCodingAgentPlugin: PiCodingAgentPlugin | null = null; constructor(config: BraintrustPluginConfig = {}) { super(); @@ -171,6 +173,12 @@ export class BraintrustPlugin extends BasePlugin { this.gitHubCopilotPlugin = new GitHubCopilotPlugin(); this.gitHubCopilotPlugin.enable(); } + + if (integrations.piCodingAgent !== false) { + this.piCodingAgentPlugin = new PiCodingAgentPlugin(); + this.piCodingAgentPlugin.enable(); + } + if (getIntegrationConfig(integrations, "flue") !== false) { this.fluePlugin = new FluePlugin(); this.fluePlugin.enable(); @@ -274,6 +282,11 @@ export class BraintrustPlugin extends BasePlugin { this.gitHubCopilotPlugin = null; } + if (this.piCodingAgentPlugin) { + this.piCodingAgentPlugin.disable(); + this.piCodingAgentPlugin = null; + } + if (this.fluePlugin) { this.fluePlugin.disable(); this.fluePlugin = null; diff --git a/js/src/instrumentation/config.ts b/js/src/instrumentation/config.ts index 3d37b8a7e..299b2b827 100644 --- a/js/src/instrumentation/config.ts +++ b/js/src/instrumentation/config.ts @@ -21,6 +21,7 @@ export interface InstrumentationIntegrationsConfig { genkit?: boolean; gitHubCopilot?: boolean; openaiCodexSDK?: boolean; + piCodingAgent?: boolean; langchain?: boolean; langgraph?: boolean; } @@ -43,6 +44,11 @@ const envIntegrationAliases: Record< openaicodexsdk: "openaiCodexSDK", codex: "openaiCodexSDK", "codex-sdk": "openaiCodexSDK", + "pi-coding-agent": "piCodingAgent", + "pi-coding-agent-sdk": "piCodingAgent", + picodingagent: "piCodingAgent", + picodingagentsdk: "piCodingAgent", + "@earendil-works/pi-coding-agent": "piCodingAgent", anthropic: "anthropic", aisdk: "aisdk", "ai-sdk": "aisdk", @@ -113,6 +119,7 @@ export function getDefaultInstrumentationIntegrations(): Record< gitHubCopilot: true, langchain: true, langgraph: true, + piCodingAgent: true, }; } diff --git a/js/src/instrumentation/core/channel-tracing.test.ts b/js/src/instrumentation/core/channel-tracing.test.ts index bf1dbe9a5..6ad09b986 100644 --- a/js/src/instrumentation/core/channel-tracing.test.ts +++ b/js/src/instrumentation/core/channel-tracing.test.ts @@ -7,6 +7,7 @@ import { type TestBackgroundLogger, } from "../../logger"; import { configureNode } from "../../node/config"; +import { runWithAutoInstrumentationSuppressed } from "../auto-instrumentation-suppression"; import { channel, defineChannels } from "./channel-definitions"; import { traceAsyncChannel } from "./channel-tracing"; @@ -74,4 +75,37 @@ describe("traceAsyncChannel current span binding", () => { const spans = await backgroundLogger.drain(); expect(spans).toHaveLength(1); }); + + it("skips auto instrumentation spans while suppression is active", async () => { + const unsubscribe = traceAsyncChannel(testChannels.asyncCall, { + name: "channel-tracing-test", + type: "function", + extractInput: () => ({ + input: "input", + metadata: undefined, + }), + extractOutput: (result) => result, + extractMetrics: () => ({}), + }); + + try { + await runWithAutoInstrumentationSuppressed(() => + testChannels.asyncCall.tracePromise( + async () => { + expect(currentSpan()).toBe(NOOP_SPAN); + await Promise.resolve(); + expect(currentSpan()).toBe(NOOP_SPAN); + + return { ok: true as const }; + }, + { arguments: [{}] } as any, + ), + ); + } finally { + unsubscribe(); + } + + const spans = await backgroundLogger.drain(); + expect(spans).toHaveLength(0); + }); }); diff --git a/js/src/instrumentation/core/channel-tracing.ts b/js/src/instrumentation/core/channel-tracing.ts index 2d07c866f..88a4ea5b1 100644 --- a/js/src/instrumentation/core/channel-tracing.ts +++ b/js/src/instrumentation/core/channel-tracing.ts @@ -24,6 +24,7 @@ import { mergeInputMetadata, type ChannelConfig, } from "./channel-tracing-utils"; +import { isAutoInstrumentationSuppressed } from "../auto-instrumentation-suppression"; type SpanState = { span: Span; @@ -275,6 +276,10 @@ function bindCurrentSpanStoreToStart< startChannel.bindStore( currentSpanStore, (event: ChannelMessage) => { + if (isAutoInstrumentationSuppressed()) { + return currentSpanStore.getStore(); + } + const span = ensureSpanStateForEvent( states, config, @@ -357,6 +362,10 @@ export function traceAsyncChannel( const handlers: IsoChannelHandlers> = { start: (event) => { + if (isAutoInstrumentationSuppressed()) { + return; + } + ensureSpanStateForEvent( states, config, @@ -434,6 +443,10 @@ export function traceStreamingChannel( const handlers: IsoChannelHandlers> = { start: (event) => { + if (isAutoInstrumentationSuppressed()) { + return; + } + ensureSpanStateForEvent( states, config, @@ -628,6 +641,10 @@ export function traceSyncStreamChannel( const handlers: IsoChannelHandlers> = { start: (event) => { + if (isAutoInstrumentationSuppressed()) { + return; + } + ensureSpanStateForEvent( states, config, diff --git a/js/src/instrumentation/plugins/anthropic-plugin.ts b/js/src/instrumentation/plugins/anthropic-plugin.ts index 132b6a001..c16bb50be 100644 --- a/js/src/instrumentation/plugins/anthropic-plugin.ts +++ b/js/src/instrumentation/plugins/anthropic-plugin.ts @@ -10,6 +10,7 @@ import { isObject, isPromiseLike, } from "../../../util/index"; +import { isAutoInstrumentationSuppressed } from "../auto-instrumentation-suppression"; import { filterFrom, getCurrentUnixTimestamp } from "../../util"; import { finalizeAnthropicTokens } from "../../wrappers/anthropic-tokens-util"; import { anthropicChannels } from "./anthropic-channels"; @@ -137,6 +138,10 @@ export class AnthropicPlugin extends BasePlugin { ChannelMessage > = { start: (event) => { + if (isAutoInstrumentationSuppressed()) { + return; + } + const params = (event.arguments[0] ?? {}) as AnthropicToolRunnerParams; const span = startSpan({ name: "anthropic.beta.messages.toolRunner", diff --git a/js/src/instrumentation/plugins/pi-coding-agent-channels.ts b/js/src/instrumentation/plugins/pi-coding-agent-channels.ts new file mode 100644 index 000000000..5fae5c460 --- /dev/null +++ b/js/src/instrumentation/plugins/pi-coding-agent-channels.ts @@ -0,0 +1,19 @@ +import { channel, defineChannels } from "../core/channel-definitions"; +import type { + PiAgentSession, + PiPromptOptions, +} from "../../vendor-sdk-types/pi-coding-agent"; + +export const piCodingAgentChannels = defineChannels( + "@earendil-works/pi-coding-agent", + { + prompt: channel< + [string, PiPromptOptions | undefined], + void, + { session?: PiAgentSession } + >({ + channelName: "AgentSession.prompt", + kind: "async", + }), + }, +); diff --git a/js/src/instrumentation/plugins/pi-coding-agent-plugin.test.ts b/js/src/instrumentation/plugins/pi-coding-agent-plugin.test.ts new file mode 100644 index 000000000..8992a7a80 --- /dev/null +++ b/js/src/instrumentation/plugins/pi-coding-agent-plugin.test.ts @@ -0,0 +1,732 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockBindStore, + mockNewAsyncLocalStorage, + mockStartSpan, + mockUnbindStore, +} = vi.hoisted(() => ({ + mockBindStore: vi.fn(), + mockNewAsyncLocalStorage: vi.fn(() => { + let current: unknown; + return { + enterWith: vi.fn((store: unknown) => { + current = store; + }), + getStore: vi.fn(() => current), + run: vi.fn((store: unknown, callback: () => unknown) => { + const previous = current; + current = store; + try { + return callback(); + } finally { + current = previous; + } + }), + }; + }), + mockStartSpan: vi.fn(), + mockUnbindStore: vi.fn(), +})); + +vi.mock("../../isomorph", () => ({ + default: { + newAsyncLocalStorage: mockNewAsyncLocalStorage, + newTracingChannel: vi.fn(), + }, +})); + +vi.mock("../../logger", () => ({ + startSpan: (...args: unknown[]) => mockStartSpan(...args), +})); + +import iso from "../../isomorph"; +import { isAutoInstrumentationSuppressed } from "../auto-instrumentation-suppression"; +import { PiCodingAgentPlugin } from "./pi-coding-agent-plugin"; + +const mockNewTracingChannel = iso.newTracingChannel as ReturnType; + +describe("PiCodingAgentPlugin", () => { + let handlersByName: Map; + let spans: Array<{ + args: any; + end: ReturnType; + export: ReturnType; + log: ReturnType; + name?: string; + }>; + + beforeEach(() => { + handlersByName = new Map(); + spans = []; + mockNewTracingChannel.mockImplementation((name: string) => ({ + start: { + bindStore: mockBindStore, + unbindStore: mockUnbindStore, + }, + subscribe: vi.fn((handlers) => handlersByName.set(name, handlers)), + unsubscribe: vi.fn(), + })); + mockStartSpan.mockImplementation((args: any) => { + const span = { + args, + end: vi.fn(), + export: vi.fn(async () => `${args.name}-export-${spans.length}`), + log: vi.fn(), + name: args.name, + }; + if (args.event) { + span.log(args.event); + } + spans.push(span); + return span; + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("subscribes to AgentSession.prompt", () => { + const plugin = new PiCodingAgentPlugin(); + plugin.enable(); + + expect( + handlersByName.has( + "orchestrion:@earendil-works/pi-coding-agent:AgentSession.prompt", + ), + ).toBe(true); + }); + + it("binds auto instrumentation suppression while AgentSession.prompt runs", () => { + const plugin = new PiCodingAgentPlugin(); + plugin.enable(); + + expect(mockBindStore).toHaveBeenCalledTimes(1); + + plugin.disable(); + + expect(mockUnbindStore).toHaveBeenCalledTimes(1); + }); + + it("wraps streamFn for exact LLM input and restores it on completion", async () => { + const plugin = new PiCodingAgentPlugin(); + plugin.enable(); + + const handlers = handlersByName.get( + "orchestrion:@earendil-works/pi-coding-agent:AgentSession.prompt", + ); + const finalMessage = makeAssistantMessage("done"); + const stream = makeStream(finalMessage); + const originalStreamFn = vi.fn(async () => { + expect(isAutoInstrumentationSuppressed()).toBe(true); + return stream; + }); + const unsubscribe = vi.fn(); + const agent = { + state: { model: anthropicModel(), tools: [bashTool()] }, + streamFn: originalStreamFn, + subscribe: vi.fn(() => unsubscribe), + }; + const session = { + agent, + model: anthropicModel(), + prompt: vi.fn(), + sessionId: "session-1", + getActiveToolNames: () => ["bash"], + }; + const event = { + arguments: ["hello", undefined], + moduleVersion: "0.79.1", + self: session, + }; + + handlers.start(event); + expect(isAutoInstrumentationSuppressed()).toBe(false); + expect(agent.streamFn).not.toBe(originalStreamFn); + + const context = { + systemPrompt: "system", + messages: [ + { + role: "user", + content: [{ type: "text", text: "hello" }], + timestamp: 1, + }, + ], + tools: [bashTool()], + }; + const patchedStream = await agent.streamFn(anthropicModel(), context, { + apiKey: "secret", + headers: { authorization: "secret" }, + reasoning: "low", + }); + await patchedStream.result(); + await handlers.asyncEnd(event); + expect(isAutoInstrumentationSuppressed()).toBe(false); + + const llmSpan = spans.find( + (span) => span.name === "anthropic.messages.create", + ); + expect(originalStreamFn).toHaveBeenCalledWith( + anthropicModel(), + context, + expect.objectContaining({ apiKey: "secret" }), + ); + expect(llmSpan?.args.event.input).toEqual([ + { role: "system", content: "system" }, + { role: "user", content: [{ type: "text", text: "hello" }] }, + ]); + expect(llmSpan?.args.event.metadata).toMatchObject({ + model: "claude-haiku-4-5", + provider: "anthropic", + tools: [ + { + type: "function", + function: expect.objectContaining({ name: "bash" }), + }, + ], + }); + expect(llmSpan?.args.event.metadata).not.toHaveProperty("apiKey"); + expect(llmSpan?.args.event.metadata).not.toHaveProperty("headers"); + expect(llmSpan?.log).toHaveBeenCalledWith( + expect.objectContaining({ + metrics: expect.objectContaining({ + completion_tokens: 3, + prompt_tokens: 5, + tokens: 8, + }), + output: expect.any(Array), + }), + ); + expect(agent.streamFn).toBe(originalStreamFn); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it("creates tool spans from awaited agent events", async () => { + const plugin = new PiCodingAgentPlugin(); + plugin.enable(); + + const handlers = handlersByName.get( + "orchestrion:@earendil-works/pi-coding-agent:AgentSession.prompt", + ); + let listener: any; + const agent = { + state: { model: anthropicModel() }, + streamFn: vi.fn(), + subscribe: vi.fn((nextListener) => { + listener = nextListener; + return vi.fn(); + }), + }; + const event = { + arguments: ["run bash", undefined], + self: { agent, model: anthropicModel(), prompt: vi.fn() }, + }; + + handlers.start(event); + expect(isAutoInstrumentationSuppressed()).toBe(false); + await listener({ + args: { command: "printf pi_tool_ok" }, + toolCallId: "tool-1", + toolName: "bash", + type: "tool_execution_start", + }); + expect(isAutoInstrumentationSuppressed()).toBe(false); + await listener({ + isError: false, + result: { stdout: "pi_tool_ok" }, + toolCallId: "tool-1", + toolName: "bash", + type: "tool_execution_end", + }); + expect(isAutoInstrumentationSuppressed()).toBe(false); + await handlers.asyncEnd(event); + expect(isAutoInstrumentationSuppressed()).toBe(false); + + const toolSpan = spans.find((span) => span.name === "bash"); + expect(toolSpan?.args.event.input).toEqual({ + command: "printf pi_tool_ok", + }); + expect(toolSpan?.args.event.metadata).toMatchObject({ + "gen_ai.tool.call.id": "tool-1", + "gen_ai.tool.name": "bash", + }); + expect(toolSpan?.log).toHaveBeenCalledWith( + expect.objectContaining({ + output: { stdout: "pi_tool_ok" }, + }), + ); + expect(toolSpan?.end).toHaveBeenCalledTimes(1); + }); + + it("does not double-count prompt metrics from LLM and turn usage", async () => { + const plugin = new PiCodingAgentPlugin(); + plugin.enable(); + + const handlers = handlersByName.get( + "orchestrion:@earendil-works/pi-coding-agent:AgentSession.prompt", + ); + let listener: any; + const agent = { + state: { model: anthropicModel() }, + streamFn: vi.fn(async () => makeStream(makeAssistantMessage("done"))), + subscribe: vi.fn((nextListener) => { + listener = nextListener; + return vi.fn(); + }), + }; + const event = { + arguments: ["count metrics", undefined], + self: { agent, model: anthropicModel(), prompt: vi.fn() }, + }; + + handlers.start(event); + const patchedStream = await agent.streamFn(anthropicModel(), { + messages: [{ role: "user", content: "count metrics" }], + }); + await patchedStream.result(); + await listener({ + message: makeAssistantMessage("done"), + toolResults: [], + turnIndex: 0, + type: "turn_end", + }); + await handlers.asyncEnd(event); + + const rootSpan = spans.find((span) => span.name === "AgentSession.prompt"); + const finalLog = + rootSpan?.log.mock.calls[rootSpan.log.mock.calls.length - 1]?.[0]; + expect(finalLog?.metrics).toMatchObject({ + completion_tokens: 3, + prompt_cache_creation_tokens: 0, + prompt_cached_tokens: 0, + prompt_tokens: 5, + tokens: 8, + }); + }); + + it("keeps one shared streamFn patch for overlapping prompts on the same agent", async () => { + const plugin = new PiCodingAgentPlugin(); + plugin.enable(); + + const handlers = handlersByName.get( + "orchestrion:@earendil-works/pi-coding-agent:AgentSession.prompt", + ); + const stream = makeStream(makeAssistantMessage("done")); + const originalStreamFn = vi.fn(async () => stream); + const agent = { + state: { model: anthropicModel() }, + streamFn: originalStreamFn, + subscribe: vi.fn(() => vi.fn()), + }; + const eventA = { + arguments: ["first", undefined], + self: { agent, model: anthropicModel(), prompt: vi.fn() }, + }; + const eventB = { + arguments: ["second", undefined], + self: { agent, model: anthropicModel(), prompt: vi.fn() }, + }; + + handlers.start(eventA); + const sharedWrappedStreamFn = agent.streamFn; + handlers.start(eventB); + + expect(agent.streamFn).toBe(sharedWrappedStreamFn); + + const patchedStream = await agent.streamFn(anthropicModel(), { + messages: [{ role: "user", content: "second" }], + }); + await patchedStream.result(); + + expect(originalStreamFn).toHaveBeenCalledTimes(1); + expect( + spans.filter((span) => span.name === "anthropic.messages.create"), + ).toHaveLength(1); + + await handlers.asyncEnd(eventB); + expect(agent.streamFn).toBe(sharedWrappedStreamFn); + + await handlers.asyncEnd(eventA); + expect(agent.streamFn).toBe(originalStreamFn); + }); + + it("finalizes LLM spans when the Pi stream is consumed through iteration only", async () => { + const plugin = new PiCodingAgentPlugin(); + plugin.enable(); + + const handlers = handlersByName.get( + "orchestrion:@earendil-works/pi-coding-agent:AgentSession.prompt", + ); + const finalMessage = makeAssistantMessage("done"); + const { result, stream } = makeIteratorBackedStream([ + { partial: finalMessage, type: "start" }, + { message: finalMessage, type: "done" }, + ]); + const agent = { + state: { model: anthropicModel() }, + streamFn: vi.fn(async () => stream), + subscribe: vi.fn(() => vi.fn()), + }; + const event = { + arguments: ["iterate", undefined], + self: { agent, model: anthropicModel(), prompt: vi.fn() }, + }; + + handlers.start(event); + const patchedStream = await agent.streamFn(anthropicModel(), { + messages: [{ role: "user", content: "iterate" }], + }); + for await (const _event of patchedStream) { + // consume the full iterator without calling result() + } + await handlers.asyncEnd(event); + + const llmSpan = spans.find( + (span) => span.name === "anthropic.messages.create", + ); + expect(result).not.toHaveBeenCalled(); + expect(llmSpan?.log).toHaveBeenCalledWith( + expect.objectContaining({ + metrics: expect.objectContaining({ + completion_tokens: 3, + prompt_tokens: 5, + tokens: 8, + }), + output: expect.any(Array), + }), + ); + expect(llmSpan?.end).toHaveBeenCalledTimes(1); + }); + + it("forwards Pi stream iterator cancellation to the underlying iterator", async () => { + const plugin = new PiCodingAgentPlugin(); + plugin.enable(); + + const handlers = handlersByName.get( + "orchestrion:@earendil-works/pi-coding-agent:AgentSession.prompt", + ); + const finalMessage = makeAssistantMessage("done"); + const { iterator, stream } = makeIteratorBackedStream([ + { partial: finalMessage, type: "start" }, + ]); + const agent = { + state: { model: anthropicModel() }, + streamFn: vi.fn(async () => stream), + subscribe: vi.fn(() => vi.fn()), + }; + const event = { + arguments: ["cancel", undefined], + self: { agent, model: anthropicModel(), prompt: vi.fn() }, + }; + + handlers.start(event); + const patchedStream = await agent.streamFn(anthropicModel(), { + messages: [{ role: "user", content: "cancel" }], + }); + const patchedIterator = patchedStream[Symbol.asyncIterator](); + + await patchedIterator.next(); + await patchedIterator.return?.("stopped"); + await handlers.asyncEnd(event); + + const llmSpan = spans.find( + (span) => span.name === "anthropic.messages.create", + ); + expect(iterator.return).toHaveBeenCalledWith("stopped"); + expect(llmSpan?.end).toHaveBeenCalledTimes(1); + }); + + it("forwards Pi stream iterator throw to the underlying iterator", async () => { + const plugin = new PiCodingAgentPlugin(); + plugin.enable(); + + const handlers = handlersByName.get( + "orchestrion:@earendil-works/pi-coding-agent:AgentSession.prompt", + ); + const finalMessage = makeAssistantMessage("done"); + const { iterator, stream } = makeIteratorBackedStream([ + { partial: finalMessage, type: "start" }, + ]); + const agent = { + state: { model: anthropicModel() }, + streamFn: vi.fn(async () => stream), + subscribe: vi.fn(() => vi.fn()), + }; + const event = { + arguments: ["throw", undefined], + self: { agent, model: anthropicModel(), prompt: vi.fn() }, + }; + const error = new Error("stream aborted"); + + handlers.start(event); + const patchedStream = await agent.streamFn(anthropicModel(), { + messages: [{ role: "user", content: "throw" }], + }); + const patchedIterator = patchedStream[Symbol.asyncIterator](); + + await patchedIterator.next(); + await expect(patchedIterator.throw?.(error)).rejects.toThrow( + "stream aborted", + ); + await handlers.asyncEnd(event); + + const llmSpan = spans.find( + (span) => span.name === "anthropic.messages.create", + ); + expect(iterator.throw).toHaveBeenCalledWith(error); + expect(llmSpan?.log).toHaveBeenCalledWith( + expect.objectContaining({ error: "stream aborted" }), + ); + expect(llmSpan?.end).toHaveBeenCalledTimes(1); + }); + + it("keeps queued follow-up prompts active until their deferred turn runs", async () => { + const plugin = new PiCodingAgentPlugin(); + plugin.enable(); + + const handlers = handlersByName.get( + "orchestrion:@earendil-works/pi-coding-agent:AgentSession.prompt", + ); + const subscriptions: Array<{ + active: boolean; + listener: (event: any, signal: AbortSignal) => unknown; + unsubscribe: ReturnType; + }> = []; + const originalStreamFn = vi.fn(async () => + makeStream(makeAssistantMessage("done")), + ); + const agent = { + state: { model: anthropicModel() }, + streamFn: originalStreamFn, + subscribe: vi.fn((listener) => { + const subscription = { + active: true, + listener, + unsubscribe: vi.fn(() => { + subscription.active = false; + }), + }; + subscriptions.push(subscription); + return subscription.unsubscribe; + }), + }; + const activeEvent = { + arguments: ["active prompt", undefined], + self: { agent, model: anthropicModel(), prompt: vi.fn() }, + }; + const queuedEvent = { + arguments: ["queued prompt", { streamingBehavior: "followUp" as const }], + self: { agent, model: anthropicModel(), prompt: vi.fn() }, + }; + const emit = async (event: any) => { + for (const subscription of subscriptions) { + if (subscription.active) { + await subscription.listener(event, new AbortController().signal); + } + } + }; + + handlers.start(activeEvent); + const sharedWrappedStreamFn = agent.streamFn; + const activeStream = await agent.streamFn(anthropicModel(), { + messages: [{ role: "user", content: "active prompt" }], + }); + await activeStream.result(); + + handlers.start(queuedEvent); + await handlers.asyncEnd(queuedEvent); + + const rootSpans = spans.filter( + (span) => span.name === "AgentSession.prompt", + ); + expect(rootSpans[1]?.end).not.toHaveBeenCalled(); + expect(agent.streamFn).toBe(sharedWrappedStreamFn); + expect(subscriptions[1]?.active).toBe(true); + + await emit({ + message: makeAssistantMessage("active done"), + toolResults: [], + turnIndex: 0, + type: "turn_end", + }); + await handlers.asyncEnd(activeEvent); + + expect(rootSpans[0]?.end).toHaveBeenCalledTimes(1); + expect(rootSpans[1]?.end).not.toHaveBeenCalled(); + expect(agent.streamFn).toBe(sharedWrappedStreamFn); + + const queuedStream = await agent.streamFn(anthropicModel(), { + messages: [{ role: "user", content: "queued prompt" }], + }); + await queuedStream.result(); + await emit({ + args: { command: "printf pi_tool_ok" }, + toolCallId: "tool-queued", + toolName: "bash", + type: "tool_execution_start", + }); + await emit({ + isError: false, + result: { stdout: "pi_tool_ok" }, + toolCallId: "tool-queued", + toolName: "bash", + type: "tool_execution_end", + }); + await emit({ + message: makeAssistantMessage("queued done"), + toolResults: [], + turnIndex: 1, + type: "turn_end", + }); + + const llmInputs = spans + .filter((span) => span.name === "anthropic.messages.create") + .map((span) => span.args.event.input); + expect(llmInputs).toEqual([ + [{ role: "user", content: "active prompt" }], + [{ role: "user", content: "queued prompt" }], + ]); + expect(spans.filter((span) => span.name === "bash")).toHaveLength(1); + expect(rootSpans[1]?.end).toHaveBeenCalledTimes(1); + expect(subscriptions[1]?.unsubscribe).toHaveBeenCalledTimes(1); + expect(agent.streamFn).toBe(originalStreamFn); + }); + + it("restores active prompt patches when the plugin is disabled", async () => { + const plugin = new PiCodingAgentPlugin(); + plugin.enable(); + + const handlers = handlersByName.get( + "orchestrion:@earendil-works/pi-coding-agent:AgentSession.prompt", + ); + const unsubscribe = vi.fn(); + const originalStreamFn = vi.fn(); + const agent = { + state: { model: anthropicModel() }, + streamFn: originalStreamFn, + subscribe: vi.fn(() => unsubscribe), + }; + const event = { + arguments: ["disable cleanup", undefined], + self: { agent, model: anthropicModel(), prompt: vi.fn() }, + }; + + handlers.start(event); + expect(agent.streamFn).not.toBe(originalStreamFn); + + plugin.disable(); + await Promise.resolve(); + + const rootSpan = spans.find((span) => span.name === "AgentSession.prompt"); + expect(agent.streamFn).toBe(originalStreamFn); + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(rootSpan?.end).toHaveBeenCalledTimes(1); + }); + + it("restores streamFn and ends open spans on error", async () => { + const plugin = new PiCodingAgentPlugin(); + plugin.enable(); + + const handlers = handlersByName.get( + "orchestrion:@earendil-works/pi-coding-agent:AgentSession.prompt", + ); + const unsubscribe = vi.fn(); + const originalStreamFn = vi.fn(); + const agent = { + state: { model: anthropicModel() }, + streamFn: originalStreamFn, + subscribe: vi.fn(() => unsubscribe), + }; + const event = { + arguments: ["hello", undefined], + self: { agent, model: anthropicModel(), prompt: vi.fn() }, + }; + + handlers.start(event); + (event as any).error = new Error("boom"); + await handlers.error(event); + + const rootSpan = spans.find((span) => span.name === "AgentSession.prompt"); + expect(agent.streamFn).toBe(originalStreamFn); + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(rootSpan?.log).toHaveBeenCalledWith( + expect.objectContaining({ error: "boom" }), + ); + expect(rootSpan?.end).toHaveBeenCalledTimes(1); + }); +}); + +function anthropicModel() { + return { + api: "anthropic-messages", + id: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + provider: "anthropic", + }; +} + +function bashTool() { + return { + description: "Run a shell command.", + name: "bash", + parameters: { + type: "object", + properties: { + command: { type: "string" }, + }, + }, + }; +} + +function makeAssistantMessage(text: string) { + return { + api: "anthropic-messages", + content: [{ type: "text", text }], + model: "claude-haiku-4-5", + provider: "anthropic", + role: "assistant", + stopReason: "stop", + usage: { + cacheRead: 0, + cacheWrite: 0, + input: 5, + output: 3, + totalTokens: 8, + }, + }; +} + +function makeStream(message: ReturnType) { + return { + async *[Symbol.asyncIterator]() { + yield { partial: message, type: "start" }; + yield { message, type: "done" }; + }, + result: vi.fn(async () => message), + }; +} + +function makeIteratorBackedStream(events: any[]) { + const pendingEvents = [...events]; + const result = vi.fn(async () => makeAssistantMessage("done")); + const iterator = { + next: vi.fn(async () => { + const event = pendingEvents.shift(); + if (!event) { + return { done: true, value: undefined }; + } + return { done: false, value: event }; + }), + return: vi.fn(async (value?: unknown) => ({ done: true, value })), + throw: vi.fn(async (error?: unknown) => { + throw error; + }), + }; + const stream = { + [Symbol.asyncIterator]: vi.fn(() => iterator), + result, + }; + return { iterator, result, stream }; +} diff --git a/js/src/instrumentation/plugins/pi-coding-agent-plugin.ts b/js/src/instrumentation/plugins/pi-coding-agent-plugin.ts new file mode 100644 index 000000000..7d5d44464 --- /dev/null +++ b/js/src/instrumentation/plugins/pi-coding-agent-plugin.ts @@ -0,0 +1,1230 @@ +import { BasePlugin } from "../core"; +import type { ChannelMessage } from "../core/channel-definitions"; +import iso, { + type IsoAsyncLocalStorage, + type IsoChannelHandlers, +} from "../../isomorph"; +import { debugLogger } from "../../debug-logger"; +import { startSpan } from "../../logger"; +import type { Span } from "../../logger"; +import { getCurrentUnixTimestamp } from "../../util"; +import { SpanTypeAttribute, isObject } from "../../../util/index"; +import { processInputAttachments } from "../../wrappers/attachment-utils"; +import { + bindAutoInstrumentationSuppressionToStart, + enterAutoInstrumentationAllowed, + runWithAutoInstrumentationSuppressed, +} from "../auto-instrumentation-suppression"; +import { piCodingAgentChannels } from "./pi-coding-agent-channels"; +import type { + PiAgent, + PiAgentEvent, + PiAgentSession, + PiAssistantMessage, + PiAssistantMessageEvent, + PiAssistantMessageEventStream, + PiContext, + PiImageContent, + PiMessage, + PiModel, + PiPromptOptions, + PiSimpleStreamOptions, + PiStreamFn, + PiTextContent, + PiTool, + PiToolCall, + PiToolResultMessage, +} from "../../vendor-sdk-types/pi-coding-agent"; + +type PiPromptState = { + activeLlmSpans: Set; + activeToolSpans: Map; + agent: PiAgent; + collectedLlmUsageMetrics: boolean; + deferCompletionUntilTurnEnd: boolean; + finalized: boolean; + metrics: Record; + onFinalize?: (state: PiPromptState) => void; + metadata: Record; + output?: unknown; + promptCallEnded: boolean; + promptText?: string; + queued: boolean; + restorePromptContext?: () => void; + sawStreamFn: boolean; + span: Span; + startTime: number; + streamPatchState: PiStreamPatchState; + turnEnded: boolean; + unsubscribeAgent?: () => void; +}; + +type PiLlmSpanState = { + finalized: boolean; + metadata: Record; + metrics: Record; + span: Span; + startTime: number; +}; + +type PiToolSpanState = { + restoreAutoInstrumentation?: () => void; + span: Span; +}; + +type PiStreamPatchState = { + activePromptStates: Set; + agent: PiAgent; + eventPromptState?: PiPromptState; + originalStreamFn: PiStreamFn; + queuedPromptStates: PiPromptState[]; + wrappedStreamFn: PiStreamFn; +}; + +type PiPromptContextFrame = { + id: symbol; + state: PiPromptState; +}; + +type PiPromptContextState = { + frames: PiPromptContextFrame[]; +}; + +const piStreamPatchStates = new WeakMap(); +let piPromptContextStore: + | IsoAsyncLocalStorage + | undefined; + +export class PiCodingAgentPlugin extends BasePlugin { + private readonly activePromptStates = new Set(); + + protected onEnable(): void { + this.subscribeToPrompt(); + } + + protected onDisable(): void { + for (const unsubscribe of this.unsubscribers) { + unsubscribe(); + } + this.unsubscribers = []; + + for (const state of [...this.activePromptStates]) { + void finalizePiPromptRun(state).catch((error) => { + logInstrumentationError("Pi Coding Agent disable cleanup", error); + }); + } + } + + private subscribeToPrompt(): void { + const channel = piCodingAgentChannels.prompt.tracingChannel(); + const states = new WeakMap(); + const unbindAutoInstrumentationSuppression = + bindAutoInstrumentationSuppressionToStart(channel); + + const handlers: IsoChannelHandlers< + ChannelMessage + > = { + start: (event) => { + const state = startPiPromptRun(event, (state) => { + this.activePromptStates.delete(state); + }); + if (state) { + this.activePromptStates.add(state); + states.set(event, state); + } + }, + asyncEnd: async (event) => { + const state = states.get(event); + if (!state) { + return; + } + states.delete(event); + state.promptCallEnded = true; + if ( + !state.finalized && + state.deferCompletionUntilTurnEnd && + !state.turnEnded + ) { + if (!state.sawStreamFn) { + state.queued = true; + if (!state.streamPatchState.queuedPromptStates.includes(state)) { + state.streamPatchState.queuedPromptStates.push(state); + } + } + return; + } + await finalizePiPromptRun(state); + }, + error: async (event) => { + const state = states.get(event); + if (!state) { + return; + } + states.delete(event); + await finalizePiPromptRun(state, event.error); + }, + }; + + channel.subscribe(handlers); + this.unsubscribers.push(() => { + unbindAutoInstrumentationSuppression?.(); + channel.unsubscribe(handlers); + }); + } +} + +function startPiPromptRun( + event: ChannelMessage, + onFinalize?: (state: PiPromptState) => void, +): PiPromptState | undefined { + const session = extractSession(event); + const agent = session?.agent; + if (!session || !isPiAgent(agent)) { + return undefined; + } + + const metadata = { + ...extractSessionMetadata(session), + ...extractPromptOptionsMetadata(event.arguments[1]), + "pi_coding_agent.operation": "AgentSession.prompt", + provider: session.model?.provider ?? agent.state?.model?.provider ?? "pi", + ...(session.model?.id || agent.state?.model?.id + ? { model: session.model?.id ?? agent.state?.model?.id } + : {}), + ...(event.moduleVersion + ? { "pi_coding_agent.version": event.moduleVersion } + : {}), + }; + const span = startSpan({ + event: { + input: extractPromptInput(event.arguments[0], event.arguments[1]), + metadata, + }, + name: "AgentSession.prompt", + spanAttributes: { type: SpanTypeAttribute.TASK }, + }); + const streamPatchState = installPiStreamPatch(agent); + const options = event.arguments[1]; + const promptText = event.arguments[0]; + + const state: PiPromptState = { + activeLlmSpans: new Set(), + activeToolSpans: new Map(), + agent, + collectedLlmUsageMetrics: false, + deferCompletionUntilTurnEnd: + options?.streamingBehavior === "followUp" || + options?.streamingBehavior === "steer", + finalized: false, + metadata, + metrics: {}, + onFinalize, + promptCallEnded: false, + ...(typeof promptText === "string" ? { promptText } : {}), + queued: false, + span, + sawStreamFn: false, + startTime: getCurrentUnixTimestamp(), + streamPatchState, + turnEnded: false, + }; + state.restorePromptContext = enterPiPromptContext(state); + streamPatchState.activePromptStates.add(state); + + try { + state.unsubscribeAgent = agent.subscribe(async (agentEvent) => { + try { + await runWithAutoInstrumentationSuppressed(() => + handlePiAgentEvent(state, agentEvent), + ); + } catch (error) { + logInstrumentationError("Pi Coding Agent event", error); + } + }); + } catch (error) { + logInstrumentationError("Pi Coding Agent event subscription", error); + } + + return state; +} + +function extractSession( + event: ChannelMessage, +): PiAgentSession | undefined { + const candidate = event.session ?? event.self; + return isObject(candidate) && typeof candidate.prompt === "function" + ? (candidate as PiAgentSession) + : undefined; +} + +function isPiAgent(value: unknown): value is PiAgent { + return ( + isObject(value) && + typeof value.streamFn === "function" && + typeof value.subscribe === "function" + ); +} + +function promptContextStore(): IsoAsyncLocalStorage< + PiPromptContextState | undefined +> { + piPromptContextStore ??= iso.newAsyncLocalStorage< + PiPromptContextState | undefined + >(); + return piPromptContextStore; +} + +function currentPromptContextFrames(): PiPromptContextFrame[] { + return promptContextStore().getStore()?.frames ?? []; +} + +function currentPiPromptState(): PiPromptState | undefined { + const frames = currentPromptContextFrames(); + return frames[frames.length - 1]?.state; +} + +function enterPiPromptContext(state: PiPromptState): () => void { + const frame = { + id: Symbol("braintrust.pi-coding-agent.prompt"), + state, + }; + promptContextStore().enterWith({ + frames: [...currentPromptContextFrames(), frame], + }); + + return () => { + const frames = currentPromptContextFrames().filter( + (candidate) => candidate.id !== frame.id, + ); + promptContextStore().enterWith(frames.length > 0 ? { frames } : undefined); + }; +} + +function installPiStreamPatch(agent: PiAgent): PiStreamPatchState { + const existing = piStreamPatchStates.get(agent); + if (existing) { + if (agent.streamFn !== existing.wrappedStreamFn) { + debugLogger.debug( + "Pi Coding Agent streamFn changed while Braintrust instrumentation was active; preserving existing patch state.", + ); + } + return existing; + } + + const patchState = { + activePromptStates: new Set(), + agent, + originalStreamFn: agent.streamFn, + queuedPromptStates: [], + wrappedStreamFn: agent.streamFn, + } satisfies PiStreamPatchState; + patchState.wrappedStreamFn = makeSharedInstrumentedStreamFn(patchState); + agent.streamFn = patchState.wrappedStreamFn; + piStreamPatchStates.set(agent, patchState); + return patchState; +} + +function resolveStreamPromptState( + patchState: PiStreamPatchState, + context: PiContext, +): PiPromptState | undefined { + let lastUserText: string | undefined; + if (Array.isArray(context.messages)) { + for (let i = context.messages.length - 1; i >= 0; i--) { + const message = context.messages[i]; + if (isPiUserMessage(message)) { + if (typeof message.content === "string") { + lastUserText = message.content; + } else { + lastUserText = message.content + .flatMap((part) => (part.type === "text" ? [part.text] : [])) + .join(""); + } + break; + } + } + } + + if (lastUserText !== undefined) { + const queuedMatch = patchState.queuedPromptStates.find( + (state) => state.promptText === lastUserText, + ); + if (queuedMatch) { + return queuedMatch; + } + + const matches = [...patchState.activePromptStates].filter( + (state) => state.promptText === lastUserText, + ); + if (matches.length === 1) { + return matches[0]; + } + } + + const contextState = currentPiPromptState(); + if ( + contextState && + patchState.activePromptStates.has(contextState) && + (!contextState.queued || + (lastUserText !== undefined && contextState.promptText === lastUserText)) + ) { + return contextState; + } + + if (patchState.activePromptStates.size === 1) { + return [...patchState.activePromptStates][0]; + } + + return undefined; +} + +function makeSharedInstrumentedStreamFn( + patchState: PiStreamPatchState, +): PiStreamFn { + return async function instrumentedPiStreamFn( + this: unknown, + model: PiModel, + context: PiContext, + options?: PiSimpleStreamOptions, + ) { + const state = resolveStreamPromptState(patchState, context); + if (!state) { + const invokeOriginal = () => + Reflect.apply(patchState.originalStreamFn, this, [ + model, + context, + options, + ]); + return patchState.activePromptStates.size > 0 + ? runWithAutoInstrumentationSuppressed(invokeOriginal) + : invokeOriginal(); + } + + state.sawStreamFn = true; + removeQueuedPromptState(state); + state.streamPatchState.eventPromptState = state; + const llmState = await startPiLlmSpan(state, model, context, options); + try { + const stream = await runWithAutoInstrumentationSuppressed(() => + Reflect.apply(patchState.originalStreamFn, this, [ + model, + context, + options, + ]), + ); + return patchAssistantMessageStream(stream, state, llmState); + } catch (error) { + finishPiLlmSpan(state, llmState, undefined, error); + throw error; + } + }; +} + +async function startPiLlmSpan( + state: PiPromptState, + model: PiModel, + context: PiContext, + options?: PiSimpleStreamOptions, +): Promise { + const metadata = { + ...extractModelMetadata(model), + ...extractStreamOptionsMetadata(options), + ...extractToolMetadata(context.tools), + "pi_coding_agent.operation": "agent.streamFn", + }; + const span = startSpan({ + event: { + input: processInputAttachments(normalizePiContextInput(context)), + metadata, + }, + name: getLlmSpanName(model), + parent: await state.span.export(), + spanAttributes: { type: SpanTypeAttribute.LLM }, + }); + const llmState = { + finalized: false, + metadata, + metrics: {}, + span, + startTime: getCurrentUnixTimestamp(), + }; + state.activeLlmSpans.add(llmState); + return llmState; +} + +function patchAssistantMessageStream( + stream: PiAssistantMessageEventStream, + promptState: PiPromptState, + llmState: PiLlmSpanState, +): PiAssistantMessageEventStream { + if (!isObject(stream)) { + return stream; + } + + const streamRecord = stream as PiAssistantMessageEventStream & + Record; + const originalResult = stream.result; + if (typeof originalResult === "function") { + streamRecord.result = function patchedPiResult( + this: PiAssistantMessageEventStream, + ) { + return Promise.resolve(Reflect.apply(originalResult, this, [])).then( + (message) => { + finishPiLlmSpan(promptState, llmState, message); + return message; + }, + (error) => { + finishPiLlmSpan(promptState, llmState, undefined, error); + throw error; + }, + ); + }; + } + + const originalIterator = stream[Symbol.asyncIterator]; + if (typeof originalIterator === "function") { + streamRecord[Symbol.asyncIterator] = function patchedPiIterator( + this: PiAssistantMessageEventStream, + ): AsyncIterator & + AsyncIterable { + const iterator = Reflect.apply( + originalIterator, + this, + [], + ) as AsyncIterator; + + return { + async next() { + try { + const result = await iterator.next(); + if (result.done) { + finishPiLlmSpan(promptState, llmState); + return result; + } + + recordPiAssistantMessageEvent(promptState, llmState, result.value); + return result; + } catch (error) { + finishPiLlmSpan(promptState, llmState, undefined, error); + throw error; + } + }, + async return(value?: unknown) { + try { + if (typeof iterator.return === "function") { + return await iterator.return(value); + } + return { + done: true, + value, + } as IteratorResult; + } catch (error) { + finishPiLlmSpan(promptState, llmState, undefined, error); + throw error; + } finally { + finishPiLlmSpan(promptState, llmState); + } + }, + async throw(error?: unknown) { + try { + if (typeof iterator.throw === "function") { + return await iterator.throw(error); + } + throw error; + } catch (thrownError) { + finishPiLlmSpan(promptState, llmState, undefined, thrownError); + throw thrownError; + } + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + }; + } + + return stream; +} + +function recordPiAssistantMessageEvent( + promptState: PiPromptState, + llmState: PiLlmSpanState, + event: PiAssistantMessageEvent, +): void { + recordFirstTokenMetric(llmState, event); + + const message = "message" in event ? event.message : undefined; + const errorMessage = "error" in event ? event.error : undefined; + + if (event.type === "done" && isPiAssistantMessage(message)) { + finishPiLlmSpan(promptState, llmState, message); + } else if (event.type === "error" && isPiAssistantMessage(errorMessage)) { + finishPiLlmSpan(promptState, llmState, errorMessage); + } +} + +function recordFirstTokenMetric( + state: PiLlmSpanState, + event: PiAssistantMessageEvent, +): void { + if ( + state.metrics.time_to_first_token !== undefined || + event.type === "start" + ) { + return; + } + + state.metrics.time_to_first_token = + getCurrentUnixTimestamp() - state.startTime; +} + +async function handlePiAgentEvent( + state: PiPromptState, + event: PiAgentEvent, +): Promise { + if (state.finalized) { + return; + } + const eventPromptState = state.streamPatchState.eventPromptState; + if (eventPromptState && eventPromptState !== state) { + return; + } + if ( + !eventPromptState && + (state.queued || + (state.streamPatchState.activePromptStates.size > 1 && + currentPiPromptState() !== state)) + ) { + return; + } + + switch (event.type) { + case "message_end": + if (isPiAssistantMessage(event.message)) { + state.output = extractAssistantOutput(event.message); + } + return; + case "turn_end": + state.turnEnded = true; + if (isPiAssistantMessage(event.message)) { + state.output = extractAssistantOutput(event.message); + if (!state.collectedLlmUsageMetrics) { + addMetrics(state.metrics, extractUsageMetrics(event.message.usage)); + } + } + if (state.streamPatchState.eventPromptState === state) { + state.streamPatchState.eventPromptState = undefined; + } + if (state.promptCallEnded && state.deferCompletionUntilTurnEnd) { + await finalizePiPromptRun(state); + } + return; + case "tool_execution_start": + await startPiToolSpan(state, event); + return; + case "tool_execution_end": + finishPiToolSpan(state, event); + return; + default: + return; + } +} + +async function startPiToolSpan( + state: PiPromptState, + event: Extract, +): Promise { + if (!event.toolCallId || state.activeToolSpans.has(event.toolCallId)) { + return; + } + + const restoreAutoInstrumentation = enterAutoInstrumentationAllowed(); + const metadata = { + "gen_ai.tool.call.id": event.toolCallId, + "gen_ai.tool.name": event.toolName, + "pi_coding_agent.tool.name": event.toolName, + }; + try { + const span = startSpan({ + event: { + input: event.args, + metadata, + }, + name: event.toolName || "tool", + parent: await state.span.export(), + spanAttributes: { type: SpanTypeAttribute.TOOL }, + }); + state.activeToolSpans.set(event.toolCallId, { + restoreAutoInstrumentation, + span, + }); + } catch (error) { + restoreAutoInstrumentation(); + throw error; + } +} + +function finishPiToolSpan( + state: PiPromptState, + event: Extract, +): void { + const toolState = state.activeToolSpans.get(event.toolCallId); + if (!toolState) { + return; + } + + state.activeToolSpans.delete(event.toolCallId); + const metadata = { + "gen_ai.tool.call.id": event.toolCallId, + "gen_ai.tool.name": event.toolName, + "pi_coding_agent.tool.name": event.toolName, + "pi_coding_agent.tool.is_error": event.isError, + }; + try { + safeLog(toolState.span, { + ...(event.isError ? { error: stringifyUnknown(event.result) } : {}), + metadata, + output: event.result, + }); + } finally { + try { + toolState.span.end(); + } finally { + toolState.restoreAutoInstrumentation?.(); + } + } +} + +async function finalizePiPromptRun( + state: PiPromptState, + error?: unknown, +): Promise { + if (state.finalized) { + return; + } + state.finalized = true; + state.onFinalize?.(state); + restorePiStreamFn(state); + + try { + state.unsubscribeAgent?.(); + } catch (unsubscribeError) { + logInstrumentationError("Pi Coding Agent unsubscribe", unsubscribeError); + } + + await finishOpenLlmSpans(state, error); + finishOpenToolSpans(state, error); + + const metadata = { + ...state.metadata, + ...extractModelMetadata(state.agent.state?.model), + }; + try { + safeLog(state.span, { + ...(error ? { error: stringifyUnknown(error) } : {}), + metadata, + metrics: { + ...cleanMetrics(state.metrics), + ...buildDurationMetrics(state.startTime), + }, + output: state.output, + }); + } finally { + state.span.end(); + } +} + +function restorePiStreamFn(state: PiPromptState): void { + const patchState = state.streamPatchState; + patchState.activePromptStates.delete(state); + removeQueuedPromptState(state); + if (patchState.eventPromptState === state) { + patchState.eventPromptState = undefined; + } + state.restorePromptContext?.(); + + if (patchState.activePromptStates.size > 0) { + return; + } + + if (patchState.agent.streamFn === patchState.wrappedStreamFn) { + patchState.agent.streamFn = patchState.originalStreamFn; + } + piStreamPatchStates.delete(patchState.agent); +} + +function removeQueuedPromptState(state: PiPromptState): void { + state.queued = false; + const queuedPromptStates = state.streamPatchState.queuedPromptStates; + const index = queuedPromptStates.indexOf(state); + if (index >= 0) { + queuedPromptStates.splice(index, 1); + } +} + +async function finishOpenLlmSpans( + state: PiPromptState, + error?: unknown, +): Promise { + for (const llmState of [...state.activeLlmSpans]) { + finishPiLlmSpan(state, llmState, undefined, error); + } +} + +function finishPiLlmSpan( + promptState: PiPromptState, + llmState: PiLlmSpanState, + message?: PiAssistantMessage, + error?: unknown, +): void { + if (llmState.finalized) { + return; + } + llmState.finalized = true; + promptState.activeLlmSpans.delete(llmState); + + const messageError = message?.stopReason === "error" && message.errorMessage; + const metrics = { + ...extractUsageMetrics(message?.usage), + ...cleanMetrics(llmState.metrics), + ...buildDurationMetrics(llmState.startTime), + }; + const usageMetrics = extractUsageMetrics(message?.usage); + if (Object.keys(usageMetrics).length > 0) { + promptState.collectedLlmUsageMetrics = true; + addMetrics(promptState.metrics, usageMetrics); + } + + try { + safeLog(llmState.span, { + ...(error || messageError + ? { error: stringifyUnknown(error ?? messageError) } + : {}), + metadata: { + ...llmState.metadata, + ...(message ? extractAssistantMetadata(message) : {}), + }, + metrics, + ...(message ? { output: extractAssistantOutput(message) } : {}), + }); + } finally { + llmState.span.end(); + } +} + +function finishOpenToolSpans(state: PiPromptState, error?: unknown): void { + for (const [, toolState] of state.activeToolSpans) { + try { + safeLog(toolState.span, { + error: error ? stringifyUnknown(error) : "Pi tool did not complete", + }); + toolState.span.end(); + } finally { + toolState.restoreAutoInstrumentation?.(); + } + } + state.activeToolSpans.clear(); +} + +function normalizePiContextInput(context: PiContext): unknown[] { + const messages = context.messages.flatMap((message) => + normalizePiMessage(message), + ); + if (context.systemPrompt) { + return [{ role: "system", content: context.systemPrompt }, ...messages]; + } + return messages; +} + +function normalizePiMessage(message: PiMessage): unknown[] { + if (isPiUserMessage(message)) { + return [ + { + role: "user", + content: normalizeUserContent(message.content), + }, + ]; + } + if (isPiAssistantMessage(message)) { + return [normalizeAssistantMessage(message)]; + } + if (isPiToolResultMessage(message)) { + return [normalizeToolResultMessage(message)]; + } + return []; +} + +function normalizeAssistantMessage(message: PiAssistantMessage): unknown { + const text = message.content + .flatMap((part) => (part.type === "text" ? [part.text] : [])) + .join(""); + const thinking = message.content + .flatMap((part) => + part.type === "thinking" && !part.redacted ? [part.thinking] : [], + ) + .join(""); + const toolCalls = message.content.flatMap((part) => + part.type === "toolCall" ? [normalizeToolCall(part)] : [], + ); + + return { + role: "assistant", + content: text || (toolCalls.length > 0 ? null : ""), + ...(thinking ? { reasoning: thinking } : {}), + ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), + }; +} + +function normalizeToolResultMessage(message: PiToolResultMessage): unknown { + return { + role: "tool", + tool_call_id: message.toolCallId, + content: normalizeUserContent(message.content), + }; +} + +function normalizeUserContent( + content: string | Array, +): unknown { + if (typeof content === "string") { + return content; + } + + return content.map((part) => { + if (part.type === "text") { + return { type: "text", text: part.text }; + } + if (part.type === "image") { + return { + type: "image_url", + image_url: { + url: `data:${part.mimeType};base64,${part.data}`, + }, + }; + } + return part; + }); +} + +function normalizeToolCall(toolCall: PiToolCall): unknown { + return { + id: toolCall.id, + type: "function", + function: { + name: toolCall.name, + arguments: stringifyArguments(toolCall.arguments), + }, + }; +} + +function extractAssistantOutput(message: PiAssistantMessage): unknown { + return processInputAttachments([ + { + finish_reason: normalizeStopReason(message.stopReason), + index: 0, + message: normalizeAssistantMessage(message), + }, + ]); +} + +function isPiUserMessage( + message: PiMessage, +): message is Extract { + return message.role === "user" && "content" in message; +} + +function isPiAssistantMessage(message: unknown): message is PiAssistantMessage { + return ( + isObject(message) && + message.role === "assistant" && + Array.isArray((message as Partial).content) + ); +} + +function isPiToolResultMessage( + message: PiMessage, +): message is PiToolResultMessage { + return ( + message.role === "toolResult" && + Array.isArray((message as Partial).content) + ); +} + +function normalizeStopReason(reason: string | undefined): string { + switch (reason) { + case "toolUse": + return "tool_calls"; + case "length": + case "stop": + return reason; + default: + return reason ?? "stop"; + } +} + +function extractPromptInput( + text: string | undefined, + options: PiPromptOptions | undefined, +): unknown { + const images = options?.images; + if (!images || images.length === 0) { + return text; + } + return processInputAttachments([ + { + role: "user", + content: [ + { type: "text", text: text ?? "" }, + ...images.map((image) => ({ + type: "image_url", + image_url: { + url: `data:${image.mimeType};base64,${image.data}`, + }, + })), + ], + }, + ]); +} + +function extractToolMetadata( + tools: PiTool[] | undefined, +): Record { + if (!tools || tools.length === 0) { + return {}; + } + + return { + tools: tools.map((tool) => ({ + type: "function", + function: { + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + ...(tool.parameters ? { parameters: tool.parameters } : {}), + }, + })), + }; +} + +function extractModelMetadata( + model: PiModel | undefined, +): Record { + if (!model) { + return {}; + } + + return { + ...(model.provider ? { provider: model.provider } : {}), + ...(model.id ? { model: model.id, "pi_coding_agent.model": model.id } : {}), + ...(model.api ? { "pi_coding_agent.api": model.api } : {}), + ...(model.name ? { "pi_coding_agent.model_name": model.name } : {}), + }; +} + +function extractAssistantMetadata( + message: PiAssistantMessage, +): Record { + return { + ...(message.provider ? { provider: message.provider } : {}), + ...(message.responseModel || message.model + ? { model: message.responseModel ?? message.model } + : {}), + ...(message.api ? { "pi_coding_agent.api": message.api } : {}), + ...(message.model ? { "pi_coding_agent.model": message.model } : {}), + ...(message.responseModel + ? { "pi_coding_agent.response_model": message.responseModel } + : {}), + ...(message.responseId + ? { "pi_coding_agent.response_id": message.responseId } + : {}), + ...(message.stopReason + ? { "pi_coding_agent.stop_reason": message.stopReason } + : {}), + }; +} + +function extractSessionMetadata( + session: PiAgentSession, +): Record { + return { + ...extractModelMetadata(session.model), + ...(session.sessionId + ? { "pi_coding_agent.session_id": session.sessionId } + : {}), + ...(session.sessionName + ? { "pi_coding_agent.session_name": session.sessionName } + : {}), + ...(session.thinkingLevel + ? { "pi_coding_agent.thinking_level": session.thinkingLevel } + : {}), + ...(typeof session.getActiveToolNames === "function" + ? { "pi_coding_agent.active_tools": session.getActiveToolNames() } + : {}), + }; +} + +function extractPromptOptionsMetadata( + options: PiPromptOptions | undefined, +): Record { + if (!options) { + return {}; + } + + return { + ...(options.source ? { "pi_coding_agent.source": options.source } : {}), + ...(options.streamingBehavior + ? { "pi_coding_agent.streaming_behavior": options.streamingBehavior } + : {}), + ...(options.expandPromptTemplates !== undefined + ? { + "pi_coding_agent.expand_prompt_templates": + options.expandPromptTemplates, + } + : {}), + }; +} + +function extractStreamOptionsMetadata( + options: PiSimpleStreamOptions | undefined, +): Record { + if (!options) { + return {}; + } + + return { + ...(options.temperature !== undefined + ? { temperature: options.temperature } + : {}), + ...(options.maxTokens !== undefined + ? { max_tokens: options.maxTokens } + : {}), + ...(options.reasoning + ? { "pi_coding_agent.reasoning": options.reasoning } + : {}), + ...(options.transport + ? { "pi_coding_agent.transport": options.transport } + : {}), + ...(options.cacheRetention + ? { "pi_coding_agent.cache_retention": options.cacheRetention } + : {}), + ...(options.sessionId + ? { "pi_coding_agent.session_id": options.sessionId } + : {}), + ...(options.timeoutMs !== undefined + ? { "pi_coding_agent.timeout_ms": options.timeoutMs } + : {}), + ...(options.maxRetries !== undefined + ? { "pi_coding_agent.max_retries": options.maxRetries } + : {}), + ...(options.maxRetryDelayMs !== undefined + ? { "pi_coding_agent.max_retry_delay_ms": options.maxRetryDelayMs } + : {}), + ...(options.metadata + ? { "pi_coding_agent.metadata": options.metadata } + : {}), + }; +} + +function getLlmSpanName(model: PiModel): string { + switch (model.api) { + case "anthropic-messages": + return "anthropic.messages.create"; + case "openai-completions": + return "Chat Completion"; + case "openai-responses": + case "azure-openai-responses": + case "openai-codex-responses": + return "openai.responses.create"; + case "google-generative-ai": + case "google-vertex": + return "generate_content"; + case "mistral-conversations": + return "mistral.chat.stream"; + case "bedrock-converse-stream": + return "bedrock.converse_stream"; + default: + return "pi_ai.streamSimple"; + } +} + +function extractUsageMetrics( + usage: PiAssistantMessage["usage"] | undefined, +): Record { + if (!usage) { + return {}; + } + + return cleanMetrics({ + completion_tokens: usage.output, + prompt_cache_creation_tokens: usage.cacheWrite, + prompt_cached_tokens: usage.cacheRead, + prompt_tokens: usage.input, + tokens: usage.totalTokens ?? usage.tokens, + }); +} + +function addMetrics( + target: Record, + source: Record, +): void { + for (const [key, value] of Object.entries(source)) { + target[key] = (target[key] ?? 0) + value; + } +} + +function buildDurationMetrics(startTime: number): Record { + const end = getCurrentUnixTimestamp(); + return { + duration: end - startTime, + end, + start: startTime, + }; +} + +function cleanMetrics( + metrics: Record, +): Record { + const cleaned: Record = {}; + for (const [key, value] of Object.entries(metrics)) { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + cleaned[key] = value; + } + } + return cleaned; +} + +function stringifyArguments(value: unknown): string { + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return stringifyUnknown(value); + } +} + +function stringifyUnknown(value: unknown): string { + if (value instanceof Error) { + return value.message; + } + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function safeLog(span: Span, event: Parameters[0]): void { + try { + span.log(event); + } catch (error) { + logInstrumentationError("Pi Coding Agent span log", error); + } +} + +function logInstrumentationError(context: string, error: unknown): void { + debugLogger.debug(`${context}:`, error); +} diff --git a/js/src/vendor-sdk-types/pi-coding-agent.ts b/js/src/vendor-sdk-types/pi-coding-agent.ts new file mode 100644 index 000000000..74364f67d --- /dev/null +++ b/js/src/vendor-sdk-types/pi-coding-agent.ts @@ -0,0 +1,234 @@ +/** + * Vendored types for @earendil-works/pi-coding-agent used by Braintrust + * instrumentation. + * + * Keep this surface intentionally narrow. These types are not exported to SDK + * users and should only cover fields we read, wrap, or log. + */ + +export interface PiCodingAgentModule { + AgentSession: PiAgentSessionClass; + [key: string]: unknown; +} + +export interface PiAgentSessionClass { + prototype: PiAgentSession; + new (...args: unknown[]): PiAgentSession; + [key: string]: unknown; +} + +export interface PiAgentSession { + readonly agent?: PiAgent; + readonly model?: PiModel; + readonly sessionId?: string; + readonly sessionName?: string; + readonly thinkingLevel?: string; + prompt(text: string, options?: PiPromptOptions): Promise; + getActiveToolNames?: () => string[]; + dispose?: () => void; + [key: string | symbol]: unknown; +} + +export interface PiPromptOptions { + images?: PiImageContent[]; + expandPromptTemplates?: boolean; + source?: "interactive" | "rpc" | "extension" | string; + streamingBehavior?: "steer" | "followUp"; + preflightResult?: (success: boolean) => void; + [key: string]: unknown; +} + +export interface PiAgent { + streamFn: PiStreamFn; + subscribe(listener: PiAgentEventListener): () => void; + readonly state?: { + model?: PiModel; + systemPrompt?: string; + tools?: PiTool[]; + [key: string]: unknown; + }; + readonly sessionId?: string; + [key: string | symbol]: unknown; +} + +export type PiAgentEventListener = ( + event: PiAgentEvent, + signal: AbortSignal, +) => Promise | void; + +export type PiAgentEvent = + | { type: "agent_start" } + | { type: "agent_end"; messages: PiMessage[] } + | { type: "turn_start"; turnIndex: number; timestamp: number } + | { + type: "turn_end"; + turnIndex: number; + message: PiMessage; + toolResults: PiToolResultMessage[]; + } + | { type: "message_start"; message: PiMessage } + | { + type: "message_update"; + message: PiMessage; + assistantMessageEvent?: PiAssistantMessageEvent; + } + | { type: "message_end"; message: PiMessage } + | { + type: "tool_execution_start"; + toolCallId: string; + toolName: string; + args: unknown; + } + | { + type: "tool_execution_update"; + toolCallId: string; + toolName: string; + args: unknown; + partialResult: unknown; + } + | { + type: "tool_execution_end"; + toolCallId: string; + toolName: string; + result: unknown; + isError: boolean; + }; + +export type PiStreamFn = ( + model: PiModel, + context: PiContext, + options?: PiSimpleStreamOptions, +) => PiAssistantMessageEventStream | Promise; + +export interface PiSimpleStreamOptions { + temperature?: number; + maxTokens?: number; + signal?: AbortSignal; + apiKey?: string; + headers?: Record; + reasoning?: string; + transport?: string; + cacheRetention?: string; + sessionId?: string; + timeoutMs?: number; + maxRetries?: number; + maxRetryDelayMs?: number; + metadata?: Record; + [key: string]: unknown; +} + +export interface PiModel { + id: string; + name?: string; + api?: string; + provider?: string; + baseUrl?: string; + reasoning?: boolean; + input?: string[]; + contextWindow?: number; + maxTokens?: number; + [key: string]: unknown; +} + +export interface PiContext { + systemPrompt?: string; + messages: PiMessage[]; + tools?: PiTool[]; +} + +export interface PiTool { + name: string; + description?: string; + parameters?: unknown; + [key: string]: unknown; +} + +export type PiMessage = + | PiUserMessage + | PiAssistantMessage + | PiToolResultMessage + | { role?: string; [key: string]: unknown }; + +export interface PiUserMessage { + role: "user"; + content: string | PiUserContent[]; + timestamp?: number; + [key: string]: unknown; +} + +export type PiUserContent = PiTextContent | PiImageContent; + +export interface PiTextContent { + type: "text"; + text: string; + [key: string]: unknown; +} + +export interface PiImageContent { + type: "image"; + data: string; + mimeType: string; + [key: string]: unknown; +} + +export interface PiThinkingContent { + type: "thinking"; + thinking: string; + redacted?: boolean; + [key: string]: unknown; +} + +export interface PiToolCall { + type: "toolCall"; + id: string; + name: string; + arguments: Record; + [key: string]: unknown; +} + +export interface PiUsage { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + totalTokens?: number; + tokens?: number; + [key: string]: unknown; +} + +export interface PiAssistantMessage { + role: "assistant"; + content: Array; + api?: string; + provider?: string; + model?: string; + responseModel?: string; + responseId?: string; + usage?: PiUsage; + stopReason?: string; + errorMessage?: string; + timestamp?: number; + [key: string]: unknown; +} + +export interface PiToolResultMessage { + role: "toolResult"; + toolCallId: string; + toolName: string; + content: Array; + details?: unknown; + isError?: boolean; + timestamp?: number; + [key: string]: unknown; +} + +export type PiAssistantMessageEvent = + | { type: "start"; partial: PiAssistantMessage } + | { type: "done"; message: PiAssistantMessage } + | { type: "error"; error: PiAssistantMessage } + | { type?: string; partial?: PiAssistantMessage; [key: string]: unknown }; + +export interface PiAssistantMessageEventStream extends AsyncIterable { + result(): Promise; + [key: string | symbol]: unknown; +} diff --git a/js/src/wrappers/pi-coding-agent.test.ts b/js/src/wrappers/pi-coding-agent.test.ts new file mode 100644 index 000000000..544927f8a --- /dev/null +++ b/js/src/wrappers/pi-coding-agent.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { tracePromise } = vi.hoisted(() => ({ + tracePromise: vi.fn((fn: () => Promise) => fn()), +})); + +vi.mock("../isomorph", () => ({ + default: { + newTracingChannel: vi.fn(() => ({ + subscribe: vi.fn(), + tracePromise, + unsubscribe: vi.fn(), + })), + }, +})); + +import { wrapPiCodingAgentSDK } from "./pi-coding-agent"; + +describe("wrapPiCodingAgentSDK", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("returns invalid modules unchanged", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const sdk = { Session: class {} }; + + expect(wrapPiCodingAgentSDK(sdk)).toBe(sdk); + expect(warnSpy).toHaveBeenCalledWith( + "Unsupported Pi Coding Agent SDK. Not wrapping.", + ); + + warnSpy.mockRestore(); + }); + + it("wraps AgentSession.prompt", async () => { + class AgentSession { + async prompt(text: string) { + return text; + } + } + + const sdk = { AgentSession }; + wrapPiCodingAgentSDK(sdk); + const session = new sdk.AgentSession(); + + await expect(session.prompt("hello")).resolves.toBe("hello"); + expect(tracePromise).toHaveBeenCalledTimes(1); + expect(tracePromise.mock.calls[0][1]).toMatchObject({ + arguments: ["hello", undefined], + self: session, + session, + }); + }); + + it("patches AgentSession.prompt only once", async () => { + class AgentSession { + async prompt(text: string) { + return text; + } + } + + const sdk = { AgentSession }; + wrapPiCodingAgentSDK(sdk); + wrapPiCodingAgentSDK(sdk); + + await new sdk.AgentSession().prompt("hello"); + expect(tracePromise).toHaveBeenCalledTimes(1); + }); + + it("preserves prompt rejections", async () => { + class AgentSession { + async prompt() { + throw new Error("prompt failed"); + } + } + + const sdk = { AgentSession }; + wrapPiCodingAgentSDK(sdk); + + await expect(new sdk.AgentSession().prompt()).rejects.toThrow( + "prompt failed", + ); + expect(tracePromise).toHaveBeenCalledTimes(1); + }); +}); diff --git a/js/src/wrappers/pi-coding-agent.ts b/js/src/wrappers/pi-coding-agent.ts new file mode 100644 index 000000000..8a468b47a --- /dev/null +++ b/js/src/wrappers/pi-coding-agent.ts @@ -0,0 +1,74 @@ +import { piCodingAgentChannels } from "../instrumentation/plugins/pi-coding-agent-channels"; +import type { + PiAgentSession, + PiAgentSessionClass, + PiCodingAgentModule, + PiPromptOptions, +} from "../vendor-sdk-types/pi-coding-agent"; + +const WRAPPED_PROMPT = Symbol.for("braintrust.pi-coding-agent.wrapped-prompt"); + +/** + * Wraps the Pi Coding Agent SDK with Braintrust tracing. The wrapper emits + * diagnostics-channel events; the Pi Coding Agent plugin owns span lifecycle. + */ +export function wrapPiCodingAgentSDK(sdk: T): T { + if (!sdk || typeof sdk !== "object") { + return sdk; + } + + const maybeSDK = sdk as Record; + if (!maybeSDK.AgentSession || typeof maybeSDK.AgentSession !== "function") { + // eslint-disable-next-line no-restricted-properties -- preserving intentional console usage. + console.warn("Unsupported Pi Coding Agent SDK. Not wrapping."); + return sdk; + } + + patchAgentSessionClass( + maybeSDK.AgentSession as unknown as PiAgentSessionClass, + ); + return sdk as T & PiCodingAgentModule; +} + +function patchAgentSessionClass(AgentSession: PiAgentSessionClass): void { + const prototype = AgentSession.prototype as PiAgentSession & + Record; + if (!prototype || prototype[WRAPPED_PROMPT]) { + return; + } + + const descriptor = Object.getOwnPropertyDescriptor(prototype, "prompt"); + if (!descriptor || typeof descriptor.value !== "function") { + // eslint-disable-next-line no-restricted-properties -- preserving intentional console usage. + console.warn("Unsupported Pi Coding Agent SDK. Not wrapping."); + return; + } + + const originalPrompt = descriptor.value as PiAgentSession["prompt"]; + Object.defineProperty(prototype, "prompt", { + ...descriptor, + value: function wrappedPiCodingAgentPrompt( + this: PiAgentSession, + text: string, + options?: PiPromptOptions, + ) { + const args = [text, options] as [string, PiPromptOptions | undefined]; + return piCodingAgentChannels.prompt.tracePromise( + () => Reflect.apply(originalPrompt, this, args), + { + arguments: args, + self: this, + session: this, + }, + ); + }, + }); + + Object.defineProperty(prototype, WRAPPED_PROMPT, { + configurable: false, + enumerable: false, + value: true, + }); +} + +export type { PiCodingAgentModule };