mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🪜 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:
parent
9446f3278c
commit
cb5454d364
3 changed files with 95 additions and 3 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue