mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-08 02:24:24 +01:00
🎬 fix: Code Session Context In Event Driven Mode (#11673)
* 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.
This commit is contained in:
parent
968e97b4d2
commit
a771d70b10
7 changed files with 294 additions and 17 deletions
|
|
@ -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",
|
||||
|
|
|
|||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
178
packages/api/src/agents/handlers.spec.ts
Normal file
178
packages/api/src/agents/handlers.spec.ts
Normal file
|
|
@ -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<string, unknown>[]) {
|
||||
return {
|
||||
name,
|
||||
invoke: jest.fn(async (_args: unknown, config: Record<string, unknown>) => {
|
||||
capturedConfigs.push({ ...(config.toolCall as Record<string, unknown>) });
|
||||
return {
|
||||
content: `stdout:\n${name} executed\n`,
|
||||
artifact: { session_id: `result-session-${name}`, files: [] },
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createHandler(
|
||||
capturedConfigs: Record<string, unknown>[],
|
||||
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<typeof createToolExecuteHandler>,
|
||||
toolCalls: ToolCallRequest[],
|
||||
): Promise<ToolExecuteResult[]> {
|
||||
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<string, unknown>[] = [];
|
||||
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<string, unknown>[] = [];
|
||||
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<string, unknown>[] = [];
|
||||
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<string, unknown>[] = [];
|
||||
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<string, unknown>[] = [];
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<TMessageContentParts | undefined> = [
|
||||
{ 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<TMessageContentParts | undefined> = [
|
||||
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<TMessageContentParts | undefined> = [undefined, undefined, undefined];
|
||||
expect(parseTextParts(parts)).toBe('');
|
||||
});
|
||||
|
||||
test('should handle parts with missing type property', () => {
|
||||
const parts: Array<TMessageContentParts | undefined> = [
|
||||
{ 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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -349,13 +349,13 @@ export const parseCompactConvo = ({
|
|||
};
|
||||
|
||||
export function parseTextParts(
|
||||
contentParts: a.TMessageContentParts[],
|
||||
contentParts: Array<a.TMessageContentParts | undefined>,
|
||||
skipReasoning: boolean = false,
|
||||
): string {
|
||||
let result = '';
|
||||
|
||||
for (const part of contentParts) {
|
||||
if (!part.type) {
|
||||
if (!part?.type) {
|
||||
continue;
|
||||
}
|
||||
if (part.type === ContentTypes.TEXT) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue