Skip to content

Add a Cursor SDK harness adapter - #72

Closed
jacobprall wants to merge 3 commits into
render-examples:mainfrom
jacobprall:harness-cursor
Closed

jacobprall wants to merge 3 commits into
render-examples:mainfrom
jacobprall:harness-cursor

Conversation

@jacobprall

Copy link
Copy Markdown
Collaborator

Fixes #68. Depends on #71, which depends on #70; their commits are included until they merge. Review the last commit.

cursor({ model: { id: "composer-2.5" } }) runs an agent on a local Cursor agent inside the Sandbox, with Cursor-hosted models and CURSOR_API_KEY delivered on stdin.

Design

  • Tools: the factory's only. Cursor's built-in tools are off (tools: ["mcp"]). The model gets the factory's five tools and submit as custom tools, the only MCP server it sees. So tool authority and the read-only shell policy match pi and Pydantic AI. This differs from Add a Cursor SDK harness adapter #68's plan, which kept Cursor's native file tools: the SDK has no native write, and the extension runbook in Run agents on imported harness adapters #70 says a harness offers only factory tools.
  • Ambient config: off. settingSources: []. The spike confirmed that a canary rule, an AGENTS.md instruction, and a hook in the checkout's .cursor/ all had no effect.
  • System prompt: not replaced by default. Cursor enables system-prompt replacement per account, and our test account doesn't have it ("Custom system prompt is not enabled for this account"). By default the agent's prompt leads the first message, under Cursor's own system prompt; replaceSystemPrompt: true opts in.
  • Limits: turns are counted per tool-call batch. A run stops at max_turns, at its time budget, after the last rejected submission, or when work continues past an accepted one. A run that ends without submitting gets one reminder, as on pi.
  • Runtime: local only, inside the Sandbox. The cloud runtime isn't offered: it runs outside the Sandbox and can write to GitHub itself.

Packaging

In 1.0.40, the SDK's flat build doesn't load on Node: it imports bun:sqlite and calls Bun-only code. Its default build lazy-loads its own files, so it can't be single-file bundled. So:

  • the runner bundle leaves @cursor/sdk external;
  • the adapter's provision (from Add a Pydantic AI harness adapter #71) ships cursor/package.json and cursor/package-lock.json, and runs npm ci with the base image's npm into a root-owned APP_DIR/cursor-sdk, about 2.5 seconds. The Linux platform package comes from the lockfile.
  • Node 22.13+ is checked in the adapter's self-test; the base image ships Node v24.14.1.

Tests

  • Static checks, always run: the adapter declares CURSOR_API_KEY and ships the locked runtime; the pins in cursor/package.json, package.json, and CURSOR_SDK_VERSION agree; --test-stub runs are refused.
  • Live checks, on request: Cursor's models can't be scripted, so the contract tests run with CURSOR_LIVE=1 CURSOR_API_KEY=… npx vitest run tests/harness-cursor.test.ts. The agent reads a file and submits valid output; a read-only agent's git push is rejected by the factory's policy; no key appears in the output or the tool environment. Both live tests pass on Composer 2.5.

Verification

  • npm run check passes: 165 tests, plus 2 live tests skipped by default.
  • One autopilot run on the test deployment covered all three harnesses: Triage on Pydantic AI, the spec writer on pi, then the implementer and PR Reviewer on Cursor. It verified the change, opened a draft PR adding one file, and posted a COMMENT review, in under five minutes.
  • A snapshot refresh applied both provisions at build time.

Known gaps

  • No Cursor cost in telemetry. The SDK reports tokens but not dollars, so costUsd is 0 for Cursor agents. Spend shows in Cursor's dashboard.
  • The Cursor conversation store lives in scratch. The harness keeps its run store in the Sandbox's scratch directory, which the agent's tools can write. This is listed in the residual risks.

Deploying

Deploy factory-workflows, add CURSOR_API_KEY to .env, and run npm run setup:render; the key is derived from the agents' harnesses. Use a team service-account key with a spend limit. Then refresh snapshots so Sandboxes don't each reinstall the SDK.

An agent now names a harness value built by an adapter in app/harness/
from its library's own configuration, instead of a model tier string.
The pi adapter takes a typed model from Pi's catalog, so a mistyped
model ID fails typecheck. config.models, ModelTier, and modelFor are
gone; the built-in choices live in app/agents/models.ts.

A harness is the runtime the runner calls in the Sandbox (run and
selfTest, app/runner/core/harness.ts) plus what the host needs before a
run (a label and the credential env var, app/harness/types.ts). What a
harness must do beyond those signatures, such as giving the model only
the factory's tools and submit, is pinned by the conformance suite.

The runner is split into a library-neutral core (versioned contract,
emitter, submit checks, input parsing, keys), shared factory tools with
a library-neutral view, and the pi loop. Agent definitions are bundled
into the runner, which runs the agent named in its input on that
agent's harness.

Each Sandbox replaces a snapshot's runner bundle with this deploy's
when their hashes differ, before verify.sh, so harness and model
changes need no snapshot refresh. Keys come from the harnesses:
runAgent, doctor, and the workflows env group derive them from each
harness's credential. agent.finished records the model the runner
reports.

Tests run a conformance suite over the runner contract against the pi
harness and a scripted harness with no agent library, and check that
the bundle imports no host-only code.

Refs render-examples#67
pydantic({ module, credential }) runs an agent on a Pydantic AI agent
built in python/agents/<module> and handed over with serve(agent). The
runner starts python -m factory_harness as the runner user and passes
the input and key on stdin; the wrapper makes itself non-dumpable before
reading the key and sets it under the credential's name for the
provider. The factory supplies the prompt as run instructions, the
submit output tool checked like core/submit.ts, the turn and time
limits, and its own tools: runner.mjs --mcp-tools serves exactly the
agent's factory tools over MCP stdio, with the same environment and
read-only policy as pi. Events are revalidated before they're forwarded,
and the self-test reports the installed Pydantic AI version.

A harness declares its Sandbox needs with provision (app/harness/types.ts):
files shipped beside the bundle and a root command keyed by a hash. The
runner bundle check becomes a payload check (bundle plus provisioned
files), swapped in root-owned when it differs, and provisions run at
snapshot build and on open before the self-test. The pydantic adapter
provisions its Python sources and builds a root-owned venv from
hash-pinned requirements with the base image's python3 and pip.

createEmitter's terminated getter was copied as a value by Object.assign
and always read false; it now reflects the stream.

The conformance suite runs the pydantic harness when python/.venv
exists, and CI gets a Python job.

Refs render-examples#69
cursor({ model }) runs an agent on a local Cursor agent inside the
Sandbox, with CURSOR_API_KEY as its credential, delivered on stdin.
Cursor's own tools are off (tools: ["mcp"]) and so are ambient settings
from the checkout (settingSources: []): the model gets the factory's
five tools and submit as custom tools, so tool authority and the
read-only shell policy match pi and Pydantic AI. The cloud runtime isn't
offered.

Replacing Cursor's system prompt is enabled per account, so by default
the agent's prompt leads the first message; replaceSystemPrompt opts in.
Turns are counted per tool batch; a run stops at max_turns, at its time
budget, after the last rejected submission, or once work continues past
an accepted one. The self-test and started event report the installed
SDK version.

The SDK's flat build doesn't load on Node and its default build can't be
single-file bundled, so the runner bundle leaves @cursor/sdk external and
the adapter provisions it: cursor/package-lock.json ships with the
payload and npm ci installs it root-owned at APP_DIR/cursor-sdk.

Cursor's models can't be scripted, so the adapter's contract tests run
live on request (CURSOR_LIVE=1).

Refs render-examples#68
@jacobprall

Copy link
Copy Markdown
Collaborator Author

Consolidated into #73.

@jacobprall jacobprall closed this Oct 27, 2026
@jacobprall
jacobprall deleted the harness-cursor branch October 27, 2026 23:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Add a Cursor SDK harness adapter

1 participant