Skip to content

Commit d4d12b7

Browse files
authored
Merge branch 'dev' into fix/modelspec-modellabel-context
2 parents 52b0657 + 1ef4aa9 commit d4d12b7

196 files changed

Lines changed: 13313 additions & 2171 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/app/clients/BaseClient.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const {
1616
getLangfuseTraceMessageFields,
1717
isContentFilterError,
1818
assertModelBoundProviderContent,
19+
reportLocatorTraversalFailure,
1920
collectModelBoundHistoricalFileIdState,
2021
projectModelBoundSourceFiles,
2122
isModelBoundAttachmentFile,
@@ -302,6 +303,7 @@ class BaseClient {
302303
: [{ role: 'user', content: payload, isCreatedByUser: true, isUserSubmitted: true }];
303304
const fileProjection = this.getModelBoundFileProjection();
304305
assertModelBoundProviderContent({
306+
onTraversalFailure: reportLocatorTraversalFailure,
305307
filters: this.options.req?.config?.filters,
306308
legacyPii: this.options.req?.config?.messageFilter?.pii,
307309
providerMessages: messages,
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
const {
2+
AIMessage,
3+
HumanMessage,
4+
ToolMessage,
5+
FunctionMessage,
6+
} = require('@langchain/core/messages');
7+
const { syncBudgetDerivedFields } = require('@librechat/agents');
8+
9+
function snapshot(messageTokens = 1000, calibrationRatio = 1) {
10+
return {
11+
calibrationRatio,
12+
contextBudget: 1100,
13+
effectiveInstructionTokens: 100,
14+
remainingContextTokens: 1000 - messageTokens,
15+
breakdown: { messageTokens },
16+
};
17+
}
18+
19+
function result(id, name) {
20+
return new ToolMessage({ content: 'result', tool_call_id: id, name });
21+
}
22+
23+
describe('agents tool context accounting', () => {
24+
test('counts retained results and tool-only invocations without double counting raw calls', () => {
25+
const usage = snapshot();
26+
const messages = [
27+
new HumanMessage('question'),
28+
new AIMessage({
29+
content: '',
30+
tool_calls: [{ id: 'a', name: 'read_file', args: {} }],
31+
additional_kwargs: {
32+
tool_calls: [
33+
{ id: 'a', type: 'function', function: { name: 'read_file', arguments: '{}' } },
34+
],
35+
},
36+
}),
37+
result('a'),
38+
new AIMessage('answer'),
39+
];
40+
syncBudgetDerivedFields(usage, messages, () => 10);
41+
expect(usage.breakdown.toolMessageTokens).toBe(20);
42+
expect(usage.breakdown.toolMessageTokenCounts).toEqual({ read_file: 10 });
43+
});
44+
45+
test('keeps visible text and media assistant content outside the tool share', () => {
46+
const content = [
47+
'explaining',
48+
['explaining'],
49+
[{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } }],
50+
];
51+
for (const mixed of content) {
52+
const usage = snapshot();
53+
const messages = [
54+
new AIMessage({ content: mixed, tool_calls: [{ id: 'a', name: 'read_file', args: {} }] }),
55+
result('a'),
56+
];
57+
syncBudgetDerivedFields(usage, messages, () => 10);
58+
expect(usage.breakdown.toolMessageTokens).toBe(10);
59+
expect(usage.breakdown.toolMessageTokenCounts).toEqual({ read_file: 10 });
60+
}
61+
});
62+
63+
test('counts tool-only reasoning and inline provider results without attributing invocation overhead', () => {
64+
const usage = snapshot();
65+
syncBudgetDerivedFields(
66+
usage,
67+
[
68+
new AIMessage({
69+
content: [
70+
{ type: 'thinking', thinking: 'reasoning' },
71+
{ type: 'tool_use', id: 'a', name: 'file_search', input: {} },
72+
{ type: 'web_search_tool_result', tool_use_id: 'a', content: [] },
73+
],
74+
}),
75+
result('a', 'file_search'),
76+
],
77+
() => 10,
78+
);
79+
expect(usage.breakdown.toolMessageTokens).toBe(20);
80+
expect(usage.breakdown.toolMessageTokenCounts).toEqual({ file_search: 10 });
81+
});
82+
83+
test('attributes raw calls, legacy functions, and unknown results without guessing an unrelated name', () => {
84+
const usage = snapshot();
85+
syncBudgetDerivedFields(
86+
usage,
87+
[
88+
new AIMessage({
89+
content: '',
90+
additional_kwargs: {
91+
tool_calls: [
92+
{ id: 'raw', type: 'function', function: { name: 'raw_tool', arguments: '{}' } },
93+
],
94+
},
95+
}),
96+
result('raw'),
97+
new AIMessage({
98+
content: '',
99+
additional_kwargs: { function_call: { name: 'legacy_tool', arguments: '{}' } },
100+
}),
101+
new FunctionMessage({ content: 'legacy result', name: '' }),
102+
result('missing', 'explicit_tool'),
103+
result('missing'),
104+
],
105+
() => 10,
106+
);
107+
expect(usage.breakdown.toolMessageTokens).toBe(60);
108+
expect(usage.breakdown.toolMessageTokenCounts).toEqual({
109+
raw_tool: 10,
110+
legacy_tool: 10,
111+
explicit_tool: 10,
112+
unknown_tool: 10,
113+
});
114+
});
115+
116+
test('preserves prototype-sensitive tool names through JSON serialization', () => {
117+
const usage = snapshot();
118+
syncBudgetDerivedFields(
119+
usage,
120+
[result('a', '__proto__'), result('b', 'constructor'), result('c', 'toString')],
121+
() => 10,
122+
);
123+
const counts = JSON.parse(JSON.stringify(usage)).breakdown.toolMessageTokenCounts;
124+
expect(Object.hasOwn(counts, '__proto__')).toBe(true);
125+
expect(counts.__proto__).toBe(10);
126+
expect(counts.constructor).toBe(10);
127+
expect(counts.toString).toBe(10);
128+
});
129+
130+
test('apportions fractional calibration and budget clamping without exceeding the tool total', () => {
131+
for (const ratio of [0.5, 1, 1.5, 5]) {
132+
for (const available of [0, 1, 2, 10]) {
133+
const usage = snapshot(available, ratio);
134+
syncBudgetDerivedFields(
135+
usage,
136+
[result('a', 'a'), result('b', 'b'), result('c', 'c')],
137+
() => 1,
138+
);
139+
const { toolMessageTokens, toolMessageTokenCounts } = usage.breakdown;
140+
expect(toolMessageTokens).toBe(Math.min(available, Math.round(3 * ratio)));
141+
const counts = Object.values(toolMessageTokenCounts ?? {});
142+
expect(counts.every((count) => Number.isSafeInteger(count) && count >= 0)).toBe(true);
143+
expect(counts.reduce((sum, count) => sum + count, 0)).toBe(toolMessageTokens);
144+
}
145+
}
146+
});
147+
148+
test('distinguishes a known empty share from an unavailable counter', () => {
149+
const known = snapshot();
150+
syncBudgetDerivedFields(known, [new HumanMessage('hello')], () => 10);
151+
expect(known.breakdown.toolMessageTokens).toBe(0);
152+
expect(known.breakdown.toolMessageTokenCounts).toBeUndefined();
153+
const unavailable = snapshot();
154+
syncBudgetDerivedFields(unavailable, [result('a')]);
155+
expect(unavailable.breakdown.toolMessageTokens).toBeUndefined();
156+
});
157+
158+
test('drops an unavailable tool share without failing the model call', () => {
159+
for (const value of [NaN, Infinity, -1, Number.MAX_SAFE_INTEGER + 1]) {
160+
const usage = snapshot();
161+
usage.breakdown.toolMessageTokens = 20;
162+
usage.breakdown.toolMessageTokenCounts = { read_file: 10 };
163+
syncBudgetDerivedFields(usage, [result('a')], () => value);
164+
expect(usage.breakdown.toolMessageTokens).toBeUndefined();
165+
expect(usage.breakdown.toolMessageTokenCounts).toBeUndefined();
166+
expect(usage.breakdown.messageTokens).toBe(1000);
167+
}
168+
});
169+
170+
test('accepts approximate fractional token counters', () => {
171+
const usage = snapshot();
172+
syncBudgetDerivedFields(usage, [result('a', 'file_search')], () => 1.5);
173+
expect(usage.breakdown.toolMessageTokens).toBe(2);
174+
expect(usage.breakdown.toolMessageTokenCounts).toEqual({ file_search: 2 });
175+
});
176+
});

api/server/controllers/agents/__tests__/usageEvents.integration.spec.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,14 @@ describe('usage events through the real agents pipeline', () => {
232232
expect(remainingContextTokens).toBeLessThan(contextBudget);
233233
expect(breakdown.toolTokenCounts.add).toBeGreaterThan(0);
234234
}
235+
expect(contextEvents[0].data.breakdown.toolMessageTokens).toBe(0);
236+
const toolBreakdown = contextEvents[1].data.breakdown;
237+
expect(toolBreakdown.toolMessageTokens).toBeGreaterThan(0);
238+
expect(toolBreakdown.toolMessageTokens).toBeLessThanOrEqual(toolBreakdown.messageTokens);
239+
expect(toolBreakdown.toolMessageTokenCounts.add).toBeGreaterThan(0);
240+
expect(
241+
Object.values(toolBreakdown.toolMessageTokenCounts).reduce((sum, count) => sum + count, 0),
242+
).toBeLessThanOrEqual(toolBreakdown.toolMessageTokens);
235243

236244
/** Tool loop grows the context between calls */
237245
expect(contextEvents[1].data.prePruneContextTokens).toBeGreaterThan(

api/server/controllers/agents/client.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ const {
145145
decrementPendingRequest,
146146
maybePrewarmCodeSandbox,
147147
assertModelBoundContent,
148+
reportLocatorTraversalFailure,
148149
filterFilesByEndpointRuntimeConfig,
149150
createModelBoundChatModelCallback: createModelBoundContentCallback,
150151
createInitialModelBoundAdmissionCallback,
@@ -516,6 +517,7 @@ class AgentClient extends BaseClient {
516517
admitSteerAttachments(files, steerId) {
517518
const modelBoundFiles = files.filter(isModelBoundAttachmentFile);
518519
assertModelBoundContent({
520+
onTraversalFailure: reportLocatorTraversalFailure,
519521
filters: this.options.req?.config?.filters,
520522
files: modelBoundFiles,
521523
});
@@ -2047,6 +2049,7 @@ class AgentClient extends BaseClient {
20472049
return;
20482050
}
20492051
assertModelBoundContent({
2052+
onTraversalFailure: reportLocatorTraversalFailure,
20502053
legacyPii,
20512054
storedMessages: this.modelBoundStoredMessages,
20522055
});
@@ -2063,6 +2066,7 @@ class AgentClient extends BaseClient {
20632066
const persistence = BaseClient.prototype.getModelBoundUserMessagePersistence.call(this);
20642067
return createModelBoundContentCallback(
20652068
{
2069+
onTraversalFailure: reportLocatorTraversalFailure,
20662070
filters: this.options.req?.config?.filters,
20672071
legacyPii: this.options.req?.config?.messageFilter?.pii,
20682072
storedMessages: this.modelBoundStoredMessages,
@@ -2393,6 +2397,7 @@ class AgentClient extends BaseClient {
23932397
]);
23942398
void earlySharedContextPromise.catch(() => {});
23952399
assertModelBoundContent({
2400+
onTraversalFailure: reportLocatorTraversalFailure,
23962401
filters: this.options.req.config?.filters,
23972402
legacyPii: this.options.req.config?.messageFilter?.pii,
23982403
agents: allAgents.map(({ agent }) => agent),
@@ -2473,6 +2478,7 @@ class AgentClient extends BaseClient {
24732478
this.modelBoundCurrentFiles = [...modelBoundRequestAttachments];
24742479

24752480
assertModelBoundContent({
2481+
onTraversalFailure: reportLocatorTraversalFailure,
24762482
filters: this.options.req.config?.filters,
24772483
files: modelBoundRequestAttachments,
24782484
});
@@ -2720,6 +2726,7 @@ class AgentClient extends BaseClient {
27202726
* user payload so strict file policy cannot be skipped by a late media
27212727
* adapter. */
27222728
assertModelBoundContent({
2729+
onTraversalFailure: reportLocatorTraversalFailure,
27232730
filters: this.options.req.config?.filters,
27242731
legacyPii: this.options.req.config?.messageFilter?.pii,
27252732
submittedMessages: [{ role: 'user', content: latestFormatted.content }],
@@ -3013,6 +3020,7 @@ class AgentClient extends BaseClient {
30133020
});
30143021
if (assertLateBoundContent) {
30153022
assertModelBoundContent({
3023+
onTraversalFailure: reportLocatorTraversalFailure,
30163024
filters: this.options.req.config?.filters,
30173025
legacyPii: this.options.req.config?.messageFilter?.pii,
30183026
agents: [agent],
@@ -3041,6 +3049,7 @@ class AgentClient extends BaseClient {
30413049
this.modelBoundMemoryContexts = [...modelBoundMemoryContexts];
30423050
this.modelBoundFileContexts = [...modelBoundFileContexts];
30433051
assertModelBoundContent({
3052+
onTraversalFailure: reportLocatorTraversalFailure,
30443053
filters: this.options.req.config?.filters,
30453054
legacyPii: this.options.req.config?.messageFilter?.pii,
30463055
agents: allAgents.map(({ agent }) => agent),
@@ -4676,6 +4685,7 @@ class AgentClient extends BaseClient {
46764685
}
46774686

46784687
assertModelBoundContent({
4688+
onTraversalFailure: reportLocatorTraversalFailure,
46794689
filters: appConfig?.filters,
46804690
legacyPii: appConfig?.messageFilter?.pii,
46814691
agents: reachableAgents,
@@ -5346,6 +5356,7 @@ class AgentClient extends BaseClient {
53465356
},
53475357
{
53485358
getAgentCheckpointer,
5359+
onTraversalFailure: reportLocatorTraversalFailure,
53495360
getMessages: db.getMessages,
53505361
getFiles: db.getFiles,
53515362
},
@@ -5433,6 +5444,7 @@ class AgentClient extends BaseClient {
54335444
agent === this.options.agent ? this.options.req.body.ephemeralAgent : undefined,
54345445
});
54355446
assertModelBoundContent({
5447+
onTraversalFailure: reportLocatorTraversalFailure,
54365448
filters: this.options.req.config?.filters,
54375449
legacyPii: this.options.req.config?.messageFilter?.pii,
54385450
agents: [agent],
@@ -5531,6 +5543,7 @@ class AgentClient extends BaseClient {
55315543
sharedRunContext: scopedContext ?? '',
55325544
});
55335545
assertModelBoundContent({
5546+
onTraversalFailure: reportLocatorTraversalFailure,
55345547
filters: this.options.req.config?.filters,
55355548
legacyPii: this.options.req.config?.messageFilter?.pii,
55365549
agents: [agent],

api/server/controllers/agents/openai.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const {
5050
isContentTraversalProtected,
5151
isContentTraversalLimitError,
5252
assertModelBoundContent,
53+
reportLocatorTraversalFailure,
5354
hasModelBoundContentProtection,
5455
isContentFilterError,
5556
getSafeErrorMetadata,
@@ -750,6 +751,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
750751
const manualSkillPrimes = primaryConfig.manualSkillPrimes;
751752
const alwaysApplySkillPrimes = primaryConfig.alwaysApplySkillPrimes;
752753
assertModelBoundContent({
754+
onTraversalFailure: reportLocatorTraversalFailure,
753755
filters: appConfig?.filters,
754756
legacyPii: appConfig?.messageFilter?.pii,
755757
submittedMessages: request.messages,

api/server/controllers/agents/responses.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const {
5050
isContentTraversalLimitError,
5151
prependContentTraversalFragments,
5252
assertModelBoundContent,
53+
reportLocatorTraversalFailure,
5354
hasModelBoundContentProtection,
5455
isContentFilterError,
5556
getSafeErrorMetadata,
@@ -710,6 +711,7 @@ const executeResponse = async (envelope, { req, res }) => {
710711
: [];
711712
if (request.previous_response_id) {
712713
assertModelBoundContent({
714+
onTraversalFailure: reportLocatorTraversalFailure,
713715
filters: appConfig?.filters,
714716
legacyPii: appConfig?.messageFilter?.pii,
715717
storedMessages: previousMessages,
@@ -1000,6 +1002,7 @@ const executeResponse = async (envelope, { req, res }) => {
10001002
const modelBoundAgents = [...modelBoundAgentsById.values()];
10011003
const mergedMCPAuthMap = discoveredMCPAuthMap ?? primaryConfig.userMCPAuthMap;
10021004
assertModelBoundContent({
1005+
onTraversalFailure: reportLocatorTraversalFailure,
10031006
filters: appConfig?.filters,
10041007
legacyPii: appConfig?.messageFilter?.pii,
10051008
agents: modelBoundAgents,
@@ -1094,6 +1097,7 @@ const executeResponse = async (envelope, { req, res }) => {
10941097
}
10951098

10961099
assertModelBoundContent({
1100+
onTraversalFailure: reportLocatorTraversalFailure,
10971101
filters: appConfig?.filters,
10981102
legacyPii: appConfig?.messageFilter?.pii,
10991103
submittedMessages: inputMessages,

api/server/controllers/agents/resume.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const {
3131
getAgentCheckpointer,
3232
isContentFilterError,
3333
preflightResumeContent,
34+
reportLocatorTraversalFailure,
3435
getResumeProvenance,
3536
getUserFacingResumeError,
3637
decrementPendingRequest,
@@ -197,6 +198,7 @@ async function deleteFailedResumeCheckpoint(args, context) {
197198
const GENERIC_RESUME_ERROR = 'Resume failed';
198199

199200
const resumeContentProtectionDependencies = {
201+
onTraversalFailure: reportLocatorTraversalFailure,
200202
getAgentCheckpointer,
201203
checkAccess,
202204
getMessages,

api/server/controllers/agents/v1.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const {
3737
isContentTraversalProtected,
3838
isContentTraversalLimitError,
3939
resolveCanonicalFileReferences,
40+
reportLocatorTraversalFailure,
4041
} = require('@librechat/api');
4142
const {
4243
Time,
@@ -176,6 +177,7 @@ const blockFilteredAgentContent = async (req, res, agentData) => {
176177
if (filePolicyActive) {
177178
try {
178179
const fileInspection = await resolveCanonicalFileReferences({
180+
onTraversalFailure: reportLocatorTraversalFailure,
179181
filters,
180182
input: agentData,
181183
user: req.user,

0 commit comments

Comments
 (0)