Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
22 changes: 22 additions & 0 deletions packages/ai/src/api/cursor-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4133,6 +4133,28 @@ function buildConversationTurns(
return turns;
}

/** Returns the serialized byte cost of Cursor's complete stored history representation. */
export function measureCursorHistorySerializedBytes(
messages: Message[],
activeUserMessageIndex = findLastUserMessageIndex(messages),
): number {
return buildCursorHistoryWireBytesForTest(messages, activeUserMessageIndex).reduce(
(total, bytes) => total + bytes.byteLength,
0,
);
}

/** Exported for tests: returns the encoded Cursor history blob payloads. */
export function buildCursorHistoryWireBytesForTest(
messages: Message[],
activeUserMessageIndex = findLastUserMessageIndex(messages),
): Uint8Array[] {
const blobStore = new Map<string, Uint8Array>();
buildRootPromptMessagesJson(messages, [], blobStore, activeUserMessageIndex);
buildConversationTurns(messages, blobStore, activeUserMessageIndex);
return [...blobStore.values()];
}

/** Exported for tests: decodes Cursor history blobs built from conversation messages. */
export function buildCursorHistoryForTest(
messages: Message[],
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

### Fixed

- Cursor tool-result request views now truncate text and image payloads without mutating shared agent state or persisted session history; aggregate eviction preserves newest results, accounts conservatively for escaped/enveloped serialization, never amplifies output with markers, and remains grapheme-safe (#1043).

- `cursor-cli-oauth` no longer mixes Cursor's internal tool-call protocol, arguments, and results into assistant text; Cursor still owns execution, while Senpi now stores and renders only the model's actual prose ([OmO #7169](https://github.com/code-yeongyu/oh-my-openagent/issues/7169)).

- On the `claude-sdk-oauth` lane, the "Compaction rejected: the Claude Agent SDK owns compaction for
Expand Down
19 changes: 19 additions & 0 deletions packages/coding-agent/src/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# changes

## Measure Cursor tool-result history at the wire representation (2026-08-29)

### What changed

- `packages/coding-agent/src/core/agent-session.ts` admits Cursor context using the actual serialized history bytes, accounting for tool names, call IDs, MIME types, arguments, and framing instead of a fixed envelope estimate. Oldest result bodies are emptied first, with complete oldest turns removed only when metadata alone exceeds the bound.
- `packages/ai/src/api/cursor-agent.ts` exposes the shared serialized-history measurement used by the admission transform.

### Why

- Cursor history must stay at or below 50,000 serialized bytes without evicting results from histories that genuinely fit.

### Why an extension could not handle it

- Admission occurs in the core Cursor context transform before provider execution.

### Expected merge conflict zones

- LOW: Cursor admission constants and `truncateToolResultBodies()` in `agent-session.ts`.

## Honor --auto-title-sessions outside interactive mode (2026-08-28)

### What changed
Expand Down
148 changes: 142 additions & 6 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
} from "@earendil-works/pi-agent-core";
import { ProviderRetryWatchdogAbortError, prepareAgentToolCall } from "@earendil-works/pi-agent-core";
import { contentText, SERVER_FALLBACK_ABORTED_DIAGNOSTIC, type ThinkingSelection } from "@earendil-works/pi-ai";
import { measureCursorHistorySerializedBytes } from "@earendil-works/pi-ai/api/cursor-agent";
import type {
Api,
AssistantMessage,
Expand Down Expand Up @@ -146,7 +147,12 @@ import type {
} from "./extensions/types.ts";
import { normalizeToolExposure, RUNTIME_EXTENSION_PATH } from "./extensions/types.ts";
import { shouldWarnHighReasoning } from "./high-reasoning-warning.ts";
import { type BashExecutionMessage, type CustomMessage, filterContextExcludedMessages } from "./messages.ts";
import {
type BashExecutionMessage,
type CustomMessage,
convertToLlm,
filterContextExcludedMessages,
} from "./messages.ts";
import { ModelRegistry } from "./model-registry.ts";
import { type AvailableModelsSource, getModelNarrowingPatterns, resolveModelScope } from "./model-resolver.ts";
import type { ModelRuntime } from "./model-runtime.ts";
Expand Down Expand Up @@ -824,6 +830,121 @@ const THINKING_LEVELS_WITH_MAX: ThinkingLevel[] = ["off", "minimal", "low", "med
/** Caps explicit skill expansion so one prompt cannot consume unbounded context. */
export const MAX_SKILL_EXPANSIONS_PER_PROMPT = 5;

/** Cursor ingest rejects large verbatim tool payloads. The bound is UTF-8 bytes. */
export const CURSOR_TOOL_RESULT_MAX_CHARS = 2000;
export const CURSOR_TOOL_RESULT_MAX_BYTES = 50_000;
const CURSOR_TRUNCATION_MARKER = "\n...[truncated]";

export function truncateToolResultBodies(
messages: AgentMessage[] | undefined,
maxChars = CURSOR_TOOL_RESULT_MAX_CHARS,
maxBytes = CURSOR_TOOL_RESULT_MAX_BYTES,
): { messages: AgentMessage[] | undefined; changed: boolean } {
if (!Array.isArray(messages) || messages.length === 0) return { messages, changed: false };
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const markerChars = [...segmenter.segment(CURSOR_TRUNCATION_MARKER)].length;
const result = messages.slice();
let changed = false;

// Apply the per-result character cap first, independent of the aggregate wire cap.
for (let messageIndex = result.length - 1; messageIndex >= 0; messageIndex--) {
const message = result[messageIndex];
if (message.role !== "toolResult" || !Array.isArray(message.content)) continue;
let content = message.content;
for (let partIndex = content.length - 1; partIndex >= 0; partIndex--) {
const part = content[partIndex];
if (part.type === "image" && typeof part.data === "string") continue;
if (part.type !== "text" || typeof part.text !== "string") continue;
const graphemes = [...segmenter.segment(part.text)].map((item) => item.segment);
if (graphemes.length <= maxChars) continue;
const kept = graphemes.slice(0, Math.max(0, maxChars - markerChars)).join("");
const nextText = kept + CURSOR_TRUNCATION_MARKER;
content = content === message.content ? content.slice() : content;
content[partIndex] = { ...part, text: nextText };
result[messageIndex] = { ...message, content };
changed = true;
}
}

const measure = (candidate: AgentMessage[]): number => {
const converted = convertToLlm(candidate);
const activeUserMessageIndex = converted.at(-1)?.role === "user" ? converted.length - 1 : -1;
return measureCursorHistorySerializedBytes(converted, activeUserMessageIndex);
};
const fits = (candidate = result) => measure(candidate) <= maxBytes;
if (fits()) return { messages: changed ? result : messages, changed };

// Empty the oldest result bodies. Search the monotonic prefix of candidates
// rather than serializing once for every result (admission must stay bounded).
const emptyToolResult = (message: AgentMessage): AgentMessage => {
if (message.role !== "toolResult" || !Array.isArray(message.content)) return message;
const emptiedContent = message.content.map((part) =>
part.type === "text" ? { ...part, text: "" } : part.type === "image" ? { ...part, data: "" } : part,
);
const content = emptiedContent.filter((part, index) => {
if (index === 0) return true;
const previous = emptiedContent[index - 1];
const empty = part.type === "text" ? part.text === "" : part.type === "image" && part.data === "";
const previousEmpty =
previous.type === "text" ? previous.text === "" : previous.type === "image" && previous.data === "";
return !empty || !previousEmpty;
});
return { ...message, content };
};
const toolResultIndexes = result.flatMap((message, index) =>
message.role === "toolResult" && Array.isArray(message.content) ? [index] : [],
);
const withEmptyPrefix = (count: number): AgentMessage[] => {
const candidate = result.slice();
for (let i = 0; i < count; i++)
candidate[toolResultIndexes[i]] = emptyToolResult(candidate[toolResultIndexes[i]]);
return candidate;
};
let low = 0;
let high = toolResultIndexes.length;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (fits(withEmptyPrefix(middle + 1))) high = middle;
else low = middle + 1;
}
const emptyCount = Math.min(low + 1, toolResultIndexes.length);
if (emptyCount > 0) {
result.splice(0, result.length, ...withEmptyPrefix(emptyCount));
changed = true;
}
if (fits()) return { messages: changed ? result : messages, changed };

// If metadata alone exceeds the cap, discard the oldest complete turns. This
// search is also monotonic and avoids quadratic whole-history reserialization.
const turnRanges: Array<[number, number]> = [];
const isConvertedUser = (message: AgentMessage): boolean => convertToLlm([message])[0]?.role === "user";
for (let index = 0; index < result.length; index++) {
if (!isConvertedUser(result[index])) continue;
const nextUser = result.findIndex((message, nextIndex) => nextIndex > index && isConvertedUser(message));
turnRanges.push([index, nextUser < 0 ? result.length : nextUser]);
}
const withoutTurns = (count: number): AgentMessage[] => {
if (count === 0) return result;
const start = turnRanges[0]?.[0] ?? 0;
const end = turnRanges[count - 1]?.[1] ?? start;
return [...result.slice(0, start), ...result.slice(end)];
};
low = 0;
high = turnRanges.length;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (fits(withoutTurns(middle + 1))) high = middle;
else low = middle + 1;
}
const turnCount = Math.min(low + 1, turnRanges.length);
if (turnCount > 0) {
const next = withoutTurns(turnCount);
result.splice(0, result.length, ...next);
changed = true;
}
return { messages: changed ? result : messages, changed };
}

// ============================================================================
// AgentSession Class
// ============================================================================
Expand Down Expand Up @@ -1323,6 +1444,15 @@ export class AgentSession {
(this.agent.prepareNextTurn
? async (_turn: PrepareNextTurnContext, signal?: AbortSignal) => await this.agent.prepareNextTurn?.(signal)
: undefined);
const previousTransformContext = this.agent.transformContext;
this.agent.transformContext = async (messages, signal) => {
const transformed = previousTransformContext ? await previousTransformContext(messages, signal) : messages;
if (this.model?.provider === "cursor" || this.model?.provider === "cursor-cli-oauth") {
return (await truncateToolResultBodies(transformed)).messages ?? transformed;
}
return transformed;
};

this.agent.prepareNextTurnWithContext = async (turn, signal) => {
// Enforce compaction only when this prepare precedes an actual provider
// admission: a tool continuation or queued steer/follow-up messages. A
Expand All @@ -1331,7 +1461,8 @@ export class AgentSession {
const compactBeforeNextAdmission = async (): Promise<boolean> => {
const provider = this.model?.provider;
// Cursor rebuilds the full conversation each hop. Compacting here
// mutates rootPrompt mid-run and poisons conversationId.
// mutates rootPrompt mid-run and poisons conversationId (#984).
// Still truncate verbatim toolResult bodies so the skip cannot send MB-scale payloads (#1043).
if (provider === "cursor" || provider === "cursor-cli-oauth") {
return false;
}
Expand Down Expand Up @@ -5276,7 +5407,7 @@ export class AgentSession {
return await this._rejectCompaction(request, requestId, operationId, "stale-revision", false);
}

if (this._wouldCompactionOverflow(pathEntries, compactionResult, fromExtension, model)) {
if (await this._wouldCompactionOverflow(pathEntries, compactionResult, fromExtension, model)) {
return await this._rejectCompaction(request, requestId, operationId, "would-overflow", false);
}

Expand Down Expand Up @@ -5383,12 +5514,12 @@ export class AgentSession {
}
}

private _wouldCompactionOverflow(
private async _wouldCompactionOverflow(
pathEntries: SessionEntry[],
compactionResult: CompactionResult,
fromExtension: boolean,
model: Model<Api>,
): boolean {
): Promise<boolean> {
const currentLeaf = pathEntries[pathEntries.length - 1];
if (!currentLeaf) return false;

Expand All @@ -5404,10 +5535,15 @@ export class AgentSession {
fromHook: fromExtension,
};

const simulatedMessages = buildSessionContext(
let simulatedMessages = buildSessionContext(
[...pathEntries, simulatedCompactionEntry],
simulatedCompactionEntry.id,
).messages;
// Size the same retained context that will be admitted to Cursor. Persisted JSONL
// remains verbatim, but the in-memory request representation is bounded first.
if (model.provider === "cursor" || model.provider === "cursor-cli-oauth") {
simulatedMessages = truncateToolResultBodies(simulatedMessages).messages ?? simulatedMessages;
}
const contextTokens = estimateMessagesTokens(filterContextExcludedMessages(simulatedMessages));
const settings = this.settingsManager.getCompactionSettings();
return contextTokens > model.contextWindow - settings.reserveTokens;
Expand Down
31 changes: 31 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,40 @@
# changes

## 2026-08-29 - Bound Cursor serialized tool-result admissions

### What changed

- `packages/coding-agent/src/core/agent-session.ts`: Cursor tool-result request views now use a conservative escaped/enveloped serialization bound and never amplify output with markers.

### Why

- Cursor duplicates history across JSON and protobuf envelopes, and JSON escaping makes raw-byte heuristics unsafe.

### Why an extension could not handle it

- AgentSession owns provider admission and the request-only transform boundary.

### Expected merge conflict zones

- `packages/coding-agent/src/core/agent-session.ts`

## 2026-08-29 - Make externally owned compaction delegation sticky

### What changed

- `packages/coding-agent/src/core/agent-session.ts`: Cursor tool-result truncation is immutable and request-scoped, bounds worst-case escaped/enveloped serialized payload cost, preserves newest results under the aggregate bound, and omits the marker when its serialized cost cannot fit.

### Why

- Cursor serializes tool-result history into duplicated JSON and protobuf envelopes; the request view must be bounded before provider admission.

### Why an extension could not handle it

- AgentSession owns provider admission and the request-only transform boundary.

### Expected merge conflict zones

- `packages/coding-agent/src/core/agent-session.ts`
- `packages/coding-agent/src/core/agent-session.ts`: remember the provider and model id after an automatic compaction is rejected by an external owner, suppressing repeated automatic attempts until that key changes, compaction is accepted, or runtime ownership is reconfigured by reload/registry refresh. Manual compaction remains admitted.
- Added `test/suite/regressions/1174-sticky-delegated-compaction.test.ts` covering repeated turns, manual compaction, and model changes.

Expand Down
Loading