From ccf4301093f8dd2321a42380339f82bcd66be825 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 4 Aug 2026 23:05:05 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A6=20feat:=20Configurable=20Circuit?= =?UTF-8?q?=20Breakers=20for=20Runaway=20Streamed=20Tool=20Args=20(#14613)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🚦 feat: Configurable Circuit Breakers for Runaway Streamed Tool Args * docs: forewarn create_file about the streamed tool-argument limit The breaker failing a near-limit write should not be the model's first exposure to the bound. Both create_file variants now state the default 64 KB per-call limit and the incremental pattern (create the first section, extend with edit_file) in the tool description and the content parameter description. * fix: keep skill create_file description under the provider advisory cap The limit-guidance paragraph pushed the skill-aware description to 1169 chars, past the 1024-char advisory bound where providers may truncate. The skill variant now carries the guidance only in its content parameter description, which sits closest to the generated payload and is not at truncation risk; the shorter code-sandbox variant keeps the full paragraph. * 🚦 feat: per-tool streamed-arg limits with a create_file default Thirty days of production data show create_file is the only tool class with legitimate near-limit arguments (p99 80.6 KiB; every other tool p99 under 10 KiB). Rather than loosening the global 64 KiB cap for all tools, the yaml gains maxToolCallArgBytesByTool (per-tool overrides, keyed by model-facing tool name, 0 disables that tool's guard) and LibreChat ships { create_file: 131072 } by default; yaml entries merge over and can replace it. Pairs with maxToolCallArgBytesByTool support in the agents SDK and stays inert until the dependency bump. * test: pass per-tool spec configs as plain Partial literals The as-TAgentsEndpoint casts fail TS2352 for object-valued fields: comparability does not grant nested literals the implicit index signature that plain assignability does, so casts carrying maxToolCallArgBytesByTool never sufficiently overlap. The mapper already accepts Partial, so the new cases pass uncast literals instead. * chore(deps): bump @librechat/agents to 3.3.12 --- api/package.json | 2 +- librechat.example.yaml | 11 +++++ package-lock.json | 49 ++++++++++++++++++--- packages/api/package.json | 2 +- packages/api/src/agents/config.spec.ts | 60 +++++++++++++++++++++++++- packages/api/src/agents/config.ts | 41 ++++++++++++++++++ packages/api/src/agents/run.ts | 10 ++++- packages/api/src/agents/tools.ts | 10 +++-- packages/data-provider/src/config.ts | 9 ++++ 9 files changed, 182 insertions(+), 12 deletions(-) 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),