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 <[email protected]>
This commit is contained in:
Cursor Agentandmorluto committed 2026-10-21 22:09:42 +00:00
1 parent 95c8c9d157
commit 2a926a0a1b
13 files changed
+122 -28

No files matched your search

+3 -1
View File
@@ -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 "reason": …}` with the view's reason, `no-view` or `unrecorded-request`. A
state answer carries the base record's status, content type and `replay` state answer carries the base record's status, content type and `replay`
provenance with `truncated: false`; one whose JSON would exceed 256 KiB is 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, 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 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`. served and answer from their recordings with the reason `query-body`.
@@ -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 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 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 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 recorded write (even under another root), which leaves it `fields-differ`. A
template with a parameter before its end) and `query-varies` when they share value whose rendered JSON exceeds 256 KiB stays `rendered-oversize` and is
one path. With every such read now a document, `path-varies`, `query-varies` replayed from the recorded body, which itself fit. More request lines give
or `body-unusable`, the unreachable `not-entity` reason leaves the result `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 schema, and `no-identity` names item tools only. A document holds the value
of its one `path`. of its one `path`.
- When no path parameter proves an identity and no identity name identifies - 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; team list among them) and object, array and enveloped-list descriptions;
synthetic sources cover the `2xx` preference over an earlier error body, the synthetic sources cover the `2xx` preference over an earlier error body, the
first of several JSON records, the labelled fallback, the manifest's statuses, first of several JSON records, the labelled fallback, the manifest's statuses,
empty, scalar and fieldless bodies, a truncated member name, non-JSON bodies empty, scalar and fieldless bodies, a truncated member name, a name whose
and the catalog budget; the in-process and compiled MCP servers advertise the 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 hints. A filtered list may still answer with one element whose identity name
repeats the requested value; list filters, pagination, lossless big integers repeats the requested value; list filters, pagination, lossless big integers
and cross-endpoint entity linking remain out of scope. Documents inline whole and cross-endpoint entity linking remain out of scope. Documents inline whole
+5 -4
View File
@@ -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`, reasons are `query-body` (a query `POST`), `no-init-recording`,
`body-unusable`, `no-identity` (an item read without a proved identity), `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 `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 parameter before its end), `query-varies` (another read of one path under
more than one query). A document holds the value of its one `path`, so for such 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 a template it says nothing about the template's other paths. A body is usable
when its content type is when its content type is
JSON-like, it is UTF-8 text of at most 256 KiB, every number survives native 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, 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 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 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 values whose recorded text and rendered JSON are each at most 256 KiB, so a
correspondingly larger state result. The view performs no network activity 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 and serves nothing; `rea environment-serve` derives it from the capture at
startup, serves read tools from it and loads replacement states through the startup, serves read tools from it and loads replacement states through the
AgentEnv data plane, as AgentEnv data plane, as
@@ -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", () => { 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. // 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( const reads = servedSource.manifest.tools.filter(
({ method }) => method === "GET", ({ method }) => method === "GET",
); );
const tools = Array.from({ length: 350 }, (_, index) => { const tools = Array.from({ length: 300 }, (_, index) => {
const tool = reads[index % reads.length]!; const tool = reads[index % reads.length]!;
return index < reads.length return index < reads.length
? tool ? tool
@@ -416,7 +417,7 @@ describe("planEnvironmentBundle without a state", () => {
served: false, served: false,
code: "resource_constraint", code: "resource_constraint",
}); });
expect(planned.value.toolCount).toBe(350); expect(planned.value.toolCount).toBe(300);
expect(planned.value.check).toEqual({ expect(planned.value.check).toEqual({
tool: "get_annotations", tool: "get_annotations",
recordOrdinal: 25, recordOrdinal: 25,
@@ -461,12 +461,12 @@ describe("rea-state/v1 validation", () => {
"a document for a tool that is not a document view", "a document for a tool that is not a document view",
(document: Editable) => { (document: Editable) => {
document.rea_state.documents.push({ document.rea_state.documents.push({
tool: "get_org", tool: "get_folders",
path: "/api/org", path: "/api/folders",
value: {}, value: {},
}); });
}, },
"rea_state/documents/0/tool", `rea_state/documents/${String(state.documents.length)}/tool`,
], ],
[ [
"a member the format does not define", "a member the format does not define",
@@ -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", () => { it("answers a document's recorded line with the state's value for it", () => {
const capture = timedCaptureEvidence([ const capture = timedCaptureEvidence([
["GET", "/api/settings", { theme: "dark", density: "compact" }], ["GET", "/api/settings", { theme: "dark", density: "compact" }],
@@ -190,7 +190,7 @@ describe("environment tool response shapes", () => {
{ bodyText: JSON.stringify({ [member]: [{ id: 1 }, { id: 2 }] }) }, { bodyText: JSON.stringify({ [member]: [{ id: 1 }, { id: 2 }] }) },
]), ]),
).toContain( ).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).toContain('whose "items; recorded statuses 500\\nmmm');
expect(shown).not.toContain("items; recorded statuses 500\n"); 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 (?<name>"(?:\\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", () => { describe("environment tool response summaries", () => {
@@ -83,7 +83,16 @@ const environmentToolAnnotations = ({
/** Agent-context budget for each captured display sample, excluding its provenance notice. */ /** Agent-context budget for each captured display sample, excluding its provenance notice. */
const DESCRIPTION_SAMPLE_BYTES = 1024; 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 = ( const describeText = (
text: string, text: string,
label: string, label: string,
@@ -93,13 +102,26 @@ const describeText = (
if ( if (
text.length <= DESCRIPTION_SAMPLE_BYTES && text.length <= DESCRIPTION_SAMPLE_BYTES &&
Buffer.byteLength(text, "utf8") <= DESCRIPTION_SAMPLE_BYTES Buffer.byteLength(text, "utf8") <= DESCRIPTION_SAMPLE_BYTES
) ) {
return show(text); const shown = show(text);
const { read } = new TextEncoder().encodeInto( if (sampleFits(shown)) return shown;
text, }
new Uint8Array(DESCRIPTION_SAMPLE_BYTES), let low = 0;
); let high = Math.min(text.length, DESCRIPTION_SAMPLE_BYTES);
return `${show(text.slice(0, read))}… (${label} display truncated; full value in ${holder})`; 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. */ /** Sample whole values before joining; keep full lists in the Evidence that owns them. */
@@ -18,6 +18,7 @@ import {
deeperThan, deeperThan,
hasPrototypeKey, hasPrototypeKey,
isStateWrite, isStateWrite,
STATE_BODY_LIMIT_BYTES,
type ParsedBody, type ParsedBody,
type StateRecordFacts, type StateRecordFacts,
type StateTimeline, type StateTimeline,
@@ -50,8 +51,7 @@ type StateFields = Pick<
| "limitations" | "limitations"
>; >;
/** Bodies beyond this many UTF-8 bytes are not parsed into state; their views stay recorded. */ export { STATE_BODY_LIMIT_BYTES };
export const STATE_BODY_LIMIT_BYTES = 256 * 1024;
/** /**
* Levels the state's own nesting adds above a recorded value: Evidence, a `data/get` reply and a * Levels the state's own nesting adds above a recorded value: Evidence, a `data/get` reply and a
@@ -17,6 +17,7 @@ import {
isStateWrite, isStateWrite,
jsonBytes, jsonBytes,
lastSegmentValue, lastSegmentValue,
STATE_BODY_LIMIT_BYTES,
listItems, listItems,
locateItem, locateItem,
locateList, locateList,
@@ -553,6 +554,16 @@ export class EnvironmentStateExtraction {
this.#recorded(tool, "body-unusable"); this.#recorded(tool, "body-unusable");
continue; 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( this.#retain(
ENTRY_BYTES + jsonBytes(value) + textBytes(record.path.length), ENTRY_BYTES + jsonBytes(value) + textBytes(record.path.length),
); );
@@ -47,7 +47,15 @@ export type RecordedViewReason =
| "query-varies" | "query-varies"
| "fields-differ" | "fields-differ"
| "envelope-varies" | "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 { export interface StateType {
readonly collection_template: string; readonly collection_template: string;
@@ -430,6 +430,25 @@ it("holds a read-only non-entity read as a document unless it varies by request
expect(state.types).toEqual([]); 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", () => { it("infers a suffix-named identity only when no identity name identifies the listed items", () => {
const state = stateOf( const state = stateOf(
captureOf([ captureOf([
@@ -82,6 +82,7 @@ const viewSchema = z.discriminatedUnion("shape", [
"fields-differ", "fields-differ",
"envelope-varies", "envelope-varies",
"list-differs", "list-differs",
"rendered-oversize",
]), ]),
}), }),
]); ]);