From 2a926a0a1b1b01aa5b3dac800570f36d69192dfd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 22:09:42 +0000 Subject: [PATCH] fix(environment): keep rendered documents and quoted samples inside their budgets A recorded body of exponential numbers can render past 256 KiB and turn a working read into a state size error, so that document stays a recorded replay. Description samples are measured after JSON quoting, and the replay-fallback catalog check uses a tool count the longer response descriptions still separate. Co-authored-by: morluto --- docs/agent-environments.md | 4 +- .../agent-environments/state-coverage/spec.md | 17 ++++++--- docs/web-network-captures.md | 9 +++-- .../environment/environmentBundle.test.ts | 5 ++- .../environment/environmentState.test.ts | 6 +-- .../environmentStateAnswer.test.ts | 11 ++++++ .../environment/environmentTools.test.ts | 15 +++++++- .../environment/environmentTools.ts | 38 +++++++++++++++---- .../EnvironmentStateAccumulator.ts | 4 +- .../EnvironmentStateExtraction.ts | 11 ++++++ .../analysisView/environmentStateRules.ts | 10 ++++- .../analysisView/environmentStateView.test.ts | 19 ++++++++++ .../analysisView/environmentStateView.ts | 1 + 13 files changed, 122 insertions(+), 28 deletions(-) diff --git a/docs/agent-environments.md b/docs/agent-environments.md index 19562c4667..c2398fc3da 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -431,7 +431,9 @@ naming the entity, collection or document path, or `{"source": "recording", "reason": …}` with the view's reason, `no-view` or `unrecorded-request`. A state answer carries the base record's status, content type and `replay` provenance with `truncated: false`; one whose JSON would exceed 256 KiB is -refused as a `resource_constraint` tool error rather than cut. Serving is +refused as a `resource_constraint` tool error rather than cut. Extraction does +not state-back a document whose rendered JSON exceeds 256 KiB; that read stays +recorded (`rendered-oversize`) and replays the captured body. Serving is read-only: `POST`, `PUT`, `PATCH` and `DELETE` tools, other than query `POST`s, are not served, so nothing a call does changes the state. Query `POST`s stay served and answer from their recordings with the reason `query-body`. diff --git a/docs/specs/agent-environments/state-coverage/spec.md b/docs/specs/agent-environments/state-coverage/spec.md index a18f09061c..0477bb9f50 100644 --- a/docs/specs/agent-environments/state-coverage/spec.md +++ b/docs/specs/agent-environments/state-coverage/spec.md @@ -18,10 +18,13 @@ tool, view kind, provider, network activity or write effect. item or list plan holds its latest initial value as a `document` when its initial reads have one request line, whether or not the capture writes its path, unless its value changed between two of those reads across any - recorded write (even under another root), which leaves it `fields-differ`. More request lines give `path-varies` when they differ by path (a - template with a parameter before its end) and `query-varies` when they share - one path. With every such read now a document, `path-varies`, `query-varies` - or `body-unusable`, the unreachable `not-entity` reason leaves the result + recorded write (even under another root), which leaves it `fields-differ`. A + value whose rendered JSON exceeds 256 KiB stays `rendered-oversize` and is + replayed from the recorded body, which itself fit. More request lines give + `path-varies` when they differ by path (a template with a parameter before + its end) and `query-varies` when they share one path. With every such read + now a document, `path-varies`, `query-varies`, `body-unusable` or + `rendered-oversize`, the unreachable `not-entity` reason leaves the result schema, and `no-identity` names item tools only. A document holds the value of its one `path`. - When no path parameter proves an identity and no identity name identifies @@ -80,8 +83,10 @@ Grafana-shaped fixture covers documents for its read-only paths (its one-member team list among them) and object, array and enveloped-list descriptions; synthetic sources cover the `2xx` preference over an earlier error body, the first of several JSON records, the labelled fallback, the manifest's statuses, -empty, scalar and fieldless bodies, a truncated member name, non-JSON bodies -and the catalog budget; the in-process and compiled MCP servers advertise the +empty, scalar and fieldless bodies, a truncated member name, a name whose +JSON quoting exceeds the sample, a recorded body under 256 KiB whose rendered +JSON exceeds it, non-JSON bodies and the catalog budget; the in-process and +compiled MCP servers advertise the hints. A filtered list may still answer with one element whose identity name repeats the requested value; list filters, pagination, lossless big integers and cross-endpoint entity linking remain out of scope. Documents inline whole diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 38ea4eb727..c82f18fa79 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -610,8 +610,9 @@ with reason `envelope-varies`, `fields-differ` or `list-differs`; the other reasons are `query-body` (a query `POST`), `no-init-recording`, `body-unusable`, `no-identity` (an item read without a proved identity), `path-varies` (another read under more than one path, as a template with a -parameter before its end) and `query-varies` (another read of one path under -more than one query). A document holds the value of its one `path`, so for such +parameter before its end), `query-varies` (another read of one path under +more than one query) and `rendered-oversize` (a document value whose JSON +renders to more than 256 KiB). A document holds the value of its one `path`, so for such a template it says nothing about the template's other paths. A body is usable when its content type is JSON-like, it is UTF-8 text of at most 256 KiB, every number survives native @@ -626,8 +627,8 @@ computes responses or derived values, and no effects of writes. The state accumulator charges the manifest's admission and grouping, every record line, each body as temporary bytes before it is decoded and parsed, and every value it keeps, within the same 96 MiB budget. Documents hold whole recorded response -values of up to 256 KiB each, so a capture with many read-only paths yields a -correspondingly larger state result. The view performs no network activity +values whose recorded text and rendered JSON are each at most 256 KiB, so a +capture with many read-only paths yields a correspondingly larger state result. The view performs no network activity and serves nothing; `rea environment-serve` derives it from the capture at startup, serves read tools from it and loads replacement states through the AgentEnv data plane, as diff --git a/src/application/environment/environmentBundle.test.ts b/src/application/environment/environmentBundle.test.ts index 5f2727b6cd..276dcfcf02 100644 --- a/src/application/environment/environmentBundle.test.ts +++ b/src/application/environment/environmentBundle.test.ts @@ -387,10 +387,11 @@ describe("planEnvironmentBundle without a state", () => { it("serves and predicts replay when the state's catalog exceeds the discovery budget that replay's fits", () => { // Read tools only, so leaving out write tools saves nothing while each state description is longer. + // 300 sits between the two catalogs on this fixture: replay still fits and the state catalog does not. const reads = servedSource.manifest.tools.filter( ({ method }) => method === "GET", ); - const tools = Array.from({ length: 350 }, (_, index) => { + const tools = Array.from({ length: 300 }, (_, index) => { const tool = reads[index % reads.length]!; return index < reads.length ? tool @@ -416,7 +417,7 @@ describe("planEnvironmentBundle without a state", () => { served: false, code: "resource_constraint", }); - expect(planned.value.toolCount).toBe(350); + expect(planned.value.toolCount).toBe(300); expect(planned.value.check).toEqual({ tool: "get_annotations", recordOrdinal: 25, diff --git a/src/application/environment/environmentState.test.ts b/src/application/environment/environmentState.test.ts index bdb66a88b9..6ea7c01211 100644 --- a/src/application/environment/environmentState.test.ts +++ b/src/application/environment/environmentState.test.ts @@ -461,12 +461,12 @@ describe("rea-state/v1 validation", () => { "a document for a tool that is not a document view", (document: Editable) => { document.rea_state.documents.push({ - tool: "get_org", - path: "/api/org", + tool: "get_folders", + path: "/api/folders", value: {}, }); }, - "rea_state/documents/0/tool", + `rea_state/documents/${String(state.documents.length)}/tool`, ], [ "a member the format does not define", diff --git a/src/application/environment/environmentStateAnswer.test.ts b/src/application/environment/environmentStateAnswer.test.ts index cc4d9e588f..52ac7667ef 100644 --- a/src/application/environment/environmentStateAnswer.test.ts +++ b/src/application/environment/environmentStateAnswer.test.ts @@ -311,6 +311,17 @@ describe("recorded and document answers", () => { }); }); + it("replays a read whose rendered document exceeds the body limit", () => { + const values = Array.from({ length: 60_000 }, () => "1e3").join(","); + const body = `{"values":[${values}]}`; + const serving = servingFrom([["GET", "/api/config", body]]); + expect(serving.store.state.documents).toEqual([]); + expect(answer(serving, "get_config", {})).toMatchObject({ + replay: { record_ordinal: 0 }, + state: { source: "recording", reason: "rendered-oversize" }, + }); + }); + it("answers a document's recorded line with the state's value for it", () => { const capture = timedCaptureEvidence([ ["GET", "/api/settings", { theme: "dark", density: "compact" }], diff --git a/src/application/environment/environmentTools.test.ts b/src/application/environment/environmentTools.test.ts index e1e57646df..67be8b2cff 100644 --- a/src/application/environment/environmentTools.test.ts +++ b/src/application/environment/environmentTools.test.ts @@ -190,7 +190,7 @@ describe("environment tool response shapes", () => { { bodyText: JSON.stringify({ [member]: [{ id: 1 }, { id: 2 }] }) }, ]), ).toContain( - `{numeric}; returns a JSON object whose "${"m".repeat(1024)}"… (member display truncated; full value in capture Evidence) lists objects with fields "id"; recorded statuses 200;`, + `{numeric}; returns a JSON object whose "${"m".repeat(1022)}"… (member display truncated; full value in capture Evidence) lists objects with fields "id"; recorded statuses 200;`, ); }); @@ -219,6 +219,19 @@ describe("environment tool response shapes", () => { expect(shown).toContain('whose "items; recorded statuses 500\\nmmm'); expect(shown).not.toContain("items; recorded statuses 500\n"); }); + + it("applies the sample budget after JSON escaping", () => { + const member = "\u0000".repeat(1024); + const description = usersAnswering([ + { bodyText: JSON.stringify({ [member]: [{ id: 1 }] }) }, + ]); + const shown = /whose (?"(?:\\u0000)*")/u.exec(description ?? "") + ?.groups?.name; + expect(shown).toBeDefined(); + expect(Buffer.byteLength(shown ?? "")).toBeLessThanOrEqual(1024); + expect(description).toContain("member display truncated"); + expect(description).not.toContain("\u0000"); + }); }); describe("environment tool response summaries", () => { diff --git a/src/application/environment/environmentTools.ts b/src/application/environment/environmentTools.ts index 7fe08d9de9..847be3d76d 100644 --- a/src/application/environment/environmentTools.ts +++ b/src/application/environment/environmentTools.ts @@ -83,7 +83,16 @@ const environmentToolAnnotations = ({ /** Agent-context budget for each captured display sample, excluding its provenance notice. */ const DESCRIPTION_SAMPLE_BYTES = 1024; -/** A display prefix is explicitly separate from the complete identity held by the Evidence named; `show` renders the text or its prefix. */ +/** Whether a rendered sample fits the display budget. Callers' `show` never shortens its input. */ +const sampleFits = (shown: string): boolean => + shown.length <= DESCRIPTION_SAMPLE_BYTES && + Buffer.byteLength(shown, "utf8") <= DESCRIPTION_SAMPLE_BYTES; + +/** + * A display prefix is explicitly separate from the complete identity held by the Evidence named. + * `show` renders the text or its prefix. The budget applies to that rendering, so JSON quoting of a + * name full of NULs or newlines cannot outgrow the sample. + */ const describeText = ( text: string, label: string, @@ -93,13 +102,26 @@ const describeText = ( if ( text.length <= DESCRIPTION_SAMPLE_BYTES && Buffer.byteLength(text, "utf8") <= DESCRIPTION_SAMPLE_BYTES - ) - return show(text); - const { read } = new TextEncoder().encodeInto( - text, - new Uint8Array(DESCRIPTION_SAMPLE_BYTES), - ); - return `${show(text.slice(0, read))}… (${label} display truncated; full value in ${holder})`; + ) { + const shown = show(text); + if (sampleFits(shown)) return shown; + } + let low = 0; + let high = Math.min(text.length, DESCRIPTION_SAMPLE_BYTES); + let best = 0; + while (low <= high) { + const mid = (low + high) >> 1; + if (sampleFits(show(text.slice(0, mid)))) { + best = mid; + low = mid + 1; + } else high = mid - 1; + } + // A lone surrogate is not a character. Step off a high surrogate so the prefix stays whole. + if (best > 0 && best < text.length) { + const unit = text.charCodeAt(best - 1); + if (unit >= 0xd8_00 && unit <= 0xdb_ff) best -= 1; + } + return `${show(text.slice(0, best))}… (${label} display truncated; full value in ${holder})`; }; /** Sample whole values before joining; keep full lists in the Evidence that owns them. */ diff --git a/src/domain/analysisView/EnvironmentStateAccumulator.ts b/src/domain/analysisView/EnvironmentStateAccumulator.ts index d1c3711fd0..6413a213c7 100644 --- a/src/domain/analysisView/EnvironmentStateAccumulator.ts +++ b/src/domain/analysisView/EnvironmentStateAccumulator.ts @@ -18,6 +18,7 @@ import { deeperThan, hasPrototypeKey, isStateWrite, + STATE_BODY_LIMIT_BYTES, type ParsedBody, type StateRecordFacts, type StateTimeline, @@ -50,8 +51,7 @@ type StateFields = Pick< | "limitations" >; -/** Bodies beyond this many UTF-8 bytes are not parsed into state; their views stay recorded. */ -export const STATE_BODY_LIMIT_BYTES = 256 * 1024; +export { STATE_BODY_LIMIT_BYTES }; /** * Levels the state's own nesting adds above a recorded value: Evidence, a `data/get` reply and a diff --git a/src/domain/analysisView/EnvironmentStateExtraction.ts b/src/domain/analysisView/EnvironmentStateExtraction.ts index f967b7e5ed..c3c2168011 100644 --- a/src/domain/analysisView/EnvironmentStateExtraction.ts +++ b/src/domain/analysisView/EnvironmentStateExtraction.ts @@ -17,6 +17,7 @@ import { isStateWrite, jsonBytes, lastSegmentValue, + STATE_BODY_LIMIT_BYTES, listItems, locateItem, locateList, @@ -553,6 +554,16 @@ export class EnvironmentStateExtraction { this.#recorded(tool, "body-unusable"); continue; } + // Parsing can lengthen a body that was admitted under the recorded-text limit: + // `1e3` is four bytes once rendered as `1000`. Serving refuses that rendering, + // so an oversized document stays the recorded replay instead of a failing read. + if ( + Buffer.byteLength(JSON.stringify(value), "utf8") > + STATE_BODY_LIMIT_BYTES + ) { + this.#recorded(tool, "rendered-oversize"); + continue; + } this.#retain( ENTRY_BYTES + jsonBytes(value) + textBytes(record.path.length), ); diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index 0a2e9de412..e62cbda51f 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -47,7 +47,15 @@ export type RecordedViewReason = | "query-varies" | "fields-differ" | "envelope-varies" - | "list-differs"; + | "list-differs" + | "rendered-oversize"; + +/** + * Recorded response text, and a document value rendered back to JSON, stay within this many UTF-8 + * bytes. A larger recorded body is not parsed; a parsed value whose JSON rendering exceeds it stays + * a recorded replay (`rendered-oversize`) rather than a document the server would have to refuse. + */ +export const STATE_BODY_LIMIT_BYTES = 256 * 1024; export interface StateType { readonly collection_template: string; diff --git a/src/domain/analysisView/environmentStateView.test.ts b/src/domain/analysisView/environmentStateView.test.ts index 7b3cabcdd6..863e5fefbd 100644 --- a/src/domain/analysisView/environmentStateView.test.ts +++ b/src/domain/analysisView/environmentStateView.test.ts @@ -430,6 +430,25 @@ it("holds a read-only non-entity read as a document unless it varies by request expect(state.types).toEqual([]); }); +it("leaves a document recorded when rendering it exceeds the body limit", () => { + const small = stateOf( + captureOf([["GET", "/api/config", '{"values":[1e3]}']]), + ); + expect(viewOf(small, "get_config")).toMatchObject({ shape: "document" }); + expect(small.documents[0]?.value).toEqual({ values: [1000] }); + // 60,000 `1e3` tokens are 240,012 recorded bytes and 300,012 once rendered as `1000`. + const values = Array.from({ length: 60_000 }, () => "1e3").join(","); + const body = `{"values":[${values}]}`; + expect(Buffer.byteLength(body, "utf8")).toBe(240_012); + const state = stateOf(captureOf([["GET", "/api/config", body]])); + expect(state.documents).toEqual([]); + expect(viewOf(state, "get_config")).toEqual({ + tool: "get_config", + shape: "recorded", + reason: "rendered-oversize", + }); +}); + it("infers a suffix-named identity only when no identity name identifies the listed items", () => { const state = stateOf( captureOf([ diff --git a/src/domain/analysisView/environmentStateView.ts b/src/domain/analysisView/environmentStateView.ts index 3a63ba7700..fd8273c7dc 100644 --- a/src/domain/analysisView/environmentStateView.ts +++ b/src/domain/analysisView/environmentStateView.ts @@ -82,6 +82,7 @@ const viewSchema = z.discriminatedUnion("shape", [ "fields-differ", "envelope-varies", "list-differs", + "rendered-oversize", ]), }), ]);