Skip to content

Commit a690907

Browse files
committed
fix(tables): make the agent tool picker version-aware and enrich the v2 query
The Agent block's tool picker gated on a hardcoded `CORE_AGENT_TOOL_TYPES` set naming `'table'` with no version resolution. Both Table blocks are `category: 'blocks'`, so that set is their only route into the picker — meaning `table_v2` would never appear there, even once it ships and the toolbar has moved to it. Store base types and match after stripping the `_vN` suffix so a cutover never has to re-list a block here. The existing `hideFromToolbar` guard already excludes superseded versions (file v1-v4) and unrevealed preview blocks, so broadening `file_v5` to `file` admits nothing new. `BUILT_IN_TOOL_TYPES` had the same gap and would have filed v2 under Integrations. `table_query_rows_v2` also shipped with no `toolEnrichment` while all seven other table tools have one, so an agent using it never saw the table's column names and had to guess field values. The enrichment module only knew v1's MongoDB grammar, so adding the id to the existing sets would have taught the wrong syntax — v2 gets its own branch emitting predicate examples, `order` rather than `sort`, and cursor paging guidance. Unlike v1, a v2 query with no filter is valid and returns every row, so the new branch deliberately does not push `filter` into `required`. The shared bulk tools stay on `$eq`: the rows route accepts either grammar via `resolveBulkFilter`, and enrichment is keyed on tool id alone, so it cannot tell which block invoked it.
1 parent bd75254 commit a690907

6 files changed

Lines changed: 292 additions & 5 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,35 @@ describe('isAgentToolBlock', () => {
2828
false
2929
)
3030
})
31+
32+
/**
33+
* The set stores base types, so a superseded version does not have to be
34+
* re-listed at cutover time. `table_v2` shipped while the set still named
35+
* only `table`, which kept it out of the picker entirely.
36+
*/
37+
it('includes a versioned core block whose base type is listed', () => {
38+
expect(isAgentToolBlock({ type: 'table_v2', category: 'blocks', hideFromToolbar: false })).toBe(
39+
true
40+
)
41+
})
42+
43+
it('excludes a superseded version of a listed core block', () => {
44+
expect(isAgentToolBlock({ type: 'table', category: 'blocks', hideFromToolbar: true })).toBe(
45+
false
46+
)
47+
})
48+
49+
it('does not admit a versioned block whose base type is unlisted', () => {
50+
expect(
51+
isAgentToolBlock({ type: 'memory_v2', category: 'blocks', hideFromToolbar: false })
52+
).toBe(false)
53+
})
54+
55+
it('includes every tools-category block regardless of version', () => {
56+
expect(
57+
isAgentToolBlock({ type: 'confluence_v2', category: 'tools', hideFromToolbar: false })
58+
).toBe(true)
59+
})
3160
})
3261

3362
describe('isMcpToolAlreadySelected', () => {

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1+
import { stripVersionSuffix } from '@sim/utils/string'
12
import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types'
23
import type { BlockConfig } from '@/blocks/types'
34

5+
/**
6+
* Core blocks the agent tool picker offers even though they are not
7+
* `category: 'tools'`. Stored as BASE types and matched after stripping the
8+
* `_vN` suffix, so a superseded version never has to be re-listed here at
9+
* cutover time. Naming a specific version instead is what kept `table_v2` out
10+
* of the picker while the toolbar had already moved to it.
11+
*/
412
const CORE_AGENT_TOOL_TYPES = new Set([
513
'api',
614
'webhook_request',
@@ -9,18 +17,22 @@ const CORE_AGENT_TOOL_TYPES = new Set([
917
'knowledge',
1018
'function',
1119
'table',
12-
'file_v5',
20+
'file',
1321
])
1422

1523
/**
1624
* Checks whether a registered block should appear in the agent tool picker.
25+
*
26+
* The `hideFromToolbar` guard is what keeps older versions out: superseded
27+
* blocks carry it statically, and `getAllBlocks()` projects unrevealed
28+
* `preview` blocks into clones that carry it too.
1729
*/
1830
export function isAgentToolBlock(
1931
block: Pick<BlockConfig, 'category' | 'hideFromToolbar' | 'type'>
2032
): boolean {
21-
return (
22-
!block.hideFromToolbar && (block.category === 'tools' || CORE_AGENT_TOOL_TYPES.has(block.type))
23-
)
33+
if (block.hideFromToolbar) return false
34+
if (block.category === 'tools') return true
35+
return CORE_AGENT_TOOL_TYPES.has(stripVersionSuffix(block.type))
2436
}
2537

2638
/**

‎apps/sim/blocks/utils.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,7 @@ export const BUILT_IN_TOOL_TYPES = new Set([
662662
'stt',
663663
'memory',
664664
'table',
665+
'table_v2',
665666
'webhook_request',
666667
'workflow',
667668
])
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { enrichTableToolDescription, enrichTableToolParameters } from '@/lib/table/llm/enrichment'
6+
import type { TableSummary } from '@/lib/table/types'
7+
8+
const TABLE: TableSummary = {
9+
name: 'Players',
10+
columns: [
11+
{ name: 'status', type: 'string' },
12+
{ name: 'wins', type: 'number' },
13+
],
14+
}
15+
16+
const V2_SCHEMA = {
17+
properties: {
18+
filter: { type: 'object' },
19+
order: { type: 'array' },
20+
columns: { type: 'array' },
21+
limit: { type: 'number' },
22+
cursor: { type: 'string' },
23+
},
24+
required: [] as string[],
25+
}
26+
27+
describe('enrichTableToolDescription for table_query_rows_v2', () => {
28+
const enriched = enrichTableToolDescription('Query rows.', TABLE, 'table_query_rows_v2')
29+
30+
it('names the real columns', () => {
31+
expect(enriched).toContain('status (string)')
32+
expect(enriched).toContain('wins (number)')
33+
})
34+
35+
it('teaches the predicate grammar built from a real column', () => {
36+
expect(enriched).toContain('{"field":"wins","op":"gte","value":10}')
37+
expect(enriched).toContain('"all"')
38+
})
39+
40+
it('never teaches the v1 MongoDB grammar or offset paging', () => {
41+
expect(enriched).not.toContain('$eq')
42+
expect(enriched).not.toContain('offset')
43+
})
44+
45+
it('describes order rather than sort', () => {
46+
expect(enriched).toContain('Example order: [{"field":"wins","direction":"desc"}]')
47+
})
48+
49+
it('omits the example rather than naming a placeholder column', () => {
50+
const bare = enrichTableToolDescription(
51+
'Query rows.',
52+
{ name: 'Blobs', columns: [{ name: 'payload', type: 'json' }] },
53+
'table_query_rows_v2'
54+
)
55+
expect(bare).toContain('payload (json)')
56+
expect(bare).not.toContain('Example filter')
57+
})
58+
})
59+
60+
describe('enrichTableToolParameters for table_query_rows_v2', () => {
61+
const { properties, required } = enrichTableToolParameters(
62+
V2_SCHEMA,
63+
TABLE,
64+
'table_query_rows_v2'
65+
)
66+
67+
/**
68+
* The v1 branch force-pushes `filter` into `required` because a v1 query
69+
* without one fails. A v2 query without a filter is valid and returns every
70+
* row, so forcing it would make the model invent a filter for "list all".
71+
*/
72+
it('leaves filter optional', () => {
73+
expect(required).not.toContain('filter')
74+
})
75+
76+
it('describes filter with the predicate grammar and real columns', () => {
77+
expect(properties.filter.description).toContain('status, wins')
78+
expect(properties.filter.description).toContain('"op"')
79+
expect(properties.filter.description).not.toContain('$eq')
80+
})
81+
82+
it('enriches order, columns, limit, and cursor', () => {
83+
expect(properties.order.description).toContain('direction')
84+
expect(properties.columns.description).toContain('status, wins')
85+
expect(properties.limit.description).toContain('5MB')
86+
expect(properties.cursor.description).toContain('nextCursor')
87+
})
88+
89+
it('does not enrich a sort property that v2 does not have', () => {
90+
expect(properties.sort).toBeUndefined()
91+
})
92+
})
93+
94+
describe('v1 enrichment is unchanged', () => {
95+
it('still forces filter required and teaches $eq', () => {
96+
const { properties, required } = enrichTableToolParameters(
97+
{ properties: { filter: { type: 'object' }, sort: { type: 'object' } }, required: [] },
98+
TABLE,
99+
'table_query_rows'
100+
)
101+
expect(required).toContain('filter')
102+
expect(properties.filter.description).toContain('$eq')
103+
})
104+
105+
/**
106+
* Both Table blocks expose the bulk tools and the rows route accepts either
107+
* grammar, so these keep teaching $eq — enrichment cannot tell which block
108+
* called it.
109+
*/
110+
it('keeps the shared bulk tools on the v1 grammar', () => {
111+
const { properties } = enrichTableToolParameters(
112+
{ properties: { filter: { type: 'object' } }, required: [] },
113+
TABLE,
114+
'table_update_rows_by_filter'
115+
)
116+
expect(properties.filter.description).toContain('$eq')
117+
})
118+
})

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

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,59 @@ import { columnTypeById } from '@/lib/table/column-types'
99
import type { TableSummary } from '@/lib/table/types'
1010

1111
/**
12-
* Operations that use filters and need filter-specific enrichment.
12+
* Operations that take a v1 MongoDB-style filter (`{"col": {"$eq": v}}`) and
13+
* need filter-specific enrichment.
14+
*
15+
* The two bulk operations stay here even though the v2 Table block also exposes
16+
* them: `resolveBulkFilter` in the rows route accepts either grammar, and
17+
* enrichment is keyed on tool id alone — it cannot see which block invoked it —
18+
* so one grammar has to be taught, and `$eq` is the one that works for both.
19+
* Only the query tool has a v2-exclusive id, which is why it is the only one
20+
* that gets a predicate-grammar branch.
1321
*/
1422
export const FILTER_OPERATIONS = new Set([
1523
'table_query_rows',
1624
'table_update_rows_by_filter',
1725
'table_delete_rows_by_filter',
1826
])
1927

28+
/**
29+
* The v2 row query. Its filter is a typed predicate
30+
* (`{"field":"wins","op":"gte","value":10}`) with `all`/`any` groups, it orders
31+
* via `order` rather than `sort`, and it pages by opaque `cursor` rather than
32+
* offset — so none of the v1 enrichment above applies to it.
33+
*/
34+
const TABLE_QUERY_ROWS_V2 = 'table_query_rows_v2'
35+
36+
/**
37+
* Builds a predicate example from real columns, preferring a numeric `gte` over
38+
* a string `eq` because ranking and threshold questions are what the grammar
39+
* most often gets wrong. Returns an empty string when the table has no column
40+
* to name, so the model is never shown a placeholder it might copy literally.
41+
*/
42+
function v2PredicateExample(table: TableSummary): string {
43+
const stringCol = table.columns.find((c) => c.type === 'string')
44+
const numberCol = table.columns.find((c) => c.type === 'number')
45+
46+
if (numberCol && stringCol) {
47+
return `
48+
49+
Example filter (one condition): {"field":"${numberCol.name}","op":"gte","value":10}
50+
Example filter (AND group): {"all":[{"field":"${numberCol.name}","op":"gte","value":10},{"field":"${stringCol.name}","op":"eq","value":"active"}]}`
51+
}
52+
if (numberCol) {
53+
return `
54+
55+
Example filter: {"field":"${numberCol.name}","op":"gte","value":10}`
56+
}
57+
if (stringCol) {
58+
return `
59+
60+
Example filter: {"field":"${stringCol.name}","op":"eq","value":"active"}`
61+
}
62+
return ''
63+
}
64+
2065
/**
2166
* Operations that need column info for data construction.
2267
*/
@@ -41,6 +86,32 @@ export function enrichTableToolDescription(
4186

4287
const columnList = table.columns.map((col) => ` - ${col.name} (${col.type})`).join('\n')
4388

89+
if (toolId === TABLE_QUERY_ROWS_V2) {
90+
const numberCol = table.columns.find((c) => c.type === 'number')
91+
const orderExample = numberCol
92+
? `
93+
Example order: [{"field":"${numberCol.name}","direction":"desc"}] for highest first, "asc" for lowest first`
94+
: ''
95+
96+
return `${originalDescription}
97+
98+
INSTRUCTIONS:
99+
1. Build the filter yourself from the user's question - do NOT ask for confirmation
100+
2. A single condition is a plain object: {"field":"<column>","op":"<operator>","value":<value>}
101+
3. For multiple conditions wrap them in {"all":[...]} for AND or {"any":[...]} for OR; groups nest
102+
4. Operators: eq, ne, gt, gte, lt, lte, in, nin, like, ilike, nlike, nilike, contains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty
103+
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
109+
110+
Table "${table.name}" columns:
111+
${columnList}
112+
${v2PredicateExample(table)}${orderExample}`
113+
}
114+
44115
if (FILTER_OPERATIONS.has(toolId)) {
45116
const stringCols = table.columns.filter((c) => c.type === 'string')
46117
const numberCols = table.columns.filter((c) => c.type === 'number')
@@ -140,6 +211,50 @@ export function enrichTableToolParameters(
140211
const enrichedProperties = { ...llmSchema.properties }
141212
const enrichedRequired = llmSchema.required ? [...llmSchema.required] : []
142213

214+
if (toolId === TABLE_QUERY_ROWS_V2) {
215+
if (enrichedProperties.filter) {
216+
enrichedProperties.filter = {
217+
...enrichedProperties.filter,
218+
description: `Predicate built from the user's question using columns: ${columnNames}. One condition is {"field":"<column>","op":"<operator>","value":<value>}; combine with {"all":[...]} for AND or {"any":[...]} for OR. Operators: eq, ne, gt, gte, lt, lte, in, nin, like, ilike, nlike, nilike, contains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. Omit only to match every row.`,
219+
}
220+
}
221+
222+
if (enrichedProperties.order) {
223+
enrichedProperties.order = {
224+
...enrichedProperties.order,
225+
description: `Sort spec as [{"field":"<column>","direction":"asc"|"desc"}] over columns: ${columnNames}. REQUIRED for ranking queries (highest, lowest, Nth).`,
226+
}
227+
}
228+
229+
if (enrichedProperties.columns) {
230+
enrichedProperties.columns = {
231+
...enrichedProperties.columns,
232+
description: `Column names to include in each row. Available: ${columnNames}. Omit to return all columns.`,
233+
}
234+
}
235+
236+
if (enrichedProperties.limit) {
237+
enrichedProperties.limit = {
238+
...enrichedProperties.limit,
239+
description: `Maximum rows per page (min: 1). Omit to return every matching row; the query fails if the result exceeds 5MB, so narrow with a filter rather than guessing. For ranking queries: 1 for the highest/lowest, 2 for the second highest.`,
240+
}
241+
}
242+
243+
if (enrichedProperties.cursor) {
244+
enrichedProperties.cursor = {
245+
...enrichedProperties.cursor,
246+
description: `Opaque cursor from a prior page's nextCursor. Omit for the first page. A non-null nextCursor means more rows remain even if the page came back short - loop until it is null.`,
247+
}
248+
}
249+
250+
/*
251+
* Deliberately NOT pushed into `required`: unlike v1, a v2 query with no
252+
* filter is valid and returns every row. Forcing it would make the model
253+
* invent a filter for "list everything".
254+
*/
255+
return { properties: enrichedProperties, required: enrichedRequired }
256+
}
257+
143258
if (enrichedProperties.filter && FILTER_OPERATIONS.has(toolId)) {
144259
enrichedProperties.filter = {
145260
...enrichedProperties.filter,

‎apps/sim/tools/table/query_rows_v2.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate'
22
import { validatePredicateShape } from '@/lib/table/query-builder/validate'
3+
import { enrichTableToolSchema } from '@/tools/schema-enrichers'
34
import type { TableQueryV2Response, TableRowQueryV2Params } from '@/tools/table/types'
45
import type { InternalToolConfig } from '@/tools/types'
56

@@ -22,6 +23,17 @@ export const tableQueryRowsV2Tool: InternalToolConfig<TableRowQueryV2Params, Tab
2223
'as cursor to continue; never infer completion from page size.',
2324
version: '1.0.0',
2425

26+
/**
27+
* Without this the model never sees the table's real column names and has
28+
* to guess field values for the predicate. Every other table tool enriches;
29+
* v2's query shipped without it.
30+
*/
31+
toolEnrichment: {
32+
dependsOn: 'tableId',
33+
enrichTool: (tableId, schema, desc, context) =>
34+
enrichTableToolSchema(tableId, 'table_query_rows_v2', schema, desc, context),
35+
},
36+
2537
params: {
2638
tableId: {
2739
type: 'string',

0 commit comments

Comments
 (0)