@librechat/agents 3.2.42 → 3.2.44

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.
Files changed (74) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +4 -3
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +1 -1
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
  6. package/dist/cjs/llm/bedrock/index.cjs +1 -1
  7. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  8. package/dist/cjs/main.cjs +2 -0
  9. package/dist/cjs/messages/cache.cjs +26 -4
  10. package/dist/cjs/messages/cache.cjs.map +1 -1
  11. package/dist/cjs/messages/reducer.cjs +15 -2
  12. package/dist/cjs/messages/reducer.cjs.map +1 -1
  13. package/dist/cjs/session/JsonlSessionStore.cjs +1 -1
  14. package/dist/cjs/tools/cloudflare/CloudflareSandboxExecutionEngine.cjs +32 -16
  15. package/dist/cjs/tools/cloudflare/CloudflareSandboxExecutionEngine.cjs.map +1 -1
  16. package/dist/cjs/tools/local/CompileCheckTool.cjs +11 -3
  17. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -1
  18. package/dist/cjs/tools/local/FileCheckpointer.cjs +8 -3
  19. package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -1
  20. package/dist/cjs/tools/local/LocalCodingTools.cjs +26 -7
  21. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -1
  22. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +2 -2
  23. package/dist/cjs/tools/local/syntaxCheck.cjs +6 -2
  24. package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -1
  25. package/dist/cjs/tools/local/workspaceFS.cjs +20 -0
  26. package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -1
  27. package/dist/esm/agents/AgentContext.mjs +5 -4
  28. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  29. package/dist/esm/graphs/Graph.mjs +2 -2
  30. package/dist/esm/graphs/Graph.mjs.map +1 -1
  31. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
  32. package/dist/esm/llm/bedrock/index.mjs +2 -2
  33. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  34. package/dist/esm/main.mjs +3 -3
  35. package/dist/esm/messages/cache.mjs +26 -5
  36. package/dist/esm/messages/cache.mjs.map +1 -1
  37. package/dist/esm/messages/reducer.mjs +15 -2
  38. package/dist/esm/messages/reducer.mjs.map +1 -1
  39. package/dist/esm/session/JsonlSessionStore.mjs +1 -1
  40. package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs +32 -17
  41. package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs.map +1 -1
  42. package/dist/esm/tools/local/CompileCheckTool.mjs +11 -3
  43. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -1
  44. package/dist/esm/tools/local/FileCheckpointer.mjs +9 -4
  45. package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -1
  46. package/dist/esm/tools/local/LocalCodingTools.mjs +26 -7
  47. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -1
  48. package/dist/esm/tools/local/LocalExecutionEngine.mjs +2 -2
  49. package/dist/esm/tools/local/syntaxCheck.mjs +6 -2
  50. package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -1
  51. package/dist/esm/tools/local/workspaceFS.mjs +19 -1
  52. package/dist/esm/tools/local/workspaceFS.mjs.map +1 -1
  53. package/dist/types/agents/AgentContext.d.ts +3 -2
  54. package/dist/types/messages/cache.d.ts +12 -2
  55. package/dist/types/tools/cloudflare/CloudflareSandboxExecutionEngine.d.ts +9 -0
  56. package/dist/types/tools/local/workspaceFS.d.ts +13 -0
  57. package/package.json +1 -1
  58. package/src/agents/AgentContext.ts +8 -3
  59. package/src/graphs/Graph.ts +7 -1
  60. package/src/llm/bedrock/index.ts +2 -2
  61. package/src/messages/cache.test.ts +32 -0
  62. package/src/messages/cache.ts +31 -5
  63. package/src/messages/reducer.spec.ts +32 -0
  64. package/src/messages/reducer.ts +23 -7
  65. package/src/specs/vllm-reasoning-toolcalls.test.ts +340 -0
  66. package/src/tools/__tests__/CloudflareSandboxExecution.test.ts +192 -0
  67. package/src/tools/__tests__/LocalExecutionTools.test.ts +54 -0
  68. package/src/tools/cloudflare/CloudflareSandboxExecutionEngine.ts +106 -30
  69. package/src/tools/local/CompileCheckTool.ts +19 -3
  70. package/src/tools/local/FileCheckpointer.ts +20 -4
  71. package/src/tools/local/LocalCodingTools.ts +61 -8
  72. package/src/tools/local/__tests__/FileCheckpointer.test.ts +42 -0
  73. package/src/tools/local/syntaxCheck.ts +14 -2
  74. package/src/tools/local/workspaceFS.ts +27 -0
@@ -28,6 +28,7 @@ import {
28
28
  syncBudgetDerivedFields,
29
29
  addTailCacheControl,
30
30
  resolvePromptCacheTtl,
31
+ resolveBedrockPromptCacheTtl,
31
32
  supportsBedrockToolCache,
32
33
  getMessageId,
33
34
  makeIsDeferred,
@@ -1880,9 +1881,14 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1880
1881
  const bedrockOptions = agentContext.clientOptions as
1881
1882
  | t.BedrockAnthropicClientOptions
1882
1883
  | undefined;
1884
+ // Non-Claude models (Nova) reject the extended 1h TTL, so resolve it
1885
+ // against the model — message/system caching stays on, clamped to 5m.
1883
1886
  finalMessages = addBedrockTailCacheControl<BaseMessage>(
1884
1887
  finalMessages,
1885
- resolvePromptCacheTtl(bedrockOptions?.promptCacheTtl)
1888
+ resolveBedrockPromptCacheTtl(
1889
+ bedrockOptions?.promptCacheTtl,
1890
+ (bedrockOptions as { model?: string } | undefined)?.model
1891
+ )
1886
1892
  );
1887
1893
  }
1888
1894
 
@@ -40,7 +40,7 @@ import {
40
40
  handleConverseStreamMetadata,
41
41
  } from './utils';
42
42
  import {
43
- resolvePromptCacheTtl,
43
+ resolveBedrockPromptCacheTtl,
44
44
  supportsBedrockToolCache,
45
45
  type PromptCacheTtl,
46
46
  } from '@/messages/cache';
@@ -184,7 +184,7 @@ export class CustomChatBedrockConverse extends ChatBedrockConverse {
184
184
  ? insertBedrockToolCachePoint(
185
185
  baseParams.toolConfig,
186
186
  true,
187
- resolvePromptCacheTtl(this.promptCacheTtl)
187
+ resolveBedrockPromptCacheTtl(this.promptCacheTtl, this.cacheModelId)
188
188
  )
189
189
  : baseParams.toolConfig;
190
190
 
@@ -19,6 +19,7 @@ import {
19
19
  buildAnthropicCacheControl,
20
20
  buildBedrockCachePoint,
21
21
  resolvePromptCacheTtl,
22
+ resolveBedrockPromptCacheTtl,
22
23
  supportsBedrockToolCache,
23
24
  DEFAULT_PROMPT_CACHE_TTL,
24
25
  } from './cache';
@@ -926,6 +927,37 @@ describe('supportsBedrockToolCache', () => {
926
927
  });
927
928
  });
928
929
 
930
+ describe('resolveBedrockPromptCacheTtl', () => {
931
+ test('Claude: defaults to 1h and honors an explicit ttl', () => {
932
+ const claude = 'us.anthropic.claude-sonnet-4-5-20250929-v1:0';
933
+ expect(resolveBedrockPromptCacheTtl(undefined, claude)).toBe('1h');
934
+ expect(resolveBedrockPromptCacheTtl('1h', claude)).toBe('1h');
935
+ expect(resolveBedrockPromptCacheTtl('5m', claude)).toBe('5m');
936
+ });
937
+
938
+ test('Nova: clamps to 5m even when 1h is configured (extended TTL Anthropic-only)', () => {
939
+ const nova = 'us.amazon.nova-lite-v1:0';
940
+ expect(resolveBedrockPromptCacheTtl(undefined, nova)).toBe('5m');
941
+ expect(resolveBedrockPromptCacheTtl('1h', nova)).toBe('5m');
942
+ expect(resolveBedrockPromptCacheTtl('5m', nova)).toBe('5m');
943
+ });
944
+
945
+ test('other non-Claude families clamp to 5m', () => {
946
+ expect(
947
+ resolveBedrockPromptCacheTtl('1h', 'meta.llama3-1-70b-instruct-v1:0')
948
+ ).toBe('5m');
949
+ expect(
950
+ resolveBedrockPromptCacheTtl('1h', 'mistral.mistral-large-2407-v1:0')
951
+ ).toBe('5m');
952
+ });
953
+
954
+ test('omitted model defaults to Claude behavior (1h)', () => {
955
+ expect(resolveBedrockPromptCacheTtl(undefined, undefined)).toBe('1h');
956
+ expect(resolveBedrockPromptCacheTtl(undefined, null)).toBe('1h');
957
+ expect(resolveBedrockPromptCacheTtl('1h', undefined)).toBe('1h');
958
+ });
959
+ });
960
+
929
961
  describe('stripAnthropicCacheControl', () => {
930
962
  it('removes cache_control fields from content blocks', () => {
931
963
  const messages: TestMsg[] = [
@@ -79,6 +79,16 @@ export function buildBedrockCachePoint(
79
79
  return ttl === '1h' ? { type: 'default', ttl: '1h' } : { type: 'default' };
80
80
  }
81
81
 
82
+ /**
83
+ * Whether a Bedrock model id is an Anthropic Claude model. Claude is the only
84
+ * Bedrock family that accepts the explicit cache features below; Amazon Nova and
85
+ * others reject them. Matches both the `anthropic.` and cross-region
86
+ * (`us.anthropic.`) / bare `claude` forms.
87
+ */
88
+ function isBedrockAnthropicModel(model: string | undefined | null): boolean {
89
+ return typeof model === 'string' && /claude|anthropic/i.test(model);
90
+ }
91
+
82
92
  /**
83
93
  * A `cachePoint` under `toolConfig.tools` is only accepted on Anthropic Claude
84
94
  * models. Amazon Nova rejects it outright — `Malformed input request:
@@ -89,8 +99,7 @@ export function buildBedrockCachePoint(
89
99
  *
90
100
  * Gate ONLY the tool checkpoint on this, so a `promptCache: true` config on Nova
91
101
  * keeps its valid message/system caching instead of either 400-ing (tool point)
92
- * or losing caching entirely. This is a capability gate (the key is rejected
93
- * outright), distinct from the TTL value which Bedrock downgrades gracefully.
102
+ * or losing caching entirely.
94
103
  *
95
104
  * @see https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html
96
105
  * @see https://github.com/danny-avila/LibreChat/issues/13838
@@ -98,10 +107,27 @@ export function buildBedrockCachePoint(
98
107
  export function supportsBedrockToolCache(
99
108
  model: string | undefined | null
100
109
  ): boolean {
101
- if (typeof model !== 'string') {
102
- return false;
110
+ return isBedrockAnthropicModel(model);
111
+ }
112
+
113
+ /**
114
+ * Resolve the Bedrock `cachePoint` TTL for a model. The extended `1h` TTL is
115
+ * Anthropic-only: non-Claude models (e.g. Nova) hard-reject it with
116
+ * `Extended TTL prompt caching is only supported for Anthropic models`, so they
117
+ * are clamped to `5m` even when `1h` is configured (verified live). An omitted
118
+ * model defaults to a Claude model, and Claude downgrades an unsupported `1h` to
119
+ * `5m` server-side, so the `1h` default is safe for the Claude/undefined paths.
120
+ *
121
+ * @see https://github.com/danny-avila/LibreChat/issues/13838
122
+ */
123
+ export function resolveBedrockPromptCacheTtl(
124
+ ttl: PromptCacheTtl | undefined,
125
+ model: string | undefined | null
126
+ ): PromptCacheTtl {
127
+ if (model != null && !isBedrockAnthropicModel(model)) {
128
+ return '5m';
103
129
  }
104
- return /claude|anthropic/i.test(model);
130
+ return resolvePromptCacheTtl(ttl);
105
131
  }
106
132
 
107
133
  /**
@@ -0,0 +1,32 @@
1
+ import { HumanMessage } from '@langchain/core/messages';
2
+ import { messagesStateReducer } from './reducer';
3
+
4
+ describe('messagesStateReducer', () => {
5
+ it('drops null/undefined entries and preserves order', () => {
6
+ const a = new HumanMessage({ id: 'a', content: 'a' });
7
+ const b = new HumanMessage({ id: 'b', content: 'b' });
8
+ const c = new HumanMessage({ id: 'c', content: 'c' });
9
+
10
+ const result = messagesStateReducer(
11
+ [a, null, b] as never,
12
+ [undefined, c] as never
13
+ );
14
+
15
+ expect(result.map((m) => m.id)).toEqual(['a', 'b', 'c']);
16
+ });
17
+
18
+ it('does not throw when every entry is null/undefined', () => {
19
+ expect(() =>
20
+ messagesStateReducer(
21
+ [null, undefined] as never,
22
+ [undefined, null] as never
23
+ )
24
+ ).not.toThrow();
25
+
26
+ const result = messagesStateReducer(
27
+ [null, undefined] as never,
28
+ [undefined, null] as never
29
+ );
30
+ expect(result).toEqual([]);
31
+ });
32
+ });
@@ -34,6 +34,26 @@ export type Messages =
34
34
  | BaseMessage
35
35
  | BaseMessageLike;
36
36
 
37
+ /**
38
+ * Coerce each entry to a {@link BaseMessage} in a single pass, skipping
39
+ * null/undefined entries. Providers can emit empty/partial stream chunks that
40
+ * arrive as `undefined`; passing those to `coerceMessageLikeToMessage` throws
41
+ * "Cannot read properties of undefined (reading 'role')" and crashes the run.
42
+ * Folding the null check into the coercion loop avoids a second pass over the
43
+ * array. Refs LibreChat Discussion #12284.
44
+ */
45
+ function coerceMessages(
46
+ messages: ReadonlyArray<BaseMessageLike | null | undefined>
47
+ ): BaseMessage[] {
48
+ const coerced: BaseMessage[] = [];
49
+ for (const message of messages) {
50
+ if (message != null) {
51
+ coerced.push(coerceMessageLikeToMessage(message));
52
+ }
53
+ }
54
+ return coerced;
55
+ }
56
+
37
57
  /**
38
58
  * Prebuilt reducer that combines returned messages.
39
59
  * Can handle standard messages and special modifiers like {@link RemoveMessage}
@@ -45,13 +65,9 @@ export function messagesStateReducer(
45
65
  ): BaseMessage[] {
46
66
  const leftArray = Array.isArray(left) ? left : [left];
47
67
  const rightArray = Array.isArray(right) ? right : [right];
48
- // coerce to message
49
- const leftMessages = (leftArray as BaseMessageLike[]).map(
50
- coerceMessageLikeToMessage
51
- );
52
- const rightMessages = (rightArray as BaseMessageLike[]).map(
53
- coerceMessageLikeToMessage
54
- );
68
+ // coerce to message, skipping null/undefined entries in the same pass
69
+ const leftMessages = coerceMessages(leftArray as BaseMessageLike[]);
70
+ const rightMessages = coerceMessages(rightArray as BaseMessageLike[]);
55
71
  // assign missing ids
56
72
  for (const m of leftMessages) {
57
73
  if (m.id === null || m.id === undefined) {
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Regression coverage for a generic `custom` OpenAI-compatible endpoint
3
+ * (provider `openai`, non-OpenAI `baseURL`, default `reasoningKey`,
4
+ * non-standard model name) that streams reasoning in the modern vLLM
5
+ * `reasoning` field — `reasoning_content` is `null` throughout — and streams
6
+ * `tool_calls` with fragmented arguments (vLLM `--reasoning-parser qwen3` +
7
+ * `--tool-call-parser qwen3_coder`).
8
+ *
9
+ * A non-OpenAI `baseURL` is required so the model stays on the custom-endpoint
10
+ * (final-signal) path rather than the official-OpenAI sequential-seal path.
11
+ *
12
+ * The wire shapes mirror the captures in LibreChat discussion #13849:
13
+ * - reasoning must surface as `think` content (the Thoughts block), and
14
+ * - the fragmented streamed tool call must be assembled by the graph into a
15
+ * structured call and executed.
16
+ */
17
+ import { DynamicStructuredTool } from '@langchain/core/tools';
18
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
19
+ import type { UsageMetadata } from '@langchain/core/messages';
20
+ import type { OpenAIClient } from '@langchain/openai';
21
+ import type * as t from '@/types';
22
+ import { ContentTypes, GraphEvents, Providers, StepTypes } from '@/common';
23
+ import { ToolEndHandler, ModelEndHandler } from '@/events';
24
+ import { createContentAggregator } from '@/stream';
25
+ import { ChatOpenAI } from '@/llm/openai';
26
+ import { Run } from '@/run';
27
+
28
+ type CompletionChunk = OpenAIClient.Chat.Completions.ChatCompletionChunk;
29
+ type CompletionChoice =
30
+ OpenAIClient.Chat.Completions.ChatCompletionChunk.Choice;
31
+ type CompletionDelta = CompletionChoice['delta'] & {
32
+ reasoning?: string | null;
33
+ reasoning_content?: string | null;
34
+ };
35
+ type StreamingCompletions = {
36
+ completionWithRetry: () => Promise<AsyncIterable<CompletionChunk>>;
37
+ };
38
+
39
+ const MODEL = 'custom_llm_thinking';
40
+ const BASE_URL = 'http://vllm.internal:8000/v1';
41
+
42
+ function completionChunk(
43
+ delta: CompletionDelta,
44
+ finishReason: CompletionChoice['finish_reason'] = null
45
+ ): CompletionChunk {
46
+ return {
47
+ id: 'chatcmpl-vllm',
48
+ object: 'chat.completion.chunk',
49
+ created: 0,
50
+ model: MODEL,
51
+ choices: [{ index: 0, delta, finish_reason: finishReason }],
52
+ };
53
+ }
54
+
55
+ function customEndpointModel(): ChatOpenAI {
56
+ return new ChatOpenAI({
57
+ model: MODEL,
58
+ apiKey: 'test-key',
59
+ streaming: true,
60
+ configuration: { baseURL: BASE_URL },
61
+ });
62
+ }
63
+
64
+ function setCompletionStream(
65
+ model: ChatOpenAI,
66
+ stream: () => AsyncIterable<CompletionChunk>
67
+ ): void {
68
+ (
69
+ model as unknown as { completions: StreamingCompletions }
70
+ ).completions.completionWithRetry = async () => stream();
71
+ }
72
+
73
+ describe('custom OpenAI-compatible endpoint (vLLM reasoning + qwen3_coder tool calls)', () => {
74
+ const config = {
75
+ configurable: { thread_id: 'vllm-reasoning-toolcalls' },
76
+ streamMode: 'values' as const,
77
+ version: 'v2' as const,
78
+ };
79
+
80
+ it('renders reasoning from delta.reasoning when reasoning_content is null', async () => {
81
+ const reasoningTokens = [
82
+ 'Here',
83
+ '\'s a thinking',
84
+ ' process: 17*23',
85
+ ' = 391',
86
+ ];
87
+ const contentTokens = ['\n\nUm ', '17 ×', ' 23 = **391', '**.'];
88
+
89
+ const reasoningDeltas: t.ReasoningDeltaEvent[] = [];
90
+ const messageDeltas: t.MessageDeltaEvent[] = [];
91
+ const { contentParts, aggregateContent } = createContentAggregator();
92
+
93
+ const run = await Run.create<t.IState>({
94
+ runId: 'vllm-reasoning',
95
+ graphConfig: {
96
+ type: 'standard',
97
+ llmConfig: { provider: Providers.OPENAI, streamUsage: false },
98
+ },
99
+ returnContent: true,
100
+ skipCleanup: true,
101
+ customHandlers: {
102
+ [GraphEvents.ON_RUN_STEP]: {
103
+ handle: (
104
+ event: GraphEvents.ON_RUN_STEP,
105
+ data: t.StreamEventData
106
+ ): void => aggregateContent({ event, data: data as t.RunStep }),
107
+ },
108
+ [GraphEvents.ON_MESSAGE_DELTA]: {
109
+ handle: (
110
+ event: GraphEvents.ON_MESSAGE_DELTA,
111
+ data: t.StreamEventData
112
+ ): void => {
113
+ messageDeltas.push(data as t.MessageDeltaEvent);
114
+ aggregateContent({ event, data: data as t.MessageDeltaEvent });
115
+ },
116
+ },
117
+ [GraphEvents.ON_REASONING_DELTA]: {
118
+ handle: (
119
+ event: GraphEvents.ON_REASONING_DELTA,
120
+ data: t.StreamEventData
121
+ ): void => {
122
+ reasoningDeltas.push(data as t.ReasoningDeltaEvent);
123
+ aggregateContent({ event, data: data as t.ReasoningDeltaEvent });
124
+ },
125
+ },
126
+ },
127
+ });
128
+ if (!run.Graph) {
129
+ throw new Error('Expected graph to be initialized');
130
+ }
131
+
132
+ const model = customEndpointModel();
133
+ setCompletionStream(
134
+ model,
135
+ async function* streamChunks(): AsyncGenerator<CompletionChunk> {
136
+ yield completionChunk({ role: 'assistant', content: '' });
137
+ for (const reasoning of reasoningTokens) {
138
+ yield completionChunk({
139
+ reasoning,
140
+ reasoning_content: null,
141
+ content: null,
142
+ });
143
+ }
144
+ for (let i = 0; i < contentTokens.length; i++) {
145
+ yield completionChunk(
146
+ {
147
+ reasoning: null,
148
+ reasoning_content: null,
149
+ content: contentTokens[i],
150
+ },
151
+ i === contentTokens.length - 1 ? 'stop' : null
152
+ );
153
+ }
154
+ }
155
+ );
156
+ run.Graph.overrideModel = model as t.ChatModel;
157
+
158
+ await run.processStream(
159
+ {
160
+ messages: [
161
+ new HumanMessage('Was ist 17*23? Denk Schritt für Schritt.'),
162
+ ],
163
+ },
164
+ config
165
+ );
166
+
167
+ const thoughts = reasoningDeltas
168
+ .flatMap((delta) => delta.delta.content ?? [])
169
+ .map((part) => (part as { think?: string }).think ?? '')
170
+ .join('');
171
+ const answer = messageDeltas
172
+ .flatMap((delta) => delta.delta.content ?? [])
173
+ .map((part) => (part as { text?: string }).text ?? '')
174
+ .join('');
175
+
176
+ expect(reasoningDeltas).toHaveLength(reasoningTokens.length);
177
+ expect(thoughts).toBe(reasoningTokens.join(''));
178
+ expect(answer).toBe(contentTokens.join(''));
179
+ expect(contentParts.map((part) => part?.type)).toEqual([
180
+ ContentTypes.THINK,
181
+ ContentTypes.TEXT,
182
+ ]);
183
+ });
184
+
185
+ it('assembles fragmented streamed tool_calls and executes the tool through the graph', async () => {
186
+ let weatherArgs: unknown;
187
+ const getWeather = new DynamicStructuredTool({
188
+ name: 'get_weather',
189
+ description: 'Get the current weather for a location',
190
+ schema: {
191
+ type: 'object',
192
+ properties: { location: { type: 'string' } },
193
+ required: ['location'],
194
+ },
195
+ func: async (input: unknown): Promise<string> => {
196
+ weatherArgs = input;
197
+ return JSON.stringify({
198
+ location: 'Berlin',
199
+ summary: 'sunny',
200
+ temp_c: 18,
201
+ });
202
+ },
203
+ });
204
+
205
+ const { aggregateContent } = createContentAggregator();
206
+ const collectedUsage: UsageMetadata[] = [];
207
+ const runSteps: t.RunStep[] = [];
208
+ const streamedToolArgs: string[] = [];
209
+
210
+ const run = await Run.create<t.IState>({
211
+ runId: 'vllm-tool-calls',
212
+ graphConfig: {
213
+ type: 'standard',
214
+ llmConfig: { provider: Providers.OPENAI, streamUsage: false },
215
+ tools: [getWeather],
216
+ },
217
+ returnContent: true,
218
+ skipCleanup: true,
219
+ customHandlers: {
220
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
221
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(collectedUsage),
222
+ [GraphEvents.ON_RUN_STEP]: {
223
+ handle: (
224
+ event: GraphEvents.ON_RUN_STEP,
225
+ data: t.StreamEventData
226
+ ): void => {
227
+ runSteps.push(data as t.RunStep);
228
+ aggregateContent({ event, data: data as t.RunStep });
229
+ },
230
+ },
231
+ [GraphEvents.ON_RUN_STEP_DELTA]: {
232
+ handle: (
233
+ _event: GraphEvents.ON_RUN_STEP_DELTA,
234
+ data: t.StreamEventData
235
+ ): void => {
236
+ const { delta } = data as t.RunStepDeltaEvent;
237
+ if (
238
+ delta.type !== StepTypes.TOOL_CALLS ||
239
+ delta.tool_calls == null
240
+ ) {
241
+ return;
242
+ }
243
+ for (const toolCallDelta of delta.tool_calls) {
244
+ if (typeof toolCallDelta.args === 'string') {
245
+ streamedToolArgs.push(toolCallDelta.args);
246
+ }
247
+ }
248
+ },
249
+ },
250
+ [GraphEvents.ON_RUN_STEP_COMPLETED]: {
251
+ handle: (
252
+ event: GraphEvents.ON_RUN_STEP_COMPLETED,
253
+ data: t.StreamEventData
254
+ ): void =>
255
+ aggregateContent({
256
+ event,
257
+ data: data as unknown as { result: t.ToolEndEvent },
258
+ }),
259
+ },
260
+ [GraphEvents.ON_MESSAGE_DELTA]: {
261
+ handle: (
262
+ event: GraphEvents.ON_MESSAGE_DELTA,
263
+ data: t.StreamEventData
264
+ ): void =>
265
+ aggregateContent({ event, data: data as t.MessageDeltaEvent }),
266
+ },
267
+ },
268
+ });
269
+ if (!run.Graph) {
270
+ throw new Error('Expected graph to be initialized');
271
+ }
272
+
273
+ const model = customEndpointModel();
274
+ async function* toolCallStream(): AsyncGenerator<CompletionChunk> {
275
+ yield completionChunk({ role: 'assistant', content: '\n\n' });
276
+ yield completionChunk({
277
+ tool_calls: [
278
+ {
279
+ id: 'call_0065144d618f4f33be1491af',
280
+ type: 'function',
281
+ index: 0,
282
+ function: { name: 'get_weather', arguments: '' },
283
+ },
284
+ ],
285
+ });
286
+ yield completionChunk({
287
+ tool_calls: [{ index: 0, function: { arguments: '{' } }],
288
+ });
289
+ yield completionChunk({
290
+ tool_calls: [
291
+ { index: 0, function: { arguments: '"location": "Berlin"' } },
292
+ ],
293
+ });
294
+ yield completionChunk(
295
+ { tool_calls: [{ index: 0, function: { arguments: '}' } }] },
296
+ 'tool_calls'
297
+ );
298
+ }
299
+ async function* finalAnswerStream(): AsyncGenerator<CompletionChunk> {
300
+ yield completionChunk({
301
+ role: 'assistant',
302
+ content: 'Das Wetter in Berlin ist sonnig.',
303
+ });
304
+ yield completionChunk({ content: '' }, 'stop');
305
+ }
306
+ let modelCall = 0;
307
+ setCompletionStream(model, () => {
308
+ modelCall += 1;
309
+ return modelCall === 1 ? toolCallStream() : finalAnswerStream();
310
+ });
311
+ run.Graph.overrideModel = model as t.ChatModel;
312
+
313
+ await run.processStream(
314
+ { messages: [new HumanMessage('Wie ist das Wetter in Berlin?')] },
315
+ config
316
+ );
317
+
318
+ const toolCallStepNames = runSteps
319
+ .filter((step) => step.stepDetails.type === StepTypes.TOOL_CALLS)
320
+ .flatMap(
321
+ (step) => (step.stepDetails as t.ToolCallsDetails).tool_calls ?? []
322
+ )
323
+ .map((call) => ('function' in call ? call.function.name : call.name));
324
+
325
+ const messages = run.getRunMessages() ?? [];
326
+ const finalAnswer = messages
327
+ .filter((message): message is AIMessage => message.getType() === 'ai')
328
+ .map((message) =>
329
+ typeof message.content === 'string' ? message.content : ''
330
+ )
331
+ .join('');
332
+
333
+ expect(modelCall).toBe(2);
334
+ expect(toolCallStepNames).toContain('get_weather');
335
+ expect(streamedToolArgs.join('')).toBe('{"location": "Berlin"}');
336
+ expect(weatherArgs).toEqual({ location: 'Berlin' });
337
+ expect(messages.some((message) => message.getType() === 'tool')).toBe(true);
338
+ expect(finalAnswer).toContain('Das Wetter in Berlin ist sonnig.');
339
+ });
340
+ });