🧬 fix: Bound Subagent Expansion (#13064)

* fix: Bound subagent expansion

* fix: Preserve subagent path depth
This commit is contained in:
Danny Avila 2026-05-11 08:53:53 -04:00 committed by GitHub
parent 508168fa57
commit 70b6bb69d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 339 additions and 42 deletions

View file

@ -15,9 +15,11 @@ const {
ResourceType,
EModelEndpoint,
PermissionBits,
MAX_SUBAGENT_DEPTH,
isAgentsEndpoint,
getResponseSender,
AgentCapabilities,
MAX_SUBAGENT_GRAPH_NODES,
isEphemeralAgentId,
} = require('librechat-data-provider');
const {
@ -637,6 +639,19 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
* silently break nested delegation like A B C where B is only
* a subagent of A. */
const pureSubagentIds = new Set();
const subagentGraphIds = new Set();
const loadedSubagentConfigIds = new Set();
const assertSubagentGraphRoom = (agentId) => {
if (subagentGraphIds.has(agentId)) {
return;
}
if (subagentGraphIds.size >= MAX_SUBAGENT_GRAPH_NODES) {
throw new Error(
`Subagent graph exceeds the maximum of ${MAX_SUBAGENT_GRAPH_NODES} unique agents.`,
);
}
};
/**
* Loads `subagentAgentConfigs` for a single agent config. Shared
@ -646,13 +661,22 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
* gets them honored at runtime. Self-spawn works regardless (no DB
* lookup needed). Pruning decisions are deferred to `pureSubagentIds`.
*/
const loadSubagentsFor = async (config) => {
const loadSubagentsFor = async (config, depth = 0) => {
const sub = config.subagents;
if (!subagentsCapabilityEnabled || !sub?.enabled) {
config.subagentAgentConfigs = [];
return;
}
if (loadedSubagentConfigIds.has(config.id)) {
if ((config.subagentAgentConfigs?.length ?? 0) > 0 && depth >= MAX_SUBAGENT_DEPTH) {
throw new Error(
`Subagent graph exceeds the maximum depth of ${MAX_SUBAGENT_DEPTH} at agent ${config.id}.`,
);
}
return;
}
/** Dedupe and filter in one pass a crafted payload could
* legitimately include the same ID twice; the backend shouldn't
* create duplicate SubagentConfig entries for the LLM to see as
@ -665,6 +689,14 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
),
);
if (explicitSubagentIds.length > 0 && depth >= MAX_SUBAGENT_DEPTH) {
throw new Error(
`Subagent graph exceeds the maximum depth of ${MAX_SUBAGENT_DEPTH} at agent ${config.id}.`,
);
}
loadedSubagentConfigIds.add(config.id);
/** @type {Array<Object>} */
const resolved = [];
for (const subagentId of explicitSubagentIds) {
@ -680,9 +712,11 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
continue;
}
assertSubagentGraphRoom(subagentId);
const subagentConfig = await loadAgentById(subagentId);
if (!subagentConfig) continue;
subagentGraphIds.add(subagentConfig.id ?? subagentId);
resolved.push(subagentConfig);
if (!edgeAgentIds.has(subagentId)) {
@ -693,35 +727,32 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
config.subagentAgentConfigs = resolved;
};
/** BFS across the primary's subagent tree so nested chains like
* A B C get resolved before any pruning. Each config is
* visited once. */
const visitedConfigIds = new Set();
const pending = [primaryConfig];
while (pending.length > 0) {
const cfg = pending.shift();
if (!cfg || visitedConfigIds.has(cfg.id)) continue;
visitedConfigIds.add(cfg.id);
await loadSubagentsFor(cfg);
for (const child of cfg.subagentAgentConfigs ?? []) {
if (child?.id && !visitedConfigIds.has(child.id)) {
pending.push(child);
const maxResolvedDepthByConfigId = new Map();
/** BFS across subagent trees so nested chains like A B C get
* resolved before any pruning. Agent configs are loaded once, but
* overlapping roots can still be revisited at deeper path depths so
* the depth guard observes the deepest reachable subagent path. */
const resolveSubagentTrees = async (rootConfigs) => {
const pending = rootConfigs.map((cfg) => ({ cfg, depth: 0 }));
for (let index = 0; index < pending.length; index++) {
const { cfg, depth } = pending[index];
if (!cfg?.id) continue;
const previousDepth = maxResolvedDepthByConfigId.get(cfg.id);
if (previousDepth != null && previousDepth >= depth) continue;
maxResolvedDepthByConfigId.set(cfg.id, depth);
await loadSubagentsFor(cfg, depth);
for (const child of cfg.subagentAgentConfigs ?? []) {
const childDepth = depth + 1;
const previousChildDepth = child?.id ? maxResolvedDepthByConfigId.get(child.id) : undefined;
if (child?.id && (previousChildDepth == null || previousChildDepth < childDepth)) {
pending.push({ cfg: child, depth: childDepth });
}
}
}
}
/** Handoff targets still in the map that weren't visited via the
* primary's subagent tree also need their subagents resolved. */
for (const [id, cfg] of agentConfigs.entries()) {
if (id === primaryConfig.id || visitedConfigIds.has(id)) continue;
visitedConfigIds.add(id);
await loadSubagentsFor(cfg);
for (const child of cfg.subagentAgentConfigs ?? []) {
if (child?.id && !visitedConfigIds.has(child.id)) {
visitedConfigIds.add(child.id);
await loadSubagentsFor(child);
}
}
}
};
await resolveSubagentTrees([primaryConfig, ...agentConfigs.values()]);
/** Drop pure-subagent entries now that every reachable config has
* had its subagents resolved. They stay in `agentToolContexts` so

View file

@ -4,6 +4,8 @@ const {
PermissionBits,
PrincipalType,
PrincipalModel,
MAX_SUBAGENT_DEPTH,
MAX_SUBAGENT_GRAPH_NODES,
} = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
@ -311,6 +313,24 @@ describe('initializeClient — subagent loading', () => {
maxContextTokens: 4096,
});
const makeNestedSubagentConfig = (id, childIds = []) => ({
...makeSubagentConfig(id),
subagents: { enabled: true, allowSelf: false, agent_ids: childIds },
});
const createViewableAgent = async (id) => {
const agent = await createAgent({
id,
name: id,
provider: 'openai',
model: 'gpt-4',
author: new mongoose.Types.ObjectId(),
tools: [],
});
await grantView(agent);
return agent;
};
it('loads a configured subagent, populates `subagentAgentConfigs`, and keeps it out of `agentConfigs`', async () => {
const subAgent = await createAgent({
id: SUBAGENT_ID,
@ -453,6 +473,117 @@ describe('initializeClient — subagent loading', () => {
expect(agentClientArgs.agent.subagentAgentConfigs).toHaveLength(1);
});
it('rejects nested subagent chains deeper than MAX_SUBAGENT_DEPTH', async () => {
const ids = Array.from(
{ length: MAX_SUBAGENT_DEPTH + 1 },
(_, index) => `agent_depth_${index}`,
);
for (const id of ids) {
await createViewableAgent(id);
}
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, agent_ids: [ids[0]] },
});
const nestedConfigs = new Map(
ids.map((id, index) => [
id,
makeNestedSubagentConfig(id, index < ids.length - 1 ? [ids[index + 1]] : []),
]),
);
mockInitializeAgent.mockImplementation(({ agent }) =>
Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : nestedConfigs.get(agent.id)),
);
await expect(
initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
}),
).rejects.toThrow(`maximum depth of ${MAX_SUBAGENT_DEPTH}`);
expect(agentClientArgs).toBeUndefined();
});
it('preserves deeper path depth when a handoff root is also a nested subagent', async () => {
const chainIds = Array.from(
{ length: MAX_SUBAGENT_DEPTH },
(_, index) => `agent_overlap_depth_${index}`,
);
const allIds = [HANDOFF_AND_SUB_ID, ...chainIds];
for (const id of allIds) {
await createViewableAgent(id);
}
const edges = [{ from: PRIMARY_ID, to: HANDOFF_AND_SUB_ID, edgeType: 'handoff' }];
const primaryConfig = makePrimaryConfig({
edges,
subagents: { enabled: true, allowSelf: false, agent_ids: [HANDOFF_AND_SUB_ID] },
});
const nestedConfigs = new Map([
[HANDOFF_AND_SUB_ID, makeNestedSubagentConfig(HANDOFF_AND_SUB_ID, [chainIds[0]])],
...chainIds.map((id, index) => [
id,
makeNestedSubagentConfig(id, index < chainIds.length - 1 ? [chainIds[index + 1]] : []),
]),
]);
mockInitializeAgent.mockImplementation(({ agent }) =>
Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : nestedConfigs.get(agent.id)),
);
await expect(
initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
}),
).rejects.toThrow(`maximum depth of ${MAX_SUBAGENT_DEPTH}`);
expect(agentClientArgs).toBeUndefined();
});
it('rejects subagent graphs that exceed MAX_SUBAGENT_GRAPH_NODES unique agents', async () => {
const firstLevelIds = Array.from({ length: 10 }, (_, index) => `agent_graph_${index}`);
const secondLevelIdsByParent = new Map(
firstLevelIds.map((id) => [
id,
Array.from({ length: 5 }, (_, index) => `${id}_child_${index}`),
]),
);
const allIds = [...firstLevelIds, ...Array.from(secondLevelIdsByParent.values()).flat()];
for (const id of allIds) {
await createViewableAgent(id);
}
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, agent_ids: firstLevelIds },
});
const nestedConfigs = new Map(
firstLevelIds.map((id) => [id, makeNestedSubagentConfig(id, secondLevelIdsByParent.get(id))]),
);
for (const id of Array.from(secondLevelIdsByParent.values()).flat()) {
nestedConfigs.set(id, makeNestedSubagentConfig(id));
}
mockInitializeAgent.mockImplementation(({ agent }) =>
Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : nestedConfigs.get(agent.id)),
);
await expect(
initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
}),
).rejects.toThrow(`maximum of ${MAX_SUBAGENT_GRAPH_NODES} unique agents`);
expect(agentClientArgs).toBeUndefined();
});
it('keeps an agent in `agentConfigs` when it is BOTH a handoff target and a subagent', async () => {
/** Overlap case: the same child is used both via handoff edges (needs to
* be in agentConfigs) and as a subagent (needs to be in

View file

@ -1,6 +1,11 @@
import type { AppConfig } from '@librechat/data-schemas';
import type { SummarizationConfig, TEndpoint } from 'librechat-data-provider';
import { EModelEndpoint, FileSources } from 'librechat-data-provider';
import {
EModelEndpoint,
FileSources,
MAX_SUBAGENT_DEPTH,
MAX_SUBAGENT_RUN_CONFIGS,
} from 'librechat-data-provider';
import { createRun } from '~/agents/run';
// Mock winston logger
@ -53,6 +58,57 @@ function makeAgent(
};
}
type TestRunAgent = ReturnType<typeof makeAgent> & {
subagentAgentConfigs?: TestRunAgent[];
};
function makeSubagentChain(hops: number): TestRunAgent {
const agents = Array.from({ length: hops + 1 }, (_, index) =>
makeAgent({
id: `agent_chain_${index}`,
name: `Chain ${index}`,
}),
) as TestRunAgent[];
for (let index = 0; index < hops; index++) {
const child = agents[index + 1];
agents[index].subagents = { enabled: true, allowSelf: false, agent_ids: [child.id] };
agents[index].subagentAgentConfigs = [child];
}
return agents[0];
}
function makeLayeredSubagentDag(width: number, depth: number): TestRunAgent {
const root = makeAgent({ id: 'agent_dag_root', name: 'DAG Root' }) as TestRunAgent;
const layers: TestRunAgent[][] = [[root]];
for (let level = 1; level <= depth; level++) {
layers.push(
Array.from({ length: width }, (_, index) =>
makeAgent({
id: `agent_dag_${level}_${index}`,
name: `DAG ${level}.${index}`,
}),
) as TestRunAgent[],
);
}
for (let level = 0; level < depth; level++) {
const children = layers[level + 1];
for (const agent of layers[level]) {
agent.subagents = {
enabled: true,
allowSelf: false,
agent_ids: children.map((child) => child.id),
};
agent.subagentAgentConfigs = children;
}
}
return root;
}
/** Helper: call createRun and return the captured agentInputs array */
async function callAndCapture(
opts: {
@ -940,6 +996,32 @@ describe('subagentConfigs', () => {
expect(childInputs.initialSummary).toBeUndefined();
expect(childInputs.discoveredTools).toBeUndefined();
});
it('rejects subagent graphs deeper than MAX_SUBAGENT_DEPTH before Run.create', async () => {
await expect(
createRun({
agents: [makeSubagentChain(MAX_SUBAGENT_DEPTH + 1)] as never,
signal: new AbortController().signal,
streaming: true,
streamUsage: true,
}),
).rejects.toThrow(`maximum depth of ${MAX_SUBAGENT_DEPTH}`);
expect(Run.create).not.toHaveBeenCalled();
});
it('rejects layered DAGs that exceed MAX_SUBAGENT_RUN_CONFIGS expanded entries', async () => {
await expect(
createRun({
agents: [makeLayeredSubagentDag(3, MAX_SUBAGENT_DEPTH)] as never,
signal: new AbortController().signal,
streaming: true,
streamUsage: true,
}),
).rejects.toThrow(`maximum of ${MAX_SUBAGENT_RUN_CONFIGS} expanded entries`);
expect(Run.create).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------

View file

@ -2,6 +2,8 @@ import { logger } from '@librechat/data-schemas';
import { Run, Providers, Constants } from '@librechat/agents';
import {
KnownEndpoints,
MAX_SUBAGENT_DEPTH,
MAX_SUBAGENT_RUN_CONFIGS,
extractEnvVariable,
providerEndpointMap,
normalizeEndpointName,
@ -547,6 +549,27 @@ function computeEffectiveMaxContextTokens(
/** Identifier for the self-spawn subagent (reuses parent's AgentInputs in an isolated child graph). */
const SELF_SUBAGENT_TYPE = 'self';
interface SubagentBuildState {
configCount: number;
}
function countSubagentConfig(state: SubagentBuildState): void {
state.configCount += 1;
if (state.configCount > MAX_SUBAGENT_RUN_CONFIGS) {
throw new Error(
`Subagent run configuration exceeds the maximum of ${MAX_SUBAGENT_RUN_CONFIGS} expanded entries.`,
);
}
}
function assertSubagentDepth(depth: number, agentId: string): void {
if (depth > MAX_SUBAGENT_DEPTH) {
throw new Error(
`Subagent graph exceeds the maximum depth of ${MAX_SUBAGENT_DEPTH} at agent ${agentId}.`,
);
}
}
/**
* Recursive any-true check across the agent tree: returns `true` if this
* agent or any subagent (transitively) has the per-agent codeenv gate
@ -559,14 +582,17 @@ const SELF_SUBAGENT_TYPE = 'self';
* run without it, the subagent's `{{tool<idx>turn<turn>}}`
* placeholders would pass through to the shell unsubstituted.
*
* Cycle-safe via a `visited` set, mirroring `buildSubagentConfigs`'s
* `ancestors` pattern. The bash tool description itself is still gated
* per-agent in `initializeAgent`, so only agents that actually have
* bash registered learn the `{{…}}` syntax broadening the run-level
* Cycle-safe via a `visited` set. The bash tool description itself is
* still gated per-agent in `initializeAgent`, so only agents that actually
* have bash registered learn the `{{…}}` syntax broadening the run-level
* registry gate doesn't broaden the model-facing surface.
*/
function anyAgentHasCodeEnv(agents: RunAgent[], visited: Set<string> = new Set()): boolean {
for (const agent of agents) {
function anyAgentHasCodeEnv(agents: RunAgent[]): boolean {
const visited = new Set<string>();
const pending = [...agents];
for (let index = 0; index < pending.length; index++) {
const agent = pending[index];
if (visited.has(agent.id)) {
continue;
}
@ -574,11 +600,10 @@ function anyAgentHasCodeEnv(agents: RunAgent[], visited: Set<string> = new Set()
if (agent.codeEnvAvailable === true) {
return true;
}
if (
agent.subagentAgentConfigs != null &&
anyAgentHasCodeEnv(agent.subagentAgentConfigs, visited)
) {
return true;
for (const child of agent.subagentAgentConfigs ?? []) {
if (!visited.has(child.id)) {
pending.push(child);
}
}
}
return false;
@ -593,7 +618,9 @@ function buildSubagentConfigs(
agent: RunAgent,
agentInput: AgentInputs,
toInput: (child: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs,
state: SubagentBuildState,
ancestors: Set<string> = new Set(),
depth = 0,
): SubagentConfig[] {
if (!agent.subagents?.enabled) {
return [];
@ -604,6 +631,7 @@ function buildSubagentConfigs(
if (allowSelf) {
const selfName = agentInput.name ?? agent.name ?? 'self';
countSubagentConfig(state);
configs.push({
self: true,
type: SELF_SUBAGENT_TYPE,
@ -627,6 +655,9 @@ function buildSubagentConfigs(
if (ancestors.has(child.id)) {
continue;
}
const childDepth = depth + 1;
assertSubagentDepth(childDepth, child.id);
countSubagentConfig(state);
/**
* `buildAgentInput` applies parent-run context (initialSummary +
* discoveredTools) to the returned AgentInputs *and* to the
@ -649,7 +680,14 @@ function buildSubagentConfigs(
* `subagentConfigs`, and that only runs for the outer agents in
* `agents[]`. Cycle-safe via `nextAncestors`.
*/
const grandchildConfigs = buildSubagentConfigs(child, childInputs, toInput, nextAncestors);
const grandchildConfigs = buildSubagentConfigs(
child,
childInputs,
toInput,
state,
nextAncestors,
childDepth,
);
if (grandchildConfigs.length > 0) {
childInputs.subagentConfigs = grandchildConfigs;
}
@ -884,9 +922,15 @@ export async function createRun({
};
const agentInputs: AgentInputs[] = [];
const subagentBuildState: SubagentBuildState = { configCount: 0 };
for (const agent of agents) {
const agentInput = buildAgentInput(agent);
const subagentConfigs = buildSubagentConfigs(agent, agentInput, buildAgentInput);
const subagentConfigs = buildSubagentConfigs(
agent,
agentInput,
buildAgentInput,
subagentBuildState,
);
if (subagentConfigs.length > 0) {
agentInput.subagentConfigs = subagentConfigs;
}

View file

@ -2179,6 +2179,15 @@ export enum Constants {
/** Maximum number of explicit subagents per parent agent. UI + Zod schema share this. */
export const MAX_SUBAGENTS = 10;
/** Maximum explicit subagent hops allowed from any root agent at runtime. */
export const MAX_SUBAGENT_DEPTH = 5;
/** Maximum unique explicit subagent targets that may be loaded at runtime. */
export const MAX_SUBAGENT_GRAPH_NODES = 50;
/** Maximum expanded SubagentConfig entries embedded into one run request. */
export const MAX_SUBAGENT_RUN_CONFIGS = 100;
export enum LocalStorageKeys {
/** Key for the admin defined App Title */
APP_TITLE = 'appTitle',