Skip to content

Commit 5fb3238

Browse files
🛑 fix: Stop Agent On Aborted Tool Calls (#566)
Co-authored-by: Lia <lia@librechat.ai>
1 parent d6a66c3 commit 5fb3238

7 files changed

Lines changed: 295 additions & 12 deletions

‎src/__tests__/stream.eagerEventExecution.test.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2592,6 +2592,83 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
25922592
).toBe(true);
25932593
});
25942594

2595+
it('emits the stop instruction for an eager tool aborted by the run', async () => {
2596+
const controller = new AbortController();
2597+
const graph = createGraph({
2598+
config: {
2599+
signal: controller.signal,
2600+
configurable: { user_id: 'user_1' },
2601+
metadata: { run_id: 'run_1' },
2602+
},
2603+
});
2604+
const completedEvents: Array<{ result: t.ToolEndEvent }> = [];
2605+
jest
2606+
.spyOn(events, 'safeDispatchCustomEvent')
2607+
.mockImplementation(async (event, data): Promise<void> => {
2608+
if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
2609+
completedEvents.push(data as { result: t.ToolEndEvent });
2610+
return;
2611+
}
2612+
if (event !== GraphEvents.ON_TOOL_EXECUTE) {
2613+
return;
2614+
}
2615+
const batch = data as t.ToolExecuteBatchRequest;
2616+
controller.abort();
2617+
batch.resolve([
2618+
{
2619+
toolCallId: 'call_weather',
2620+
status: 'error',
2621+
content: '',
2622+
errorMessage: 'AbortError: This operation was aborted',
2623+
},
2624+
]);
2625+
});
2626+
2627+
const handler = new ChatModelStreamHandler();
2628+
const metadata = { langgraph_node: 'agent' };
2629+
await handler.handle(
2630+
GraphEvents.CHAT_MODEL_STREAM,
2631+
{
2632+
chunk: {
2633+
content: '',
2634+
tool_call_chunks: [
2635+
{
2636+
id: 'call_weather',
2637+
name: 'weather',
2638+
args: '{"city":"NYC"}',
2639+
index: 0,
2640+
},
2641+
],
2642+
} as unknown as t.StreamChunk,
2643+
},
2644+
metadata,
2645+
graph
2646+
);
2647+
await handler.handle(
2648+
GraphEvents.CHAT_MODEL_STREAM,
2649+
{
2650+
chunk: {
2651+
content: '',
2652+
tool_call_chunks: [
2653+
{
2654+
id: 'call_stock',
2655+
name: 'stock',
2656+
args: '{"ticker":"C',
2657+
index: 1,
2658+
},
2659+
],
2660+
} as unknown as t.StreamChunk,
2661+
},
2662+
metadata,
2663+
graph
2664+
);
2665+
await graph.eagerEventToolExecutions.get('call_weather')?.promise;
2666+
2667+
const output = completedEvents[0]?.result.tool_call?.output;
2668+
expect(output).toContain('STOP what you are doing');
2669+
expect(output).not.toContain('Please fix your mistakes');
2670+
});
2671+
25952672
it('serializes bigint output before eager completion dispatch', async () => {
25962673
const graph = createGraph();
25972674
const completedEvents: Array<{ result: t.ToolEndEvent }> = [];

‎src/stream.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
truncateToolResultContent,
5757
} from '@/utils/truncation';
5858
import { resolveToolOutcome, outcomeFieldsFromResult } from '@/tools/intentArg';
59+
import { formatToolErrorContent } from '@/tools/toolErrorContent';
5960
import { snapshotValidatedModelChunk } from '@/graphs/acceptedModelResponse';
6061
import { TOOL_OUTPUT_REF_PATTERN } from '@/tools/toolOutputReferences';
6162
import { PreparedSubagentError } from '@/tools/preparedSubagents';
@@ -940,9 +941,10 @@ async function dispatchEagerToolCompletions(args: {
940941
}
941942
let output: string;
942943
if (result.status === 'error') {
943-
output = truncateToolResultContent(
944-
`Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`,
945-
maxToolResultChars
944+
output = formatToolErrorContent(
945+
result.errorMessage,
946+
maxToolResultChars,
947+
graph.config?.signal
946948
);
947949
} else if (typeof result.content === 'string') {
948950
output = truncateToolResultContent(result.content, maxToolResultChars);

‎src/tools/ToolNode.ts‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ import {
129129
resolveLocalExecutionTools,
130130
} from '@/tools/local';
131131
import { stripCodeSessionFileSummary } from '@/tools/CodeSessionFileSummary';
132+
import { formatToolErrorContent } from '@/tools/toolErrorContent';
132133
import { Constants, GraphEvents, CODE_EXECUTION_TOOLS } from '@/common';
133134
import { PreparedSubagentError } from '@/tools/preparedSubagents';
134135
import { attachRunStepResumeState } from '@/tools/runStepResume';
@@ -2201,9 +2202,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
22012202
});
22022203
}
22032204
}
2204-
const errorContent = truncateToolResultContent(
2205-
`Error: ${e.message}\n Please fix your mistakes.`,
2206-
this.maxToolResultChars
2205+
const errorContent = formatToolErrorContent(
2206+
e.message,
2207+
this.maxToolResultChars,
2208+
config.signal,
2209+
e
22072210
);
22082211
const refMeta =
22092212
unresolvedRefs.length > 0
@@ -4461,9 +4464,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
44614464
let finalToolOutput: unknown = result.content;
44624465

44634466
if (result.status === 'error') {
4464-
contentString = truncateToolResultContent(
4465-
`Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`,
4466-
this.maxToolResultChars
4467+
contentString = formatToolErrorContent(
4468+
result.errorMessage,
4469+
this.maxToolResultChars,
4470+
config.signal
44674471
);
44684472
/**
44694473
* Error results bypass registration but stamp the
@@ -4977,9 +4981,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
49774981
): Promise<boolean> {
49784982
const output =
49794983
result.status === 'error'
4980-
? truncateToolResultContent(
4981-
`Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`,
4982-
this.maxToolResultChars
4984+
? formatToolErrorContent(
4985+
result.errorMessage,
4986+
this.maxToolResultChars,
4987+
config.signal
49834988
)
49844989
: serializeToolOutputWithinLimits(
49854990
result.content,

‎src/tools/__tests__/ToolNode.breakerSignal.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,26 @@ describe('ToolNode breaker signal composition', () => {
129129
expect(signal?.aborted).toBe(true);
130130
});
131131

132+
it('returns a stop instruction when a direct tool fails on a stopped run', async () => {
133+
const controller = new AbortController();
134+
const interrupted = createSignalBlindTool('edit_file', async () => {
135+
controller.abort();
136+
throw new DOMException('This operation was aborted', 'AbortError');
137+
});
138+
const node = new ToolNode({
139+
tools: [interrupted],
140+
getBreakerSignal: () => controller.signal,
141+
});
142+
143+
const result = (await node.invoke({
144+
messages: [createToolCallMessage('call_edit', 'edit_file')],
145+
})) as { messages: ToolMessage[] };
146+
147+
expect(result.messages[0].status).toBe('error');
148+
expect(result.messages[0].content).toContain('STOP what you are doing');
149+
expect(result.messages[0].content).not.toContain('Please fix your mistakes');
150+
});
151+
132152
it('leaves the caller signal untouched when no breaker accessor is set', async () => {
133153
const caller = new AbortController();
134154
const { tool: capture, observed } = createSignalCaptureTool('capture');

‎src/tools/__tests__/ToolNode.onResultCompletion.test.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,58 @@ describe('ToolNode per-call onResult completion emission', () => {
509509
expect(completionAttempts[1].result.tool_call.id).toBe('call_weather');
510510
});
511511

512+
it.each([true, false])(
513+
'emits a stop instruction for aborted host results (early completion: %s)',
514+
async (early) => {
515+
const controller = new AbortController();
516+
const completions: CompletionEvent[] = [];
517+
const abortedResult: t.ToolExecuteResult = {
518+
toolCallId: 'call_edit',
519+
status: 'error',
520+
content: '',
521+
errorMessage: 'This operation was aborted',
522+
};
523+
jest
524+
.spyOn(events, 'safeDispatchCustomEvent')
525+
.mockImplementation(async (event, data): Promise<void> => {
526+
if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
527+
completions.push(data as CompletionEvent);
528+
return;
529+
}
530+
if (event !== GraphEvents.ON_TOOL_EXECUTE) {
531+
return;
532+
}
533+
const batch = data as t.ToolExecuteBatchRequest;
534+
controller.abort();
535+
if (early) {
536+
batch.onResult?.(abortedResult);
537+
}
538+
batch.resolve([abortedResult]);
539+
});
540+
541+
const node = new ToolNode({
542+
tools: [createDummyTool('edit_file')],
543+
eventDrivenMode: true,
544+
getBreakerSignal: () => controller.signal,
545+
toolCallStepIds: new Map([['call_edit', 'step_edit']]),
546+
});
547+
const result = (await node.invoke({
548+
messages: [
549+
createAIMessageWithToolCalls([
550+
{ id: 'call_edit', name: 'edit_file', args: {} },
551+
]),
552+
],
553+
})) as { messages: ToolMessage[] };
554+
555+
const content = String(result.messages[0].content);
556+
expect(result.messages[0].status).toBe('error');
557+
expect(content).toContain('STOP what you are doing');
558+
expect(content).not.toContain('Please fix your mistakes');
559+
expect(completions).toHaveLength(1);
560+
expect(completions[0].result.tool_call.output).toBe(content);
561+
}
562+
);
563+
512564
it('emits error-status results with the standard error formatting', async () => {
513565
const completions: CompletionEvent[] = [];
514566

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, it, expect } from '@jest/globals';
2+
import { PreparedSubagentError } from '@/tools/preparedSubagents';
3+
import { StreamLimitExceededError } from '@/llm/streamLimits';
4+
import {
5+
STOPPED_RUN_TOOL_ERROR,
6+
formatToolErrorContent,
7+
} from '@/tools/toolErrorContent';
8+
9+
const maxChars = 1000;
10+
11+
describe('formatToolErrorContent', () => {
12+
it('instructs the model to stop for LibreChat host abort results on a stopped run', () => {
13+
const controller = new AbortController();
14+
controller.abort();
15+
16+
expect(
17+
formatToolErrorContent(
18+
'MCP error -32001: AbortError: This operation was aborted',
19+
maxChars,
20+
controller.signal
21+
)
22+
).toBe(STOPPED_RUN_TOOL_ERROR);
23+
expect(
24+
formatToolErrorContent(
25+
'This operation was aborted',
26+
maxChars,
27+
controller.signal
28+
)
29+
).toBe(STOPPED_RUN_TOOL_ERROR);
30+
});
31+
32+
it('preserves ordinary errors, even when they race with a stop', () => {
33+
const controller = new AbortController();
34+
controller.abort();
35+
expect(
36+
formatToolErrorContent('Permission denied', maxChars, controller.signal)
37+
).toBe('Error: Permission denied\n Please fix your mistakes.');
38+
expect(
39+
formatToolErrorContent('AbortError: remote timeout', maxChars)
40+
).toBe('Error: AbortError: remote timeout\n Please fix your mistakes.');
41+
});
42+
43+
it('recognizes direct-tool abort errors and the owning signal reason', () => {
44+
const controller = new AbortController();
45+
const error = new Error('User cancelled the run');
46+
controller.abort(error);
47+
48+
expect(
49+
formatToolErrorContent(error.message, maxChars, controller.signal, error)
50+
).toBe(STOPPED_RUN_TOOL_ERROR);
51+
52+
const other = new Error('Tool cancelled');
53+
other.name = 'AbortError';
54+
expect(
55+
formatToolErrorContent(other.message, maxChars, controller.signal, other)
56+
).toBe(STOPPED_RUN_TOOL_ERROR);
57+
});
58+
59+
it('does not attribute circuit-breaker safety aborts to a user stop', () => {
60+
const breaker = new AbortController();
61+
breaker.abort(
62+
new StreamLimitExceededError({
63+
kind: 'tool_call_args',
64+
limit: 10,
65+
observed: 11,
66+
toolName: 'db_query',
67+
})
68+
);
69+
expect(
70+
formatToolErrorContent('AbortError', maxChars, breaker.signal)
71+
).toBe('Error: AbortError\n Please fix your mistakes.');
72+
73+
const preparation = new AbortController();
74+
preparation.abort(new PreparedSubagentError('child run failed'));
75+
expect(
76+
formatToolErrorContent('AbortError', maxChars, preparation.signal)
77+
).toBe('Error: AbortError\n Please fix your mistakes.');
78+
});
79+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { PreparedSubagentError } from '@/tools/preparedSubagents';
2+
import { truncateToolResultContent } from '@/utils/truncation';
3+
import { StreamLimitExceededError } from '@/llm/streamLimits';
4+
5+
export const STOPPED_RUN_TOOL_ERROR =
6+
'STOP. The user doesn\'t want to proceed with this tool use. ' +
7+
'The run was stopped; the tool may not have completed, and its effects should not be assumed. ' +
8+
'STOP what you are doing and wait for the user to tell you how to proceed.';
9+
10+
/** A host result only carries error text, so require the run signal as well. */
11+
function isStoppedRunToolError(
12+
message: string | undefined,
13+
signal?: AbortSignal,
14+
error?: Error
15+
): boolean {
16+
if (
17+
signal?.aborted !== true ||
18+
signal.reason instanceof StreamLimitExceededError ||
19+
signal.reason instanceof PreparedSubagentError
20+
) {
21+
return false;
22+
}
23+
if (
24+
(error != null && error === signal.reason) ||
25+
(error instanceof Error &&
26+
(error.name === 'AbortError' ||
27+
('code' in error &&
28+
(error.code === 'ABORT_ERR' || error.code === 'ERR_CANCELED'))))
29+
) {
30+
return true;
31+
}
32+
return (
33+
message != null &&
34+
/AbortError|(?:operation|request|stream) was aborted/i.test(message)
35+
);
36+
}
37+
38+
export function formatToolErrorContent(
39+
message: string | undefined,
40+
maxChars: number,
41+
signal?: AbortSignal,
42+
error?: Error
43+
): string {
44+
const content = isStoppedRunToolError(message, signal, error)
45+
? STOPPED_RUN_TOOL_ERROR
46+
: `Error: ${message ?? 'Unknown error'}\n Please fix your mistakes.`;
47+
return truncateToolResultContent(content, maxChars);
48+
}

0 commit comments

Comments
 (0)