mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-24 11:12:49 +00:00
🧬 fix: Bound Subagent Expansion (#13064)
* fix: Bound subagent expansion * fix: Preserve subagent path depth
This commit is contained in:
parent
508168fa57
commit
70b6bb69d3
5 changed files with 339 additions and 42 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue