Skip to content

Commit f3afc1d

Browse files
committed
fix(search): defer previews when verification shares their budget, and bound session opening by the account deadline
1 parent 75f3f25 commit f3afc1d

5 files changed

Lines changed: 72 additions & 14 deletions

File tree

‎apps/sim/lib/sim-search/live/account-session.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { createManagedSearchMcpClient } from '@/lib/sim-search/live/managed-mcp'
1919
import { isManagedSearchMcpProvider } from '@/lib/sim-search/live/managed-mcp-config'
2020
import { readNotionMcp, searchNotionMcp } from '@/lib/sim-search/live/notion-mcp'
2121
import { createPolicyVerifier } from '@/lib/sim-search/live/policy'
22-
import type { LiveSearchPolicy } from '@/lib/sim-search/live/policy-schema'
22+
import { type LiveSearchPolicy, requiresScopedRetrieval } from '@/lib/sim-search/live/policy-schema'
2323
import { livePolicyFor, loadLiveSearchPolicies } from '@/lib/sim-search/live/policy-store'
2424
import { LIVE_SEARCH_PROVIDER_CATALOG } from '@/lib/sim-search/live/provider-catalog'
2525
import { isSearchProviderEnabled } from '@/lib/sim-search/live/provider-rollout'
@@ -57,6 +57,11 @@ export interface LiveAccountSession {
5757
* cancels the read when its preview is no longer wanted.
5858
*/
5959
previewText(document: NativeDocument, signal: AbortSignal): Promise<string | undefined>
60+
/**
61+
* True when `verify` spends requests from the member budget `previewText` also draws on, so a
62+
* preview read before verification finishes could leave a later result unverified.
63+
*/
64+
verifySharesPreviewBudget: boolean
6065
/** Checks a fetched document against the source settings saved now, not at session start. */
6166
verifyCurrent(document: NativeDocument): Promise<boolean>
6267
}
@@ -178,14 +183,15 @@ export async function openLiveAccountSession(
178183
signal,
179184
pool: input.pool,
180185
})
181-
if (service) return service
186+
if (service) return { ...service, spendsMemberBudget: service.spendsMemberBudget === true }
182187
const verifyPolicy = createPolicyVerifier(provider, memberPolicy, client, origin, mcp, {
183188
fresh,
184189
})
185190
return {
186191
policy: memberPolicy,
187192
partial: false,
188193
scopeSearch: undefined,
194+
spendsMemberBudget: requiresScopedRetrieval(provider, memberPolicy),
189195
verify: (document: Reference) => verifyPolicy(document, document.accessMetadata),
190196
}
191197
}
@@ -200,6 +206,7 @@ export async function openLiveAccountSession(
200206
},
201207
policy: boundary.policy,
202208
servicePartial: boundary.partial,
209+
verifySharesPreviewBudget: boundary.spendsMemberBudget,
203210
async search(search) {
204211
const searchOnce = async (request: NativeSearchInput): Promise<NativePage> => {
205212
const scoped = boundary.scopeSearch ? boundary.scopeSearch(request) : request

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,33 @@ describe('authorized live retrieval', () => {
765765
expect(previewedIds()).toEqual(['a', 'd', 'b'])
766766
})
767767

768+
it('reads no preview until a verification that spends the same request budget finishes', async () => {
769+
const googleDoc = 'application/vnd.google-apps.document'
770+
vi.mocked(livePolicyFor).mockReturnValue({
771+
...defaultLiveSearchPolicy(),
772+
fileTypes: [googleDoc],
773+
})
774+
mocks.search.mockResolvedValue({ documents: excerpts })
775+
const pendingB = createDeferred<unknown>()
776+
mocks.json.mockReset().mockImplementation(async (path: string) => {
777+
const id = path.split('/').at(-1)
778+
return id === 'b' ? pendingB.promise : { id, mimeType: googleDoc }
779+
})
780+
mocks.preview.mockImplementation(
781+
async (_provider, _client, { id }: { id: string }) => `Full ${id} launch text`
782+
)
783+
const pending = searchLiveKnowledge.execute({ principal, input })
784+
await vi.waitFor(() => expect(mocks.json).toHaveBeenCalledTimes(4))
785+
expect(mocks.preview).not.toHaveBeenCalled()
786+
pendingB.resolve({ id: 'b', mimeType: googleDoc })
787+
expect(contents(await pending)).toEqual([
788+
'Full a launch text',
789+
'Full b launch text',
790+
'Full c launch text',
791+
'Excerpt d',
792+
])
793+
})
794+
768795
it('never surfaces the preview text of a candidate verification denies', async () => {
769796
mocks.search.mockResolvedValue({ documents: excerpts })
770797
mocks.service.mockResolvedValue({
@@ -1262,6 +1289,17 @@ describe('authorized live retrieval', () => {
12621289
await pending
12631290
})
12641291

1292+
it('times an account out at its deadline when its session never opens', async () => {
1293+
mocks.service.mockReturnValue(new Promise(() => {}))
1294+
let result: Awaited<ReturnType<typeof searchLiveKnowledge.execute>> | undefined
1295+
void searchLiveKnowledge.execute({ principal, input }).then((value) => {
1296+
result = value
1297+
})
1298+
await vi.advanceTimersByTimeAsync(12_000)
1299+
expect(result?.live?.accounts[0]?.status).toBe('timeout')
1300+
expect(mocks.search).not.toHaveBeenCalled()
1301+
})
1302+
12651303
it('gives each account its own deadline from its slot, and skips one the search left no time', async () => {
12661304
mocks.accounts.mockResolvedValue(accounts(13))
12671305
mocks.resolveAccount.mockImplementation(() =>

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

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,8 @@ const PREVIEW_SOURCE_CHARACTERS = 100_000
345345
* preview is a passage around the match like other providers' results rather than a prompt to
346346
* read. A result is read only once verification permits it and it is among the top results the
347347
* denials so far leave, so reads overlap the verification of other results without reading one
348-
* that is denied. `attach` gives text only to the verified results it is passed and cancels every
348+
* that is denied. A session whose verification spends the budget reads draw on reads only after
349+
* verification, so a preview never costs a result its check. `attach` gives text only to the verified results it is passed and cancels every
349350
* other read. A read that fails or misses its deadline keeps the provider's preview.
350351
*/
351352
function streamPreviewReads(
@@ -609,7 +610,11 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({
609610
let previewed: LiveCandidate[]
610611
try {
611612
verification = await measureSearchStage('live.verify', () =>
612-
verifyCandidates(session, eligible, previews?.verified)
613+
verifyCandidates(
614+
session,
615+
eligible,
616+
session.verifySharesPreviewBudget ? undefined : previews?.verified
617+
)
613618
)
614619
matching = matchingOf(verification.permitted)
615620
previewed = previews
@@ -781,17 +786,22 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({
781786
const resolved = await measureSearchStage('live.resolve', () =>
782787
waitWithAbort(resolveListedLiveAccount(input, userId, account, signal), signal)
783788
)
789+
const opening = openLiveAccountSession({
790+
owner: input,
791+
userId,
792+
resolved,
793+
policies,
794+
signal,
795+
pool,
796+
searches: natives.length,
797+
})
798+
/** Opening reads the database, which takes no signal; a session that opens late is closed. */
784799
session = await measureSearchStage('live.session', () =>
785-
openLiveAccountSession({
786-
owner: input,
787-
userId,
788-
resolved,
789-
policies,
790-
signal,
791-
pool,
792-
searches: natives.length,
793-
})
794-
)
800+
waitWithAbort(opening, signal)
801+
).catch((error: unknown) => {
802+
opening.then((late) => late.close()).catch(() => {})
803+
throw error
804+
})
795805
return { resolved, session }
796806
})
797807
if (!opened)

‎apps/sim/lib/sim-search/live/github-service.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export function createGitHubServiceVerifier(
132132
return {
133133
policy,
134134
partial: false,
135+
spendsMemberBudget: true,
135136
async verify(document: Pick<NativeDocument, 'id' | 'container' | 'kind'>) {
136137
const repository = document.container?.toLowerCase()
137138
if (!repository) return false

‎apps/sim/lib/sim-search/live/service-session.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
3030
interface LiveServiceSession {
3131
policy: LiveSearchPolicy
3232
partial: boolean
33+
/** True when verification also spends requests from the member client's budget. */
34+
spendsMemberBudget?: boolean
3335
verify(document: Pick<NativeDocument, 'id' | 'container' | 'kind'>): Promise<boolean>
3436
scopeSearch?(input: NativeSearchInput): NativeSearchInput | null
3537
}

0 commit comments

Comments
 (0)