Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/research/CURSOR_NATIVE_RECOVERY_2026-09-08.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Cursor native request recovery

- Status: validated at the boundaries below; exploratory acceptance, not a Benchmark.
- Created / verified: 2026-09-08.
- Implementation source: `fcee687712220c8c32a18a6c334eb2b9f98a857c`; baseline: `993fd8e22ff5c7495c1b5c19f9d64c0b6b97c252`.
- Related Issue: [#234](https://github.com/openpi-dev/openpi/issues/234).
- Supersedes: none.

## Finding and source

The previous provider terminated a tool-enabled turn upon seeing a Cursor-native UI tool preview. It also terminated after replying to an unsupported native execution request. These are different protocol events: a preview does not authorize execution, and rejecting one exec need not terminate AgentService/Run.

[OMP's sendExecClientThrow implementation](https://github.com/can1357/oh-my-pi/blob/daf07999c2fee9b22edc7bf8fea1fb6272e0df5e/packages/ai/src/providers/cursor.ts#L2552) sends an in-band exception followed by a per-exec stream close. Its implementation comments identify this as the executor's no-handler path, allowing the server to surface the failure to the model. This informed the scoped fix; OMP's broader native-tool mapping was not adopted.

## Implemented boundary

With Pi tools advertised, previews are ignored and actual unsupported native execs receive the existing UNIMPLEMENTED reply plus exec stream close. The model can then request an advertised Pi MCP tool. Native tools are never executed. The fourth rejected native exec in one provider call ends the call with an explicit recovery-limit error. Malformed or unauthorized MCP requests still fail closed; chat-only behavior is unchanged.

Only the caller's AbortSignal establishes caller cancellation. A server error containing “aborted” or “canceled” remains an error, and its original message is settled before closing the HTTP/2 stream can replace it with a secondary abort event.

## Deterministic verification

New regression tests failed against the previous behavior and pass with the fix. They cover native previews and rejection followed by a complete Pi tool/result turn, bounded rejection including a late valid MCP frame, caller cancellation, and preservation of a server cancellation error.

- Cursor suite: 30 passed.
- `bun run check`: passed.
- `bun run test`: Node 1,465 passed, 1 skipped; Vitest 113 passed; no failures.

## Live exploratory acceptance

Both runs used the actual `makePiBackend` and SDK `createAgentSession`, with an isolated Pi configuration listing only this worktree as its OpenPI source. Existing Cursor credentials were supplied in memory. Child tools were limited to `read` and `bash`, with an untrusted synthetic cwd containing a random nonce file. The verifier compared the final text to the nonce after both tools completed. The native scenario explicitly asked the model to try native `read_file` first and recover through Pi if rejected.

| Scenario | Model | Elapsed | AgentService requests | Native rejections | Result |
| --- | --- | --- | --- | --- | --- |
| Ordinary Pi tools | cursor-grok-4.6-high-fast | 10.361 s | 2 | 0 | Completed; read and bash succeeded; exact nonce |
| Native request recovery | cursor-grok-4.6-high-fast | 15.507 s | 2 | 1 | Completed; read and bash succeeded; exact nonce |

Outbound protocol instrumentation counted the rejection; it did not infer recovery from prose. Each run had a 140-second outer bound and scoped backend disposal. Normalized receipts are preserved in [the adjacent JSON file](CURSOR_NATIVE_RECOVERY_2026-09-08.receipts.json). Two earlier harness setup attempts failed before any model request because isolated authentication was initially wired incorrectly; they are infrastructure setup failures, excluded from the two acceptance runs.

## Limits

This is two exploratory runs on one model, not a reliability or performance measurement. It does not establish long-task, fan-out, or all-model acceptance. Proxy tunnel timeouts and remote connection aborts remain separate transport failures. The fix neither proves nor assumes intentional third-party blocking by Cursor. Repository validation and live isolated acceptance do not mean the change is merged, released, or installed in the user's regular runtime. Credentials and private Session transcripts are not part of the published evidence.
57 changes: 57 additions & 0 deletions docs/research/CURSOR_NATIVE_RECOVERY_2026-09-08.receipts.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
[
{
"source": "fcee687712220c8c32a18a6c334eb2b9f98a857c",
"model": "cursor-grok-4.6-high-fast",
"scenario": "ordinary",
"elapsedMs": 10361,
"requests": 2,
"nativeRejections": 0,
"toolEvents": [
{
"type": "tool_end",
"name": "read",
"isError": false
},
{
"type": "tool_end",
"name": "bash",
"isError": false
}
],
"outcome": "Completed",
"error": null,
"finalMatches": true,
"cleanup": null,
"wire": []
},
{
"source": "fcee687712220c8c32a18a6c334eb2b9f98a857c",
"model": "cursor-grok-4.6-high-fast",
"scenario": "native",
"elapsedMs": 15507,
"requests": 2,
"nativeRejections": 1,
"toolEvents": [
{
"type": "tool_end",
"name": "read",
"isError": false
},
{
"type": "tool_end",
"name": "bash",
"isError": false
}
],
"outcome": "Completed",
"error": null,
"finalMatches": true,
"cleanup": null,
"wire": [
{
"type": "native_rejection",
"id": 1
}
]
}
]
2 changes: 2 additions & 0 deletions docs/research/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Research records preserve sourced investigation and distinguish observations, in

## Validated investigations

- [`CURSOR_NATIVE_RECOVERY_2026-09-08.md`](CURSOR_NATIVE_RECOVERY_2026-09-08.md) — bounded in-band native execution rejection, Pi-owned tools, cancellation identity, and actual child acceptance ([#234](https://github.com/openpi-dev/openpi/issues/234)).

- [`WEB_STARTUP_2026-09-07.md`](WEB_STARTUP_2026-09-07.md) — terminal startup feedback, browser-launch waiting, and exploratory timing limits ([#450](https://github.com/openpi-dev/openpi/issues/450)).

- [`WEB_STREAMING_MARKDOWN_2026-09-07.md`](WEB_STREAMING_MARKDOWN_2026-09-07.md) — unchanged historical Markdown parsing during streaming, bounded React component reuse and deterministic regression evidence ([#434](https://github.com/openpi-dev/openpi/issues/434)).
Expand Down
11 changes: 8 additions & 3 deletions extensions/ai-providers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,14 @@ interactive prompt is converted into an actual image attachment (up to 10 MiB)
before the request is sent. The absolute path is not exposed to the model.

When Pi supplies tools, the provider directs Cursor to the advertised Pi MCP
catalog. Without tools it uses an explicit chat-only rule. Partial tool previews
and approval-only probes never execute a tool; unknown or malformed invocations
fail explicitly. Local HTTP/2 tests exercise a normal Pi tool lifecycle and
catalog. Without tools it uses an explicit chat-only rule. Tool previews and
approval-only probes never execute a tool. With Pi tools available, unsupported
native execution requests receive an in-band protocol rejection so the model
can correct its choice; more than three such requests in one provider call fail
explicitly. Malformed or unadvertised Pi MCP invocations still fail closed.
Only a caller abort signal is classified as cancellation; server cancellation
and connection resets remain transport failures with their original errors.
Local HTTP/2 tests exercise native rejection recovery, a normal Pi tool lifecycle and
result replay, but account/model-specific compatibility still requires a live
smoke test. Cursor's native execution protocol is intentionally not enabled.

Expand Down
61 changes: 31 additions & 30 deletions extensions/ai-providers/cursor/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const CONNECT_COMPRESSED_FLAG = 0b00000001;
const MAX_CONNECT_FRAME_BYTES = 16 * 1024 * 1024;
const HEARTBEAT_INTERVAL_MS = 5_000;
const PROXY_TUNNEL_TIMEOUT_MS = 30_000;
const MAX_NATIVE_EXEC_REJECTIONS = 3;

export const CURSOR_CHAT_ONLY_SYSTEM_PROMPT =
"This Cursor provider is running in chat-only mode. No filesystem, shell, code modification, MCP, web, or user-interaction tools are available. Never emit tool calls or interaction queries. Images attached to the user message are already available for direct analysis. If required information is unavailable, explain the limitation in text instead of attempting a tool.";
Expand Down Expand Up @@ -679,17 +680,6 @@ function errorFromEndStream(data: Uint8Array): Error | undefined {
}
}

function isAbortError(
error: unknown,
signal: AbortSignal | undefined,
): boolean {
return (
Boolean(signal?.aborted) ||
(error instanceof Error &&
/aborted|cancelled|canceled/i.test(error.message))
);
}

/** Cursor AgentService/Run with Pi-owned tool execution across provider turns. */
export function streamCursor(
model: Model<Api>,
Expand Down Expand Up @@ -724,6 +714,7 @@ export function streamCursor(
let finished = false;
const pendingCalls = new Map<string, ToolCall>();
let handingOffTools = false;
let nativeExecRejections = 0;

const closeBlocks = () => {
if (currentText) {
Expand Down Expand Up @@ -752,9 +743,9 @@ export function streamCursor(
if (finished) return;
finished = true;
closeBlocks();
output.stopReason = isAbortError(error, options?.signal)
? "aborted"
: "error";
// Peer resets and server cancellation messages are transport failures.
// Only the caller's signal establishes a locally requested interruption.
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage =
error instanceof Error ? error.message : String(error);
stream.push({
Expand Down Expand Up @@ -836,13 +827,17 @@ export function streamCursor(
};
const frames = new ConnectFrameReader(MAX_CONNECT_FRAME_BYTES);
const processFrame = (flags: number, bytes: Uint8Array) => {
if (handingOffTools) return;
if (handingOffTools || terminalError) return;
if ((flags & CONNECT_COMPRESSED_FLAG) !== 0) {
throw new Error("Compressed Cursor Connect frames are unsupported");
}
if ((flags & CONNECT_END_STREAM_FLAG) !== 0) {
terminalError = errorFromEndStream(bytes);
if (terminalError) h2Request?.close();
if (terminalError) {
// Preserve the server's error before close emits an aborted event.
settle(terminalError);
h2Request?.close();
}
return;
}
const message = fromBinary(AgentServerMessageSchema, bytes);
Expand Down Expand Up @@ -963,17 +958,31 @@ export function streamCursor(
? "Cursor requested unsupported native execution outside Pi"
: "Cursor requested a tool that is unavailable in chat-only mode",
);
terminalError = error;
if (!h2Request) {
settle(error);
return;
}
// Like OMP's sendExecClientThrow, reject this exec frame in band.
// streamClose closes the rejected exec, not AgentService/Run: the
// model may recover by requesting one of the advertised Pi tools.
const recover =
Boolean(context.tools?.length) &&
++nativeExecRejections <= MAX_NATIVE_EXEC_REJECTIONS;
if (!recover) {
terminalError = context.tools?.length
? new Error(
`Cursor native execution recovery limit (${MAX_NATIVE_EXEC_REJECTIONS}) exceeded; last exec id ${exec.id}`,
)
: error;
}
h2Request.write(
frameConnectMessage(toBinary(AgentClientMessageSchema, throwReply)),
);
h2Request.write(
frameConnectMessage(toBinary(AgentClientMessageSchema, closeReply)),
() => settle(error),
() => {
if (!recover) settle(terminalError);
},
);
return;
}
Expand All @@ -992,23 +1001,15 @@ export function streamCursor(
context.tools?.length &&
(update.message.case === "partialToolCall" ||
update.message.case === "toolCallStarted" ||
update.message.case === "toolCallCompleted")
update.message.case === "toolCallCompleted" ||
update.message.case === "toolCallDelta")
) {
const preview = update.message.value.toolCall;
if (preview && preview.tool.case !== "mcpToolCall") {
throw new Error(
"Cursor-native tools are unavailable; use the advertised Pi MCP tools",
);
}
// Only exec mcpArgs is an invocation. UI previews may be partial,
// duplicated, or emitted for approval probes, and never execute.
// Native previews are not an execution request either. Wait for its
// exec frame so we can reject it with the correct protocol identity.
return;
}
if (context.tools?.length && update.message.case === "toolCallDelta") {
throw new Error(
"Cursor-native tool deltas are unavailable; use the advertised Pi MCP tools",
);
}
processInteraction(
message.message.value,
output,
Expand Down
Loading
Loading