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
18 changes: 15 additions & 3 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,21 @@ jobs:
BEDROCK_AWS_SECRET_ACCESS_KEY: ${{ secrets.BEDROCK_AWS_SECRET_ACCESS_KEY }}

summarization-tests:
name: 'summarization tests'
name: 'summarization tests (${{ matrix.group }})'
needs: install
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- group: anthropic
pattern: 'Anthropic Summarization E2E|Token accounting audit'
- group: openai
pattern: 'OpenAI Summarization E2E'
- group: bedrock
pattern: 'Bedrock Summarization E2E'
- group: local
pattern: 'no API keys'
steps:
- uses: actions/checkout@v4

Expand All @@ -146,8 +158,8 @@ jobs:
path: node_modules
key: node-modules-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

- name: Run summarization tests
run: npx jest src/specs/summarization.test.ts --maxWorkers=50%
- name: Run summarization tests (${{ matrix.group }})
run: npx jest src/specs/summarization.test.ts -t "${{ matrix.pattern }}" --maxWorkers=50%
env:
NODE_OPTIONS: '--experimental-vm-modules'
NODE_ENV: test
Expand Down
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.2.55",
"version": "3.2.57",
"main": "./dist/cjs/main.cjs",
"module": "./dist/esm/main.mjs",
"types": "./dist/types/index.d.ts",
Expand Down
144 changes: 144 additions & 0 deletions src/__tests__/stream.eagerEventExecution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4726,4 +4726,148 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
expect(toolExecuteCalls).toHaveLength(0);
expect(graph.eagerEventToolExecutions.size).toBe(0);
});

it('does not prestart tools listed in excludeToolNames', async () => {
const graph = createGraph({
eagerEventToolExecution: {
enabled: true,
excludeToolNames: ['create_file'],
},
});
const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
jest
.spyOn(events, 'safeDispatchCustomEvent')
.mockImplementation(async (event, data): Promise<void> => {
if (event !== GraphEvents.ON_TOOL_EXECUTE) {
return;
}
const batch = data as t.ToolExecuteBatchRequest;
toolExecuteCalls.push(batch);
batch.resolve([
{ toolCallId: 'call_file', status: 'success', content: 'ok' },
]);
});

await new ChatModelStreamHandler().handle(
GraphEvents.CHAT_MODEL_STREAM,
{
chunk: {
content: '',
tool_calls: [
{
id: 'call_file',
name: 'create_file',
args: { path: '/mnt/data/x.py', content: 'print(1)' },
},
],
response_metadata: finalToolCallResponseMetadata,
} as unknown as t.StreamChunk,
},
{ langgraph_node: 'agent' },
graph
);

// Excluded: no eager execution started; the call falls through to ToolNode.
expect(toolExecuteCalls).toHaveLength(0);
expect(graph.eagerEventToolExecutions.has('call_file')).toBe(false);
});

it('does not prestart codeSessionToolNames tools even without excludeToolNames', async () => {
// A declared session-writing host tool is side-effecting, so it must not be
// eagerly prestarted even when the host didn't also list it in excludeToolNames.
const graph = createGraph({
eagerEventToolExecution: { enabled: true },
codeSessionToolNames: ['create_file', 'edit_file'],
});
const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
jest
.spyOn(events, 'safeDispatchCustomEvent')
.mockImplementation(async (event, data): Promise<void> => {
if (event !== GraphEvents.ON_TOOL_EXECUTE) {
return;
}
const batch = data as t.ToolExecuteBatchRequest;
toolExecuteCalls.push(batch);
batch.resolve([
{ toolCallId: 'call_cf2', status: 'success', content: 'ok' },
]);
});

await new ChatModelStreamHandler().handle(
GraphEvents.CHAT_MODEL_STREAM,
{
chunk: {
content: '',
tool_calls: [
{
id: 'call_cf2',
name: 'create_file',
args: { path: '/mnt/data/y.py', content: 'print(2)' },
},
],
response_metadata: finalToolCallResponseMetadata,
} as unknown as t.StreamChunk,
},
{ langgraph_node: 'agent' },
graph
);

expect(toolExecuteCalls).toHaveLength(0);
expect(graph.eagerEventToolExecutions.has('call_cf2')).toBe(false);
});

it('keeps the direct-tool batch guard when an excluded tool is also direct', async () => {
// edit_file is both a direct graph tool AND excluded from eager. A mixed
// batch with a direct tool must suppress eager for the whole batch, so the
// sibling event tool must NOT prestart — excluding edit_file must not hide
// it from the batch-level direct-tool guard.
const graph = createGraph({
eagerEventToolExecution: {
enabled: true,
excludeToolNames: ['edit_file'],
},
getAgentContext: jest.fn(() => ({
provider: Providers.ANTHROPIC,
reasoningKey: 'reasoning',
toolDefinitions: [{ name: 'weather' }, { name: 'edit_file' }],
graphTools: [{ name: 'edit_file' }],
agentId: 'agent_1',
})) as unknown as StandardGraph['getAgentContext'],
});
const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
jest
.spyOn(events, 'safeDispatchCustomEvent')
.mockImplementation(async (event, data): Promise<void> => {
if (event !== GraphEvents.ON_TOOL_EXECUTE) {
return;
}
toolExecuteCalls.push(data as t.ToolExecuteBatchRequest);
(data as t.ToolExecuteBatchRequest).resolve([]);
});

await new ChatModelStreamHandler().handle(
GraphEvents.CHAT_MODEL_STREAM,
{
chunk: {
content: '',
tool_calls: [
{ id: 'call_weather', name: 'weather', args: { city: 'NYC' } },
{
id: 'call_edit',
name: 'edit_file',
args: { path: '/mnt/data/x.py' },
},
],
response_metadata: finalToolCallResponseMetadata,
} as unknown as t.StreamChunk,
},
{ langgraph_node: 'agent' },
graph
);

// Direct tool in batch suppresses eager for the whole batch.
expect(toolExecuteCalls).toHaveLength(0);
expect(graph.eagerEventToolExecutions.has('call_weather')).toBe(false);
expect(graph.eagerEventToolExecutions.has('call_edit')).toBe(false);
});
});
7 changes: 6 additions & 1 deletion src/graphs/Graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ import {
type CallbackEntry,
} from '@/utils/callbacks';
import { partitionAndMarkOpenRouterToolCache } from '@/llm/openrouter/toolCache';
import { shouldTraceToolNodeForLangfuse } from '@/langfuseToolOutputTracing';
import { ToolNode as CustomToolNode, toolsCondition } from '@/tools/ToolNode';
import { shouldTraceToolNodeForLangfuse } from '@/langfuseToolOutputTracing';
import { createLocalCodingToolBundle } from '@/tools/local/LocalCodingTools';
import { SubagentExecutor, resolveSubagentConfigs } from '@/tools/subagent';
import { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
Expand Down Expand Up @@ -608,6 +608,7 @@ export abstract class Graph<
* consumes the settled promises while preserving final ToolMessage order.
*/
eagerEventToolExecution: t.EagerEventToolExecutionConfig | undefined;
codeSessionToolNames: string[] | undefined;
eagerEventToolExecutions: Map<string, t.EagerEventToolExecution> = new Map();
eagerEventToolUsageCount: Map<string, number> = new Map();
private eagerEventToolUsageCountsByAgentId: Map<string, Map<string, number>> =
Expand Down Expand Up @@ -656,6 +657,7 @@ export abstract class Graph<
this.humanInTheLoop = undefined;
this.toolOutputReferences = undefined;
this.eagerEventToolExecution = undefined;
this.codeSessionToolNames = undefined;
this.eagerEventToolExecutions.clear();
this.clearEagerEventToolUsageCounts();
this.eagerEventToolCallChunks.clear();
Expand Down Expand Up @@ -1260,6 +1262,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
hookRegistry: this.hookRegistry,
humanInTheLoop: this.humanInTheLoop,
eagerEventToolExecution: this.eagerEventToolExecution,
codeSessionToolNames: this.codeSessionToolNames,
eagerEventToolExecutions: this.eagerEventToolExecutions,
eagerEventToolUsageCount: this.getEagerEventToolUsageCount(
agentContext?.agentId
Expand Down Expand Up @@ -1309,6 +1312,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
toolRegistry: agentContext?.toolRegistry,
sessions: this.sessions,
toolExecution: this.toolExecution,
codeSessionToolNames: this.codeSessionToolNames,
hookRegistry: this.hookRegistry,
humanInTheLoop: this.humanInTheLoop,
maxContextTokens: agentContext?.maxContextTokens,
Expand Down Expand Up @@ -2333,6 +2337,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
*/
childGraph.toolOutputReferences = this.toolOutputReferences;
childGraph.eagerEventToolExecution = this.eagerEventToolExecution;
childGraph.codeSessionToolNames = this.codeSessionToolNames;
childGraph.toolExecution = this.toolExecution;
childGraph.eventToolExecutionAvailable =
this.handlerRegistry?.getHandler(GraphEvents.ON_TOOL_EXECUTE) !=
Expand Down
4 changes: 4 additions & 0 deletions src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export class Run<_T extends t.BaseGraphState> {
private langfuse?: t.LangfuseConfig;
private toolOutputReferences?: t.ToolOutputReferencesConfig;
private eagerEventToolExecution?: t.EagerEventToolExecutionConfig;
private codeSessionToolNames?: string[];
private toolExecution?: t.ToolExecutionConfig;
private subagentUsageSink?: t.SubagentUsageSink;
private indexTokenCountMap?: Record<string, number>;
Expand Down Expand Up @@ -182,6 +183,7 @@ export class Run<_T extends t.BaseGraphState> {
this.langfuse = config.langfuse;
this.toolOutputReferences = config.toolOutputReferences;
this.eagerEventToolExecution = config.eagerEventToolExecution;
this.codeSessionToolNames = config.codeSessionToolNames;
this.toolExecution = config.toolExecution;
this.subagentUsageSink = config.subagentUsageSink;

Expand Down Expand Up @@ -268,6 +270,7 @@ export class Run<_T extends t.BaseGraphState> {
standardGraph.humanInTheLoop = this.humanInTheLoop;
standardGraph.toolOutputReferences = this.toolOutputReferences;
standardGraph.eagerEventToolExecution = this.eagerEventToolExecution;
standardGraph.codeSessionToolNames = this.codeSessionToolNames;
standardGraph.toolExecution = this.toolExecution;
this.Graph = standardGraph;
return standardGraph.createWorkflow();
Expand Down Expand Up @@ -297,6 +300,7 @@ export class Run<_T extends t.BaseGraphState> {
multiAgentGraph.humanInTheLoop = this.humanInTheLoop;
multiAgentGraph.toolOutputReferences = this.toolOutputReferences;
multiAgentGraph.eagerEventToolExecution = this.eagerEventToolExecution;
multiAgentGraph.codeSessionToolNames = this.codeSessionToolNames;
multiAgentGraph.toolExecution = this.toolExecution;
this.Graph = multiAgentGraph;
return multiAgentGraph.createWorkflow();
Expand Down
30 changes: 28 additions & 2 deletions src/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,24 @@ function hasToolOutputReference(value: unknown): boolean {
return false;
}

function isEagerExecutionExcludedTool(
name: string,
graph: StandardGraph
): boolean {
if (name === '') {
return false;
}
const excluded = graph.eagerEventToolExecution?.excludeToolNames;
if (excluded != null && excluded.includes(name)) {
return true;
}
// A code-session participant writes to the shared sandbox, so it is
// side-effecting: never prestart it speculatively (a revised/superseded turn
// would leave the write applied). Implies exclusion without the host having
// to also list the name in excludeToolNames.
return graph.codeSessionToolNames?.includes(name) === true;
}

function isDirectGraphTool(
name: string,
agentContext: AgentContext | undefined
Expand Down Expand Up @@ -184,7 +202,8 @@ function getCodeSessionContext(
if (
!CODE_EXECUTION_TOOLS.has(name) &&
name !== Constants.SKILL_TOOL &&
name !== Constants.READ_FILE
name !== Constants.READ_FILE &&
graph.codeSessionToolNames?.includes(name) !== true
) {
return undefined;
}
Expand Down Expand Up @@ -615,14 +634,21 @@ function createEagerToolExecutionPlan(args: {
return undefined;
}

const candidateToolCalls = skipExisting
const unstartedToolCalls = skipExisting
? toolCalls.filter((toolCall) => {
if (toolCall.id == null || toolCall.id === '') {
return true;
}
return !graph.eagerEventToolExecutions.has(toolCall.id);
})
: toolCalls;
// Drop host-excluded tools only AFTER the batch-level guards above have run
// against the full batch, so excluding a call never hides a sibling direct
// tool from `hasDirectToolCallInBatch`. Excluded calls fall through to normal
// ToolNode execution; siblings may still eager-execute.
const candidateToolCalls = unstartedToolCalls.filter(
(toolCall) => !isEagerExecutionExcludedTool(toolCall.name, graph)
);
if (candidateToolCalls.length === 0) {
return [];
}
Expand Down
Loading
Loading