Skip to content

Commit a03cba0

Browse files
committed
fix(guardrails): surface a cancelled scoring run as cancellation, not a failed guardrail
1 parent f066fef commit a03cba0

2 files changed

Lines changed: 33 additions & 0 deletions

File tree

apps/sim/lib/guardrails/validate_hallucination.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,4 +182,25 @@ describe('validateHallucination', () => {
182182
expect(mockExecuteProviderRequest).not.toHaveBeenCalled()
183183
expect(registry.isComplete()).toBe(true)
184184
})
185+
186+
/**
187+
* Forwarding the caller's signal means the scoring model can now be aborted. A
188+
* cancelled run must not be reported as a guardrail verdict — `passed: false` would
189+
* block content on a run the caller abandoned, which is indistinguishable to a
190+
* consumer from the model actually hallucinating.
191+
*/
192+
it('surfaces a cancelled run as cancellation, not as a failed guardrail', async () => {
193+
const registry = new ResolvedSecretTraceRegistry()
194+
const fetchMock = vi.fn(async () =>
195+
createPrivateKnowledgeResponse({ data: { results: [{ content: 'reference' }] } })
196+
)
197+
vi.stubGlobal('fetch', fetchMock)
198+
199+
const abort = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
200+
mockExecuteProviderRequest.mockRejectedValueOnce(abort)
201+
202+
await expect(validateHallucination(createInput(registry))).rejects.toMatchObject({
203+
name: 'AbortError',
204+
})
205+
})
185206
})

apps/sim/lib/guardrails/validate_hallucination.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
2424
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
2525
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
2626
import { executeProviderRequest } from '@/providers'
27+
import { isAbortError } from '@/providers/streaming-tool-loop-shared'
2728
import { getProviderFromModel } from '@/providers/utils'
2829

2930
const logger = createLogger('HallucinationValidator')
@@ -298,6 +299,11 @@ Evaluate the consistency and provide your score and reasoning in JSON format.`
298299
cost,
299300
}
300301
} catch (error: any) {
302+
/**
303+
* A cancelled run is not a scoring failure. Rewrapping it would erase the
304+
* `AbortError` name the outer handler classifies on, so it propagates as-is.
305+
*/
306+
if (isAbortError(error)) throw error
301307
logger.error(`[${requestId}] Error scoring with LLM`, {
302308
error: error.message,
303309
})
@@ -402,6 +408,12 @@ export async function validateHallucination(
402408
: `Low confidence: score ${score}/10 is below threshold ${threshold}`,
403409
}
404410
} catch (error: any) {
411+
/**
412+
* Cancellation is surfaced as cancellation, not as a guardrail verdict. Returning
413+
* `passed: false` here would fail content on a run the caller abandoned, which is
414+
* indistinguishable to a consumer from the model actually hallucinating.
415+
*/
416+
if (isAbortError(error)) throw error
405417
logger.error(`[${requestId}] Hallucination validation error`, {
406418
error: error.message,
407419
})

0 commit comments

Comments
 (0)