Skip to content

Commit 6da76e4

Browse files
authored
improvement(search): multiple native queries per account with rank fusion and per-provider query guides (#8278)
* improvement(search): multiple native queries per account with rank fusion and per-provider query guides * fix(search): per-account query cap, literal guide examples, quoted GitHub booleans, and dedupe-key fusion * fix(search): score each fused item once per query
1 parent aced5f7 commit 6da76e4

11 files changed

Lines changed: 555 additions & 154 deletions

‎apps/sim/lib/api/contracts/mothership-assistant-tools.test.ts‎

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -78,36 +78,53 @@ describe('Assistant execution contracts', () => {
7878
expect(searchWorkspaceInputSchema.safeParse({}).success).toBe(false)
7979
})
8080

81-
it('accepts one native query per provider account and kind', () => {
81+
it('accepts up to four distinct native queries per provider account', () => {
8282
const accepts = (nativeQueries: Record<string, string>[]) =>
8383
searchWorkspaceInputSchema.safeParse({ query: 'launch', nativeQueries }).success
84-
const github = { provider: 'github', accountId: 'account', query: 'repo:org/repo launch' }
84+
const slack = { provider: 'slack', accountId: 'account' }
85+
const alternatives = ['trip', 'travel', 'visiting', 'vacation', 'holiday'].map((query) => ({
86+
...slack,
87+
query,
88+
}))
89+
expect(accepts(alternatives.slice(0, 4))).toBe(true)
90+
expect(accepts(alternatives)).toBe(false)
8591
expect(
86-
accepts([
87-
{ ...github, kind: 'issues' },
88-
{ ...github, kind: 'commits' },
89-
])
92+
accepts([...alternatives.slice(0, 4), { ...alternatives[4]!, accountId: 'other' }])
9093
).toBe(true)
94+
})
95+
96+
it('counts an account-wide native query against the busiest targeted account', () => {
97+
const accepts = (nativeQueries: Record<string, string>[]) =>
98+
searchWorkspaceInputSchema.safeParse({ query: 'launch', nativeQueries }).success
99+
const on = (accountId: string, query: string) => ({ provider: 'slack', accountId, query })
100+
const everywhere = { provider: 'slack', query: 'everywhere' }
101+
expect(accepts([on('a', '1'), on('b', '2'), on('c', '3'), on('d', '4'), everywhere])).toBe(true)
91102
expect(
92-
accepts([
93-
{ ...github, kind: 'issues' },
94-
{ ...github, kind: 'issues' },
95-
])
96-
).toBe(false)
97-
expect(accepts([{ ...github, kind: 'issues' }, github])).toBe(false)
98-
expect(
99-
accepts([
100-
{ ...github, kind: 'issues' },
101-
{ ...github, accountId: 'other', kind: 'issues' },
102-
])
103+
accepts([on('a', '1'), on('a', '2'), on('a', '3'), on('b', '4'), on('b', '5'), everywhere])
103104
).toBe(true)
104-
const gmail = { provider: 'gmail', query: 'subject:launch' }
105-
expect(
106-
accepts([
107-
{ ...gmail, kind: 'issues' },
108-
{ ...gmail, kind: 'code' },
109-
])
110-
).toBe(false)
105+
expect(accepts([on('a', '1'), on('a', '2'), on('a', '3'), on('a', '4'), everywhere])).toBe(
106+
false
107+
)
108+
})
109+
110+
it('rejects a native query that repeats a search on the same account', () => {
111+
const accepts = (nativeQueries: Record<string, string>[]) =>
112+
searchWorkspaceInputSchema.safeParse({ query: 'launch', nativeQueries }).success
113+
const trip = { provider: 'slack', accountId: 'account', query: 'trip' }
114+
expect(accepts([trip, trip])).toBe(false)
115+
expect(accepts([trip, { provider: 'slack', query: 'trip' }])).toBe(false)
116+
expect(accepts([trip, { ...trip, kind: 'issues' }])).toBe(false)
117+
expect(accepts([trip, { ...trip, accountId: 'other' }])).toBe(true)
118+
})
119+
120+
it('takes one GitHub or GitLab query per account and kind', () => {
121+
const accepts = (nativeQueries: Record<string, string>[]) =>
122+
searchWorkspaceInputSchema.safeParse({ query: 'launch', nativeQueries }).success
123+
const github = { provider: 'github', accountId: 'account', query: 'repo:org/repo launch' }
124+
const kinds = ['issues', 'commits', 'code', 'repositories'].map((kind) => ({ ...github, kind }))
125+
expect(accepts(kinds)).toBe(true)
126+
expect(accepts([kinds[0]!, { ...kinds[0]!, query: 'repo:org/repo deploy' }])).toBe(false)
127+
expect(accepts([kinds[0]!, github])).toBe(false)
111128
expect(
112129
searchWorkspaceInputSchema.safeParse({ nativeQueries: [{ provider: 'github', query: '' }] })
113130
.success

‎apps/sim/lib/api/contracts/mothership-assistant-tools.ts‎

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@ import { LIVE_SEARCH_PROVIDER_IDS } from '@/lib/sim-search/live/provider-catalog
44
export const liveSearchProviderSchema = z.enum(LIVE_SEARCH_PROVIDER_IDS)
55
export type LiveSearchProvider = z.output<typeof liveSearchProviderSchema>
66

7+
/**
8+
* Native queries one call may send to the same provider account. Alternatives run as separate
9+
* provider searches and fuse into one ranking, so the bound keeps a call within the provider's
10+
* burst limits (Slack allows about ten searches per user per minute) while leaving room for the
11+
* four GitHub or GitLab kinds.
12+
*/
13+
export const MAX_NATIVE_QUERIES_PER_ACCOUNT = 4
14+
15+
/**
16+
* Providers whose `kind` selects a separate search endpoint. They take one query per kind: their
17+
* query languages already join alternatives with OR, and each extra query fans out into several
18+
* repository or project requests against strict search rate limits.
19+
*/
20+
const KIND_PROVIDERS: ReadonlySet<LiveSearchProvider> = new Set(['github', 'gitlab'])
21+
722
const nativeSearchKindSchema = z.enum([
823
'issues',
924
'code',
@@ -13,9 +28,6 @@ const nativeSearchKindSchema = z.enum([
1328
'wiki',
1429
])
1530

16-
/** Providers whose `kind` selects a separate search endpoint; others ignore it. */
17-
const KIND_PROVIDERS: ReadonlySet<LiveSearchProvider> = new Set(['github', 'gitlab'])
18-
1931
/** Queries are data for fixed read-only provider endpoints, never URLs or credentials. */
2032
export const nativeSearchQuerySchema = z
2133
.object({
@@ -37,38 +49,50 @@ export const nativeSearchQueriesSchema = z
3749
.min(1)
3850
.max(9)
3951
.superRefine((queries, context) => {
52+
/** A query without an account ID targets every account of its provider. */
53+
const overlaps = (left: NativeSearchQuery, right: NativeSearchQuery) =>
54+
left.provider === right.provider &&
55+
(!left.accountId || !right.accountId || left.accountId === right.accountId)
4056
/**
41-
* Each account runs one query per kind: GitHub and GitLab kinds are separate endpoints, so
42-
* one call can search several of them for the same account. A query without a kind covers
43-
* the provider's default kinds and conflicts with any other query for that account.
57+
* Queries already bound for the busiest account a new query reaches: every account-wide
58+
* query, plus the most queries any one targeted account has.
4459
*/
45-
const kindOf = (query: NativeSearchQuery) =>
46-
KIND_PROVIDERS.has(query.provider) ? query.kind : undefined
60+
const busiestAccountLoad = (earlier: NativeSearchQuery[]) => {
61+
const perAccount = new Map<string, number>()
62+
for (const { accountId } of earlier)
63+
if (accountId) perAccount.set(accountId, (perAccount.get(accountId) ?? 0) + 1)
64+
return (
65+
earlier.filter(({ accountId }) => !accountId).length + Math.max(0, ...perAccount.values())
66+
)
67+
}
68+
/** The search a query runs, ignoring its account and any kind its provider does not use. */
69+
const searchKey = ({ accountId: _, kind, ...query }: NativeSearchQuery) =>
70+
JSON.stringify({ ...query, kind: KIND_PROVIDERS.has(query.provider) ? kind : undefined })
4771
for (const [index, query] of queries.entries()) {
48-
if (
49-
queries
50-
.slice(0, index)
51-
.some(
52-
(previous) =>
53-
previous.provider === query.provider &&
54-
(!previous.accountId || !query.accountId || previous.accountId === query.accountId) &&
55-
(!kindOf(previous) || !kindOf(query) || kindOf(previous) === kindOf(query))
56-
)
72+
const addIssue = (message: string) =>
73+
context.addIssue({ code: 'custom', path: [index], message })
74+
const earlier = queries.slice(0, index).filter((previous) => overlaps(previous, query))
75+
if (earlier.some((previous) => searchKey(previous) === searchKey(query)))
76+
addIssue('Duplicate native query.')
77+
else if (
78+
KIND_PROVIDERS.has(query.provider) &&
79+
earlier.some((previous) => !previous.kind || !query.kind || previous.kind === query.kind)
5780
)
58-
context.addIssue({
59-
code: 'custom',
60-
path: [index],
61-
message:
62-
'Use one query per provider account and kind per call. Combine alternatives with OR in one query, or refine in another call.',
63-
})
81+
addIssue(
82+
'Send one GitHub or GitLab query per account and kind; join alternatives with OR in one query (GitHub code search has no OR, so search code alternatives in another call).'
83+
)
84+
else if (busiestAccountLoad(earlier) >= MAX_NATIVE_QUERIES_PER_ACCOUNT)
85+
addIssue(
86+
`Send at most ${MAX_NATIVE_QUERIES_PER_ACCOUNT} native queries per provider account in one call; queries without an accountId count toward every account of their provider.`
87+
)
6488
}
6589
})
6690

6791
export const liveSearchAccountStatusSchema = z.object({
6892
accountId: z.string(),
6993
provider: liveSearchProviderSchema,
70-
/** The native query kind this status and its cursor belong to. */
71-
kind: nativeSearchKindSchema.optional(),
94+
/** Index of the native query in the request that this status and its cursor belong to. */
95+
queryIndex: z.number().int().min(0).optional(),
7296
displayName: z.string(),
7397
status: z.enum(['ok', 'partial', 'reconnect', 'rate_limited', 'unavailable', 'timeout']),
7498
message: z.string().optional(),
@@ -137,7 +161,7 @@ export const searchWorkspaceInputSchema = workspaceSearchFiltersSchema
137161
nativeQueries: nativeSearchQueriesSchema
138162
.optional()
139163
.describe(
140-
'Live search only: provider-native queries (Drive q, Gmail operators, Jira JQL, Confluence CQL, GitHub qualifiers, Slack RTS). GitHub kind commits searches commit messages with author:, committer:, author-date:, and repo: qualifiers. Send one query per account, or one per kind for GitHub and GitLab (e.g. issues and commits together); combine alternatives with OR. Omit for simple cross-provider terms. Use the returned live guidance and account IDs.'
164+
`Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS). Up to ${MAX_NATIVE_QUERIES_PER_ACCOUNT} per account run separately and merge; GitHub and GitLab take one per kind. Write them from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.`
141165
),
142166
query: z
143167
.string()

‎apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts‎

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@ export const liveSearchProviderSchema = z.enum([
1616
])
1717
export type LiveSearchProvider = z.output<typeof liveSearchProviderSchema>
1818

19+
/**
20+
* Native queries one call may send to the same provider account. Alternatives run as separate
21+
* provider searches and fuse into one ranking, so the bound keeps a call within the provider's
22+
* burst limits (Slack allows about ten searches per user per minute) while leaving room for the
23+
* four GitHub or GitLab kinds.
24+
*/
25+
export const MAX_NATIVE_QUERIES_PER_ACCOUNT = 4
26+
27+
/**
28+
* Providers whose `kind` selects a separate search endpoint. They take one query per kind: their
29+
* query languages already join alternatives with OR, and each extra query fans out into several
30+
* repository or project requests against strict search rate limits.
31+
*/
32+
const KIND_PROVIDERS: ReadonlySet<LiveSearchProvider> = new Set(['github', 'gitlab'])
33+
1934
const nativeSearchKindSchema = z.enum([
2035
'issues',
2136
'code',
@@ -25,9 +40,6 @@ const nativeSearchKindSchema = z.enum([
2540
'wiki',
2641
])
2742

28-
/** Providers whose `kind` selects a separate search endpoint; others ignore it. */
29-
const KIND_PROVIDERS: ReadonlySet<LiveSearchProvider> = new Set(['github', 'gitlab'])
30-
3143
/** Queries are data for fixed read-only provider endpoints, never URLs or credentials. */
3244
export const nativeSearchQuerySchema = z
3345
.object({
@@ -49,38 +61,50 @@ export const nativeSearchQueriesSchema = z
4961
.min(1)
5062
.max(9)
5163
.superRefine((queries, context) => {
64+
/** A query without an account ID targets every account of its provider. */
65+
const overlaps = (left: NativeSearchQuery, right: NativeSearchQuery) =>
66+
left.provider === right.provider &&
67+
(!left.accountId || !right.accountId || left.accountId === right.accountId)
5268
/**
53-
* Each account runs one query per kind: GitHub and GitLab kinds are separate endpoints, so
54-
* one call can search several of them for the same account. A query without a kind covers
55-
* the provider's default kinds and conflicts with any other query for that account.
69+
* Queries already bound for the busiest account a new query reaches: every account-wide
70+
* query, plus the most queries any one targeted account has.
5671
*/
57-
const kindOf = (query: NativeSearchQuery) =>
58-
KIND_PROVIDERS.has(query.provider) ? query.kind : undefined
72+
const busiestAccountLoad = (earlier: NativeSearchQuery[]) => {
73+
const perAccount = new Map<string, number>()
74+
for (const { accountId } of earlier)
75+
if (accountId) perAccount.set(accountId, (perAccount.get(accountId) ?? 0) + 1)
76+
return (
77+
earlier.filter(({ accountId }) => !accountId).length + Math.max(0, ...perAccount.values())
78+
)
79+
}
80+
/** The search a query runs, ignoring its account and any kind its provider does not use. */
81+
const searchKey = ({ accountId: _, kind, ...query }: NativeSearchQuery) =>
82+
JSON.stringify({ ...query, kind: KIND_PROVIDERS.has(query.provider) ? kind : undefined })
5983
for (const [index, query] of queries.entries()) {
60-
if (
61-
queries
62-
.slice(0, index)
63-
.some(
64-
(previous) =>
65-
previous.provider === query.provider &&
66-
(!previous.accountId || !query.accountId || previous.accountId === query.accountId) &&
67-
(!kindOf(previous) || !kindOf(query) || kindOf(previous) === kindOf(query))
68-
)
84+
const addIssue = (message: string) =>
85+
context.addIssue({ code: 'custom', path: [index], message })
86+
const earlier = queries.slice(0, index).filter((previous) => overlaps(previous, query))
87+
if (earlier.some((previous) => searchKey(previous) === searchKey(query)))
88+
addIssue('Duplicate native query.')
89+
else if (
90+
KIND_PROVIDERS.has(query.provider) &&
91+
earlier.some((previous) => !previous.kind || !query.kind || previous.kind === query.kind)
6992
)
70-
context.addIssue({
71-
code: 'custom',
72-
path: [index],
73-
message:
74-
'Use one query per provider account and kind per call. Combine alternatives with OR in one query, or refine in another call.',
75-
})
93+
addIssue(
94+
'Send one GitHub or GitLab query per account and kind; join alternatives with OR in one query (GitHub code search has no OR, so search code alternatives in another call).'
95+
)
96+
else if (busiestAccountLoad(earlier) >= MAX_NATIVE_QUERIES_PER_ACCOUNT)
97+
addIssue(
98+
`Send at most ${MAX_NATIVE_QUERIES_PER_ACCOUNT} native queries per provider account in one call; queries without an accountId count toward every account of their provider.`
99+
)
76100
}
77101
})
78102

79103
export const liveSearchAccountStatusSchema = z.object({
80104
accountId: z.string(),
81105
provider: liveSearchProviderSchema,
82-
/** The native query kind this status and its cursor belong to. */
83-
kind: nativeSearchKindSchema.optional(),
106+
/** Index of the native query in the request that this status and its cursor belong to. */
107+
queryIndex: z.number().int().min(0).optional(),
84108
displayName: z.string(),
85109
status: z.enum(['ok', 'partial', 'reconnect', 'rate_limited', 'unavailable', 'timeout']),
86110
message: z.string().optional(),
@@ -149,7 +173,7 @@ export const searchWorkspaceInputSchema = workspaceSearchFiltersSchema
149173
nativeQueries: nativeSearchQueriesSchema
150174
.optional()
151175
.describe(
152-
'Live search only: provider-native queries (Drive q, Gmail operators, Jira JQL, Confluence CQL, GitHub qualifiers, Slack RTS). GitHub kind commits searches commit messages with author:, committer:, author-date:, and repo: qualifiers. Send one query per account, or one per kind for GitHub and GitLab (e.g. issues and commits together); combine alternatives with OR. Omit for simple cross-provider terms. Use the returned live guidance and account IDs.'
176+
`Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS). Up to ${MAX_NATIVE_QUERIES_PER_ACCOUNT} per account run separately and merge; GitHub and GitLab take one per kind. Write them from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.`
153177
),
154178
query: z
155179
.string()

‎apps/sim/lib/mothership/generated/tool-catalog-v1.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6041,7 +6041,7 @@ export const SearchWorkspace: ToolCatalogEntry = {
60416041
},
60426042
nativeQueries: {
60436043
description:
6044-
'Live search only: provider-native queries (Drive q, Gmail operators, Jira JQL, Confluence CQL, GitHub qualifiers, Slack RTS). GitHub kind commits searches commit messages with author:, committer:, author-date:, and repo: qualifiers. Send one query per account, or one per kind for GitHub and GitLab (e.g. issues and commits together); combine alternatives with OR. Omit for simple cross-provider terms. Use the returned live guidance and account IDs.',
6044+
"Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS). Up to 4 per account run separately and merge; GitHub and GitLab take one per kind. Write them from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.",
60456045
minItems: 1,
60466046
maxItems: 9,
60476047
type: 'array',

‎apps/sim/lib/mothership/generated/tool-schemas-v1.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5972,7 +5972,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
59725972
},
59735973
nativeQueries: {
59745974
description:
5975-
'Live search only: provider-native queries (Drive q, Gmail operators, Jira JQL, Confluence CQL, GitHub qualifiers, Slack RTS). GitHub kind commits searches commit messages with author:, committer:, author-date:, and repo: qualifiers. Send one query per account, or one per kind for GitHub and GitLab (e.g. issues and commits together); combine alternatives with OR. Omit for simple cross-provider terms. Use the returned live guidance and account IDs.',
5975+
"Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS). Up to 4 per account run separately and merge; GitHub and GitLab take one per kind. Write them from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.",
59765976
minItems: 1,
59775977
maxItems: 9,
59785978
type: 'array',

0 commit comments

Comments
 (0)