@librechat/agents 3.2.54 → 3.2.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Live Bedrock tool-call truncation verification.
3
+ *
4
+ * Reproduces the production failure where a large tool argument (`content`)
5
+ * gets cut off by the max output token limit, leaving the handler with a
6
+ * well-formed-but-incomplete tool call and no truncation signal.
7
+ *
8
+ * Run with:
9
+ * RUN_BEDROCK_TRUNCATION_LIVE=1 npm test -- bedrock-truncation.live.test.ts --runInBand
10
+ */
11
+ import { config as dotenvConfig } from 'dotenv';
12
+ dotenvConfig();
13
+
14
+ import { z } from 'zod';
15
+ import { HumanMessage } from '@langchain/core/messages';
16
+ import { DynamicStructuredTool } from '@langchain/core/tools';
17
+ import { describe, expect, it, jest } from '@jest/globals';
18
+ import type * as t from '@/types';
19
+ import { GraphEvents, Providers } from '@/common';
20
+ import { ChatModelStreamHandler } from '@/stream';
21
+ import { ToolEndHandler, ModelEndHandler } from '@/events';
22
+ import { Run } from '@/run';
23
+
24
+ const shouldRunLive =
25
+ process.env.RUN_BEDROCK_TRUNCATION_LIVE === '1' &&
26
+ (process.env.BEDROCK_AWS_ACCESS_KEY_ID ?? '') !== '' &&
27
+ (process.env.BEDROCK_AWS_SECRET_ACCESS_KEY ?? '') !== '';
28
+
29
+ const describeIfLive = shouldRunLive ? describe : describe.skip;
30
+
31
+ const REGION =
32
+ [
33
+ process.env.BEDROCK_AWS_REGION,
34
+ process.env.BEDROCK_AWS_DEFAULT_REGION,
35
+ process.env.AWS_DEFAULT_REGION,
36
+ ].find(
37
+ (value): value is string => typeof value === 'string' && value !== ''
38
+ ) ?? 'us-east-1';
39
+ const MODEL = 'us.anthropic.claude-sonnet-4-5-20250929-v1:0';
40
+ const MAX_TOKENS = Number(process.env.REPRO_MAX_TOKENS ?? 350);
41
+
42
+ const schema = z.object({
43
+ path: z.string().describe('Path to write.'),
44
+ content: z.string().describe('Complete file contents.'),
45
+ overwrite: z.boolean().optional(),
46
+ });
47
+
48
+ describeIfLive('Bedrock tool-call truncation (live)', () => {
49
+ jest.setTimeout(120_000);
50
+
51
+ it('fails fast with a truncation error instead of looping', async () => {
52
+ const received: Array<{ keys: string[]; contentLen: number | null }> = [];
53
+ const stepToolCalls: string[][] = [];
54
+ let invocations = 0;
55
+
56
+ const createFile = new DynamicStructuredTool({
57
+ name: 'create_file',
58
+ description: 'Create a new file. Requires path and content.',
59
+ schema,
60
+ func: async (input: z.infer<typeof schema>) => {
61
+ invocations += 1;
62
+ const hasContent =
63
+ typeof input.content === 'string' && input.content.length > 0;
64
+ received.push({
65
+ keys: Object.keys(input as Record<string, unknown>),
66
+ contentLen: hasContent ? input.content.length : null,
67
+ });
68
+ if (!hasContent) {
69
+ return 'Error: content is required\n Please fix your mistakes.';
70
+ }
71
+ return `Wrote ${input.content.length} bytes to ${input.path}`;
72
+ },
73
+ });
74
+
75
+ const llmConfig = {
76
+ provider: Providers.BEDROCK,
77
+ model: MODEL,
78
+ region: REGION,
79
+ credentials: {
80
+ accessKeyId: process.env.BEDROCK_AWS_ACCESS_KEY_ID!,
81
+ secretAccessKey: process.env.BEDROCK_AWS_SECRET_ACCESS_KEY!,
82
+ },
83
+ maxTokens: MAX_TOKENS,
84
+ streaming: true,
85
+ streamUsage: true,
86
+ } as t.LLMConfig;
87
+
88
+ const run = await Run.create<t.IState>({
89
+ runId: 'bedrock-truncation-live',
90
+ graphConfig: {
91
+ type: 'standard',
92
+ llmConfig,
93
+ tools: [createFile],
94
+ instructions: 'You are a ClickHouse assistant. Use tools when asked.',
95
+ maxContextTokens: 89000,
96
+ },
97
+ returnContent: true,
98
+ skipCleanup: true,
99
+ customHandlers: {
100
+ [GraphEvents.TOOL_END]: new ToolEndHandler(
101
+ async (toolEndData: t.ToolEndData) => {
102
+ const out = toolEndData.output as
103
+ | { content?: unknown; name?: string; status?: string }
104
+ | undefined;
105
+ const content =
106
+ typeof out?.content === 'string'
107
+ ? out.content.slice(0, 120)
108
+ : JSON.stringify(out?.content).slice(0, 120);
109
+ // eslint-disable-next-line no-console
110
+ console.log(
111
+ `TOOL_END name=${out?.name} status=${out?.status} content=${content}`
112
+ );
113
+ }
114
+ ),
115
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
116
+ [GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(),
117
+ [GraphEvents.ON_RUN_STEP]: {
118
+ handle: (_event: string, data: t.StreamEventData): void => {
119
+ const step = data as unknown as {
120
+ stepDetails?: {
121
+ type?: string;
122
+ tool_calls?: Array<{ name?: string; args?: unknown }>;
123
+ };
124
+ };
125
+ const tcs = step.stepDetails?.tool_calls;
126
+ if ((tcs?.length ?? 0) > 0 && tcs != null) {
127
+ for (const tc of tcs) {
128
+ if (tc.name === 'create_file') {
129
+ stepToolCalls.push(
130
+ typeof tc.args === 'string'
131
+ ? ['<raw-string>']
132
+ : Object.keys((tc.args ?? {}) as Record<string, unknown>)
133
+ );
134
+ }
135
+ }
136
+ // eslint-disable-next-line no-console
137
+ console.log(
138
+ 'ON_RUN_STEP tool_calls=',
139
+ JSON.stringify(
140
+ tcs.map((tc) => ({
141
+ name: tc.name,
142
+ args:
143
+ typeof tc.args === 'string'
144
+ ? tc.args.slice(0, 80)
145
+ : Object.keys(
146
+ (tc.args ?? {}) as Record<string, unknown>
147
+ ),
148
+ }))
149
+ )
150
+ );
151
+ }
152
+ },
153
+ },
154
+ },
155
+ });
156
+
157
+ const prompt =
158
+ 'Call create_file once: path="skills/clickhouse-demo/SKILL.md", content=a complete ' +
159
+ '3000-word ClickHouse guide (schema design, MergeTree, partitioning, 10 example queries). ' +
160
+ 'Put the entire guide in the content argument.';
161
+
162
+ let loopError: string | null = null;
163
+ try {
164
+ await run.processStream(
165
+ { messages: [new HumanMessage(prompt)] },
166
+ {
167
+ configurable: { provider: Providers.BEDROCK, thread_id: 'trunc' },
168
+ version: 'v2',
169
+ recursionLimit: 8,
170
+ }
171
+ );
172
+ } catch (err) {
173
+ loopError = err instanceof Error ? err.message : String(err);
174
+ }
175
+
176
+ // eslint-disable-next-line no-console
177
+ console.log('LIVE RESULT', {
178
+ invocations,
179
+ received,
180
+ stepToolCalls,
181
+ loopError,
182
+ });
183
+
184
+ // The run fails fast with the actionable truncation error...
185
+ expect(loopError).toMatch(/truncated at the maximum output token limit/i);
186
+ // ...instead of looping until the recursion limit.
187
+ expect(loopError).not.toMatch(/Recursion limit/i);
188
+ // No truncated create_file call ever carried a usable `content` arg.
189
+ expect(stepToolCalls.every((keys) => !keys.includes('content'))).toBe(true);
190
+ });
191
+ });
package/src/stream.ts CHANGED
@@ -127,6 +127,17 @@ function hasToolOutputReference(value: unknown): boolean {
127
127
  return false;
128
128
  }
129
129
 
130
+ function isEagerExecutionExcludedTool(
131
+ name: string,
132
+ graph: StandardGraph
133
+ ): boolean {
134
+ if (name === '') {
135
+ return false;
136
+ }
137
+ const excluded = graph.eagerEventToolExecution?.excludeToolNames;
138
+ return excluded != null && excluded.includes(name);
139
+ }
140
+
130
141
  function isDirectGraphTool(
131
142
  name: string,
132
143
  agentContext: AgentContext | undefined
@@ -615,7 +626,7 @@ function createEagerToolExecutionPlan(args: {
615
626
  return undefined;
616
627
  }
617
628
 
618
- const candidateToolCalls = skipExisting
629
+ const unstartedToolCalls = skipExisting
619
630
  ? toolCalls.filter((toolCall) => {
620
631
  if (toolCall.id == null || toolCall.id === '') {
621
632
  return true;
@@ -623,6 +634,13 @@ function createEagerToolExecutionPlan(args: {
623
634
  return !graph.eagerEventToolExecutions.has(toolCall.id);
624
635
  })
625
636
  : toolCalls;
637
+ // Drop host-excluded tools only AFTER the batch-level guards above have run
638
+ // against the full batch, so excluding a call never hides a sibling direct
639
+ // tool from `hasDirectToolCallInBatch`. Excluded calls fall through to normal
640
+ // ToolNode execution; siblings may still eager-execute.
641
+ const candidateToolCalls = unstartedToolCalls.filter(
642
+ (toolCall) => !isEagerExecutionExcludedTool(toolCall.name, graph)
643
+ );
626
644
  if (candidateToolCalls.length === 0) {
627
645
  return [];
628
646
  }
@@ -38,6 +38,16 @@ export type EagerEventToolExecutionConfig = {
38
38
  * ToolMessages so provider message ordering is preserved.
39
39
  */
40
40
  enabled?: boolean;
41
+ /**
42
+ * Tool names that must never be started eagerly. Eager execution
43
+ * speculates on tool args before the model turn commits, so
44
+ * side-effecting tools (e.g. file writes) should opt out: a
45
+ * speculative write can land even if the turn is superseded, and a
46
+ * later arg revision would otherwise trip the "changed after eager
47
+ * execution" guard. Excluded calls fall through to normal ToolNode
48
+ * execution with final args.
49
+ */
50
+ excludeToolNames?: string[];
41
51
  };
42
52
 
43
53
  export type EagerEventToolExecutionOutcome =