🪜 fix: Apply Recursion Limit Config to Subagents (#14185)

* 🪜 fix: Apply recursion limit config to subagents

Subagents ran through the SDK's SubagentExecutor path (recursionLimit =
maxTurns * 3, default maxTurns 25 -> 75) which is decoupled from the
top-level resolveRecursionLimit path. buildSubagentConfigs never set
maxTurns, so subagents ignored the Agent Builder recursion_limit and the
YAML recursionLimit/maxRecursionLimit, always capping at 75 steps.

Add resolveSubagentMaxTurns (reuses resolveRecursionLimit, then
max(25, ceil(limit / 3))) and set maxTurns on each SubagentConfig:
self-spawn mirrors the parent, explicit children use their own
recursion_limit. Floor at 25 avoids regressing below the historical 75;
ceil keeps the effective graph limit within the resolved value and the
maxRecursionLimit cap.

Fixes #14181

* 🔒 fix: Clamp subagent maxTurns within maxRecursionLimit

The default floor and ceil rounding could push a subagent's effective
graph limit (maxTurns * 3) above the admin maxRecursionLimit cap while
top-level agents are capped exactly (e.g. cap 20 -> 75 steps, cap 200 ->
201). Clamp maxTurns to floor(maxRecursionLimit / 3) so the effective
limit never exceeds the cap, keeping a minimum of one turn.

* 🎯 fix: Honor lowered recursion limits for subagents

Drop the 75-step subagent floor: it kept subagents at 75 even when an
admin/user lowered recursionLimit or recursion_limit below it, defeating
cost/runaway control. Since resolveRecursionLimit already caps at
maxRecursionLimit, deriving maxTurns as floor(limit / 3) keeps the
effective graph limit at or below the resolved value, so it both honors
lowered limits and never overshoots the admin cap (subsumes the prior
explicit clamp). Minimum one turn.

* 🧮 fix: Honor sub-3 recursion caps for subagents

Drop the max(1, ...) floor on resolved subagent maxTurns. A cap of 1 or 2
previously became 1 turn (3 steps), exceeding the ceiling the top-level
path enforces. floor(limit / 3) now yields 0 turns for a sub-3 cap, so
the child never gets more steps than the resolved cap; the SDK returns a
graceful recursion error, matching a top-level run with recursionLimit
below 3.
This commit is contained in:
Danny Avila 2026-07-09 09:38:59 -04:00 committed by GitHub
parent 9446f3278c
commit cb5454d364
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 95 additions and 3 deletions

View file

@ -1,5 +1,5 @@
import type { TAgentsEndpoint } from 'librechat-data-provider';
import { resolveRecursionLimit } from './config';
import { resolveRecursionLimit, resolveSubagentMaxTurns } from './config';
describe('resolveRecursionLimit', () => {
it('returns default 50 when no config or agent provided', () => {
@ -60,3 +60,54 @@ describe('resolveRecursionLimit', () => {
expect(resolveRecursionLimit(config, { recursion_limit: 150 })).toBe(150);
});
});
describe('resolveSubagentMaxTurns', () => {
it('tracks the default resolved limit (50 -> 16 turns / 48 graph steps)', () => {
expect(resolveSubagentMaxTurns(undefined, undefined)).toBe(16);
});
it('derives maxTurns from the per-agent recursion_limit so the graph limit tracks it', () => {
const config = { recursionLimit: 50, maxRecursionLimit: 1000 } as TAgentsEndpoint;
expect(resolveSubagentMaxTurns(config, { recursion_limit: 500 })).toBe(166);
});
it('derives maxTurns from the yaml recursionLimit default', () => {
const config = { recursionLimit: 300 } as TAgentsEndpoint;
expect(resolveSubagentMaxTurns(config, {})).toBe(100);
});
it('honors an explicit recursion limit below the historical 75-step default', () => {
const config = { recursionLimit: 45 } as TAgentsEndpoint;
const turns = resolveSubagentMaxTurns(config, {});
expect(turns).toBe(15);
expect(turns * 3).toBeLessThanOrEqual(45);
});
it('honors a per-agent recursion_limit lowered below the yaml default', () => {
const config = { recursionLimit: 300 } as TAgentsEndpoint;
const turns = resolveSubagentMaxTurns(config, { recursion_limit: 30 });
expect(turns).toBe(10);
expect(turns * 3).toBeLessThanOrEqual(30);
});
it('never exceeds maxRecursionLimit when it caps the resolved limit', () => {
const config = { recursionLimit: 100, maxRecursionLimit: 150 } as TAgentsEndpoint;
const turns = resolveSubagentMaxTurns(config, { recursion_limit: 600 });
expect(turns).toBe(50);
expect(turns * 3).toBeLessThanOrEqual(150);
});
it('never exceeds a small maxRecursionLimit', () => {
const config = { maxRecursionLimit: 20 } as TAgentsEndpoint;
const turns = resolveSubagentMaxTurns(config, {});
expect(turns).toBe(6);
expect(turns * 3).toBeLessThanOrEqual(20);
});
it('yields 0 turns when the resolved cap is below the multiplier (never exceeds it)', () => {
const config = { maxRecursionLimit: 2 } as TAgentsEndpoint;
const turns = resolveSubagentMaxTurns(config, {});
expect(turns).toBe(0);
expect(turns * 3).toBeLessThanOrEqual(2);
});
});

View file

@ -2,6 +2,14 @@ import type { TAgentsEndpoint } from 'librechat-data-provider';
const DEFAULT_RECURSION_LIMIT = 50;
/**
* Mirrors `RECURSION_MULTIPLIER` in `@librechat/agents` `SubagentExecutor`,
* which derives a subagent's graph `recursionLimit` as `maxTurns * 3`. Keep in
* sync with the SDK so a subagent's effective recursion limit matches the
* resolved value it is configured for.
*/
const SUBAGENT_RECURSION_MULTIPLIER = 3;
/**
* Resolves the effective recursion limit for an agent run via a 3-step cascade:
* 1. YAML endpoint config default (falls back to 50)
@ -9,7 +17,7 @@ const DEFAULT_RECURSION_LIMIT = 50;
* 3. Global max cap from YAML (if set and positive)
*/
export function resolveRecursionLimit(
agentsEConfig: TAgentsEndpoint | undefined,
agentsEConfig: Partial<TAgentsEndpoint> | undefined,
agent: { recursion_limit?: number } | undefined,
): number {
let limit = agentsEConfig?.recursionLimit ?? DEFAULT_RECURSION_LIMIT;
@ -28,3 +36,26 @@ export function resolveRecursionLimit(
return limit;
}
/**
* Resolves a subagent's `maxTurns` so its graph `recursionLimit`
* (`maxTurns * SUBAGENT_RECURSION_MULTIPLIER` in the SDK) tracks the same
* resolved recursion limit as a top-level run. Without this, subagents ignore
* both the YAML `recursionLimit`/`maxRecursionLimit` and the per-agent
* `recursion_limit`, always running at the SDK default of 75 graph steps.
*
* `floor` keeps the effective graph limit at or below the resolved value, which
* (since `resolveRecursionLimit` already caps at `maxRecursionLimit`) also keeps
* it within the admin cap so a lowered limit applies to subagents too, and
* `maxTurns * 3` never overshoots the ceiling. A resolved limit below the
* multiplier yields 0 turns: like a top-level run with `recursionLimit < 3`, the
* child can't take a full step, and the SDK returns a graceful recursion error
* rather than silently granting more steps than the cap allows.
*/
export function resolveSubagentMaxTurns(
agentsEConfig: Partial<TAgentsEndpoint> | undefined,
agent: { recursion_limit?: number } | undefined,
): number {
const limit = resolveRecursionLimit(agentsEConfig, agent);
return Math.floor(limit / SUBAGENT_RECURSION_MULTIPLIER);
}

View file

@ -25,6 +25,7 @@ import type {
} from '@librechat/agents';
import type {
Agent,
TAgentsEndpoint,
AgentModelParameters,
AgentSubagentsConfig,
ReasoningResponseKey,
@ -47,6 +48,7 @@ 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';
@ -816,6 +818,7 @@ function buildSubagentConfigs(
agentInput: AgentInputs,
toInput: (child: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs,
state: SubagentBuildState,
agentsEConfig: Partial<TAgentsEndpoint> | undefined,
ancestors: Set<string> = new Set(),
depth = 0,
): SubagentConfig[] {
@ -834,6 +837,8 @@ function buildSubagentConfigs(
type: SELF_SUBAGENT_TYPE,
name: selfName,
description: `Spawn ${selfName} in an isolated context to handle a focused subtask. Verbose tool output stays in the child's context; only a summary returns.`,
/** Self-spawn reuses the parent's config, so mirror the parent's recursion limit. */
maxTurns: resolveSubagentMaxTurns(agentsEConfig, agent),
});
}
@ -882,6 +887,7 @@ function buildSubagentConfigs(
childInputs,
toInput,
state,
agentsEConfig,
nextAncestors,
childDepth,
);
@ -895,6 +901,8 @@ function buildSubagentConfigs(
child.description ??
`Delegate a subtask to the ${child.name ?? child.id} agent in an isolated context.`,
agentInputs: childInputs,
/** Honor each child agent's own resolved recursion limit. */
maxTurns: resolveSubagentMaxTurns(agentsEConfig, child),
});
}
@ -1213,6 +1221,8 @@ export async function createRun({
return agentInput;
};
const agentsEndpointConfig = appConfig?.endpoints?.[EModelEndpoint.agents];
const agentInputs: AgentInputs[] = [];
const subagentBuildState: SubagentBuildState = {
configCount: 0,
@ -1225,6 +1235,7 @@ export async function createRun({
agentInput,
buildAgentInput,
subagentBuildState,
agentsEndpointConfig,
);
if (subagentConfigs.length > 0) {
agentInput.subagentConfigs = subagentConfigs;
@ -1271,7 +1282,6 @@ export async function createRun({
* and the resume route). When disabled, nothing attaches and the run is identical
* to before this feature shipped.
*/
const agentsEndpointConfig = appConfig?.endpoints?.[EModelEndpoint.agents];
// Resolve the effective policy through the single seam so per-agent / per-skill
// sources can layer in later without touching this call site (see
// `resolveToolApprovalPolicy`). Only the endpoint layer is wired today, so this