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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@librechat/agents",
"version": "3.1.71",
"version": "3.1.72",
"main": "./dist/cjs/main.cjs",
"module": "./dist/esm/main.mjs",
"types": "./dist/types/index.d.ts",
Expand Down
14 changes: 13 additions & 1 deletion src/tools/ToolNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -996,9 +996,21 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
turn,
};

/**
* Emit `codeSessionContext` for any tool whose host handler may need
* to reach into the code-execution sandbox:
* - `CODE_EXECUTION_TOOLS` — direct executors that POST to /exec.
* - `SKILL_TOOL` — skill files live alongside code-env state.
* - `READ_FILE` — when the requested path is a code-env artifact
* (e.g. `/mnt/data/...`) the host falls back to reading via the
* same sandbox session; without the seeded `session_id` /
* `_injected_files` here, that fallback can't see prior-turn
* artifacts on the very first call of a turn.
*/
if (
CODE_EXECUTION_TOOLS.has(entry.call.name) ||
entry.call.name === Constants.SKILL_TOOL
entry.call.name === Constants.SKILL_TOOL ||
entry.call.name === Constants.READ_FILE
) {
request.codeSessionContext = this.getCodeSessionContext();
}
Expand Down
161 changes: 155 additions & 6 deletions src/tools/__tests__/ToolNode.session.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { z } from 'zod';
import { tool } from '@langchain/core/tools';
import { AIMessage } from '@langchain/core/messages';
import { describe, it, expect } from '@jest/globals';
import { describe, it, expect, jest, afterEach } from '@jest/globals';
import type { StructuredToolInterface } from '@langchain/core/tools';
import type * as t from '@/types';
import { ToolNode } from '../ToolNode';
import { Constants } from '@/common';
import * as events from '@/utils/events';

/**
* Creates a mock execute_code tool that captures the toolCall config it receives.
Expand Down Expand Up @@ -233,7 +234,9 @@ describe('ToolNode code execution session management', () => {
status: 'success',
},
],
new Map([['tc1', { id: 'tc1', name: Constants.EXECUTE_CODE, args: {} }]])
new Map([
['tc1', { id: 'tc1', name: Constants.EXECUTE_CODE, args: {} }],
])
);

const stored = sessions.get(
Expand Down Expand Up @@ -279,7 +282,9 @@ describe('ToolNode code execution session management', () => {
status: 'success',
},
],
new Map([['tc2', { id: 'tc2', name: Constants.EXECUTE_CODE, args: {} }]])
new Map([
['tc2', { id: 'tc2', name: Constants.EXECUTE_CODE, args: {} }],
])
);

const stored = sessions.get(
Expand Down Expand Up @@ -329,7 +334,9 @@ describe('ToolNode code execution session management', () => {
status: 'success',
},
],
new Map([['tc3', { id: 'tc3', name: Constants.EXECUTE_CODE, args: {} }]])
new Map([
['tc3', { id: 'tc3', name: Constants.EXECUTE_CODE, args: {} }],
])
);

const stored = sessions.get(
Expand Down Expand Up @@ -379,7 +386,9 @@ describe('ToolNode code execution session management', () => {
status: 'success',
},
],
new Map([['tc4', { id: 'tc4', name: Constants.EXECUTE_CODE, args: {} }]])
new Map([
['tc4', { id: 'tc4', name: Constants.EXECUTE_CODE, args: {} }],
])
);

const stored = sessions.get(
Expand Down Expand Up @@ -456,10 +465,150 @@ describe('ToolNode code execution session management', () => {
errorMessage: 'execution failed',
},
],
new Map([['tc6', { id: 'tc6', name: Constants.EXECUTE_CODE, args: {} }]])
new Map([
['tc6', { id: 'tc6', name: Constants.EXECUTE_CODE, args: {} }],
])
);

expect(sessions.has(Constants.EXECUTE_CODE)).toBe(false);
});
});

describe('codeSessionContext emission gate (event-driven request building)', () => {
/**
* Captures the `ToolExecuteBatchRequest` dispatched on ON_TOOL_EXECUTE so
* we can assert which `request.name`s receive `codeSessionContext`. Returns
* the captured requests; resolves the dispatched event with empty results
* to let `dispatchToolEvents` complete.
*/
function captureBatchRequests(): {
capturedRequests: t.ToolCallRequest[];
} {
const capturedRequests: t.ToolCallRequest[] = [];
jest
.spyOn(events, 'safeDispatchCustomEvent')
.mockImplementation(async (_event, data) => {
const batch = data as t.ToolExecuteBatchRequest;
if (Array.isArray(batch.toolCalls)) {
capturedRequests.push(...batch.toolCalls);
}
if (typeof batch.resolve === 'function') {
batch.resolve(
batch.toolCalls.map((tc) => ({
toolCallId: tc.id,
content: '',
status: 'success' as const,
}))
);
}
});
return { capturedRequests };
}

const createDummyTool = (name: string): StructuredToolInterface =>
tool(async () => 'ok', {
name,
description: 'dummy',
schema: z.object({ x: z.string().optional() }),
}) as unknown as StructuredToolInterface;

afterEach(() => {
jest.restoreAllMocks();
});

it('attaches codeSessionContext to read_file requests so the host can fall back to the code-env sandbox', async () => {
const sessions: t.ToolSessionMap = new Map();
sessions.set(Constants.EXECUTE_CODE, {
session_id: 'rf-session',
files: [{ id: 'rf1', name: 'data.csv', session_id: 'rf-session' }],
lastUpdated: Date.now(),
} satisfies t.CodeSessionContext);

const { capturedRequests } = captureBatchRequests();

const toolNode = new ToolNode({
tools: [createDummyTool(Constants.READ_FILE)],
sessions,
eventDrivenMode: true,
toolCallStepIds: new Map([['call_rf', 'step_rf']]),
});

const aiMsg = new AIMessage({
content: '',
tool_calls: [
{
id: 'call_rf',
name: Constants.READ_FILE,
args: { file_path: '/mnt/data/data.csv' },
},
],
});

await toolNode.invoke({ messages: [aiMsg] });

expect(capturedRequests).toHaveLength(1);
expect(capturedRequests[0].name).toBe(Constants.READ_FILE);
expect(capturedRequests[0].codeSessionContext).toEqual({
session_id: 'rf-session',
files: [{ session_id: 'rf-session', id: 'rf1', name: 'data.csv' }],
});
});

it('does not attach codeSessionContext to read_file when no session exists yet', async () => {
const { capturedRequests } = captureBatchRequests();

const toolNode = new ToolNode({
tools: [createDummyTool(Constants.READ_FILE)],
sessions: new Map(),
eventDrivenMode: true,
toolCallStepIds: new Map([['call_rf2', 'step_rf2']]),
});

const aiMsg = new AIMessage({
content: '',
tool_calls: [
{
id: 'call_rf2',
name: Constants.READ_FILE,
args: { file_path: 'some-skill/notes.md' },
},
],
});

await toolNode.invoke({ messages: [aiMsg] });

expect(capturedRequests).toHaveLength(1);
expect(capturedRequests[0].name).toBe(Constants.READ_FILE);
expect(capturedRequests[0].codeSessionContext).toBeUndefined();
});

it('does not attach codeSessionContext to unrelated tools', async () => {
const sessions: t.ToolSessionMap = new Map();
sessions.set(Constants.EXECUTE_CODE, {
session_id: 'unrelated-session',
files: [],
lastUpdated: Date.now(),
} satisfies t.CodeSessionContext);

const { capturedRequests } = captureBatchRequests();

const toolNode = new ToolNode({
tools: [createDummyTool('web_search')],
sessions,
eventDrivenMode: true,
toolCallStepIds: new Map([['call_ws', 'step_ws']]),
});

const aiMsg = new AIMessage({
content: '',
tool_calls: [{ id: 'call_ws', name: 'web_search', args: { x: 'q' } }],
});

await toolNode.invoke({ messages: [aiMsg] });

expect(capturedRequests).toHaveLength(1);
expect(capturedRequests[0].name).toBe('web_search');
expect(capturedRequests[0].codeSessionContext).toBeUndefined();
});
});
});
Loading