Skip to content

Commit 4bdd9f7

Browse files
committed
fix(jev): bound evaluation payloads before serialization
1 parent 3748b04 commit 4bdd9f7

9 files changed

Lines changed: 59 additions & 33 deletions

File tree

apps/sim/lib/memory/bounded-json.test.ts renamed to apps/sim/lib/core/utils/bounded-json.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/** @vitest-environment node */
22
import { describe, expect, it, vi } from 'vitest'
3-
import { stringifyBoundedMemoryJson } from '@/lib/memory/bounded-json'
3+
import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json'
44

5-
describe('bounded memory JSON', () => {
5+
describe('bounded JSON', () => {
66
it.each([
77
{ value: { text: 'hello', values: [1, false, null] } },
88
{ value: { text: 'é😀\ud800\udc00\ud800' } },
@@ -12,8 +12,8 @@ describe('bounded memory JSON', () => {
1212
])('uses the caller byte limit including UTF-8 and escaped JSON bytes', ({ value }) => {
1313
const json = JSON.stringify(value)
1414
const bytes = Buffer.byteLength(json, 'utf8')
15-
expect(stringifyBoundedMemoryJson(value, bytes)).toBe(json)
16-
expect(stringifyBoundedMemoryJson(value, bytes - 1)).toBeUndefined()
15+
expect(stringifyBoundedJson(value, bytes)).toBe(json)
16+
expect(stringifyBoundedJson(value, bytes - 1)).toBeUndefined()
1717
})
1818

1919
it('rejects cycles, excessive depth, and excessive nodes', () => {
@@ -27,7 +27,7 @@ describe('bounded memory JSON', () => {
2727
Array(100_001),
2828
Object.fromEntries(Array.from({ length: 100_001 }, (_, index) => [index, undefined])),
2929
]) {
30-
expect(stringifyBoundedMemoryJson(value, 8 * 1024 * 1024)).toBeUndefined()
30+
expect(stringifyBoundedJson(value, 8 * 1024 * 1024)).toBeUndefined()
3131
}
3232
})
3333

@@ -37,7 +37,7 @@ describe('bounded memory JSON', () => {
3737
const accessor = Object.defineProperty({}, 'secret', { enumerable: true, get: getter })
3838
const custom = Object.defineProperty({}, 'toJSON', { value: toJSON })
3939
for (const value of [accessor, custom, { output: new Uint8Array([1, 2, 3]) }]) {
40-
expect(stringifyBoundedMemoryJson(value, 1024)).toBeUndefined()
40+
expect(stringifyBoundedJson(value, 1024)).toBeUndefined()
4141
}
4242
expect(getter).not.toHaveBeenCalled()
4343
expect(toJSON).not.toHaveBeenCalled()
@@ -47,7 +47,7 @@ describe('bounded memory JSON', () => {
4747
const value = { output: 'x'.repeat(1025) }
4848
const serialize = vi.spyOn(JSON, 'stringify')
4949
try {
50-
expect(stringifyBoundedMemoryJson(value, 1024)).toBeUndefined()
50+
expect(stringifyBoundedJson(value, 1024)).toBeUndefined()
5151
expect(serialize).not.toHaveBeenCalled()
5252
} finally {
5353
serialize.mockRestore()
@@ -61,7 +61,7 @@ describe('bounded memory JSON', () => {
6161
])('rejects escaped bytes before serializing the captured graph', ({ value }) => {
6262
const serialize = vi.spyOn(JSON, 'stringify')
6363
try {
64-
expect(stringifyBoundedMemoryJson(value, 1024)).toBeUndefined()
64+
expect(stringifyBoundedJson(value, 1024)).toBeUndefined()
6565
expect(serialize).not.toHaveBeenCalled()
6666
} finally {
6767
serialize.mockRestore()
@@ -71,21 +71,21 @@ describe('bounded memory JSON', () => {
7171
it('serializes the admitted descriptors without reading proxy values or toJSON', () => {
7272
const get = vi.fn(() => 'UNADMITTED')
7373
const value = new Proxy({ text: 'admitted' }, { get })
74-
expect(stringifyBoundedMemoryJson(value, 1024)).toBe('{"text":"admitted"}')
74+
expect(stringifyBoundedJson(value, 1024)).toBe('{"text":"admitted"}')
7575
expect(get).not.toHaveBeenCalled()
7676
})
7777

7878
it('does not read inherited numeric accessors in sparse arrays', () => {
7979
const get = vi.fn(() => 'UNADMITTED')
8080
const prototype = Object.create(Array.prototype, { 0: { get } })
8181
const value = Object.setPrototypeOf(Array(1), prototype)
82-
expect(stringifyBoundedMemoryJson(value, 1024)).toBe('[null]')
82+
expect(stringifyBoundedJson(value, 1024)).toBe('[null]')
8383
expect(get).not.toHaveBeenCalled()
8484
})
8585

8686
it('allows repeated references without treating them as a cycle', () => {
8787
const result = { answer: 42 }
8888
const value = { rawResponse: result, modelResponse: result }
89-
expect(stringifyBoundedMemoryJson(value, 1024)).toBe(JSON.stringify(value))
89+
expect(stringifyBoundedJson(value, 1024)).toBe(JSON.stringify(value))
9090
})
9191
})
Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
const MAX_MEMORY_JSON_NODES = 100_000
2-
const MAX_MEMORY_JSON_DEPTH = 64
1+
const MAX_JSON_NODES = 100_000
2+
const MAX_JSON_DEPTH = 64
33

44
/** Counts JSON escapes without allocating the escaped string. */
55
function quotedStringBytes(value: string, remaining: number): number | undefined {
@@ -22,7 +22,7 @@ function quotedStringBytes(value: string, remaining: number): number | undefined
2222
}
2323

2424
/** Captures bounded plain JSON once, without executing accessors or serializing the source graph. */
25-
export function stringifyBoundedMemoryJson(value: unknown, maxBytes: number): string | undefined {
25+
export function stringifyBoundedJson(value: unknown, maxBytes: number): string | undefined {
2626
let nodes = 0
2727
let bytes = 0
2828
const invalid = Symbol('invalid JSON')
@@ -32,7 +32,7 @@ export function stringifyBoundedMemoryJson(value: unknown, maxBytes: number): st
3232
return bytes <= maxBytes
3333
}
3434
const capture = (item: unknown, depth: number): unknown => {
35-
if (++nodes > MAX_MEMORY_JSON_NODES || depth > MAX_MEMORY_JSON_DEPTH) return invalid
35+
if (++nodes > MAX_JSON_NODES || depth > MAX_JSON_DEPTH) return invalid
3636
if (typeof item === 'string') {
3737
const count = quotedStringBytes(item, maxBytes - bytes)
3838
if (count === undefined || !addBytes(count)) return invalid
@@ -53,7 +53,7 @@ export function stringifyBoundedMemoryJson(value: unknown, maxBytes: number): st
5353
: Object.create(null)
5454
if (isArray) {
5555
const length = Object.getOwnPropertyDescriptor(item, 'length')?.value
56-
if (typeof length !== 'number' || length > MAX_MEMORY_JSON_NODES - nodes) return invalid
56+
if (typeof length !== 'number' || length > MAX_JSON_NODES - nodes) return invalid
5757
for (let index = 0; index < length; index++) {
5858
const field = Object.getOwnPropertyDescriptor(item, index)
5959
if (field && !('value' in field)) return invalid
@@ -69,7 +69,7 @@ export function stringifyBoundedMemoryJson(value: unknown, maxBytes: number): st
6969
if (!field || !field.enumerable) continue
7070
if (!('value' in field)) return invalid
7171
if (field.value === undefined) {
72-
if (++nodes > MAX_MEMORY_JSON_NODES) return invalid
72+
if (++nodes > MAX_JSON_NODES) return invalid
7373
continue
7474
}
7575
const keyBytes = quotedStringBytes(key, maxBytes - bytes)

‎apps/sim/lib/memory/agent-turn-session.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { isRecordLike } from '@sim/utils/object'
66
import { truncate } from '@sim/utils/string'
77
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
88
import { decryptSecret } from '@/lib/core/security/encryption'
9+
import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json'
910
import {
1011
bindDurableSecretProvenanceToValue,
1112
durableSecretProvenanceFromRegistry,
@@ -24,7 +25,6 @@ import {
2425
} from '@/lib/memory/application/agent-turns'
2526
import { MEMORY_DELEGATION_AUDIENCE } from '@/lib/memory/application/authorization'
2627
import { getMemoryArtifactHandle } from '@/lib/memory/artifact-handle'
27-
import { stringifyBoundedMemoryJson } from '@/lib/memory/bounded-json'
2828
import {
2929
decryptMemoryCheckpoint,
3030
encryptMemoryCheckpoint,
@@ -534,7 +534,7 @@ export async function openAgentTurnSession(
534534
},
535535
async prepareResult(result) {
536536
let requiresArtifact =
537-
stringifyBoundedMemoryJson(result, MEMORY.MAX_MESSAGE_CONTENT_BYTES) === undefined
537+
stringifyBoundedJson(result, MEMORY.MAX_MESSAGE_CONTENT_BYTES) === undefined
538538
let safeError: string | undefined
539539
try {
540540
const projected = await project(result.modelResponse)
@@ -550,7 +550,7 @@ export async function openAgentTurnSession(
550550
modelResponse: { ...result.modelResponse, ...projected },
551551
}
552552
requiresArtifact ||=
553-
stringifyBoundedMemoryJson(prepared, MEMORY.MAX_MESSAGE_CONTENT_BYTES) === undefined
553+
stringifyBoundedJson(prepared, MEMORY.MAX_MESSAGE_CONTENT_BYTES) === undefined
554554
requiresArtifact ||=
555555
JSON.stringify(prepared.modelResponse).length > MAX_ARTIFACT_PREVIEW_CHARS
556556
if (requiresArtifact) {

‎apps/sim/lib/memory/artifacts.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import { dbFor } from '@sim/db'
22
import { executionLargeValues, memory, memoryArtifact } from '@sim/db/schema'
33
import { and, eq, isNull, sql } from 'drizzle-orm'
44
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
5+
import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json'
56
import {
67
collectLargeValueReferenceKeys,
78
registerLargeValueOwner,
89
} from '@/lib/execution/payloads/large-value-metadata'
910
import { isLargeValueRef, type LargeValueRef } from '@/lib/execution/payloads/large-value-ref'
1011
import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store'
11-
import { stringifyBoundedMemoryJson } from '@/lib/memory/bounded-json'
1212

1313
export const MAX_MEMORY_ARTIFACT_BYTES = 8 * 1024 * 1024
1414
export const MAX_MEMORY_ARTIFACT_STORED_BYTES = MAX_MEMORY_ARTIFACT_BYTES * 2 + 1024
@@ -71,7 +71,7 @@ function activeMemoryPredicate(scope: MemoryArtifactScope) {
7171
export async function storeMemoryArtifact(
7272
input: StoreMemoryArtifactInput
7373
): Promise<StoredMemoryArtifact | undefined> {
74-
const json = stringifyBoundedMemoryJson(input.value, MAX_MEMORY_ARTIFACT_BYTES)
74+
const json = stringifyBoundedJson(input.value, MAX_MEMORY_ARTIFACT_BYTES)
7575
if (json === undefined) return undefined
7676
const execDb = dbFor('exec')
7777
const [conversation] = await execDb
@@ -183,9 +183,7 @@ export async function readMemoryArtifact(input: ReadMemoryArtifactInput): Promis
183183
const { decrypted } = await decryptSecret(envelope.encrypted, { logFailure: false })
184184
if (Buffer.byteLength(decrypted, 'utf8') > MAX_MEMORY_ARTIFACT_BYTES) return undefined
185185
const value: unknown = JSON.parse(decrypted)
186-
return stringifyBoundedMemoryJson(value, MAX_MEMORY_ARTIFACT_BYTES) === undefined
187-
? undefined
188-
: value
186+
return stringifyBoundedJson(value, MAX_MEMORY_ARTIFACT_BYTES) === undefined ? undefined : value
189187
} catch {
190188
return undefined
191189
}

‎apps/sim/lib/memory/conversation-store.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ import { agentMemoryTurn, memory, memoryItem, memorySecretProvenance } from '@si
33
import { generateId } from '@sim/utils/id'
44
import { and, asc, desc, eq, gt, inArray, isNull, lt, type SQLWrapper, sql } from 'drizzle-orm'
55
import { OrchestrationError } from '@/lib/core/orchestration/types'
6+
import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json'
67
import type { DbOrTx, DbTransaction } from '@/lib/db/types'
78
import {
89
type DurableSecretProvenance,
910
EXACT_EMPTY_DURABLE_SECRET_PROVENANCE,
1011
hashDurableSecretProvenanceValue,
1112
mergeDurableSecretProvenance,
1213
} from '@/lib/execution/durable-secret-provenance'
13-
import { stringifyBoundedMemoryJson } from '@/lib/memory/bounded-json'
1414
import { lockMemoryConversationInTx } from '@/lib/memory/locks'
1515
import { MAX_RICH_MEMORY_PAGE_BYTES, PlainMemoryReadBudget } from '@/lib/memory/read-budget'
1616
import {
@@ -98,7 +98,7 @@ interface PlainMemoryWriteInput {
9898

9999
/** Both storage versions persist the same admitted snapshot of each new history item. */
100100
function captureMemoryItem(value: unknown): unknown {
101-
const encoded = stringifyBoundedMemoryJson(value, MAX_MEMORY_ITEM_BYTES)
101+
const encoded = stringifyBoundedJson(value, MAX_MEMORY_ITEM_BYTES)
102102
if (encoded === undefined)
103103
throw new OrchestrationError(
104104
'payload_too_large',

‎apps/sim/lib/memory/retrieval-prefix.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { dbFor } from '@sim/db'
22
import { memory, memorySecretProvenance } from '@sim/db/schema'
33
import { and, eq, isNull, sql } from 'drizzle-orm'
4+
import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json'
45
import type { DurableSecretProvenance } from '@/lib/execution/durable-secret-provenance'
56
import type { MemoryArtifactScope } from '@/lib/memory/artifacts'
6-
import { stringifyBoundedMemoryJson } from '@/lib/memory/bounded-json'
77
import { readBoundMemorySecretProvenance } from '@/lib/memory/secret-provenance'
88

99
export const MAX_MEMORY_RETRIEVAL_PREFIX_BYTES = 1024 * 1024
@@ -45,7 +45,7 @@ export async function readMemoryRetrievalPrefix(
4545
return { status: 'oversized' }
4646
if (
4747
!Array.isArray(row.data) ||
48-
stringifyBoundedMemoryJson(row.data, MAX_MEMORY_RETRIEVAL_PREFIX_BYTES) === undefined
48+
stringifyBoundedJson(row.data, MAX_MEMORY_RETRIEVAL_PREFIX_BYTES) === undefined
4949
)
5050
return { status: 'unavailable' }
5151
const provenance = readBoundMemorySecretProvenance(row)

‎apps/sim/lib/memory/retrieval.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { isRecordLike } from '@sim/utils/object'
33
import { escapeRegExp } from '@sim/utils/string'
44
import { z } from 'zod'
55
import { OrchestrationError } from '@/lib/core/orchestration/types'
6+
import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json'
67
import {
78
type DurableSecretProvenance,
89
EXACT_EMPTY_DURABLE_SECRET_PROVENANCE,
@@ -17,7 +18,6 @@ import {
1718
type MemoryArtifactScope,
1819
readMemoryArtifactByHandle,
1920
} from '@/lib/memory/artifacts'
20-
import { stringifyBoundedMemoryJson } from '@/lib/memory/bounded-json'
2121
import { readConversationItems } from '@/lib/memory/conversation-store'
2222
import { readMemoryRetrievalPrefix } from '@/lib/memory/retrieval-prefix'
2323
import type { ExecutionContext } from '@/executor/types'
@@ -187,7 +187,7 @@ async function projectText(
187187
provenance: DurableSecretProvenance,
188188
provenanceValue: unknown
189189
): Promise<string | undefined> {
190-
if (stringifyBoundedMemoryJson(value, MAX_MEMORY_ARTIFACT_BYTES) === undefined) return undefined
190+
if (stringifyBoundedJson(value, MAX_MEMORY_ARTIFACT_BYTES) === undefined) return undefined
191191
const current = input.projection.resolvedSecretTraceRegistry
192192
const registry = current?.forkForToolCall() ?? new ResolvedSecretTraceRegistry([])
193193
if (!(await importDurableSecretProvenance(registry, provenance, provenanceValue)))
@@ -198,7 +198,7 @@ async function projectText(
198198
const safe = redaction?.enabled
199199
? await redactObjectStrings(projected.value, { ...redaction, onFailure: 'throw' })
200200
: projected.value
201-
return stringifyBoundedMemoryJson(withOpaqueHandles(safe), MAX_MEMORY_ARTIFACT_BYTES)
201+
return stringifyBoundedJson(withOpaqueHandles(safe), MAX_MEMORY_ARTIFACT_BYTES)
202202
}
203203

204204
function textChunk(text: string, offset: number, args: MemoryRetrievalArguments) {

‎apps/sim/providers/typesafe/transport.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { interruptibleSleep } from '@sim/utils/helpers'
22
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
3+
import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json'
34
import { consumeOrCancelBody, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
45
import { PROVIDER_HEADERS_TIMEOUT_MS, PROVIDER_MAX_RETRIES } from '@/providers/transport'
56
import type { buildJevBody } from '@/providers/typesafe/schema'
67

78
const MAX_EVALUATION_RESPONSE_BYTES = 10 * 1024 * 1024
9+
export const MAX_EVALUATION_REQUEST_BYTES = 10 * 1024 * 1024
810

911
class TypeSafeHttpError extends Error {
1012
constructor(
@@ -21,7 +23,10 @@ export async function requestJevEvaluation(
2123
apiKey: string,
2224
abortSignal?: AbortSignal
2325
): Promise<unknown> {
24-
const payload = JSON.stringify(body)
26+
const payload = stringifyBoundedJson(body, MAX_EVALUATION_REQUEST_BYTES)
27+
if (payload === undefined) {
28+
throw new Error('TypeSafe evaluation request exceeds the size or JSON complexity limit')
29+
}
2530
for (let attempt = 0; ; attempt++) {
2631
abortSignal?.throwIfAborted()
2732
const timeout = AbortSignal.timeout(PROVIDER_HEADERS_TIMEOUT_MS)

‎apps/sim/providers/typesafe/typesafe.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { PROVIDER_MAX_RETRIES } from '@/providers/transport'
55
import type { ProviderRequest } from '@/providers/types'
66
import { typesafeProvider } from '@/providers/typesafe'
77
import { buildJevBody, parseJevResponse } from '@/providers/typesafe/schema'
8+
import { MAX_EVALUATION_REQUEST_BYTES, requestJevEvaluation } from '@/providers/typesafe/transport'
89
import type { JevEvaluationResult, JevQuestion } from '@/providers/typesafe/types'
910
import { getProviderFromModel, shouldBillModelUsage } from '@/providers/utils'
1011

@@ -211,6 +212,28 @@ describe('TypeSafe provider', () => {
211212
expect(fetchMock).toHaveBeenCalledTimes(1)
212213
})
213214

215+
it.each([
216+
{ label: 'ASCII values', character: 'x', bytes: 1, key: false },
217+
{ label: 'UTF-8 values', character: '😀', bytes: 4, key: false },
218+
{ label: 'control-character values', character: '\u0000', bytes: 6, key: false },
219+
{ label: 'control-character keys', character: '\u0000', bytes: 6, key: true },
220+
{ label: 'lone-surrogate values', character: '\ud800', bytes: 6, key: false },
221+
{ label: 'lone-surrogate keys', character: '\ud800', bytes: 6, key: true },
222+
])('rejects oversized $label before serialization or HTTP', async ({ character, bytes, key }) => {
223+
const text = character.repeat(Math.ceil(MAX_EVALUATION_REQUEST_BYTES / bytes))
224+
const body = {
225+
model: REQUEST.model,
226+
state: key ? { [text]: null } : text,
227+
questions: QUESTIONS,
228+
}
229+
const serialize = vi.spyOn(JSON, 'stringify')
230+
await expect(requestJevEvaluation(body, 'test-key')).rejects.toThrow(
231+
'size or JSON complexity limit'
232+
)
233+
expect(serialize).not.toHaveBeenCalled()
234+
expect(fetchMock).not.toHaveBeenCalled()
235+
})
236+
214237
it('honors cancellation before network access', async () => {
215238
await expect(
216239
typesafeProvider.executeRequest({ ...REQUEST, abortSignal: AbortSignal.abort() })

0 commit comments

Comments
 (0)