🧬 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