diff --git a/reference-implementations/agt/packages/guardian/src/envelope-log-sink.ts b/reference-implementations/agt/packages/guardian/src/envelope-log-sink.ts index ed217338..63437ad4 100644 --- a/reference-implementations/agt/packages/guardian/src/envelope-log-sink.ts +++ b/reference-implementations/agt/packages/guardian/src/envelope-log-sink.ts @@ -18,9 +18,11 @@ * rather than a JSON value, costing the Inspector its pretty-printing and * the round-trip contract test; the accurate sentence is the better trade. * - * Total by construction. Every write is wrapped: a failure disables the sink - * for the process lifetime, reports once, and is never propagated to the - * caller. The sink sits on the decision path: an observability feature that + * Total by construction. Every write is wrapped: a filesystem failure disables + * the sink for the process lifetime, reports once, and is never propagated to + * the caller. A value that cannot be serialized is not a filesystem failure: + * it is logged as a `serialization_failed` marker in place of the envelope, + * `write` answers false for it, and the sink stays on for the next request. The sink sits on the decision path: an observability feature that * can turn a governed tool call into an ungoverned one is the one failure * mode this module must never have. Observability degrades; governance does * not. @@ -141,10 +143,22 @@ export function createEnvelopeLogSink({ rpc_id: rpcId === undefined ? extractRpcId(envelope) : rpcId, envelope, }; - const line = `${JSON.stringify(entry)}\n`; + let line: string; + let recorded = true; + try { + line = `${JSON.stringify(entry)}\n`; + } catch (error) { + // A value JSON.stringify cannot serialize (a 400,000-deep array + // overflows the stack, a cycle or BigInt throws) costs this one + // record, not the sink: log a marker in its place and stay on. + // Only a filesystem failure below disables the sink. + const reason = error instanceof Error ? error.message : String(error); + line = `${JSON.stringify({ ...entry, envelope: { serialization_failed: true, reason } })}\n`; + recorded = false; + } appendFileSync(path, line); seq += 1; - return true; + return recorded; } catch (error) { fail(error); return false; diff --git a/reference-implementations/agt/packages/guardian/test/envelope-log-sink-wiring.test.ts b/reference-implementations/agt/packages/guardian/test/envelope-log-sink-wiring.test.ts index f21bbc41..a08fe24a 100644 --- a/reference-implementations/agt/packages/guardian/test/envelope-log-sink-wiring.test.ts +++ b/reference-implementations/agt/packages/guardian/test/envelope-log-sink-wiring.test.ts @@ -181,6 +181,23 @@ describe("Guardian envelope log wiring", () => { } }); + // One request whose params nest 400,000 arrays overflows JSON.stringify in + // the sink. It must cost that one record, not every record after it. + it("keeps logging after a request the sink cannot serialize", async () => { + await withGuardian(logIn, async (url, logPath) => { + const deepBody = `{"jsonrpc":"2.0","method":"steps/toolCallRequest","id":1,"params":${"[".repeat(400_000)}${"]".repeat(400_000)}}`; + await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: deepBody }).catch(() => undefined); + const before = readEntries(logPath).length; + + await postRaw(url, JSON.stringify(toolCallEnvelope("ls -la", { id: 21 }))); + await postRaw(url, JSON.stringify(toolCallEnvelope("ls -la", { id: 22 }))); + + const entries = readEntries(logPath); + expect(entries.length).toBe(before + 4); + expect(entries.slice(-4).map((e) => e.rpc_id)).toEqual([21, 21, 22, 22]); + }); + }); + it("writes nothing when envelopeLogPath is omitted", async () => { const dir = mkdtempSync(join(tmpdir(), "acs-envelope-log-off-")); const logPath = join(dir, "envelopes.jsonl"); diff --git a/reference-implementations/agt/packages/guardian/test/envelope-log-sink.test.ts b/reference-implementations/agt/packages/guardian/test/envelope-log-sink.test.ts index 1ab47860..dc3cdade 100644 --- a/reference-implementations/agt/packages/guardian/test/envelope-log-sink.test.ts +++ b/reference-implementations/agt/packages/guardian/test/envelope-log-sink.test.ts @@ -116,7 +116,7 @@ describe("createEnvelopeLogSink -- the envelope log's JSONL format", () => { }); }); - it("never throws on an envelope JSON.stringify cannot serialize", () => { + it("never throws on an envelope JSON.stringify cannot serialize, and keeps the sink on", () => { withTempDir((dir) => { const path = join(dir, "envelopes.jsonl"); const errors: unknown[] = []; @@ -125,7 +125,8 @@ describe("createEnvelopeLogSink -- the envelope log's JSONL format", () => { circular.self = circular; expect(() => sink.write("request", circular, "steps/toolCallRequest")).not.toThrow(); - expect(errors.length).toBe(1); + expect(errors.length).toBe(0); + expect(sink.write("request", REQUEST, "steps/toolCallRequest")).toBe(true); }); }); @@ -214,3 +215,48 @@ describe("createEnvelopeLogSink -- write says whether the line landed", () => { expect(NULL_ENVELOPE_LOG_SINK.write("finding", {}, "steps/toolCallResult")).toBe(false); }); }); + +describe("createEnvelopeLogSink -- a value that cannot be serialized", () => { + function nested(depth: number): unknown { + let value: unknown = []; + for (let i = 0; i < depth; i += 1) { + value = [value]; + } + return value; + } + + it("costs that one record, logs a failure marker, and leaves the sink on", () => { + withTempDir((dir) => { + const path = join(dir, "envelopes.jsonl"); + const errors: unknown[] = []; + const sink = createEnvelopeLogSink({ path, onError: (e) => errors.push(e) }); + + const deep = { jsonrpc: "2.0", method: "steps/toolCallRequest", id: 5, params: nested(400_000) }; + expect(sink.write("request", deep, "steps/toolCallRequest")).toBe(false); + expect(sink.write("request", REQUEST, "steps/toolCallRequest")).toBe(true); + expect(sink.write("response", RESPONSE, "steps/toolCallRequest")).toBe(true); + + expect(errors).toEqual([]); + const entries = readEntries(path); + expect(entries.map((e) => e.seq)).toEqual([1, 2, 3]); + expect(entries[0]?.rpc_id).toBe(5); + expect(entries[0]?.envelope).toMatchObject({ serialization_failed: true }); + expect(entries[1]?.envelope).toEqual(REQUEST); + }); + }); + + it("treats a cycle or a BigInt the same way", () => { + withTempDir((dir) => { + const path = join(dir, "envelopes.jsonl"); + const sink = createEnvelopeLogSink({ path, onError: () => {} }); + const cyclic: Record = { id: 1 }; + cyclic.self = cyclic; + + expect(sink.write("request", cyclic, "steps/toolCallRequest")).toBe(false); + expect(sink.write("request", { id: 2, n: 1n }, "steps/toolCallRequest")).toBe(false); + expect(sink.write("request", REQUEST, "steps/toolCallRequest")).toBe(true); + + expect(readEntries(path).map((e) => e.seq)).toEqual([1, 2, 3]); + }); + }); +});