diff --git a/packages/api/src/agents/config.spec.ts b/packages/api/src/agents/config.spec.ts index d09282b5e2..556da21640 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 } 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); + }); +}); diff --git a/packages/api/src/agents/config.ts b/packages/api/src/agents/config.ts index c5c1808f66..46f54314bd 100644 --- a/packages/api/src/agents/config.ts +++ b/packages/api/src/agents/config.ts @@ -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 | 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 | undefined, + agent: { recursion_limit?: number } | undefined, +): number { + const limit = resolveRecursionLimit(agentsEConfig, agent); + return Math.floor(limit / SUBAGENT_RECURSION_MULTIPLIER); +} diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 71f82ddec8..7a76fc62b6 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -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 | undefined, ancestors: Set = 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