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:
13 files changed
+122
-28
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in new issue
Block a user