Skip to content

[bot] Cursor Python SDK (cursor-sdk) not instrumented — add integration and auto-instrumentation for Cursor agents #663

Description

Summary

Cursor's Python SDK (cursor-sdk, latest version 1.0.26) exposes Cursor's coding-agent runtime programmatically with synchronous and asynchronous clients. This repository currently has no dedicated Cursor SDK support: no integration package, setup_cursor_sdk() entry point, auto-instrumentation wiring, provider version matrix, nox session, cassette/transport recordings, examples, or focused tests.

The SDK is a close parallel to our existing Claude Agent SDK integration. Users can create or resume local and cloud agents, submit prompts, stream normalized messages or raw events, observe tool-call lifecycles, execute custom Python tools, inspect token usage, and wait for terminal run results. Production workflows built on cursor-sdk should get Braintrust traces for agent runs, model calls, tool executions, token usage, errors, cancellation, and final results.

Specification requirement

The implementation must follow the Braintrust instrumentation-spec skill and its canonical instrumentation guide, including the linked specifications for token and cost metrics, attachments, and tool approval metadata.

This issue does not authorize new metadata, metric keys, span types, or captured fields. If Cursor-specific fields are needed but not allowed by the instrumentation specification, update braintrustdata/braintrust-spec first rather than inventing cursor_sdk.* telemetry fields here.

Relevant SDK surfaces

The Cursor Python SDK exposes these important sync and async surfaces:

SDK surface Description
Agent.create() / AsyncAgent.create() Create a local or Cursor-hosted cloud agent
Agent.prompt() / AsyncAgent.prompt() One-shot create/send/wait/dispose flow
Agent.resume() / AsyncAgent.resume() Reattach to an existing local or cloud agent
agent.send() Start a run with per-run model, mode, MCP servers, environment variables, on_delta, and on_step options
run.messages() / run.stream() Sync or async stream of normalized SDKMessage events (assistant, thinking, tool_call, status, task, request, usage, etc.)
run.events() / direct run iteration / run.observe() Lower-level RunStreamEvent envelopes and resumable observation
run.iter_text() / run.text() / run.wait() Consume text or wait for the terminal RunResult
run.cancel() / run.conversation() Cancel a run or inspect its structured conversation
CursorClient / AsyncClient resource namespaces Explicit lifecycle and client.agents.create() / resume() flows
CustomTool.execute User-defined Python tool execution for local agents

These are candidate patch/lifecycle surfaces, not a requirement to create a span for every method. CRUD/catalog/history/artifact calls such as list(), get(), list_runs(), get_run(), and download_artifact() should not become llm or tool spans merely because they are available. Instrument the AI-generating agent run and its model/tool children.

No coverage in any Braintrust instrumentation layer:

  • No py/src/braintrust/integrations/cursor_sdk/ integration
  • No setup_cursor_sdk() or manual wrapping helper
  • No CursorSDKIntegration export or auto_instrument() wiring
  • No cursor-sdk entry in py/pyproject.toml's provider matrix or cassette-directory map
  • No dedicated nox session
  • No examples, focused tests, or versioned transport/VCR coverage

A case-insensitive grep for cursor-sdk, cursor_sdk, and Cursor SDK concepts under py/ returns no SDK-specific matches.

Desired experience

Suggested API, modeled after setup_claude_agent_sdk():

import os

from cursor_sdk import Agent, LocalAgentOptions
from braintrust.integrations.cursor_sdk import setup_cursor_sdk

setup_cursor_sdk(project="my-project")

with Agent.create(
    model="composer-2.5",
    local=LocalAgentOptions(cwd=os.getcwd()),
) as agent:
    result = agent.send("Summarize what this repository does").wait()
    print(result.result)

The async client should produce an equivalent trace:

from cursor_sdk import AsyncClient, LocalAgentOptions

async with await AsyncClient.launch_bridge(workspace=".") as client:
    async with await client.agents.create(
        model="composer-2.5",
        local=LocalAgentOptions(cwd="."),
    ) as agent:
        run = await agent.send("Summarize what this repository does")
        print(await run.text())

braintrust.auto_instrument() should also patch cursor_sdk whether it is imported before or after instrumentation setup.

Required span shape

Cursor is an agentic API, so it must follow the specification's agentic span tree without creating duplicate nested llm spans.

First, add a real VCR-backed characterization test that runs the Cursor SDK with relevant downstream provider instrumentation enabled and asserts the emitted span tree. Use that test to determine whether a Cursor model turn already contains a lower-level provider llm leaf (for example, an OpenAI llm span).

If no lower-level provider span is emitted:

task  "Cursor Agent"              # one parent for one prompt/run
├── llm   <Cursor model turn 1>    # Cursor owns the observable model call
├── tool  <tool name>              # one per actual tool execution
├── llm   <Cursor model turn 2>
└── ...

If a lower-level provider llm span is emitted, the Cursor span around that call must be a task, not another llm:

task  "Cursor Agent"
├── task  <Cursor model turn 1>    # orchestration/framework span
│   └── llm  <provider call 1>     # OpenAI/Anthropic/etc. leaf owns LLM telemetry
├── tool  <tool name>
├── task  <Cursor model turn 2>
│   └── llm  <provider call 2>
└── ...

Do not emit two nested llm spans for one provider request.

Parent run span

  • span_attributes.type = "task"
  • A stable descriptive name such as Cursor Agent
  • input: the initial user prompt/message that started this run
  • output: the final response after the agent/tool loop completes
  • metrics: aggregate canonical token metrics across child LLM calls when available
  • Lifetime: from prompt submission until terminal completion, error, cancellation, or expiration
  • Provider errors must propagate unchanged and be logged in the top-level error field

Do not add spans for individual assistant/status/request/usage stream events unless an event corresponds to a model call or actual tool execution required by the spec. Stream events should be used to reconstruct the specified parent/child spans, not produce an event-shaped trace tree.

Child model-call spans and leaf ownership

For each model call/turn, choose the span type from the VCR-observed hierarchy:

  • No downstream provider llm leaf: emit a Cursor llm span with the messages sent for that call, the complete response, resolved metadata.model, metadata.provider = "cursor", allowed metadata.tools, and canonical per-call token metrics.
  • Downstream provider llm leaf exists: emit the Cursor model-turn wrapper as task, keep model/provider attribution when available, and let the lower-level provider llm span own canonical LLM input/output, token metrics, streaming metrics, and cost attribution. Do not copy those metrics onto the intermediate Cursor task.

The lower-level leaf must retain its own provider integration origin (for example, openai-auto); Cursor instrumentation must not overwrite it with cursor-sdk-auto.

Cursor does not have a dedicated provider-native payload exception in the specification, so any Cursor-owned llm input/output should use the canonical OpenAI Chat Completions shape. If the SDK/bridge does not expose enough information to identify model-call boundaries or reconstruct a compliant Cursor-owned leaf, investigate lower-level bridge events. Do not create a token-less Cursor llm span above another provider llm span.

Child tool spans

Each actual model-initiated tool execution must produce exactly one tool child span:

  • span_attributes.name: tool/function name
  • input: model-provided tool arguments
  • output: tool return value
  • failed execution: non-null top-level error
  • metadata.tool_approval: only approved or denied, and only when Cursor exposes a reliable approval/denial signal for that exact interaction

Correlate Cursor's repeated SDKToolUseMessage start/completion/error events internally so they create one span rather than one span per event. This applies to built-in, MCP, subagent/delegation, shell, and Python CustomTool.execute calls when the SDK exposes actual execution boundaries. Preserve custom-tool return values, exceptions, and Braintrust context.

Do not infer approval from a missing completion event, and do not use approval metadata to represent execution success/failure.

Payload, metrics, and streaming requirements

Token metrics

Use only canonical Braintrust metric names and omit values Cursor did not report; do not fabricate zeroes. Based on Cursor's documented TokenUsage semantics, verify and map per-turn usage as follows:

  • prompt_tokens = input_tokens + cache_read_tokens + cache_write_tokens
  • completion_tokens = output_tokens
  • tokens = prompt_tokens + completion_tokens
  • prompt_cached_tokens = cache_read_tokens
  • prompt_cache_creation_tokens = cache_write_tokens
  • completion_reasoning_tokens = reasoning_tokens when reported

All token counts must be non-negative integers. Cache-read and cache-write counts are subsets of prompt_tokens, not additional tokens beyond it. Apply this per-turn mapping only when Cursor owns the llm leaf. If a lower-level provider llm span exists, that leaf owns per-call token and cost telemetry; the intermediate Cursor model-turn task must not duplicate it. The top-level Cursor run task may still aggregate run usage for display. Do not call agent.get_usage() solely to enrich traces or emit cost data that was not returned by the observed run.

For streaming spans, measure time_to_first_token in seconds from request start to the first generated chunk. Use start/end for timing; do not invent duration_ms or other metric keys.

Streaming behavior

Streaming must accumulate into the same complete span shape as non-streaming execution while preserving Cursor's one-shot stream semantics. Instrumentation must not eagerly consume, double-drain, buffer in a way that changes backpressure, or change sync/async iterator and return types.

A run exposes several mutually consuming interfaces (messages, events, direct iteration, iter_text, text, and wait). Whichever path the user chooses must finalize each run/model/tool span exactly once on normal completion, error, cancellation, or expiration.

User-provided on_delta and on_step callbacks must be preserved. Sync callbacks run inline; async callbacks may be sync or async, and awaitable returns must still be awaited before the next event is processed.

Multimodal input

For UserMessage images:

  • replace inline bytes/base64/data URLs in input with Braintrust attachment references at the original media leaf
  • use the canonical image_url.url placement for normalized image content
  • preserve remote URLs without fetching them solely to create attachments
  • if conversion fails, preserve the original payload and never fail the user's Cursor call
  • never add a separate top-level or metadata attachment list

API keys, bridge bearer tokens, environment-variable values, MCP authorization headers, and other credentials must never be captured.

Reasoning

Capture provider-surfaced reasoning summaries using the specification's reasoning output structure and map reported reasoning usage to completion_reasoning_tokens. Include prior reasoning context in subsequent model-call inputs when Cursor exposes it. Do not invent a Cursor-specific reasoning metric or capture additional hidden chain-of-thought fields outside the specification.

Metadata and provenance

Only emit metadata allowed by the instrumentation specification. In particular, do not emit ad hoc cursor_sdk.agent_id, cursor_sdk.run_id, runtime, status, duration, repository, branch, PR, or mode metadata without first adding it to braintrust-spec.

Every integration-created span must include stable origin provenance with:

context.span_origin.instrumentation.name = "cursor-sdk-auto"

Implementation parallels with Claude Agent SDK

Cursor SDK support can likely follow the existing integrations architecture:

  • CursorSDKIntegration built on BaseIntegration
  • patch stable public classes/functions while handling imports that occur before setup
  • a setup_cursor_sdk() entry point plus auto_instrument() support
  • tracing helpers that own run state across send/stream/wait operations
  • explicit version pins in [tool.braintrust.matrix], a dedicated nox session, and versioned recordings
  • subprocess auto-instrumentation coverage

Differences to account for:

  • Cursor has both sync and async mirrors (Agent/AsyncAgent, Run/AsyncRun, CursorClient/AsyncClient)
  • static helpers, explicit client resource namespaces, and aliases all reach the same underlying surfaces
  • the Python package talks to a bundled bridge over loopback Connect/protobuf and may spawn a subprocess; start with a real VCR-backed test and use a transport-level recorder only if standard VCR cannot observe the relevant bridge traffic
  • custom Python tools use callback transport from the bridge and should retain Braintrust context

Testing / acceptance criteria

  • Before implementation, add a real-package VCR-backed characterization test that enables relevant lower-level provider instrumentation and records/asserts the actual span hierarchy
  • The characterization test determines whether each Cursor model-turn span is a Cursor-owned llm leaf or a task above an existing provider llm leaf; never allow nested llm spans for one request
  • Versioned integration coverage for both sync and async APIs, retaining VCR playback coverage in CI
  • Agent.create().send().messages() and AsyncAgent.create().send().messages()
  • Agent.prompt() / AsyncAgent.prompt()
  • Agent.resume().send().wait() and explicit client.agents.* flows
  • every run consumption path (messages, events, direct iteration, iter_text, text, wait, observe) finalizes spans once and preserves return/iterator types
  • exact ordered agentic hierarchy, including parallel tools where applicable
  • when Cursor owns the leaf: canonical child llm input/output, metadata.model, metadata.provider = "cursor", and canonical per-turn token metrics
  • when a downstream leaf exists: Cursor model-turn task → provider llm, with leaf-owned metrics/provider provenance and no duplicated intermediate LLM telemetry
  • parent input/output and correctly aggregated token metrics
  • callback preservation for on_delta and on_step, including awaited async callbacks
  • tool start/completion/error correlation, top-level tool errors, optional normalized approval metadata, and CustomTool.execute tracing
  • attachment conversion and secret-redaction assertions
  • time_to_first_token for streaming and reasoning/cache metric mapping
  • cancellation, expiration, and provider error propagation
  • sync/async context-manager cleanup and no leaked bridge process
  • instrumentation failures never alter the user's Cursor call
  • context.span_origin.instrumentation.name == "cursor-sdk-auto" on every integration-created span without leaking that origin onto user-created nested spans
  • assertions that no unapproved metadata or metric keys are emitted
  • setup idempotence, import-before-setup behavior, and subprocess auto_instrument() coverage

Upstream references

Local files inspected

  • py/src/braintrust/integrations/ — no Cursor SDK integration
  • py/src/braintrust/wrappers/ — no Cursor SDK compatibility wrapper
  • py/src/braintrust/auto.py — no Cursor SDK auto-instrumentation
  • py/pyproject.toml — no cursor-sdk matrix or cassette mapping
  • py/noxfile.py — no Cursor SDK test session
  • Repo-wide case-insensitive grep under py/ — no Cursor SDK-specific matches

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions