Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions src/__tests__/stream.eagerEventExecution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2592,6 +2592,83 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
).toBe(true);
});

it('emits the stop instruction for an eager tool aborted by the run', async () => {
const controller = new AbortController();
const graph = createGraph({
config: {
signal: controller.signal,
configurable: { user_id: 'user_1' },
metadata: { run_id: 'run_1' },
},
});
const completedEvents: Array<{ result: t.ToolEndEvent }> = [];
jest
.spyOn(events, 'safeDispatchCustomEvent')
.mockImplementation(async (event, data): Promise<void> => {
if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
completedEvents.push(data as { result: t.ToolEndEvent });
return;
}
if (event !== GraphEvents.ON_TOOL_EXECUTE) {
return;
}
const batch = data as t.ToolExecuteBatchRequest;
controller.abort();
batch.resolve([
{
toolCallId: 'call_weather',
status: 'error',
content: '',
errorMessage: 'AbortError: This operation was aborted',
},
]);
});

const handler = new ChatModelStreamHandler();
const metadata = { langgraph_node: 'agent' };
await handler.handle(
GraphEvents.CHAT_MODEL_STREAM,
{
chunk: {
content: '',
tool_call_chunks: [
{
id: 'call_weather',
name: 'weather',
args: '{"city":"NYC"}',
index: 0,
},
],
} as unknown as t.StreamChunk,
},
metadata,
graph
);
await handler.handle(
GraphEvents.CHAT_MODEL_STREAM,
{
chunk: {
content: '',
tool_call_chunks: [
{
id: 'call_stock',
name: 'stock',
args: '{"ticker":"C',
index: 1,
},
],
} as unknown as t.StreamChunk,
},
metadata,
graph
);
await graph.eagerEventToolExecutions.get('call_weather')?.promise;

const output = completedEvents[0]?.result.tool_call?.output;
expect(output).toContain('STOP what you are doing');
expect(output).not.toContain('Please fix your mistakes');
});

it('serializes bigint output before eager completion dispatch', async () => {
const graph = createGraph();
const completedEvents: Array<{ result: t.ToolEndEvent }> = [];
Expand Down
8 changes: 5 additions & 3 deletions src/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
truncateToolResultContent,
} from '@/utils/truncation';
import { resolveToolOutcome, outcomeFieldsFromResult } from '@/tools/intentArg';
import { formatToolErrorContent } from '@/tools/toolErrorContent';
import { snapshotValidatedModelChunk } from '@/graphs/acceptedModelResponse';
import { TOOL_OUTPUT_REF_PATTERN } from '@/tools/toolOutputReferences';
import { PreparedSubagentError } from '@/tools/preparedSubagents';
Expand Down Expand Up @@ -940,9 +941,10 @@ async function dispatchEagerToolCompletions(args: {
}
let output: string;
if (result.status === 'error') {
output = truncateToolResultContent(
`Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`,
maxToolResultChars
output = formatToolErrorContent(
result.errorMessage,
maxToolResultChars,
graph.config?.signal
);
} else if (typeof result.content === 'string') {
output = truncateToolResultContent(result.content, maxToolResultChars);
Expand Down
23 changes: 14 additions & 9 deletions src/tools/ToolNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ import {
resolveLocalExecutionTools,
} from '@/tools/local';
import { stripCodeSessionFileSummary } from '@/tools/CodeSessionFileSummary';
import { formatToolErrorContent } from '@/tools/toolErrorContent';
import { Constants, GraphEvents, CODE_EXECUTION_TOOLS } from '@/common';
import { PreparedSubagentError } from '@/tools/preparedSubagents';
import { attachRunStepResumeState } from '@/tools/runStepResume';
Expand Down Expand Up @@ -2201,9 +2202,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
});
}
}
const errorContent = truncateToolResultContent(
`Error: ${e.message}\n Please fix your mistakes.`,
this.maxToolResultChars
const errorContent = formatToolErrorContent(
e.message,
this.maxToolResultChars,
config.signal,
e
);
const refMeta =
unresolvedRefs.length > 0
Expand Down Expand Up @@ -4461,9 +4464,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
let finalToolOutput: unknown = result.content;

if (result.status === 'error') {
contentString = truncateToolResultContent(
`Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`,
this.maxToolResultChars
contentString = formatToolErrorContent(
result.errorMessage,
this.maxToolResultChars,
config.signal
);
/**
* Error results bypass registration but stamp the
Expand Down Expand Up @@ -4977,9 +4981,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
): Promise<boolean> {
const output =
result.status === 'error'
? truncateToolResultContent(
`Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`,
this.maxToolResultChars
? formatToolErrorContent(
result.errorMessage,
this.maxToolResultChars,
config.signal
)
: serializeToolOutputWithinLimits(
result.content,
Expand Down
20 changes: 20 additions & 0 deletions src/tools/__tests__/ToolNode.breakerSignal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,26 @@ describe('ToolNode breaker signal composition', () => {
expect(signal?.aborted).toBe(true);
});

it('returns a stop instruction when a direct tool fails on a stopped run', async () => {
const controller = new AbortController();
const interrupted = createSignalBlindTool('edit_file', async () => {
controller.abort();
throw new DOMException('This operation was aborted', 'AbortError');
});
const node = new ToolNode({
tools: [interrupted],
getBreakerSignal: () => controller.signal,
});

const result = (await node.invoke({
messages: [createToolCallMessage('call_edit', 'edit_file')],
})) as { messages: ToolMessage[] };

expect(result.messages[0].status).toBe('error');
expect(result.messages[0].content).toContain('STOP what you are doing');
expect(result.messages[0].content).not.toContain('Please fix your mistakes');
});

it('leaves the caller signal untouched when no breaker accessor is set', async () => {
const caller = new AbortController();
const { tool: capture, observed } = createSignalCaptureTool('capture');
Expand Down
52 changes: 52 additions & 0 deletions src/tools/__tests__/ToolNode.onResultCompletion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,58 @@ describe('ToolNode per-call onResult completion emission', () => {
expect(completionAttempts[1].result.tool_call.id).toBe('call_weather');
});

it.each([true, false])(
'emits a stop instruction for aborted host results (early completion: %s)',
async (early) => {
const controller = new AbortController();
const completions: CompletionEvent[] = [];
const abortedResult: t.ToolExecuteResult = {
toolCallId: 'call_edit',
status: 'error',
content: '',
errorMessage: 'This operation was aborted',
};
jest
.spyOn(events, 'safeDispatchCustomEvent')
.mockImplementation(async (event, data): Promise<void> => {
if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
completions.push(data as CompletionEvent);
return;
}
if (event !== GraphEvents.ON_TOOL_EXECUTE) {
return;
}
const batch = data as t.ToolExecuteBatchRequest;
controller.abort();
if (early) {
batch.onResult?.(abortedResult);
}
batch.resolve([abortedResult]);
});

const node = new ToolNode({
tools: [createDummyTool('edit_file')],
eventDrivenMode: true,
getBreakerSignal: () => controller.signal,
toolCallStepIds: new Map([['call_edit', 'step_edit']]),
});
const result = (await node.invoke({
messages: [
createAIMessageWithToolCalls([
{ id: 'call_edit', name: 'edit_file', args: {} },
]),
],
})) as { messages: ToolMessage[] };

const content = String(result.messages[0].content);
expect(result.messages[0].status).toBe('error');
expect(content).toContain('STOP what you are doing');
expect(content).not.toContain('Please fix your mistakes');
expect(completions).toHaveLength(1);
expect(completions[0].result.tool_call.output).toBe(content);
}
);

it('emits error-status results with the standard error formatting', async () => {
const completions: CompletionEvent[] = [];

Expand Down
79 changes: 79 additions & 0 deletions src/tools/__tests__/toolErrorContent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect } from '@jest/globals';
import { PreparedSubagentError } from '@/tools/preparedSubagents';
import { StreamLimitExceededError } from '@/llm/streamLimits';
import {
STOPPED_RUN_TOOL_ERROR,
formatToolErrorContent,
} from '@/tools/toolErrorContent';

const maxChars = 1000;

describe('formatToolErrorContent', () => {
it('instructs the model to stop for LibreChat host abort results on a stopped run', () => {
const controller = new AbortController();
controller.abort();

expect(
formatToolErrorContent(
'MCP error -32001: AbortError: This operation was aborted',
maxChars,
controller.signal
)
).toBe(STOPPED_RUN_TOOL_ERROR);
expect(
formatToolErrorContent(
'This operation was aborted',
maxChars,
controller.signal
)
).toBe(STOPPED_RUN_TOOL_ERROR);
});

it('preserves ordinary errors, even when they race with a stop', () => {
const controller = new AbortController();
controller.abort();
expect(
formatToolErrorContent('Permission denied', maxChars, controller.signal)
).toBe('Error: Permission denied\n Please fix your mistakes.');
expect(
formatToolErrorContent('AbortError: remote timeout', maxChars)
).toBe('Error: AbortError: remote timeout\n Please fix your mistakes.');
});

it('recognizes direct-tool abort errors and the owning signal reason', () => {
const controller = new AbortController();
const error = new Error('User cancelled the run');
controller.abort(error);

expect(
formatToolErrorContent(error.message, maxChars, controller.signal, error)
).toBe(STOPPED_RUN_TOOL_ERROR);

const other = new Error('Tool cancelled');
other.name = 'AbortError';
expect(
formatToolErrorContent(other.message, maxChars, controller.signal, other)
).toBe(STOPPED_RUN_TOOL_ERROR);
});

it('does not attribute circuit-breaker safety aborts to a user stop', () => {
const breaker = new AbortController();
breaker.abort(
new StreamLimitExceededError({
kind: 'tool_call_args',
limit: 10,
observed: 11,
toolName: 'db_query',
})
);
expect(
formatToolErrorContent('AbortError', maxChars, breaker.signal)
).toBe('Error: AbortError\n Please fix your mistakes.');

const preparation = new AbortController();
preparation.abort(new PreparedSubagentError('child run failed'));
expect(
formatToolErrorContent('AbortError', maxChars, preparation.signal)
).toBe('Error: AbortError\n Please fix your mistakes.');
});
});
48 changes: 48 additions & 0 deletions src/tools/toolErrorContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { PreparedSubagentError } from '@/tools/preparedSubagents';
import { truncateToolResultContent } from '@/utils/truncation';
import { StreamLimitExceededError } from '@/llm/streamLimits';

export const STOPPED_RUN_TOOL_ERROR =
'STOP. The user doesn\'t want to proceed with this tool use. ' +
'The run was stopped; the tool may not have completed, and its effects should not be assumed. ' +
'STOP what you are doing and wait for the user to tell you how to proceed.';

/** A host result only carries error text, so require the run signal as well. */
function isStoppedRunToolError(
message: string | undefined,
signal?: AbortSignal,
error?: Error
): boolean {
if (
signal?.aborted !== true ||
signal.reason instanceof StreamLimitExceededError ||
signal.reason instanceof PreparedSubagentError
) {
return false;
}
if (
(error != null && error === signal.reason) ||
(error instanceof Error &&
(error.name === 'AbortError' ||
('code' in error &&
(error.code === 'ABORT_ERR' || error.code === 'ERR_CANCELED'))))
) {
return true;
}
return (
message != null &&
/AbortError|(?:operation|request|stream) was aborted/i.test(message)
);
}

export function formatToolErrorContent(
message: string | undefined,
maxChars: number,
signal?: AbortSignal,
error?: Error
): string {
const content = isStoppedRunToolError(message, signal, error)
? STOPPED_RUN_TOOL_ERROR
: `Error: ${message ?? 'Unknown error'}\n Please fix your mistakes.`;
return truncateToolResultContent(content, maxChars);
}
Loading