Skip to content

Commit 13c845e

Browse files
authored
feat(evals): add Cursor agent bench harness (integrations/cursor-sdk) (#2812)
## What Adds `--harness cursor` to evals, backed by a new private `@browserbasehq/stagehand-integrations-cursor-sdk` package, plus a minimal `packages/integrations/cursor` facade example (mcp.json + README, fx-package shape). The Cursor agent is CLI-only (`agent`, legacy `cursor-agent`; no npm SDK). - `packages/integrations/cursor-sdk` — `runCursorSession`: spawns `agent -p --output-format stream-json` with a project-local `.cursor/mcp.json` pointing at the stagehand facade stdio server, parses the stream into tool-call/result/usage events, abort → child kill, redaction, status/stopReason normalization. - `packages/evals/framework/cursorToolAdapter.ts` — `via:"mcp"` mounts only (stagehand_facade first, playwright_mcp, chrome_devtools_mcp; browse_cli/code surfaces rejected). - `packages/evals/framework/harnesses/cursorAdapter.ts` — events → `NormalizedToolCall[]`. - Registered via `defineExternalHarness` on the shared external-runner skeleton. ## Testing - Full unit gate green (evals + cursor-sdk 8 + all other suites). - Connected smoke **not yet run**: the `agent` CLI is not installed on the dev machine (`curl https://cursor.com/install -fsS | bash`). Command when available: `evals run b:webvoyager --harness cursor --tool stagehand_facade -l 1 -t 1 -e browserbase`. Stacked on the fx PR; top of the harness stack. Implemented with Codex (gpt-5.6) under supervision. <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds a registered Cursor agent CLI harness so evals can run with `--harness cursor` over MCP-only mounts, without launching a browser or modifying files. The Cursor CLI reports no token usage, so token metrics are zero. - Supported tool surfaces: `stagehand_facade` (default), `playwright_mcp`, `chrome_devtools_mcp`; other surfaces (e.g., `browse_cli`, code mounts) are rejected. - Registration: `cursor` is in the bench harness registry with default model `cursor/auto`; wired into CI, `turbo`, and `vitest` with unit tests for registry, runner, adapter, and SDK. - Process/workspace: writes a temp `.cursor/mcp.json` for active MCP servers, inherits env, and cleans up. Override the binary with `EVAL_CURSOR_AGENT_PATH`; toggle sandbox via `EVAL_CURSOR_SANDBOX=enabled|disabled`; cap completed tool steps via `EVAL_CURSOR_MAX_STEPS` or `AGENT_EVAL_MAX_STEPS` (default 50). - CLI invocation: runs `agent -p --output-format stream-json --force --trust --approve-mcps`; API keys are never passed on the command line, and stream output is sanitized for secrets. - Metrics: `cursor_input_tokens`, `cursor_output_tokens`, `cursor_total_tokens` (always zero), `cursor_duration_ms`, `cursor_tool_steps`. - Trajectory mapping: assistant text becomes reasoning; started/completed `tool_call` pairs become steps, and started calls without a completed envelope (abort/kill mid-call) fail closed; MCP images become evidence with a final-observation fallback; step observations attach by MCP tool ordinal. Results are parsed strictly first, then leniently for fenced/prose JSON. - New private package `@browserbasehq/stagehand-integrations-cursor-sdk` adds the process runner and parsers; `packages/integrations/cursor` provides an `mcp.json` template and AGENTS.md guidance. - Run locally: install the Cursor CLI (`curl https://cursor.com/install -fsS | bash`), set `CURSOR_API_KEY`, then e.g. `evals run b:webvoyager --harness cursor --tool stagehand_facade -l 1 -t 1 -e browserbase`. <sup>Written for commit c20d28b. Summary will update on new commits.</sup> <a href="https://cubic.dev/pr/browserbase/stagehand/pull/2812?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
1 parent daec8d6 commit 13c845e

25 files changed

Lines changed: 2043 additions & 12 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ jobs:
266266
packages/integrations/eve-sdk/dist/**
267267
packages/integrations/deepagents-sdk/dist/**
268268
packages/integrations/fx-sdk/dist/**
269+
packages/integrations/cursor-sdk/dist/**
269270
packages/evals/dist/**
270271
retention-days: 1
271272

packages/evals/framework/benchHarness.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { runDeepagentsAgent } from "./deepagentsRunner.js";
2020
import { DEEPAGENTS_TOOL_SURFACES, prepareDeepagentsToolAdapter } from "./deepagentsToolAdapter.js";
2121
import { runFxAgent } from "./fxRunner.js";
2222
import { FX_TOOL_SURFACES, prepareFxToolAdapter } from "./fxToolAdapter.js";
23+
import { runCursorAgent } from "./cursorRunner.js";
24+
import { CURSOR_TOOL_SURFACES, prepareCursorToolAdapter } from "./cursorToolAdapter.js";
2325
import {
2426
buildExternalHarnessTaskPlan,
2527
type ExternalHarnessTaskPlan,
@@ -336,6 +338,14 @@ export const fxHarness = defineExternalHarness({
336338
runAgent: runFxAgent,
337339
});
338340

341+
export const cursorHarness = defineExternalHarness({
342+
harness: "cursor",
343+
supportedToolSurfaces: CURSOR_TOOL_SURFACES,
344+
defaultModels: ["cursor/auto" as AvailableModel],
345+
prepareToolAdapter: prepareCursorToolAdapter,
346+
runAgent: runCursorAgent,
347+
});
348+
339349
const harnessRegistry = new Map<Harness, BenchHarness>([
340350
["stagehand", stagehandHarness],
341351
["claude_code", claudeCodeHarness],
@@ -345,6 +355,7 @@ const harnessRegistry = new Map<Harness, BenchHarness>([
345355
["eve", eveHarness],
346356
["deepagents", deepagentsHarness],
347357
["fx", fxHarness],
358+
["cursor", cursorHarness],
348359
]);
349360

350361
export function registerBenchHarness(harness: BenchHarness): () => void {
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import {
2+
buildCursorTranscript,
3+
extractCursorToolCall,
4+
runCursorAgentSession,
5+
stringifyError,
6+
type CursorProcessRunner,
7+
type CursorTokenUsage,
8+
} from "@browserbasehq/stagehand-integrations-cursor-sdk";
9+
import type { AvailableModel } from "stagehand-v3";
10+
import type { EvalLogger } from "../logger.js";
11+
import type { PreparedCursorToolAdapter } from "./cursorToolAdapter.js";
12+
import type { ExternalHarnessTaskPlan } from "./externalHarnessPlan.js";
13+
import { cursorAdapter } from "./harnesses/cursorAdapter.js";
14+
import {
15+
buildExternalHarnessPrompt,
16+
metricValue,
17+
parseEvalResult,
18+
runExternalHarnessTask,
19+
type ExternalHarnessToolAdapterLike,
20+
type MetricValue,
21+
type ParsedEvalResult,
22+
} from "./harnesses/externalRunner.js";
23+
import type { TaskResult } from "./types.js";
24+
import type { ExternalHarnessVerifierConfig } from "./verifierAdapter.js";
25+
26+
export type { CursorProcessRunner } from "@browserbasehq/stagehand-integrations-cursor-sdk";
27+
28+
export interface CursorRunnerInput {
29+
plan: ExternalHarnessTaskPlan;
30+
model: AvailableModel;
31+
logger: EvalLogger;
32+
toolAdapter?: PreparedCursorToolAdapter;
33+
signal?: AbortSignal;
34+
runProcess?: CursorProcessRunner;
35+
verifier?: ExternalHarnessVerifierConfig;
36+
}
37+
38+
export interface ParsedCursorResult extends ParsedEvalResult {}
39+
40+
const MCP_ONLY_LINE =
41+
"Your only browser access is the MCP server configured in this workspace; never launch a browser yourself or run shell commands to browse.";
42+
43+
function composeCursorToolInstructions(toolInstructions?: string): string {
44+
return [
45+
toolInstructions ?? "Use the available browser/web tools to complete the task.",
46+
MCP_ONLY_LINE,
47+
"Do not edit repository files.",
48+
].join("\n");
49+
}
50+
51+
export function buildCursorPrompt(
52+
plan: ExternalHarnessTaskPlan,
53+
toolInstructions?: string,
54+
): string {
55+
return buildExternalHarnessPrompt({
56+
plan,
57+
toolInstructions: composeCursorToolInstructions(toolInstructions),
58+
resultContract: "marker",
59+
});
60+
}
61+
62+
export function parseCursorResult(raw: string): ParsedCursorResult {
63+
const parsed = parseEvalResult(raw);
64+
if (parsed.success) return parsed;
65+
return { ...parseEvalResult(`EVAL_RESULT: ${raw}`), raw };
66+
}
67+
68+
export async function runCursorAgent({
69+
plan,
70+
model,
71+
logger,
72+
toolAdapter,
73+
signal,
74+
runProcess,
75+
verifier,
76+
}: CursorRunnerInput): Promise<TaskResult> {
77+
const adapterLike: ExternalHarnessToolAdapterLike = {
78+
promptInstructions: composeCursorToolInstructions(toolAdapter?.promptInstructions),
79+
captureEvidence: toolAdapter?.captureEvidence,
80+
drainStepObservations: toolAdapter?.drainStepObservations,
81+
observedToolMatcher: toolAdapter?.observedToolMatcher,
82+
};
83+
return runExternalHarnessTask({
84+
harness: "cursor",
85+
plan,
86+
logger,
87+
toolAdapter: adapterLike,
88+
verifier,
89+
resultContract: "marker",
90+
fallbackErrorMessage: "Cursor did not report success",
91+
// Cursor often emits the result as fenced/prose JSON without the marker;
92+
// the lenient retry only runs after the strict parse fails.
93+
parseResult: parseCursorResult,
94+
runSession: async (prompt) => {
95+
const sessionResult = await runCursorAgentSession({
96+
prompt,
97+
model,
98+
logger,
99+
signal,
100+
runProcess,
101+
session: {
102+
...(toolAdapter?.cwd && { cwd: toolAdapter.cwd }),
103+
...(toolAdapter?.env && { env: toolAdapter.env }),
104+
...(process.env.EVAL_CURSOR_AGENT_PATH && {
105+
binaryPath: process.env.EVAL_CURSOR_AGENT_PATH,
106+
}),
107+
// CURSOR_API_KEY reaches the CLI through the inherited environment;
108+
// never pass it as --api-key, which would expose it in process listings.
109+
...(readCursorSandbox(process.env.EVAL_CURSOR_SANDBOX) && {
110+
sandbox: readCursorSandbox(process.env.EVAL_CURSOR_SANDBOX),
111+
}),
112+
force: true,
113+
trust: true,
114+
approveMcps: true,
115+
},
116+
maxToolSteps: readCursorMaxToolSteps(),
117+
onToolResult: toolAdapter?.onToolResult
118+
? (name) => toolAdapter.onToolResult!(name)
119+
: undefined,
120+
});
121+
const usage = sessionResult.tokenUsage;
122+
return {
123+
raw: sessionResult,
124+
resultText: sessionResult.resultText,
125+
transcriptText: buildCursorTranscript(sessionResult.events),
126+
iterationError: sessionResult.iterationError,
127+
status: sessionResult.status,
128+
stopReason:
129+
sessionResult.stopReason ||
130+
(sessionResult.status === "sdk_error"
131+
? stringifyError(sessionResult.iterationError) || undefined
132+
: undefined),
133+
usage: {
134+
inputTokens: usage.inputTokens,
135+
outputTokens: usage.outputTokens,
136+
totalTokens: usage.totalTokens,
137+
},
138+
metrics: buildCursorMetrics(usage, sessionResult.resultEvent, sessionResult.events),
139+
};
140+
},
141+
toTrajectory: (
142+
{ raw, parsed, finalObservation, stepObservations, observedToolName, status },
143+
taskSpec,
144+
) =>
145+
cursorAdapter.fromHarnessResult(
146+
{
147+
events: raw.events,
148+
...(finalObservation && { finalObservation }),
149+
...(stepObservations?.length && { stepObservations }),
150+
...(observedToolName && { observedToolName }),
151+
finalAnswer: parsed.finalAnswer ?? raw.resultText,
152+
status,
153+
usage: {
154+
input_tokens: 0,
155+
output_tokens: 0,
156+
},
157+
},
158+
taskSpec,
159+
),
160+
});
161+
}
162+
163+
export function readCursorSandbox(value: unknown): "enabled" | "disabled" | undefined {
164+
return value === "enabled" || value === "disabled" ? value : undefined;
165+
}
166+
167+
export function readCursorMaxToolSteps(): number {
168+
for (const key of ["EVAL_CURSOR_MAX_STEPS", "AGENT_EVAL_MAX_STEPS"]) {
169+
const parsed = Number.parseInt(process.env[key] ?? "", 10);
170+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
171+
}
172+
return 50;
173+
}
174+
175+
function buildCursorMetrics(
176+
usage: CursorTokenUsage,
177+
resultEvent: Record<string, unknown> | undefined,
178+
events: Array<Record<string, unknown>>,
179+
): Record<string, MetricValue> {
180+
const toolSteps = events.filter((event) => {
181+
const view = extractCursorToolCall(event);
182+
return view?.subtype === "completed";
183+
}).length;
184+
return {
185+
cursor_input_tokens: metricValue(usage.inputTokens),
186+
cursor_output_tokens: metricValue(usage.outputTokens),
187+
cursor_total_tokens: metricValue(usage.totalTokens),
188+
cursor_duration_ms: metricValue(resultEvent?.duration_ms),
189+
cursor_tool_steps: metricValue(toolSteps),
190+
};
191+
}

0 commit comments

Comments
 (0)