Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 41 additions & 24 deletions apps/sim/lib/api/contracts/mothership-assistant-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,36 +78,53 @@ describe('Assistant execution contracts', () => {
expect(searchWorkspaceInputSchema.safeParse({}).success).toBe(false)
})

it('accepts one native query per provider account and kind', () => {
it('accepts up to four distinct native queries per provider account', () => {
const accepts = (nativeQueries: Record<string, string>[]) =>
searchWorkspaceInputSchema.safeParse({ query: 'launch', nativeQueries }).success
const github = { provider: 'github', accountId: 'account', query: 'repo:org/repo launch' }
const slack = { provider: 'slack', accountId: 'account' }
const alternatives = ['trip', 'travel', 'visiting', 'vacation', 'holiday'].map((query) => ({
...slack,
query,
}))
expect(accepts(alternatives.slice(0, 4))).toBe(true)
expect(accepts(alternatives)).toBe(false)
expect(
accepts([
{ ...github, kind: 'issues' },
{ ...github, kind: 'commits' },
])
accepts([...alternatives.slice(0, 4), { ...alternatives[4]!, accountId: 'other' }])
).toBe(true)
})

it('counts an account-wide native query against the busiest targeted account', () => {
const accepts = (nativeQueries: Record<string, string>[]) =>
searchWorkspaceInputSchema.safeParse({ query: 'launch', nativeQueries }).success
const on = (accountId: string, query: string) => ({ provider: 'slack', accountId, query })
const everywhere = { provider: 'slack', query: 'everywhere' }
expect(accepts([on('a', '1'), on('b', '2'), on('c', '3'), on('d', '4'), everywhere])).toBe(true)
expect(
accepts([
{ ...github, kind: 'issues' },
{ ...github, kind: 'issues' },
])
).toBe(false)
expect(accepts([{ ...github, kind: 'issues' }, github])).toBe(false)
expect(
accepts([
{ ...github, kind: 'issues' },
{ ...github, accountId: 'other', kind: 'issues' },
])
accepts([on('a', '1'), on('a', '2'), on('a', '3'), on('b', '4'), on('b', '5'), everywhere])
).toBe(true)
const gmail = { provider: 'gmail', query: 'subject:launch' }
expect(
accepts([
{ ...gmail, kind: 'issues' },
{ ...gmail, kind: 'code' },
])
).toBe(false)
expect(accepts([on('a', '1'), on('a', '2'), on('a', '3'), on('a', '4'), everywhere])).toBe(
false
)
})

it('rejects a native query that repeats a search on the same account', () => {
const accepts = (nativeQueries: Record<string, string>[]) =>
searchWorkspaceInputSchema.safeParse({ query: 'launch', nativeQueries }).success
const trip = { provider: 'slack', accountId: 'account', query: 'trip' }
expect(accepts([trip, trip])).toBe(false)
expect(accepts([trip, { provider: 'slack', query: 'trip' }])).toBe(false)
expect(accepts([trip, { ...trip, kind: 'issues' }])).toBe(false)
expect(accepts([trip, { ...trip, accountId: 'other' }])).toBe(true)
})

it('takes one GitHub or GitLab query per account and kind', () => {
const accepts = (nativeQueries: Record<string, string>[]) =>
searchWorkspaceInputSchema.safeParse({ query: 'launch', nativeQueries }).success
const github = { provider: 'github', accountId: 'account', query: 'repo:org/repo launch' }
const kinds = ['issues', 'commits', 'code', 'repositories'].map((kind) => ({ ...github, kind }))
expect(accepts(kinds)).toBe(true)
expect(accepts([kinds[0]!, { ...kinds[0]!, query: 'repo:org/repo deploy' }])).toBe(false)
expect(accepts([kinds[0]!, github])).toBe(false)
expect(
searchWorkspaceInputSchema.safeParse({ nativeQueries: [{ provider: 'github', query: '' }] })
.success
Expand Down
76 changes: 50 additions & 26 deletions apps/sim/lib/api/contracts/mothership-assistant-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ import { LIVE_SEARCH_PROVIDER_IDS } from '@/lib/sim-search/live/provider-catalog
export const liveSearchProviderSchema = z.enum(LIVE_SEARCH_PROVIDER_IDS)
export type LiveSearchProvider = z.output<typeof liveSearchProviderSchema>

/**
* Native queries one call may send to the same provider account. Alternatives run as separate
* provider searches and fuse into one ranking, so the bound keeps a call within the provider's
* burst limits (Slack allows about ten searches per user per minute) while leaving room for the
* four GitHub or GitLab kinds.
*/
export const MAX_NATIVE_QUERIES_PER_ACCOUNT = 4

/**
* Providers whose `kind` selects a separate search endpoint. They take one query per kind: their
* query languages already join alternatives with OR, and each extra query fans out into several
* repository or project requests against strict search rate limits.
*/
const KIND_PROVIDERS: ReadonlySet<LiveSearchProvider> = new Set(['github', 'gitlab'])

const nativeSearchKindSchema = z.enum([
'issues',
'code',
Expand All @@ -13,9 +28,6 @@ const nativeSearchKindSchema = z.enum([
'wiki',
])

/** Providers whose `kind` selects a separate search endpoint; others ignore it. */
const KIND_PROVIDERS: ReadonlySet<LiveSearchProvider> = new Set(['github', 'gitlab'])

/** Queries are data for fixed read-only provider endpoints, never URLs or credentials. */
export const nativeSearchQuerySchema = z
.object({
Expand All @@ -37,38 +49,50 @@ export const nativeSearchQueriesSchema = z
.min(1)
.max(9)
.superRefine((queries, context) => {
/** A query without an account ID targets every account of its provider. */
const overlaps = (left: NativeSearchQuery, right: NativeSearchQuery) =>
left.provider === right.provider &&
(!left.accountId || !right.accountId || left.accountId === right.accountId)
/**
* Each account runs one query per kind: GitHub and GitLab kinds are separate endpoints, so
* one call can search several of them for the same account. A query without a kind covers
* the provider's default kinds and conflicts with any other query for that account.
* Queries already bound for the busiest account a new query reaches: every account-wide
* query, plus the most queries any one targeted account has.
*/
const kindOf = (query: NativeSearchQuery) =>
KIND_PROVIDERS.has(query.provider) ? query.kind : undefined
const busiestAccountLoad = (earlier: NativeSearchQuery[]) => {
const perAccount = new Map<string, number>()
for (const { accountId } of earlier)
if (accountId) perAccount.set(accountId, (perAccount.get(accountId) ?? 0) + 1)
return (
earlier.filter(({ accountId }) => !accountId).length + Math.max(0, ...perAccount.values())
)
}
/** The search a query runs, ignoring its account and any kind its provider does not use. */
const searchKey = ({ accountId: _, kind, ...query }: NativeSearchQuery) =>
JSON.stringify({ ...query, kind: KIND_PROVIDERS.has(query.provider) ? kind : undefined })
for (const [index, query] of queries.entries()) {
if (
queries
.slice(0, index)
.some(
(previous) =>
previous.provider === query.provider &&
(!previous.accountId || !query.accountId || previous.accountId === query.accountId) &&
(!kindOf(previous) || !kindOf(query) || kindOf(previous) === kindOf(query))
)
const addIssue = (message: string) =>
context.addIssue({ code: 'custom', path: [index], message })
const earlier = queries.slice(0, index).filter((previous) => overlaps(previous, query))
if (earlier.some((previous) => searchKey(previous) === searchKey(query)))
addIssue('Duplicate native query.')
else if (
KIND_PROVIDERS.has(query.provider) &&
earlier.some((previous) => !previous.kind || !query.kind || previous.kind === query.kind)
)
context.addIssue({
code: 'custom',
path: [index],
message:
'Use one query per provider account and kind per call. Combine alternatives with OR in one query, or refine in another call.',
})
addIssue(
'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).'
)
else if (busiestAccountLoad(earlier) >= MAX_NATIVE_QUERIES_PER_ACCOUNT)
addIssue(
`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.`
)
}
})

export const liveSearchAccountStatusSchema = z.object({
accountId: z.string(),
provider: liveSearchProviderSchema,
/** The native query kind this status and its cursor belong to. */
kind: nativeSearchKindSchema.optional(),
/** Index of the native query in the request that this status and its cursor belong to. */
queryIndex: z.number().int().min(0).optional(),
displayName: z.string(),
status: z.enum(['ok', 'partial', 'reconnect', 'rate_limited', 'unavailable', 'timeout']),
message: z.string().optional(),
Expand Down Expand Up @@ -137,7 +161,7 @@ export const searchWorkspaceInputSchema = workspaceSearchFiltersSchema
nativeQueries: nativeSearchQueriesSchema
.optional()
.describe(
'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.'
`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.`
),
query: z
.string()
Expand Down
76 changes: 50 additions & 26 deletions apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ export const liveSearchProviderSchema = z.enum([
])
export type LiveSearchProvider = z.output<typeof liveSearchProviderSchema>

/**
* Native queries one call may send to the same provider account. Alternatives run as separate
* provider searches and fuse into one ranking, so the bound keeps a call within the provider's
* burst limits (Slack allows about ten searches per user per minute) while leaving room for the
* four GitHub or GitLab kinds.
*/
export const MAX_NATIVE_QUERIES_PER_ACCOUNT = 4

/**
* Providers whose `kind` selects a separate search endpoint. They take one query per kind: their
* query languages already join alternatives with OR, and each extra query fans out into several
* repository or project requests against strict search rate limits.
*/
const KIND_PROVIDERS: ReadonlySet<LiveSearchProvider> = new Set(['github', 'gitlab'])

const nativeSearchKindSchema = z.enum([
'issues',
'code',
Expand All @@ -25,9 +40,6 @@ const nativeSearchKindSchema = z.enum([
'wiki',
])

/** Providers whose `kind` selects a separate search endpoint; others ignore it. */
const KIND_PROVIDERS: ReadonlySet<LiveSearchProvider> = new Set(['github', 'gitlab'])

/** Queries are data for fixed read-only provider endpoints, never URLs or credentials. */
export const nativeSearchQuerySchema = z
.object({
Expand All @@ -49,38 +61,50 @@ export const nativeSearchQueriesSchema = z
.min(1)
.max(9)
.superRefine((queries, context) => {
/** A query without an account ID targets every account of its provider. */
const overlaps = (left: NativeSearchQuery, right: NativeSearchQuery) =>
left.provider === right.provider &&
(!left.accountId || !right.accountId || left.accountId === right.accountId)
/**
* Each account runs one query per kind: GitHub and GitLab kinds are separate endpoints, so
* one call can search several of them for the same account. A query without a kind covers
* the provider's default kinds and conflicts with any other query for that account.
* Queries already bound for the busiest account a new query reaches: every account-wide
* query, plus the most queries any one targeted account has.
*/
const kindOf = (query: NativeSearchQuery) =>
KIND_PROVIDERS.has(query.provider) ? query.kind : undefined
const busiestAccountLoad = (earlier: NativeSearchQuery[]) => {
const perAccount = new Map<string, number>()
for (const { accountId } of earlier)
if (accountId) perAccount.set(accountId, (perAccount.get(accountId) ?? 0) + 1)
return (
earlier.filter(({ accountId }) => !accountId).length + Math.max(0, ...perAccount.values())
)
}
/** The search a query runs, ignoring its account and any kind its provider does not use. */
const searchKey = ({ accountId: _, kind, ...query }: NativeSearchQuery) =>
JSON.stringify({ ...query, kind: KIND_PROVIDERS.has(query.provider) ? kind : undefined })
for (const [index, query] of queries.entries()) {
if (
queries
.slice(0, index)
.some(
(previous) =>
previous.provider === query.provider &&
(!previous.accountId || !query.accountId || previous.accountId === query.accountId) &&
(!kindOf(previous) || !kindOf(query) || kindOf(previous) === kindOf(query))
)
const addIssue = (message: string) =>
context.addIssue({ code: 'custom', path: [index], message })
const earlier = queries.slice(0, index).filter((previous) => overlaps(previous, query))
if (earlier.some((previous) => searchKey(previous) === searchKey(query)))
addIssue('Duplicate native query.')
else if (
KIND_PROVIDERS.has(query.provider) &&
earlier.some((previous) => !previous.kind || !query.kind || previous.kind === query.kind)
)
context.addIssue({
code: 'custom',
path: [index],
message:
'Use one query per provider account and kind per call. Combine alternatives with OR in one query, or refine in another call.',
})
addIssue(
'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).'
)
else if (busiestAccountLoad(earlier) >= MAX_NATIVE_QUERIES_PER_ACCOUNT)
addIssue(
`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.`
)
}
})

export const liveSearchAccountStatusSchema = z.object({
accountId: z.string(),
provider: liveSearchProviderSchema,
/** The native query kind this status and its cursor belong to. */
kind: nativeSearchKindSchema.optional(),
/** Index of the native query in the request that this status and its cursor belong to. */
queryIndex: z.number().int().min(0).optional(),
displayName: z.string(),
status: z.enum(['ok', 'partial', 'reconnect', 'rate_limited', 'unavailable', 'timeout']),
message: z.string().optional(),
Expand Down Expand Up @@ -149,7 +173,7 @@ export const searchWorkspaceInputSchema = workspaceSearchFiltersSchema
nativeQueries: nativeSearchQueriesSchema
.optional()
.describe(
'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.'
`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.`
),
query: z
.string()
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/mothership/generated/tool-catalog-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6041,7 +6041,7 @@ export const SearchWorkspace: ToolCatalogEntry = {
},
nativeQueries: {
description:
'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.',
"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.",
minItems: 1,
maxItems: 9,
type: 'array',
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/mothership/generated/tool-schemas-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5972,7 +5972,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
},
nativeQueries: {
description:
'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.',
"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.",
minItems: 1,
maxItems: 9,
type: 'array',
Expand Down
Loading
Loading