diff --git a/api/package.json b/api/package.json index 5fd1cb6908..64315703e4 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.3.11", + "@librechat/agents": "^3.3.12", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/librechat.example.yaml b/librechat.example.yaml index b6cac68e6d..4622cf2b68 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -407,6 +407,17 @@ endpoints: # recursionLimit: 50 # # (optional) Max recursion depth for agents, defaults to 25 # maxRecursionLimit: 100 + # # (optional) Abort a run once a single streamed tool call's arguments exceed this many bytes. + # # Guards against runaway malformed tool-call generation. Defaults to 65536 (64 KiB); 0 disables. + # maxToolCallArgBytes: 65536 + # # (optional) Abort a run once a single model generation emits more than this many stream events. + # # Defense in depth against looping provider streams. Disabled by default. + # maxDeltaEventsPerTurn: 100000 + # # (optional) Per-tool overrides for maxToolCallArgBytes, keyed by tool name; 0 disables that + # # tool's guard. LibreChat ships { create_file: 131072 } so document-sized file writes are not + # # cut off; entries here merge over (and can replace) that default. + # maxToolCallArgBytesByTool: + # create_file: 131072 # # (optional) Disable the builder interface for agents # disableBuilder: false # # (optional) When conversation titles are generated: diff --git a/package-lock.json b/package-lock.json index 441d40922a..bb9f80f521 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.3.11", + "@librechat/agents": "^3.3.12", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -10615,9 +10615,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.3.11.tgz", - "integrity": "sha512-rCCUQbi6b2HcUfq11a/1FXR9JveCmV/30HH64TkVyVHoUkL7QlSd99rddaYRjnAcsGlWb1zY+0oHEO1jQ3m93A==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.3.12.tgz", + "integrity": "sha512-kTB50KMlEXR3jMLmGMZzzSkne019k57Wy3Fv0dGX7I4G/oxJMgqZsw70KqiAMYokNOQnc1DoWGmIcZLgJYcewA==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.115.0", @@ -10653,6 +10653,7 @@ "okapibm25": "^1.4.1", "openai": "^6.46.0", "reo-census": "^1.2.9", + "socks-proxy-agent": "^8.0.5", "uuid": "^11.1.1" }, "engines": { @@ -38978,12 +38979,50 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/smob": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/smob/-/smob-1.4.1.tgz", "integrity": "sha512-9LK+E7Hv5R9u4g4C3p+jjLstaLe11MDsL21UpYaCNmapvMkYhqCV4A/f/3gyH8QjMyh6l68q9xC85vihY9ahMQ==", "dev": true }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -42658,7 +42697,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.3.11", + "@librechat/agents": "^3.3.12", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/package.json b/packages/api/package.json index 1b87780d68..e410a7213f 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -107,7 +107,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.3.11", + "@librechat/agents": "^3.3.12", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/src/agents/config.spec.ts b/packages/api/src/agents/config.spec.ts index 556da21640..af98cf04b5 100644 --- a/packages/api/src/agents/config.spec.ts +++ b/packages/api/src/agents/config.spec.ts @@ -1,5 +1,5 @@ import type { TAgentsEndpoint } from 'librechat-data-provider'; -import { resolveRecursionLimit, resolveSubagentMaxTurns } from './config'; +import { resolveRecursionLimit, resolveStreamLimits, resolveSubagentMaxTurns } from './config'; describe('resolveRecursionLimit', () => { it('returns default 50 when no config or agent provided', () => { @@ -111,3 +111,61 @@ describe('resolveSubagentMaxTurns', () => { expect(turns * 3).toBeLessThanOrEqual(2); }); }); + +describe('resolveStreamLimits', () => { + const CREATE_FILE_DEFAULT = { create_file: 131072 }; + + it('ships only the create_file override when no yaml fields are set, so SDK defaults apply', () => { + expect(resolveStreamLimits(undefined)).toEqual({ + maxToolCallArgBytesByTool: CREATE_FILE_DEFAULT, + }); + expect(resolveStreamLimits({} as TAgentsEndpoint)).toEqual({ + maxToolCallArgBytesByTool: CREATE_FILE_DEFAULT, + }); + }); + + it('maps both global yaml fields onto the SDK streamLimits shape', () => { + const config = { + maxToolCallArgBytes: 131072, + maxDeltaEventsPerTurn: 100000, + } as TAgentsEndpoint; + expect(resolveStreamLimits(config)).toEqual({ + maxToolCallArgBytes: 131072, + maxToolCallArgBytesByTool: CREATE_FILE_DEFAULT, + maxDeltaEventsPerTurn: 100000, + }); + }); + + it('passes each global field independently, omitting the unset one', () => { + expect(resolveStreamLimits({ maxToolCallArgBytes: 1024 } as TAgentsEndpoint)).toEqual({ + maxToolCallArgBytes: 1024, + maxToolCallArgBytesByTool: CREATE_FILE_DEFAULT, + }); + expect(resolveStreamLimits({ maxDeltaEventsPerTurn: 5000 } as TAgentsEndpoint)).toEqual({ + maxToolCallArgBytesByTool: CREATE_FILE_DEFAULT, + maxDeltaEventsPerTurn: 5000, + }); + }); + + it('passes an explicit 0 through so admins can disable the SDK default', () => { + expect(resolveStreamLimits({ maxToolCallArgBytes: 0 } as TAgentsEndpoint)).toEqual({ + maxToolCallArgBytes: 0, + maxToolCallArgBytesByTool: CREATE_FILE_DEFAULT, + }); + }); + + it('merges yaml per-tool entries over the shipped create_file default', () => { + expect(resolveStreamLimits({ maxToolCallArgBytesByTool: { my_mcp_tool: 32768 } })).toEqual({ + maxToolCallArgBytesByTool: { create_file: 131072, my_mcp_tool: 32768 }, + }); + }); + + it('lets a yaml create_file entry replace the shipped default, including 0 to disable', () => { + expect(resolveStreamLimits({ maxToolCallArgBytesByTool: { create_file: 262144 } })).toEqual({ + maxToolCallArgBytesByTool: { create_file: 262144 }, + }); + expect(resolveStreamLimits({ maxToolCallArgBytesByTool: { create_file: 0 } })).toEqual({ + maxToolCallArgBytesByTool: { create_file: 0 }, + }); + }); +}); diff --git a/packages/api/src/agents/config.ts b/packages/api/src/agents/config.ts index 46f54314bd..df413e69bc 100644 --- a/packages/api/src/agents/config.ts +++ b/packages/api/src/agents/config.ts @@ -1,4 +1,5 @@ import type { TAgentsEndpoint } from 'librechat-data-provider'; +import { CREATE_FILE_TOOL_NAME } from '~/agents/tools'; const DEFAULT_RECURSION_LIMIT = 50; @@ -59,3 +60,43 @@ export function resolveSubagentMaxTurns( const limit = resolveRecursionLimit(agentsEConfig, agent); return Math.floor(limit / SUBAGENT_RECURSION_MULTIPLIER); } + +/** Mirrors `StreamLimits` in `@librechat/agents` (agents#381). */ +export interface StreamLimitsConfig { + maxToolCallArgBytes?: number; + maxToolCallArgBytesByTool?: Record; + maxDeltaEventsPerTurn?: number; +} + +/** + * LibreChat's shipped per-tool override: create_file legitimately streams + * whole documents as its content argument (production p99 of 80.6 KiB versus + * under 10 KiB for every other tool class), so it runs at twice the SDK's + * 64 KiB global default instead of loosening the cap for all tools. + */ +const CREATE_FILE_MAX_TOOL_CALL_ARG_BYTES = 131_072; + +/** + * Maps the librechat.yaml stream circuit-breaker fields + * (`endpoints.agents.maxToolCallArgBytes` / `maxToolCallArgBytesByTool` / + * `maxDeltaEventsPerTurn`) to the SDK's `RunConfig.streamLimits`. Unset + * global fields keep the SDK defaults (64 KiB per streamed tool call's + * arguments, per-turn delta event cap off), while the per-tool map always + * ships the create_file override; a yaml entry for the same tool wins. Value + * normalization (0 disables, NaN falls back) lives in the SDK's + * `resolveStreamLimits`. + */ +export function resolveStreamLimits( + agentsEConfig: Partial | undefined, +): StreamLimitsConfig { + const maxToolCallArgBytes = agentsEConfig?.maxToolCallArgBytes; + const maxDeltaEventsPerTurn = agentsEConfig?.maxDeltaEventsPerTurn; + return { + ...(maxToolCallArgBytes != null && { maxToolCallArgBytes }), + maxToolCallArgBytesByTool: { + [CREATE_FILE_TOOL_NAME]: CREATE_FILE_MAX_TOOL_CALL_ARG_BYTES, + ...agentsEConfig?.maxToolCallArgBytesByTool, + }, + ...(maxDeltaEventsPerTurn != null && { maxDeltaEventsPerTurn }), + }; +} diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 240c311763..94acfba396 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -52,6 +52,7 @@ import { applyCustomHandoffPromptKeyCompatibility } from '~/agents/handoffPrompt import { stripIntentFromToolRegistry, stripIntentFromToolDefinitions } from '~/agents/intent'; import { isSteeringSupported, isSteerPreemptSupported } from '~/agents/steering/runtime'; import { getLLMConfig as getAnthropicLLMConfig } from '~/endpoints/anthropic/llm'; +import { resolveStreamLimits, resolveSubagentMaxTurns } from '~/agents/config'; import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME } from '~/agents/tools'; import { getProviderConfig } from '~/endpoints/config/providers'; import { extractDefaultParams } from '~/endpoints/openai/llm'; @@ -59,7 +60,6 @@ import { resolveHeaders, createSafeUser } from '~/utils/env'; import { getAgentCheckpointer } from '~/agents/checkpointer'; import { getOpenAIConfig } from '~/endpoints/openai/config'; import { buildHITLRunWiring } from '~/agents/hitl/runtime'; -import { resolveSubagentMaxTurns } from '~/agents/config'; import { buildLangfuseConfig } from '~/langfuse/config'; import { resolveConfigHeaders } from '~/utils/headers'; import { applyTestRunHook } from '~/agents/testHook'; @@ -1540,6 +1540,8 @@ export async function createRun({ } } + const streamLimits = resolveStreamLimits(agentsEndpointConfig); + /** * Built as a variable (not an inline literal) so the extra * `subagentUsageSink` field passes assignability against SDK versions @@ -1643,6 +1645,12 @@ export async function createRun({ // live, so gating both on the same capability keeps them in lockstep. ...(steering?.preemption != null && isSteerPreemptSupported() && { preemption: steering.preemption }), + // Stream circuit breakers (librechat.yaml endpoints.agents.maxToolCallArgBytes / + // maxDeltaEventsPerTurn). Omitted when unset so the SDK defaults apply: a runaway + // streamed tool-call argument aborts the run at 64 KiB, the per-turn delta event + // cap stays off. Requires @librechat/agents with streamLimits support (agents#381); + // older versions ignore the field. + ...(streamLimits && { streamLimits }), }; const run = await Run.create(runConfig); diff --git a/packages/api/src/agents/tools.ts b/packages/api/src/agents/tools.ts index 3f789d9a1a..c89c750677 100644 --- a/packages/api/src/agents/tools.ts +++ b/packages/api/src/agents/tools.ts @@ -181,7 +181,8 @@ const SKILL_CREATE_FILE_PARAMETERS: LCTool['parameters'] = Object.freeze({ }, content: { type: 'string', - description: 'Complete file contents.', + description: + 'Complete file contents. Keep a single call well under the streamed tool-argument limit (64 KB by default); build larger files incrementally with edit_file.', }, overwrite: { type: 'boolean', @@ -202,7 +203,8 @@ const CODE_CREATE_FILE_PARAMETERS: LCTool['parameters'] = Object.freeze({ }, content: { type: 'string', - description: 'Complete file contents.', + description: + 'Complete file contents. Keep a single call well under the streamed tool-argument limit (64 KB by default); build larger files incrementally with edit_file.', }, overwrite: { type: 'boolean', @@ -295,7 +297,9 @@ const CODE_CREATE_FILE_DESCRIPTION = `Create a new file, or overwrite an existin Use for new files and full rewrites where the change is larger than half the file. Requires overwrite: true to replace existing files. Refuses otherwise. -Targets code-execution sandbox paths. Prefer /mnt/data/{file} for files that should remain available to later sandbox calls.`; +Targets code-execution sandbox paths. Prefer /mnt/data/{file} for files that should remain available to later sandbox calls. + +Very long content can exceed the streamed tool-argument limit (64 KB by default) and fail the call. For large files, create the file with its first section, then extend it with edit_file.`; const SKILL_EDIT_FILE_DESCRIPTION = `Apply targeted text replacements to an existing file. diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 83fed8dfd0..05304e958c 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -916,6 +916,15 @@ export const agentsEndpointSchema = baseEndpointSchema recursionLimit: z.number().optional(), disableBuilder: z.boolean().optional().default(false), maxRecursionLimit: z.number().optional(), + /** Max cumulative bytes a single streamed tool call's arguments may reach before the run + * aborts. Defaults to 64 KiB in the agents SDK; `0` disables the guard. */ + maxToolCallArgBytes: z.number().optional(), + /** Max streamed chunk events per model generation before the run aborts. Off by default. */ + maxDeltaEventsPerTurn: z.number().optional(), + /** Per-tool overrides of `maxToolCallArgBytes`, keyed by model-facing tool name; `0` + * disables the guard for that tool only. Merged over LibreChat's shipped default of + * `{ create_file: 131072 }`. */ + maxToolCallArgBytesByTool: z.record(z.number()).optional(), maxCitations: z.number().min(1).max(50).optional().default(30), maxCitationsPerFile: z.number().min(1).max(10).optional().default(7), minRelevanceScore: z.number().min(0.0).max(1.0).optional().default(0.45),