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.73",
"version": "3.1.74",
"main": "./dist/cjs/main.cjs",
"module": "./dist/esm/main.mjs",
"types": "./dist/types/index.d.ts",
Expand Down
93 changes: 66 additions & 27 deletions src/agents/AgentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,10 +681,47 @@ export class AgentContext {
if (!this.toolDefinitions) {
return [];
}
return this.toolDefinitions.filter(
(def) =>
/**
* Mirror `getEventDrivenToolsForBinding`'s gate: a definition is only
* bound to the model when its `allowed_callers` include `'direct'` and
* (if deferred) it has been discovered. Filtering by `defer_loading`
* alone left programmatic-only definitions counted in
* `toolSchemaTokens` even though they were never bound.
*/
return this.toolDefinitions.filter((def) => {
const allowedCallers = def.allowed_callers ?? ['direct'];
if (!allowedCallers.includes('direct')) {
return false;
}
return (
def.defer_loading !== true || this.discoveredToolNames.has(def.name)
);
);
});
}

/**
* Single source of truth for "which entries of `this.tools` should be
* treated as actually bound". Callers:
* - `getToolsForBinding` (non-event-driven branch)
* - `getEventDrivenToolsForBinding` (appends instance tools alongside
* schema-only definitions)
* - `calculateInstructionTokens` (counts schema bytes for accounting)
*
* In event-driven mode (`toolDefinitions` present) instance tools are
* appended unfiltered; outside event-driven mode they pass through
* `filterToolsForBinding`. Centralizing the decision here prevents the
* accounting/binding paths from drifting apart, which was the root
* cause of the original miscount.
*/
private getEffectiveInstanceTools(): t.GraphTools | undefined {
if (!this.tools) {
return undefined;
}
const isEventDriven = (this.toolDefinitions?.length ?? 0) > 0;
if (isEventDriven || !this.toolRegistry) {
return this.tools;
}
return this.filterToolsForBinding(this.tools);
}

/**
Expand All @@ -703,9 +740,17 @@ export class AgentContext {
* populated after `fromConfig()` kicks off the initial calculation, so
* callers that mutate `graphTools` must re-trigger this method to
* refresh `toolSchemaTokens`.
*
* Use `getEffectiveInstanceTools()` so accounting reflects exactly the
* subset that `getToolsForBinding` would emit — preventing the
* worst-case-ceiling miscount that triggered spurious `empty_messages`
* preflight rejections at low `maxContextTokens`. Deferred and
* non-`'direct'` `toolDefinitions` are excluded by
* `getActiveToolDefinitions()` below.
*/
const instanceTools: t.GraphTools = [
...((this.tools as t.GenericTool[] | undefined) ?? []),
...((this.getEffectiveInstanceTools() as t.GenericTool[] | undefined) ??
[]),
...((this.graphTools as t.GenericTool[] | undefined) ?? []),
];

Expand Down Expand Up @@ -900,8 +945,16 @@ export class AgentContext {
*/
getTokenBudgetBreakdown(messages?: BaseMessage[]): t.TokenBudgetBreakdown {
const maxContextTokens = this.maxContextTokens ?? 0;
const toolCount =
(this.tools?.length ?? 0) + this.getActiveToolDefinitions().length;
/**
* Derive `toolCount` from `getToolsForBinding()` so the diagnostic stays
* aligned with what is actually bound to the model — and with what
* `calculateInstructionTokens` counts into `toolSchemaTokens`. Using raw
* `this.tools.length` would inflate the count whenever the registry
* marks instance tools as deferred-undiscovered or non-`'direct'`,
* producing the same misleading "N tools" diagnostic this fix is meant
* to eliminate.
*/
const toolCount = this.getToolsForBinding()?.length ?? 0;
const messageCount = messages?.length ?? 0;

let messageTokens = 0;
Expand Down Expand Up @@ -1014,10 +1067,7 @@ export class AgentContext {
return this.getEventDrivenToolsForBinding();
}

const filtered =
!this.tools || !this.toolRegistry
? this.tools
: this.filterToolsForBinding(this.tools);
const filtered = this.getEffectiveInstanceTools();

if (this.graphTools && this.graphTools.length > 0) {
return [...(filtered ?? []), ...this.graphTools];
Expand All @@ -1032,30 +1082,19 @@ export class AgentContext {
return this.graphTools ?? [];
}

const defsToInclude = this.toolDefinitions.filter((def) => {
const allowedCallers = def.allowed_callers ?? ['direct'];
if (!allowedCallers.includes('direct')) {
return false;
}
if (
def.defer_loading === true &&
!this.discoveredToolNames.has(def.name)
) {
return false;
}
return true;
});

const schemaTools = createSchemaOnlyTools(defsToInclude) as t.GraphTools;
const schemaTools = createSchemaOnlyTools(
this.getActiveToolDefinitions()
) as t.GraphTools;

const allTools = [...schemaTools];

if (this.graphTools && this.graphTools.length > 0) {
allTools.push(...this.graphTools);
}

if (this.tools && this.tools.length > 0) {
allTools.push(...this.tools);
const instanceTools = this.getEffectiveInstanceTools();
if (instanceTools && instanceTools.length > 0) {
allTools.push(...instanceTools);
}

return allTools;
Expand Down
178 changes: 178 additions & 0 deletions src/agents/__tests__/AgentContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,141 @@ describe('AgentContext', () => {
expect(ctxWithDeferred.toolSchemaTokens).toBe(ctxBase.toolSchemaTokens);
});

it('excludes programmatic-only toolDefinitions from toolSchemaTokens', async () => {
// getEventDrivenToolsForBinding excludes definitions whose
// allowed_callers omit 'direct'. Accounting must mirror that — a
// programmatic-only definition is never bound to the model and
// shouldn't inflate toolSchemaTokens.
const activeDef: t.LCTool = {
name: 'active_tool',
description: 'Always loaded',
parameters: { type: 'object', properties: {} },
};
const programmaticDef: t.LCTool = {
name: 'programmatic_tool',
description: 'Only callable via code execution',
parameters: { type: 'object', properties: {} },
allowed_callers: ['code_execution'],
};

const ctxBase = createBasicContext({
agentConfig: { toolDefinitions: [activeDef] },
tokenCounter: mockTokenCounter,
});
const ctxWithProgrammatic = createBasicContext({
agentConfig: { toolDefinitions: [activeDef, programmaticDef] },
tokenCounter: mockTokenCounter,
});

await ctxBase.tokenCalculationPromise;
await ctxWithProgrammatic.tokenCalculationPromise;

expect(ctxWithProgrammatic.toolSchemaTokens).toBe(
ctxBase.toolSchemaTokens
);
});

it('excludes deferred-undiscovered instance tools from toolSchemaTokens', async () => {
const activeTool = createMockTool('active_tool');
const deferredTool = createMockTool('deferred_tool');
const programmaticTool = createMockTool('programmatic_tool');
const toolRegistry: t.LCToolRegistry = new Map([
['active_tool', { name: 'active_tool' }],
['deferred_tool', { name: 'deferred_tool', defer_loading: true }],
[
'programmatic_tool',
{
name: 'programmatic_tool',
allowed_callers: ['code_execution'],
},
],
]);

const ctxBase = createBasicContext({
agentConfig: { tools: [activeTool], toolRegistry },
tokenCounter: mockTokenCounter,
});
const ctxWithExcluded = createBasicContext({
agentConfig: {
tools: [activeTool, deferredTool, programmaticTool],
toolRegistry,
},
tokenCounter: mockTokenCounter,
});

await ctxBase.tokenCalculationPromise;
await ctxWithExcluded.tokenCalculationPromise;

expect(ctxWithExcluded.toolSchemaTokens).toBe(ctxBase.toolSchemaTokens);
});

it('includes deferred instance tools once discovered via discoveredTools input', async () => {
const tools = [createMockTool('deferred_tool')];
const toolRegistry: t.LCToolRegistry = new Map([
['deferred_tool', { name: 'deferred_tool', defer_loading: true }],
]);

const ctxUndiscovered = createBasicContext({
agentConfig: { tools, toolRegistry },
tokenCounter: mockTokenCounter,
});
const ctxDiscovered = createBasicContext({
agentConfig: {
tools,
toolRegistry,
discoveredTools: ['deferred_tool'],
},
tokenCounter: mockTokenCounter,
});

await ctxUndiscovered.tokenCalculationPromise;
await ctxDiscovered.tokenCalculationPromise;

expect(ctxUndiscovered.toolSchemaTokens).toBe(0);
expect(ctxDiscovered.toolSchemaTokens).toBeGreaterThan(0);
});

it('does not filter instance tools in event-driven mode (matches getEventDrivenToolsForBinding)', async () => {
// In event-driven mode, getEventDrivenToolsForBinding appends
// `this.tools` UNFILTERED. Accounting must do the same — otherwise we
// under-count and risk exceeding the model's context budget.
const activeDef: t.LCTool = {
name: 'active_def',
description: 'Always loaded',
parameters: { type: 'object', properties: {} },
};
const nativeTool = createMockTool('native_tool');
// Registry marks the native tool as deferred-undiscovered. In the
// non-event-driven path this would exclude it; in event-driven mode
// it is still bound and must still be counted.
const toolRegistry: t.LCToolRegistry = new Map([
['native_tool', { name: 'native_tool', defer_loading: true }],
]);

const ctxWithoutNative = createBasicContext({
agentConfig: {
toolDefinitions: [activeDef],
toolRegistry,
},
tokenCounter: mockTokenCounter,
});
const ctxWithNative = createBasicContext({
agentConfig: {
toolDefinitions: [activeDef],
tools: [nativeTool],
toolRegistry,
},
tokenCounter: mockTokenCounter,
});

await ctxWithoutNative.tokenCalculationPromise;
await ctxWithNative.tokenCalculationPromise;

expect(ctxWithNative.toolSchemaTokens).toBeGreaterThan(
ctxWithoutNative.toolSchemaTokens
);
});

it('includes deferred toolDefinitions once discovered via discoveredTools input', async () => {
const toolDefinitions: t.LCTool[] = [
{
Expand Down Expand Up @@ -448,6 +583,36 @@ describe('AgentContext', () => {
expect(ctx.getTokenBudgetBreakdown().toolCount).toBe(1);
});

it('getTokenBudgetBreakdown toolCount excludes deferred-undiscovered instance tools', () => {
// Mirrors the toolDefinitions test for the instance-tools path so
// toolCount stays aligned with toolSchemaTokens (and with what
// getToolsForBinding actually emits) for non-event-driven runs.
const tools = [
createMockTool('active_tool'),
createMockTool('deferred_tool'),
createMockTool('programmatic_tool'),
];
const toolRegistry: t.LCToolRegistry = new Map([
['active_tool', { name: 'active_tool' }],
['deferred_tool', { name: 'deferred_tool', defer_loading: true }],
[
'programmatic_tool',
{
name: 'programmatic_tool',
allowed_callers: ['code_execution'],
},
],
]);

const ctx = createBasicContext({
agentConfig: { tools, toolRegistry },
});

expect(ctx.getTokenBudgetBreakdown().toolCount).toBe(1);
ctx.markToolsAsDiscovered(['deferred_tool']);
expect(ctx.getTokenBudgetBreakdown().toolCount).toBe(2);
});

it('getTokenBudgetBreakdown toolCount reflects newly discovered deferred tools', () => {
const toolDefinitions: t.LCTool[] = [
{
Expand All @@ -464,6 +629,19 @@ describe('AgentContext', () => {
expect(ctx.getTokenBudgetBreakdown().toolCount).toBe(1);
});

it('getTokenBudgetBreakdown toolCount includes graphTools', () => {
// graphTools (handoff/subagent) are bound to the model alongside
// instance tools. Now that toolCount derives from getToolsForBinding(),
// graphTools are reflected in the diagnostic just like they're
// counted in toolSchemaTokens. Locks in that alignment.
const ctx = createBasicContext({
agentConfig: { tools: [createMockTool('direct_tool')] },
});
ctx.graphTools = [createMockTool('handoff_tool')];

expect(ctx.getTokenBudgetBreakdown().toolCount).toBe(2);
});

it('toolSchemaTokens snapshot does not auto-update after markToolsAsDiscovered', async () => {
const toolDefinitions: t.LCTool[] = [
{
Expand Down
Loading
Loading