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 <morluto@users.noreply.github.com>
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
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`.
@@ -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
+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`,
`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
@@ -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,
@@ -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",
@@ -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" }],
@@ -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 (?<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", () => {
@@ -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. */
@@ -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
@@ -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),
);
@@ -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;
@@ -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([
@@ -82,6 +82,7 @@ const viewSchema = z.discriminatedUnion("shape", [
"fields-differ",
"envelope-varies",
"list-differs",
"rendered-oversize",
]),
}),
]);