mirror of
https://github.com/morluto/rea
synced 2026-10-27 22:45:19 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]),
|
||||
}),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user