Skip to content

Commit 353ac76

Browse files
committed
fix(snowflake): cancel Cortex Analyst's generated SQL with the run
The generated SQL used to run as a nested tool call, which inherited the run's abort signal; running it directly dropped that. postProcess now receives the executor's effective abort signal as an optional fourth argument (the same signal the tool's own request used), and Cortex Analyst forwards it to the DNS check, the guarded fetch, and the response read. Other tools' postProcess hooks are unchanged.
1 parent 9aff510 commit 353ac76

5 files changed

Lines changed: 65 additions & 9 deletions

File tree

‎apps/sim/tools/index.test.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3737,6 +3737,51 @@ describe('Internal Route Trust', () => {
37373737
}
37383738
)
37393739

3740+
it('aborts Cortex Analyst’s generated SQL when the run is cancelled', async () => {
3741+
;(tools as Record<string, unknown>)[snowflakeCortexAnalystAskTool.id] =
3742+
snowflakeCortexAnalystAskTool
3743+
const controller = new AbortController()
3744+
let statementSignal: AbortSignal | undefined
3745+
try {
3746+
mockResolveExecutorCredentialToken.mockResolvedValue({
3747+
credentialType: 'service_account',
3748+
domain: CREDENTIAL_HOST,
3749+
accessToken: 'pat-secret',
3750+
})
3751+
mockSecureFetchWithPinnedIP
3752+
.mockResolvedValueOnce(
3753+
toSecureFetchResponse(
3754+
Response.json({
3755+
message: { role: 'analyst', content: [{ type: 'sql', statement: 'SELECT 1' }] },
3756+
})
3757+
)
3758+
)
3759+
.mockImplementationOnce(
3760+
async (_url: string, _ip: string, init: { signal?: AbortSignal }) => {
3761+
statementSignal = init.signal
3762+
controller.abort(new Error('run cancelled'))
3763+
throw new Error('aborted')
3764+
}
3765+
)
3766+
3767+
const result = await executeTool(
3768+
snowflakeCortexAnalystAskTool.id,
3769+
{
3770+
oauthCredential: 'snowflake-credential',
3771+
question: 'How many?',
3772+
semanticView: 'DB.SCHEMA.VIEW',
3773+
executeSql: true,
3774+
},
3775+
{ signal: controller.signal }
3776+
)
3777+
3778+
expect(statementSignal?.aborted).toBe(true)
3779+
expect(result.success).toBe(false)
3780+
} finally {
3781+
Reflect.deleteProperty(tools, snowflakeCortexAnalystAskTool.id)
3782+
}
3783+
})
3784+
37403785
it('sends a keyless credential’s token through workload identity federation', async () => {
37413786
const { headers } = await snowflakeRequestHeaders(
37423787
{ accessToken: 'signed-jwt', authStyle: 'snowflake-workload-identity' },
@@ -4520,7 +4565,8 @@ describe('Internal Route Trust', () => {
45204565
expect(postProcess).toHaveBeenCalledWith(
45214566
expect.any(Object),
45224567
expect.objectContaining({ prompt: 'direct-secret', apiKey: 'direct-secret' }),
4523-
expect.any(Function)
4568+
expect.any(Function),
4569+
expect.any(Object)
45244570
)
45254571
expect(params).toEqual(originalParams)
45264572
} finally {

‎apps/sim/tools/index.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2369,7 +2369,9 @@ async function executeToolImplementation(
23692369
let finalResult = result
23702370
if (tool.postProcess && result.success && !skipPostProcess) {
23712371
try {
2372-
finalResult = await tool.postProcess(result, contextParams, executeNestedTool)
2372+
finalResult = await tool.postProcess(result, contextParams, executeNestedTool, {
2373+
signal: effectiveSignal,
2374+
})
23732375
} catch (error) {
23742376
logger.error(`Post-processing error for ${toolId}`, {
23752377
requestId,
@@ -2462,7 +2464,9 @@ async function executeToolImplementation(
24622464
let finalResult = result
24632465
if (tool.postProcess && result.success && !skipPostProcess) {
24642466
try {
2465-
finalResult = await tool.postProcess(result, contextParams, executeNestedTool)
2467+
finalResult = await tool.postProcess(result, contextParams, executeNestedTool, {
2468+
signal: effectiveSignal,
2469+
})
24662470
} catch (error) {
24672471
const normalizedError = toError(error)
24682472
logger.error(

‎apps/sim/tools/snowflake/cortex_analyst_ask.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ export const cortexAnalystAskTool: ToolConfig<
143143
),
144144
}
145145
},
146-
postProcess: async (result, params) => {
146+
postProcess: async (result, params, _executeTool, context) => {
147147
const { sql } = result.output
148148
if (!sql || !shouldExecuteSql(params.executeSql)) return result
149149

@@ -159,7 +159,8 @@ export const cortexAnalystAskTool: ToolConfig<
159159
const execution = await runSnowflakeStatement(
160160
params,
161161
sql,
162-
cortexAnalystSqlContext(params, result.output.semanticModelSelection?.index)
162+
cortexAnalystSqlContext(params, result.output.semanticModelSelection?.index),
163+
context?.signal
163164
)
164165
return { ...result, output: { ...result.output, execution } }
165166
} catch (error) {

‎apps/sim/tools/snowflake/run-statement.server.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,16 @@ import {
2222
export async function runSnowflakeStatement(
2323
params: SnowflakeResultParams,
2424
statement: string,
25-
context: { database?: string; schema?: string }
25+
context: { database?: string; schema?: string },
26+
signal?: AbortSignal
2627
): Promise<SnowflakeStatementOutput> {
2728
const url = `${getSnowflakeBaseUrl(params)}/api/v2/statements`
2829
const body = buildSnowflakeStatementBody(
2930
params,
3031
{ statement },
3132
{ context, warehouse: params.warehouse, maxRows: params.maxRows }
3233
)
33-
const validation = await validateUrlWithDNS(url, 'snowflakeUrl', 'requestTarget')
34+
const validation = await validateUrlWithDNS(url, 'snowflakeUrl', 'requestTarget', { signal })
3435
if (!validation.isValid) {
3536
throw new Error(`Invalid Snowflake URL: ${validation.error ?? 'DNS resolution failed'}`)
3637
}
@@ -41,12 +42,14 @@ export async function runSnowflakeStatement(
4142
body: JSON.stringify(body),
4243
maxRedirects: 0,
4344
maxResponseBytes: SNOWFLAKE_MAX_RESPONSE_BYTES,
45+
signal,
4446
})
4547
return readSnowflakeResult(
4648
new Response(response.body, {
4749
status: response.status,
4850
statusText: response.statusText,
4951
headers: response.headers.toRecord(),
50-
})
52+
}),
53+
{ signal }
5154
)
5255
}

‎apps/sim/tools/types.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,9 @@ export interface ToolConfig<P = any, R = any> {
331331
postProcess?: (
332332
result: R extends ToolResponse ? R : ToolResponse,
333333
params: P,
334-
executeTool: (toolId: string, params: Record<string, any>) => Promise<ToolResponse>
334+
executeTool: (toolId: string, params: Record<string, any>) => Promise<ToolResponse>,
335+
/** The run's abort signal, the same one the tool's own request used. */
336+
context?: { signal?: AbortSignal }
335337
) => Promise<R extends ToolResponse ? R : ToolResponse>
336338

337339
// Response handling

0 commit comments

Comments
 (0)