From a771d70b10515a2a28d579ebfd8e6bb1e97bc687 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 7 Feb 2026 03:09:55 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=AC=20fix:=20Code=20Session=20Context?= =?UTF-8?q?=20In=20Event=20Driven=20Mode=20(#11673)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Update parseTextParts to handle undefined content parts - Modified the parseTextParts function to accept an array of content parts that may include undefined values. - Implemented optional chaining to safely check for the type of each part, preventing potential runtime errors when accessing properties of undefined elements. * refactor: Tool Call Configuration with Session Context - Added support for including session ID and injected files in the tool call configuration when a code session context is present. - Improved handling of tool call configurations to accommodate additional context data, enhancing the functionality of the tool execution handler. * chore: Update @librechat/agents to version 3.1.37 in package.json and package-lock.json * test: Add unit tests for createToolExecuteHandler - Introduced a new test suite for the createToolExecuteHandler function, validating the handling of session context in tool calls. - Added tests to ensure correct passing of session IDs and injected files based on the presence of codeSessionContext. - Included scenarios for handling multiple tool calls and ensuring non-code execution tools are unaffected by session context. * test: Update createToolExecuteHandler tests for session context handling - Renamed test to clarify that it checks for the absence of session context in non-code-execution tools. - Updated assertions to ensure that session_id and _injected_files are undefined when non-code-execution tools are invoked, enhancing test accuracy. --- api/package.json | 2 +- package-lock.json | 10 +- packages/api/package.json | 2 +- packages/api/src/agents/handlers.spec.ts | 178 +++++++++++++++++++ packages/api/src/agents/handlers.ts | 11 ++ packages/data-provider/specs/parsers.spec.ts | 104 ++++++++++- packages/data-provider/src/parsers.ts | 4 +- 7 files changed, 294 insertions(+), 17 deletions(-) create mode 100644 packages/api/src/agents/handlers.spec.ts diff --git a/api/package.json b/api/package.json index 8cb95ef50b..f31ddfe5ca 100644 --- a/api/package.json +++ b/api/package.json @@ -44,7 +44,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.36", + "@librechat/agents": "^3.1.37", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/package-lock.json b/package-lock.json index 69a7058a1c..ba61959041 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.36", + "@librechat/agents": "^3.1.37", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -11207,9 +11207,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.1.36", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.36.tgz", - "integrity": "sha512-JC99+1KviR/WXov8cIqfkVfpWEWz3wqFxWZxYXgQWyHyO8IzDlhrdhiMKFBt03e5B+ebcjIMYz9OsjKSoOru2Q==", + "version": "3.1.37", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.37.tgz", + "integrity": "sha512-179dSddx8uQcJFLu5LMhZQckIQHoV3kmkJj+py6uewGNlf9gsmG6M8JYi6i65Y4X73u05KRKUtO9U+n3Z85dOw==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", @@ -42102,7 +42102,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.36", + "@librechat/agents": "^3.1.37", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.26.0", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/package.json b/packages/api/package.json index f77532159e..18bb9ec5a4 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -87,7 +87,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.36", + "@librechat/agents": "^3.1.37", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.26.0", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts new file mode 100644 index 0000000000..5b8072f743 --- /dev/null +++ b/packages/api/src/agents/handlers.spec.ts @@ -0,0 +1,178 @@ +import { Constants } from '@librechat/agents'; +import type { + ToolExecuteBatchRequest, + ToolExecuteResult, + ToolCallRequest, +} from '@librechat/agents'; +import { createToolExecuteHandler, ToolExecuteOptions } from './handlers'; + +function createMockTool(name: string, capturedConfigs: Record[]) { + return { + name, + invoke: jest.fn(async (_args: unknown, config: Record) => { + capturedConfigs.push({ ...(config.toolCall as Record) }); + return { + content: `stdout:\n${name} executed\n`, + artifact: { session_id: `result-session-${name}`, files: [] }, + }; + }), + }; +} + +function createHandler( + capturedConfigs: Record[], + toolNames: string[] = [Constants.EXECUTE_CODE], +) { + const mockTools = toolNames.map((name) => createMockTool(name, capturedConfigs)); + const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({ + loadedTools: mockTools as never[], + })); + return createToolExecuteHandler({ loadTools }); +} + +function invokeHandler( + handler: ReturnType, + toolCalls: ToolCallRequest[], +): Promise { + return new Promise((resolve, reject) => { + const request: ToolExecuteBatchRequest = { + toolCalls, + resolve, + reject, + }; + handler.handle('on_tool_execute', request); + }); +} + +describe('createToolExecuteHandler', () => { + describe('code execution session context passthrough', () => { + it('passes session_id and _injected_files from codeSessionContext to toolCallConfig', async () => { + const capturedConfigs: Record[] = []; + const handler = createHandler(capturedConfigs); + + const toolCalls: ToolCallRequest[] = [ + { + id: 'call_1', + name: Constants.EXECUTE_CODE, + args: { lang: 'python', code: 'print("hi")' }, + codeSessionContext: { + session_id: 'prev-session-abc', + files: [ + { session_id: 'prev-session-abc', id: 'f1', name: 'data.parquet' }, + { session_id: 'prev-session-abc', id: 'f2', name: 'chart.png' }, + ], + }, + }, + ]; + + await invokeHandler(handler, toolCalls); + + expect(capturedConfigs).toHaveLength(1); + expect(capturedConfigs[0].session_id).toBe('prev-session-abc'); + expect(capturedConfigs[0]._injected_files).toEqual([ + { session_id: 'prev-session-abc', id: 'f1', name: 'data.parquet' }, + { session_id: 'prev-session-abc', id: 'f2', name: 'chart.png' }, + ]); + }); + + it('passes session_id without _injected_files when session has no files', async () => { + const capturedConfigs: Record[] = []; + const handler = createHandler(capturedConfigs); + + const toolCalls: ToolCallRequest[] = [ + { + id: 'call_2', + name: Constants.EXECUTE_CODE, + args: { lang: 'python', code: 'import pandas' }, + codeSessionContext: { + session_id: 'session-no-files', + }, + }, + ]; + + await invokeHandler(handler, toolCalls); + + expect(capturedConfigs).toHaveLength(1); + expect(capturedConfigs[0].session_id).toBe('session-no-files'); + expect(capturedConfigs[0]._injected_files).toBeUndefined(); + }); + + it('does not inject session context when codeSessionContext is absent', async () => { + const capturedConfigs: Record[] = []; + const handler = createHandler(capturedConfigs); + + const toolCalls: ToolCallRequest[] = [ + { + id: 'call_3', + name: Constants.EXECUTE_CODE, + args: { lang: 'python', code: 'x = 1' }, + }, + ]; + + await invokeHandler(handler, toolCalls); + + expect(capturedConfigs).toHaveLength(1); + expect(capturedConfigs[0].session_id).toBeUndefined(); + expect(capturedConfigs[0]._injected_files).toBeUndefined(); + }); + + it('passes session context independently for multiple code execution calls', async () => { + const capturedConfigs: Record[] = []; + const handler = createHandler(capturedConfigs); + + const toolCalls: ToolCallRequest[] = [ + { + id: 'call_a', + name: Constants.EXECUTE_CODE, + args: { lang: 'python', code: 'step_1()' }, + codeSessionContext: { + session_id: 'session-A', + files: [{ session_id: 'session-A', id: 'fa', name: 'a.csv' }], + }, + }, + { + id: 'call_b', + name: Constants.EXECUTE_CODE, + args: { lang: 'python', code: 'step_2()' }, + codeSessionContext: { + session_id: 'session-A', + files: [{ session_id: 'session-A', id: 'fa', name: 'a.csv' }], + }, + }, + ]; + + await invokeHandler(handler, toolCalls); + + expect(capturedConfigs).toHaveLength(2); + for (const config of capturedConfigs) { + expect(config.session_id).toBe('session-A'); + expect(config._injected_files).toEqual([ + { session_id: 'session-A', id: 'fa', name: 'a.csv' }, + ]); + } + }); + + it('does not pass session context to non-code-execution tools', async () => { + const capturedConfigs: Record[] = []; + const handler = createHandler(capturedConfigs, ['web_search']); + + const toolCalls: ToolCallRequest[] = [ + { + id: 'call_ws', + name: 'web_search', + args: { query: 'test' }, + codeSessionContext: { + session_id: 'should-be-ignored', + files: [{ session_id: 'x', id: 'y', name: 'z' }], + }, + }, + ]; + + await invokeHandler(handler, toolCalls); + + expect(capturedConfigs).toHaveLength(1); + expect(capturedConfigs[0].session_id).toBeUndefined(); + expect(capturedConfigs[0]._injected_files).toBeUndefined(); + }); + }); +}); diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 38f0ffb1f7..62200b1a46 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -85,6 +85,17 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand turn: tc.turn, }; + if ( + tc.codeSessionContext && + (tc.name === Constants.EXECUTE_CODE || + tc.name === Constants.PROGRAMMATIC_TOOL_CALLING) + ) { + toolCallConfig.session_id = tc.codeSessionContext.session_id; + if (tc.codeSessionContext.files && tc.codeSessionContext.files.length > 0) { + toolCallConfig._injected_files = tc.codeSessionContext.files; + } + } + if (tc.name === Constants.PROGRAMMATIC_TOOL_CALLING) { const toolRegistry = mergedConfigurable?.toolRegistry as LCToolRegistry | undefined; const ptcToolMap = mergedConfigurable?.ptcToolMap as diff --git a/packages/data-provider/specs/parsers.spec.ts b/packages/data-provider/specs/parsers.spec.ts index f506d16c6c..012d33177b 100644 --- a/packages/data-provider/specs/parsers.spec.ts +++ b/packages/data-provider/specs/parsers.spec.ts @@ -1,6 +1,8 @@ -import { replaceSpecialVars, parseCompactConvo } from '../src/parsers'; +import { replaceSpecialVars, parseCompactConvo, parseTextParts } from '../src/parsers'; import { specialVariables } from '../src/config'; import { EModelEndpoint } from '../src/schemas'; +import { ContentTypes } from '../src/types/runs'; +import type { TMessageContentParts } from '../src/types/assistants'; import type { TUser, TConversation } from '../src/types'; // Mock dayjs module with consistent date/time values regardless of environment @@ -141,7 +143,7 @@ describe('parseCompactConvo', () => { }); expect(result).not.toBeNull(); - expect(result?.iconURL).toBeUndefined(); + expect(result?.['iconURL']).toBeUndefined(); expect(result?.model).toBe('gpt-4'); }); @@ -159,7 +161,7 @@ describe('parseCompactConvo', () => { }); expect(result).not.toBeNull(); - expect(result?.iconURL).toBeUndefined(); + expect(result?.['iconURL']).toBeUndefined(); expect(result?.agent_id).toBe('agent_123'); }); @@ -177,7 +179,7 @@ describe('parseCompactConvo', () => { }); expect(result).not.toBeNull(); - expect(result?.iconURL).toBeUndefined(); + expect(result?.['iconURL']).toBeUndefined(); expect(result?.model).toBe('claude-3-opus'); }); @@ -195,7 +197,7 @@ describe('parseCompactConvo', () => { }); expect(result).not.toBeNull(); - expect(result?.iconURL).toBeUndefined(); + expect(result?.['iconURL']).toBeUndefined(); expect(result?.model).toBe('gemini-pro'); }); @@ -213,7 +215,7 @@ describe('parseCompactConvo', () => { }); expect(result).not.toBeNull(); - expect(result?.iconURL).toBeUndefined(); + expect(result?.['iconURL']).toBeUndefined(); expect(result?.assistant_id).toBe('asst_123'); }); @@ -234,7 +236,7 @@ describe('parseCompactConvo', () => { }); expect(result).not.toBeNull(); - expect(result?.iconURL).toBeUndefined(); + expect(result?.['iconURL']).toBeUndefined(); expect(result?.model).toBe('gpt-4'); expect(result?.temperature).toBe(0.7); expect(result?.top_p).toBe(0.9); @@ -254,8 +256,94 @@ describe('parseCompactConvo', () => { }); expect(result).not.toBeNull(); - expect(result?.iconURL).toBeUndefined(); + expect(result?.['iconURL']).toBeUndefined(); expect(result?.model).toBe('gpt-4'); }); }); }); + +describe('parseTextParts', () => { + test('should concatenate text parts', () => { + const parts: TMessageContentParts[] = [ + { type: ContentTypes.TEXT, text: 'Hello' }, + { type: ContentTypes.TEXT, text: 'World' }, + ]; + expect(parseTextParts(parts)).toBe('Hello World'); + }); + + test('should handle text parts with object-style text values', () => { + const parts: TMessageContentParts[] = [ + { type: ContentTypes.TEXT, text: { value: 'structured text' } }, + ]; + expect(parseTextParts(parts)).toBe('structured text'); + }); + + test('should include think parts by default', () => { + const parts: TMessageContentParts[] = [ + { type: ContentTypes.TEXT, text: 'Answer:' }, + { type: ContentTypes.THINK, think: 'reasoning step' }, + ]; + expect(parseTextParts(parts)).toBe('Answer: reasoning step'); + }); + + test('should skip think parts when skipReasoning is true', () => { + const parts: TMessageContentParts[] = [ + { type: ContentTypes.THINK, think: 'internal reasoning' }, + { type: ContentTypes.TEXT, text: 'visible answer' }, + ]; + expect(parseTextParts(parts, true)).toBe('visible answer'); + }); + + test('should skip non-text/think part types', () => { + const parts: TMessageContentParts[] = [ + { type: ContentTypes.TEXT, text: 'before' }, + { type: ContentTypes.IMAGE_FILE } as TMessageContentParts, + { type: ContentTypes.TEXT, text: 'after' }, + ]; + expect(parseTextParts(parts)).toBe('before after'); + }); + + test('should handle undefined elements in the content parts array', () => { + const parts: Array = [ + { type: ContentTypes.TEXT, text: 'first' }, + undefined, + { type: ContentTypes.TEXT, text: 'third' }, + ]; + expect(parseTextParts(parts)).toBe('first third'); + }); + + test('should handle multiple consecutive undefined elements', () => { + const parts: Array = [ + undefined, + undefined, + { type: ContentTypes.TEXT, text: 'only text' }, + undefined, + ]; + expect(parseTextParts(parts)).toBe('only text'); + }); + + test('should handle an array of all undefined elements', () => { + const parts: Array = [undefined, undefined, undefined]; + expect(parseTextParts(parts)).toBe(''); + }); + + test('should handle parts with missing type property', () => { + const parts: Array = [ + { text: 'no type field' } as unknown as TMessageContentParts, + { type: ContentTypes.TEXT, text: 'valid' }, + ]; + expect(parseTextParts(parts)).toBe('valid'); + }); + + test('should return empty string for empty array', () => { + expect(parseTextParts([])).toBe(''); + }); + + test('should not add extra spaces when parts already have spacing', () => { + const parts: TMessageContentParts[] = [ + { type: ContentTypes.TEXT, text: 'Hello ' }, + { type: ContentTypes.TEXT, text: 'World' }, + ]; + expect(parseTextParts(parts)).toBe('Hello World'); + }); +}); diff --git a/packages/data-provider/src/parsers.ts b/packages/data-provider/src/parsers.ts index f7add8bc1d..ff47375769 100644 --- a/packages/data-provider/src/parsers.ts +++ b/packages/data-provider/src/parsers.ts @@ -349,13 +349,13 @@ export const parseCompactConvo = ({ }; export function parseTextParts( - contentParts: a.TMessageContentParts[], + contentParts: Array, skipReasoning: boolean = false, ): string { let result = ''; for (const part of contentParts) { - if (!part.type) { + if (!part?.type) { continue; } if (part.type === ContentTypes.TEXT) {