Skip to content

Commit ec0249b

Browse files
committed
feat(mothership): enrich opaque tool errors with actionable context
Bare AbortSignal.timeout / abort messages ("The operation timed out.") reaching the model-facing tool result now carry the tool name, elapsed time, and the may-have-landed honesty with a verify-before-retry suggestion. Informative messages pass through untouched. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w
1 parent fed8259 commit ec0249b

2 files changed

Lines changed: 61 additions & 1 deletion

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { enrichOpaqueToolError } from '@/lib/mothership/request/tools/executor'
6+
7+
describe('enrichOpaqueToolError', () => {
8+
it('wraps the bare AbortSignal.timeout message with tool context', () => {
9+
const out = enrichOpaqueToolError(
10+
'The operation timed out.',
11+
'run_workflow',
12+
Date.now() - 90_000
13+
)
14+
expect(out).toContain('run_workflow timed out after ~90s')
15+
expect(out).toContain('read the affected resource back')
16+
})
17+
18+
it('wraps bare abort messages', () => {
19+
expect(enrichOpaqueToolError('This operation was aborted', 'run_code', undefined)).toContain(
20+
'run_code timed out'
21+
)
22+
})
23+
24+
it('leaves informative messages untouched', () => {
25+
const informative =
26+
"Tool 'run_code' timed out after 300s on the Sim executor and was abandoned."
27+
expect(enrichOpaqueToolError(informative, 'run_code', Date.now())).toBe(informative)
28+
expect(enrichOpaqueToolError('Invalid API key', 'sim_cli', Date.now())).toBe('Invalid API key')
29+
})
30+
})

‎apps/sim/lib/mothership/request/tools/executor.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,32 @@ export function pendingToolWaitBudgetMs(
267267
return toolWatchdogTimeoutMs(toolCall?.name)
268268
}
269269

270+
/**
271+
* Bare timeout/abort messages (AbortSignal.timeout's "The operation timed out.",
272+
* a DOMException's "This operation was aborted") strip everything the model
273+
* needs to reason about the failure. Name the tool, the elapsed time, and the
274+
* honest uncertainty; leave every informative message untouched.
275+
*/
276+
export function enrichOpaqueToolError(
277+
message: string,
278+
toolName: string,
279+
startTimeMs: number | undefined
280+
): string {
281+
if (
282+
!/^(the operation timed out\.?|this operation was aborted\.?|timeout|aborted)$/i.test(
283+
message.trim()
284+
)
285+
) {
286+
return message
287+
}
288+
const elapsed = startTimeMs ? ` after ~${Math.round((Date.now() - startTimeMs) / 1000)}s` : ''
289+
return (
290+
`${toolName} timed out${elapsed} inside its handler ("${message}"). ` +
291+
'The operation may or may not have landed — read the affected resource back before ' +
292+
'retrying, and prefer a narrower invocation if this was a heavy call.'
293+
)
294+
}
295+
270296
class ToolExecutionTimeoutError extends Error {
271297
constructor(toolName: string, timeoutMs: number) {
272298
super(
@@ -880,7 +906,11 @@ async function executeToolAndReportInner(
880906
...(terminalData !== undefined ? { data: terminalData } : {}),
881907
})
882908
} catch (error) {
883-
const thrownMessage = toError(error).message
909+
const thrownMessage = enrichOpaqueToolError(
910+
toError(error).message,
911+
toolCall.name,
912+
toolCall.startTime
913+
)
884914
const projection = inspectToolResultForCopilot(
885915
{ success: false, error: thrownMessage },
886916
toolExecutionContext.resolvedSecretTraceRegistry,

0 commit comments

Comments
 (0)