Skip to content

Commit 5b4ce4a

Browse files
authored
improvement(search): shorter Jira any-term budget and a shared batched read budget (#9984)
* improvement(search): shorter Jira any-term budget and a shared batched read budget * fix(search): reserve standard read pages before sharing the call budget
1 parent cfc4083 commit 5b4ce4a

9 files changed

Lines changed: 216 additions & 93 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { shareReadBudget } from '@/lib/knowledge/mcp/read-budget'
6+
7+
const read = (characters: number, text = 'x') => ({
8+
documentId: `doc-${characters}`,
9+
chunks: [
10+
{
11+
chunkIndex: 0,
12+
content: text.repeat(characters),
13+
startOffset: 0,
14+
endOffset: characters,
15+
totalCharacters: characters * 2,
16+
},
17+
],
18+
hasMore: false,
19+
next: null,
20+
})
21+
22+
const lengths = (reads: { chunks: { content: string }[] }[]) =>
23+
reads.map((entry) => entry.chunks[0]?.content.length)
24+
25+
describe('shareReadBudget', () => {
26+
it('returns every read whole when the call fits its budget', () => {
27+
const reads = [read(20_000), read(15_000), read(4_000)]
28+
expect(shareReadBudget(reads)).toEqual(reads)
29+
})
30+
31+
it('gives the budget short documents leave unused to the long one', () => {
32+
const shared = shareReadBudget([read(20_000), read(20_000), ...Array(4).fill(read(1_000))])
33+
expect(lengths(shared)).toEqual([18_000, 18_000, 1_000, 1_000, 1_000, 1_000])
34+
})
35+
36+
it('cuts long documents to an equal share and continues each where it stops', () => {
37+
const [first] = shareReadBudget([read(20_000), read(20_000), read(20_000), read(20_000)])
38+
expect(first?.chunks[0]).toMatchObject({ endOffset: 10_000 })
39+
expect(first?.chunks[0]?.content).toHaveLength(10_000)
40+
expect(first).toMatchObject({
41+
hasMore: true,
42+
next: { startChunkIndex: 0, startOffset: 10_000 },
43+
})
44+
})
45+
46+
it('never cuts a document below the standard page', () => {
47+
expect(lengths(shareReadBudget(Array(6).fill(read(20_000))))).toEqual(Array(6).fill(8_000))
48+
})
49+
50+
it('counts text as JSON encodes it', () => {
51+
const [quoted] = shareReadBudget([read(30_000, '"'), read(1_000), read(1_000)])
52+
const content = quoted?.chunks[0]?.content ?? ''
53+
expect(JSON.stringify(content).length - 2).toBeLessThanOrEqual(38_000)
54+
expect(content.length).toBeGreaterThan(18_000)
55+
})
56+
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+
67+
it('passes a failed read through and leaves its share to the others', () => {
68+
const failed = { documentId: 'missing', error: 'Not found' }
69+
const shared = shareReadBudget([failed, read(20_000), read(20_000), read(20_000)])
70+
expect(shared[0]).toBe(failed)
71+
expect(lengths(shared.slice(1) as ReturnType<typeof read>[])).toEqual([13_333, 13_333, 13_333])
72+
})
73+
})
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { encodedWindowEnd, READ_WINDOW_CHARACTERS } from '@/lib/sim-search/live/read-window'
2+
3+
/**
4+
* Characters of document text one batched read call returns. With about a thousand characters of
5+
* 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. A call whose standard
7+
* pages alone exceed it returns those pages, as a read without sharing would.
8+
*/
9+
const READ_CALL_CHARACTERS = 40_000
10+
11+
interface WindowedRead {
12+
chunks: { content: string; startOffset: number; endOffset: number }[]
13+
hasMore: boolean
14+
next: { startChunkIndex: number; startOffset: number } | null
15+
}
16+
17+
const encodedLength = (text: string) => JSON.stringify(text).length - 2
18+
19+
const isWindowedRead = (document: object): document is WindowedRead => 'chunks' in document
20+
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+
33+
/**
34+
* Shares one call's text budget across the documents it read; an entry without text, such as a
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`.
38+
*/
39+
export function shareReadBudget<T extends object>(reads: readonly T[]): T[] {
40+
const sizes = reads.map(encodedSize)
41+
if (sizes.reduce((total, size) => total + size.length, 0) <= READ_CALL_CHARACTERS)
42+
return [...reads]
43+
const extras = sizes.map((size) => size.length - size.kept)
44+
let remaining = READ_CALL_CHARACTERS - sizes.reduce((total, size) => total + size.kept, 0)
45+
let left = reads.length
46+
let cap = 0
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) {
50+
cap = share
51+
break
52+
}
53+
remaining -= extra
54+
left--
55+
}
56+
return reads.map((read, index) =>
57+
isWindowedRead(read) && extras[index] > cap ? cut(read, sizes[index].kept + cap) : read
58+
)
59+
}
60+
61+
/** A single-chunk read shortened so its text encodes within `budget`, continuing where it stops. */
62+
function cut<T extends WindowedRead>(read: T, budget: number): T {
63+
const [chunk] = read.chunks
64+
if (!chunk || read.chunks.length !== 1) return read
65+
const end = encodedWindowEnd(
66+
chunk.content,
67+
0,
68+
budget,
69+
Math.min(chunk.content.length, READ_WINDOW_CHARACTERS)
70+
)
71+
if (end >= chunk.content.length) return read
72+
const endOffset = chunk.startOffset + end
73+
return {
74+
...read,
75+
chunks: [{ ...chunk, content: chunk.content.slice(0, end), endOffset }],
76+
hasMore: true,
77+
next: { startChunkIndex: 0, startOffset: endOffset },
78+
}
79+
}

‎apps/sim/lib/knowledge/mcp/server.protocol.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ describe('Search MCP protocol', () => {
100100
expect(read.isError).not.toBe(true)
101101
expect(mocks.liveRead.mock.lastCall?.[0].input).toMatchObject({
102102
limit: 8,
103-
scaledWindow: { documentsInCall: 1 },
103+
scaledWindow: true,
104104
})
105105
expect(read.content).toEqual([
106106
{ type: 'text', text: expect.stringContaining('Read evidence') },
@@ -112,7 +112,7 @@ describe('Search MCP protocol', () => {
112112
expect(batchRead.isError).not.toBe(true)
113113
expect(mocks.liveRead.mock.lastCall?.[0].input).toMatchObject({
114114
limit: 8,
115-
scaledWindow: { documentsInCall: 2 },
115+
scaledWindow: true,
116116
})
117117
const offsetless = await client.callTool({
118118
name: 'search',

‎apps/sim/lib/knowledge/mcp/server.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
recordOrganizationSearchMcpActivity,
2323
type SearchMcpActivityInput,
2424
} from '@/lib/knowledge/mcp/activity'
25+
import { shareReadBudget } from '@/lib/knowledge/mcp/read-budget'
2526
import {
2627
KNOWLEDGE_MCP_READ_ONLY,
2728
type KnowledgeMcpToolRunner,
@@ -298,7 +299,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe
298299
...options,
299300
documentId: id,
300301
organizationId,
301-
scaledWindow: { documentsInCall: documentId ? 1 : batch.length },
302+
scaledWindow: true,
302303
resultSecretRegistry: registry,
303304
signal,
304305
},
@@ -327,7 +328,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe
327328
}
328329
}
329330
)
330-
return projectResult({ documents }, registry)
331+
return projectResult({ documents: shareReadBudget(documents) }, registry)
331332
},
332333
Math.max(1, batch.length)
333334
)

‎apps/sim/lib/sim-search/live/application.test.ts‎

Lines changed: 15 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,62 +1214,29 @@ describe('authorized live retrieval', () => {
12141214
}
12151215
}
12161216

1217-
const scaled = (documentsInCall: number) => ({ scaledWindow: { documentsInCall } })
1218-
1219-
it('returns a proportionally longer window when a scaling caller asks for more chunks', async () => {
1220-
const { length, next } = await readWindow({ limit: 8, ...scaled(1) })
1221-
expect(length).toBe(21_333)
1222-
expect(next).toEqual({ startChunkIndex: 0, startOffset: 21_333 })
1217+
it('returns a longer window to a scaling caller, whatever its chunk limit', async () => {
1218+
for (const limit of [1, 3, 8]) {
1219+
const { length, next } = await readWindow({ limit, scaledWindow: true })
1220+
expect(length).toBe(20_000)
1221+
expect(next).toEqual({ startChunkIndex: 0, startOffset: 20_000 })
1222+
}
12231223
})
12241224

12251225
it('continues a longer window from where the previous one ended', async () => {
1226-
const { length, next } = await readWindow({ limit: 8, ...scaled(1), startOffset: 21_333 })
1227-
expect(length).toBe(30_000 - 21_333)
1226+
const { length, next } = await readWindow({ scaledWindow: true, startOffset: 20_000 })
1227+
expect(length).toBe(10_000)
12281228
expect(next).toBeNull()
12291229
})
12301230

1231-
it.each([
1232-
[5, 1, 13_333],
1233-
[8, 2, 20_000],
1234-
[8, 3, 13_333],
1235-
[8, 4, 10_000],
1236-
])(
1237-
'shares one call budget: limit %i across %i documents reads %i characters each',
1238-
async (limit, documentsInCall, expected) => {
1239-
expect((await readWindow({ limit, ...scaled(documentsInCall) })).length).toBe(expected)
1240-
}
1241-
)
1242-
1243-
it.each([
1244-
[1, 21_333],
1245-
[2, 20_000],
1246-
])(
1247-
'keeps a quote-heavy window within its share once JSON-encoded, across %i documents',
1248-
async (documentsInCall, share) => {
1249-
const quotes = '"'.repeat(30_000)
1250-
const { length, encoded, next } = await readWindow(
1251-
{ limit: 8, ...scaled(documentsInCall) },
1252-
quotes
1253-
)
1254-
expect(encoded).toBeLessThanOrEqual(share)
1255-
expect(length).toBeGreaterThanOrEqual(8_000)
1256-
expect(next).toEqual({ startChunkIndex: 0, startOffset: length })
1257-
}
1258-
)
1259-
1260-
it('keeps the standard window for quote-heavy text at the default limit', async () => {
1261-
const { length } = await readWindow({ limit: 3, ...scaled(1) }, '"'.repeat(30_000))
1262-
expect(length).toBe(8_000)
1231+
it('keeps a quote-heavy window within its size once JSON-encoded', async () => {
1232+
const { length, encoded, next } = await readWindow({ scaledWindow: true }, '"'.repeat(30_000))
1233+
expect(encoded).toBeLessThanOrEqual(20_000)
1234+
expect(length).toBeGreaterThanOrEqual(8_000)
1235+
expect(next).toEqual({ startChunkIndex: 0, startOffset: length })
12631236
})
12641237

1265-
it.each([
1266-
['at the default limit', { limit: 3, ...scaled(1) }],
1267-
['below the default limit', { limit: 1, ...scaled(1) }],
1268-
['when the call budget is spread over five documents', { limit: 8, ...scaled(5) }],
1269-
['when the call budget is spread over ten documents', { limit: 8, ...scaled(10) }],
1270-
['for a caller that does not scale', { limit: 8 }],
1271-
])('keeps the standard window %s', async (_, extra) => {
1272-
expect((await readWindow(extra)).length).toBe(8_000)
1238+
it('keeps the standard window for a caller that does not scale', async () => {
1239+
expect((await readWindow({ limit: 8 })).length).toBe(8_000)
12731240
})
12741241
})
12751242
it('refuses a chunk index other than zero before reading the document from its provider', async () => {

‎apps/sim/lib/sim-search/live/application.ts‎

Lines changed: 17 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import { loadLiveSearchPolicies } from '@/lib/sim-search/live/policy-store'
7575
import { LIVE_SEARCH_PROVIDER_IDS } from '@/lib/sim-search/live/provider-catalog'
7676
import { liveSearchGuidance } from '@/lib/sim-search/live/providers'
7777
import { LiveReadError } from '@/lib/sim-search/live/read-error'
78+
import { encodedWindowEnd, READ_WINDOW_CHARACTERS } from '@/lib/sim-search/live/read-window'
7879
import {
7980
encodeLiveReference,
8081
includesLiveDocument,
@@ -287,44 +288,25 @@ export const LIVE_NOT_STARTED_MESSAGE =
287288
export const LIVE_OVER_ACCOUNT_LIMIT_MESSAGE = `Not searched: one search covers at most ${MAX_ACCOUNTS} accounts. Search this source or account alone.`
288289
/** Previews center on the query's longest matching term, like indexed passages. */
289290
const PREVIEW_CHARACTERS = 1800
290-
/** Characters one read returns at the default limit, the page budget indexed document reads use. */
291-
const READ_WINDOW_CHARACTERS = 8000
292-
/** The read limit {@link READ_WINDOW_CHARACTERS} is sized for, which is the default limit. */
293-
const READ_WINDOW_LIMIT = 3
294291
/**
295-
* Characters the documents of one scaled read call share. Through four documents it keeps the
296-
* call's result, with about a thousand characters of metadata per document, under the
297-
* 50,000-character size at which MCP clients such as Claude Code save a tool result to a file
298-
* rather than show it. From five documents the standard page per document is the larger, so
299-
* those calls read exactly what they read without scaling.
292+
* Characters a scaled read returns for one document. Two documents at this size, with about a
293+
* thousand characters of metadata each, stay under the 50,000-character size at which MCP clients
294+
* such as Claude Code save a tool result to a file rather than show it; a call of more documents
295+
* shares that budget, as the MCP server's batched read sizes it.
300296
*/
301-
const SCALED_READ_CALL_CHARACTERS = 40_000
297+
const SCALED_READ_DOCUMENT_CHARACTERS = 20_000
302298

303299
/**
304-
* Where one read's window ends. A live document is one chunk, so a caller that scales the window
305-
* gets a longer one when it asks for more chunks than the default, bounded by an even share of the
306-
* call's budget across the documents it reads. The share counts the window as the result encodes
307-
* it, since JSON escapes quotes, backslashes and line breaks, so a longer window is shortened until
308-
* its encoded text fits. The window is never shorter than the standard page, which is also what a
309-
* read at or below the default limit, or from any other caller, returns.
300+
* Where one read's window ends: the standard page, or for a caller that scales its window, up to
301+
* {@link SCALED_READ_DOCUMENT_CHARACTERS} as the result encodes it. A live document is one chunk,
302+
* so its window is sized by characters, not by the chunk limit. The window is never shorter than
303+
* the standard page.
310304
*/
311-
function readWindowEnd(
312-
content: string,
313-
start: number,
314-
{ limit, scaledWindow }: Pick<LiveReadInput, 'limit' | 'scaledWindow'>
315-
): number {
305+
function readWindowEnd(content: string, start: number, scaledWindow: boolean | undefined): number {
316306
const standardEnd = Math.min(content.length, start + READ_WINDOW_CHARACTERS)
317-
if (!scaledWindow || limit <= READ_WINDOW_LIMIT) return standardEnd
318-
const scaled = Math.floor((READ_WINDOW_CHARACTERS * limit) / READ_WINDOW_LIMIT)
319-
const share = Math.floor(SCALED_READ_CALL_CHARACTERS / Math.max(1, scaledWindow.documentsInCall))
320-
const budget = Math.min(scaled, share)
321-
let end = Math.min(content.length, start + budget)
322-
for (let attempt = 0; attempt < 3 && end > standardEnd; attempt++) {
323-
const encoded = JSON.stringify(content.slice(start, end)).length - 2
324-
if (encoded <= budget) return end
325-
end = start + Math.floor(((end - start) * budget) / encoded)
326-
}
327-
return standardEnd
307+
return scaledWindow
308+
? encodedWindowEnd(content, start, SCALED_READ_DOCUMENT_CHARACTERS, standardEnd)
309+
: standardEnd
328310
}
329311

330312
/**
@@ -1017,11 +999,8 @@ export type LiveReadInput = ResourceOwner & {
1017999
documentId: string
10181000
filters?: WorkspaceSearchFilters
10191001
limit: number
1020-
/**
1021-
* Lengthens the window for a limit above the default, sharing one call's budget across the
1022-
* documents it reads, as {@link readWindowEnd} sizes it.
1023-
*/
1024-
scaledWindow?: { documentsInCall: number }
1002+
/** Lengthens the window past the standard page, as {@link readWindowEnd} sizes it. */
1003+
scaledWindow?: boolean
10251004
startChunkIndex?: number
10261005
startOffset?: number
10271006
resultSecretRegistry: ResolvedSecretTraceRegistry
@@ -1167,7 +1146,7 @@ export const readLiveDocument = defineAuthorizedKnowledgeUseCase({
11671146
'validation',
11681147
'This document changed. Read again from the beginning'
11691148
)
1170-
const end = readWindowEnd(content, start, input)
1149+
const end = readWindowEnd(content, start, input.scaledWindow)
11711150
return {
11721151
documentId: input.documentId,
11731152
knowledgeBaseId: '',
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/** Characters one standard read returns, the page budget indexed document reads use. */
2+
export const READ_WINDOW_CHARACTERS = 8000
3+
4+
/**
5+
* Where a window of `content` from `start` ends so its text, as JSON encodes it, fits `budget`
6+
* characters. JSON escapes quotes, backslashes and line breaks, so a window is shortened until its
7+
* encoded text fits. It never ends before `minimumEnd`.
8+
*/
9+
export function encodedWindowEnd(
10+
content: string,
11+
start: number,
12+
budget: number,
13+
minimumEnd: number
14+
): number {
15+
let end = Math.min(content.length, start + budget)
16+
for (let attempt = 0; attempt < 3 && end > minimumEnd; attempt++) {
17+
const encoded = JSON.stringify(content.slice(start, end)).length - 2
18+
if (encoded <= budget) return end
19+
end = start + Math.floor(((end - start) * budget) / encoded)
20+
}
21+
return minimumEnd
22+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { NativeDocument, NativePage, NativeSearchInput } from '@/lib/sim-se
1414
const input: NativeSearchInput = { query: 'contractor offboarding runbook', limit: 4, scopes: [] }
1515

1616
/** The any-term search's own budget, which a slow provider must not outlast. */
17-
const BUDGET_MS = 4000
17+
const BUDGET_MS = 2000
1818

1919
/** A caller that never cancels. */
2020
const live = new AbortController().signal

0 commit comments

Comments
 (0)