Skip to content

Commit 15822b2

Browse files
committed
fix(tables): teach the v2 enrichment which operators a select column accepts
Greptile and cubic both flagged this independently, and they were right. A select column accepts only a subset of the filter operators — eq/ne/in/nin when single, contains/ncontains when multi — and `buildFilterConditions` THROWS on anything else rather than returning no rows. The new v2 enrichment advertised the full operator list for every column and, worse, asserted "there are no array columns - for substring matching use ilike with *x*". A multi-select cell holds a list, so that sentence is false and it steers the model straight into a rejected query on exactly the columns it describes. The enricher could not tell single from multi because `readTableSchemaAsExecutor` dropped `multiple` when projecting the summary. Carry it through — for select columns only, where it means something — and annotate each select column in the description with the operators it actually takes. The block's bestPractices carried the same false claim, so the model reached it a second way even when enrichment was absent. Corrected there too. Also regenerate the tool metadata bundle, which is what CI was failing on: the ncontains commit changed the v2 tool description without regenerating the derived artifact.
1 parent f4ba31e commit 15822b2

7 files changed

Lines changed: 116 additions & 10 deletions

File tree

‎apps/sim/blocks/blocks/table_v2.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ export const TableV2Block: BlockConfig<TableQueryV2Response> = {
242242
- like/ilike use * as the wildcard (e.g. {"field":"name","op":"ilike","value":"*jo*"}).
243243
- Omit Limit to get the entire matching result in one response — the query fails with a clear error if it exceeds 5MB (narrow with a filter or set a Limit).
244244
- With a Limit, pages can end at the Limit or the 5MB byte budget, whichever comes first — pass nextCursor back as the cursor and loop until it is null; never infer completion from page size.
245-
- Columns are scalar (string/number/boolean/date) or opaque json — there are no array columns; for substring use ilike with *x*.
245+
- For substring matching on a text column use ilike with *x*. A select column accepts only a subset of the operators — eq/ne/in/nin when single, contains/ncontains when multi (a multi-select cell holds a list) — and the query is rejected outright on anything else.
246246
- Use Columns to Return to keep only the fields a downstream step needs (e.g. ["col_email","name"]) — the 5MB budget counts only the returned columns, so narrowing columns is another way to fit a large table; leave it empty for every column.`,
247247
docsLink: 'https://docs.sim.ai/integrations/table',
248248
category: 'blocks',

‎apps/sim/lib/internal/table/read-schema.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,44 @@ describe('readTableSchemaAsExecutor', () => {
7676
})
7777
})
7878

79+
/**
80+
* A select column's cardinality decides which filter operators it accepts, so
81+
* LLM enrichment needs it to name the right subset. It is carried only for
82+
* select columns, where it means something.
83+
*/
84+
it('carries select cardinality through and omits it elsewhere', async () => {
85+
mocks.readTable.mockResolvedValue({
86+
table: {
87+
name: 'Transactions',
88+
schema: {
89+
columns: [
90+
{ id: 'column-category', name: 'category', type: 'select' },
91+
{ id: 'column-tags', name: 'tags', type: 'select', multiple: true },
92+
{ id: 'column-amount', name: 'amount', type: 'number' },
93+
],
94+
},
95+
},
96+
})
97+
98+
const result = await readTableSchemaAsExecutor({
99+
tableId: 'table-1',
100+
context: {
101+
workflowId: 'workflow-1',
102+
executorDelegationOrigin: {
103+
subjectUserId: 'user-1',
104+
workflowId: 'workflow-1',
105+
executionId: 'execution-1',
106+
},
107+
},
108+
})
109+
110+
expect(result.columns).toEqual([
111+
{ name: 'category', type: 'select', multiple: false },
112+
{ name: 'tags', type: 'select', multiple: true },
113+
{ name: 'amount', type: 'number' },
114+
])
115+
})
116+
79117
it('fails closed when canonical schema metadata is malformed', async () => {
80118
mocks.readTable.mockResolvedValueOnce({
81119
table: { name: 'Customers', schema: { columns: [{ name: 'email', type: 'unknown' }] } },

‎apps/sim/lib/internal/table/read-schema.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@ export async function readTableSchemaAsExecutor({
3232
if (typeof column.name !== 'string' || !isColumnType(column.type)) {
3333
throw new Error(`Invalid table column ${index} while enriching schema for ${tableId}`)
3434
}
35-
return { name: column.name, type: column.type }
35+
// `multiple` is a select-only concern (it decides which filter operators the
36+
// column accepts), so it is carried only where it means something rather
37+
// than stamped onto every column.
38+
return column.type === 'select'
39+
? { name: column.name, type: column.type, multiple: column.multiple === true }
40+
: { name: column.name, type: column.type }
3641
})
3742

3843
return { name: table.name, columns }

‎apps/sim/lib/table/llm/enrichment.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,46 @@ describe('enrichTableToolDescription for table_query_rows_v2', () => {
8282
})
8383
})
8484

85+
/**
86+
* A select column rejects any operator outside its subset — the query layer
87+
* throws rather than returning no rows — so the description has to name the
88+
* subset per column instead of advertising the full operator list.
89+
*/
90+
describe('select columns in the v2 description', () => {
91+
const SELECT_TABLE: TableSummary = {
92+
name: 'Transactions',
93+
columns: [
94+
{ name: 'category', type: 'select', multiple: false },
95+
{ name: 'tags', type: 'select', multiple: true },
96+
{ name: 'description', type: 'string' },
97+
],
98+
}
99+
100+
const enriched = enrichTableToolDescription('Query rows.', SELECT_TABLE, 'table_query_rows_v2')
101+
102+
it('names the allowed operators on a single-select column', () => {
103+
expect(enriched).toContain(
104+
'category (single-select; only eq, ne, in, nin, isEmpty, isNotEmpty)'
105+
)
106+
})
107+
108+
it('names the allowed operators on a multi-select column', () => {
109+
expect(enriched).toContain('tags (multi-select; only contains, ncontains, isEmpty, isNotEmpty)')
110+
})
111+
112+
it('leaves non-select columns unannotated', () => {
113+
expect(enriched).toContain('description (string)')
114+
})
115+
116+
it('never claims the table has no array columns', () => {
117+
expect(enriched).not.toContain('no array columns')
118+
})
119+
120+
it('steers multi-select matching to contains rather than ilike', () => {
121+
expect(enriched).toContain('match it with contains, never ilike')
122+
})
123+
})
124+
85125
describe('enrichTableToolParameters for table_query_rows_v2', () => {
86126
const { properties, required } = enrichTableToolParameters(
87127
V2_SCHEMA,

‎apps/sim/lib/table/llm/enrichment.ts‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@ export const FILTER_OPERATIONS = new Set([
3333
*/
3434
const TABLE_QUERY_ROWS_V2 = 'table_query_rows_v2'
3535

36+
/**
37+
* Renders one column line for the v2 description.
38+
*
39+
* A select column accepts only a subset of the operators — `eq`/`ne`/`in`/`nin`
40+
* when single, `contains`/`ncontains` when multi — and the query layer THROWS on
41+
* anything else (`buildFilterConditions` in `lib/table/sql.ts`). Naming the
42+
* subset inline is what stops the model from reaching for `ilike` on a select
43+
* column and turning a valid question into a validation error.
44+
*/
45+
function v2ColumnLine(column: TableSummary['columns'][number]): string {
46+
if (column.type !== 'select') return ` - ${column.name} (${column.type})`
47+
const allowed = column.multiple ? 'contains, ncontains' : 'eq, ne, in, nin'
48+
const kind = column.multiple ? 'multi-select' : 'single-select'
49+
return ` - ${column.name} (${kind}; only ${allowed}, isEmpty, isNotEmpty)`
50+
}
51+
3652
/**
3753
* Builds a predicate example from real columns, preferring a numeric `gte` over
3854
* a string `eq` because ranking and threshold questions are what the grammar
@@ -87,6 +103,7 @@ export function enrichTableToolDescription(
87103
const columnList = table.columns.map((col) => ` - ${col.name} (${col.type})`).join('\n')
88104

89105
if (toolId === TABLE_QUERY_ROWS_V2) {
106+
const v2ColumnList = table.columns.map(v2ColumnLine).join('\n')
90107
const numberCol = table.columns.find((c) => c.type === 'number')
91108
const orderExample = numberCol
92109
? `
@@ -101,14 +118,15 @@ INSTRUCTIONS:
101118
3. For multiple conditions wrap them in {"all":[...]} for AND or {"any":[...]} for OR; groups nest
102119
4. Operators: eq, ne, gt, gte, lt, lte, in, nin, like, ilike, nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty
103120
5. like/ilike use * as the wildcard, e.g. {"field":"name","op":"ilike","value":"*jo*"}
104-
6. There are no array columns - for substring matching use ilike with *x*
105-
7. For ranking queries (highest, lowest, Nth, top N) set order and a small limit, e.g. limit 1 for the highest, 2 for the second highest
106-
8. Omit limit to return every matching row; the query fails if the result exceeds 5MB, so narrow with a filter instead of guessing a limit
107-
9. With a limit, a page can end early at the byte budget - a non-null nextCursor means more rows remain, so pass it back as cursor and loop until it is null. Never infer completion from page size
108-
10. Omit the filter only when the user genuinely wants every row
121+
6. Any column listed below with a restricted operator set accepts ONLY those operators - the query is rejected outright otherwise. A multi-select cell holds a list, so match it with contains, never ilike
122+
7. For substring matching on a text column use ilike with *x*
123+
8. For ranking queries (highest, lowest, Nth, top N) set order and a small limit, e.g. limit 1 for the highest, 2 for the second highest
124+
9. Omit limit to return every matching row; the query fails if the result exceeds 5MB, so narrow with a filter instead of guessing a limit
125+
10. With a limit, a page can end early at the byte budget - a non-null nextCursor means more rows remain, so pass it back as cursor and loop until it is null. Never infer completion from page size
126+
11. Omit the filter only when the user genuinely wants every row
109127
110128
Table "${table.name}" columns:
111-
${columnList}
129+
${v2ColumnList}
112130
${v2PredicateExample(table)}${orderExample}`
113131
}
114132

‎apps/sim/lib/table/types.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,12 @@ export type TableInfo = Pick<TableDefinition, 'id' | 'name' | 'schema'>
470470
/** Simplified table summary for LLM enrichment and display contexts. */
471471
export interface TableSummary {
472472
name: string
473-
columns: Array<Pick<ColumnDefinition, 'name' | 'type'>>
473+
/**
474+
* `multiple` is carried because a select column's allowed filter operators
475+
* depend on it — LLM enrichment has to name the right subset or the model
476+
* writes a predicate the query layer rejects.
477+
*/
478+
columns: Array<Pick<ColumnDefinition, 'name' | 'type' | 'multiple'>>
474479
}
475480

476481
export interface TableRow {

‎apps/sim/tools/generated/tool-metadata.ts‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)