Skip to content

Commit b16ca6c

Browse files
committed
fix(search): reserve standard read pages before sharing the call budget
1 parent a1c738f commit b16ca6c

3 files changed

Lines changed: 39 additions & 19 deletions

File tree

‎apps/sim/lib/knowledge/mcp/read-budget.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,16 @@ describe('shareReadBudget', () => {
5454
expect(content.length).toBeGreaterThan(18_000)
5555
})
5656

57+
it('keeps the call within budget when a standard page encodes beyond its share', () => {
58+
const shared = shareReadBudget([read(10_000, '"'), read(20_000), read(20_000), read(20_000)])
59+
const encoded = shared.reduce(
60+
(total, entry) => total + JSON.stringify(entry.chunks[0]?.content ?? '').length - 2,
61+
0
62+
)
63+
expect(encoded).toBeLessThanOrEqual(40_000)
64+
expect(lengths(shared)).toEqual([8_000, 8_000, 8_000, 8_000])
65+
})
66+
5767
it('passes a failed read through and leaves its share to the others', () => {
5868
const failed = { documentId: 'missing', error: 'Not found' }
5969
const shared = shareReadBudget([failed, read(20_000), read(20_000), read(20_000)])

‎apps/sim/lib/knowledge/mcp/read-budget.ts‎

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { encodedWindowEnd, READ_WINDOW_CHARACTERS } from '@/lib/sim-search/live/
33
/**
44
* Characters of document text one batched read call returns. With about a thousand characters of
55
* metadata per document, it keeps the call under the 50,000-character size at which MCP clients
6-
* such as Claude Code save a tool result to a file rather than show it. More than five long
7-
* documents exceed it, since none is cut below its standard page.
6+
* such as Claude Code save a tool result to a file rather than show it. A call whose standard
7+
* pages alone exceed it returns those pages, as a read without sharing would.
88
*/
99
const READ_CALL_CHARACTERS = 40_000
1010

@@ -18,34 +18,43 @@ const encodedLength = (text: string) => JSON.stringify(text).length - 2
1818

1919
const isWindowedRead = (document: object): document is WindowedRead => 'chunks' in document
2020

21+
/**
22+
* A read's text as JSON encodes it, and the part of that it always keeps: its standard page, or
23+
* all of it when it is not a single chunk this budget can shorten.
24+
*/
25+
function encodedSize(read: object): { length: number; kept: number } {
26+
if (!isWindowedRead(read)) return { length: 0, kept: 0 }
27+
const length = read.chunks.reduce((total, chunk) => total + encodedLength(chunk.content), 0)
28+
const [chunk] = read.chunks
29+
if (!chunk || read.chunks.length !== 1) return { length, kept: length }
30+
return { length, kept: encodedLength(chunk.content.slice(0, READ_WINDOW_CHARACTERS)) }
31+
}
32+
2133
/**
2234
* Shares one call's text budget across the documents it read; an entry without text, such as a
23-
* failed read, passes through. Documents shorter than an even share keep all their text and leave
24-
* the rest to the longer ones, which are cut to an equal share and continue through `next`. No
25-
* document is cut below the standard page.
35+
* failed read, passes through. Every document keeps its standard page, and the budget those pages
36+
* leave is shared as further text: documents needing less than an even share keep all theirs and
37+
* leave the rest to the longer ones, which are cut to an equal share and continue through `next`.
2638
*/
2739
export function shareReadBudget<T extends object>(reads: readonly T[]): T[] {
28-
const lengths = reads.map((read) =>
29-
isWindowedRead(read)
30-
? read.chunks.reduce((total, chunk) => total + encodedLength(chunk.content), 0)
31-
: 0
32-
)
33-
if (lengths.reduce((total, length) => total + length, 0) <= READ_CALL_CHARACTERS)
40+
const sizes = reads.map(encodedSize)
41+
if (sizes.reduce((total, size) => total + size.length, 0) <= READ_CALL_CHARACTERS)
3442
return [...reads]
35-
let remaining = READ_CALL_CHARACTERS
43+
const extras = sizes.map((size) => size.length - size.kept)
44+
let remaining = READ_CALL_CHARACTERS - sizes.reduce((total, size) => total + size.kept, 0)
3645
let left = reads.length
3746
let cap = 0
38-
for (const length of [...lengths].sort((a, b) => a - b)) {
39-
const share = Math.floor(remaining / left)
40-
if (length > share) {
47+
for (const extra of [...extras].sort((a, b) => a - b)) {
48+
const share = Math.max(0, Math.floor(remaining / left))
49+
if (extra > share) {
4150
cap = share
4251
break
4352
}
44-
remaining -= length
53+
remaining -= extra
4554
left--
4655
}
4756
return reads.map((read, index) =>
48-
isWindowedRead(read) && lengths[index] > cap ? cut(read, cap) : read
57+
isWindowedRead(read) && extras[index] > cap ? cut(read, sizes[index].kept + cap) : read
4958
)
5059
}
5160

‎apps/sim/lib/sim-search/live/term-fallback.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ const ANY_TERM_RESULTS = 5
1717

1818
/**
1919
* Time the any-term search may take before the all-terms page is returned without it, so a slow
20-
* OR query cannot cost the account the matches it already has. Jira answers an any-term query
21-
* within about 1.5 s or stalls until cancelled, and the whole search waits on this budget.
20+
* OR query cannot cost the account the matches it already has. Confluence and Jira answer an
21+
* any-term query within about 1.5 s, but Jira sometimes stalls until cancelled, and the whole
22+
* search waits on this budget.
2223
*/
2324
const ANY_TERM_BUDGET_MS = 2000
2425

0 commit comments

Comments
 (0)