mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 01:53:33 +00:00
✂️ fix: prune unreachable sub-agents after orphan-edge filtering
Addresses the fourth P1 codex finding on #12740: BFS eagerly initializes every sub-agent referenced in the primary's edge scan, but once `filterOrphanedEdges` drops edges whose endpoints were skipped, some of those sub-agents end up disconnected from the primary. In an `A -> B -> C` graph (edges stored directly on A) where B is skipped (missing or no VIEW), both edges are filtered, but C was already loaded and would still be passed to `createRun` — which flips into multi-agent mode on `agents.length > 1` and turns C into an unintended parallel start node. After filtering edges, compute the set of agent ids reachable from the primary through the surviving edge set and prune `agentConfigs` to that set. Two regression tests added: one for the pruning case, one that confirms agents connected via surviving edges are still kept.
This commit is contained in:
parent
7ec1ba246f
commit
4cbfffc9ee
2 changed files with 122 additions and 0 deletions
|
|
@ -264,6 +264,94 @@ describe('discoverConnectedAgents', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('prunes sub-agents disconnected from the primary after edge filtering', async () => {
|
||||
// Graph: primary A has edges [A->B, B->C] stored directly on A.
|
||||
// BFS loads B and C before permission is checked. B is skipped, and
|
||||
// both edges reference B so they are filtered out. C was initialized
|
||||
// but is now unreachable from A through any surviving edge — it must
|
||||
// NOT be returned (otherwise it becomes a stray start node when
|
||||
// createRun sees `agents.length > 1`).
|
||||
const edges: GraphEdge[] = [
|
||||
{ from: 'A', to: 'B', edgeType: 'handoff' },
|
||||
{ from: 'B', to: 'C', edgeType: 'handoff' },
|
||||
];
|
||||
const primaryConfig = makeConfig('A', edges);
|
||||
|
||||
const agentMap: Record<string, Agent> = {
|
||||
B: makeAgent('B', []),
|
||||
C: makeAgent('C', []),
|
||||
};
|
||||
const getAgent = jest.fn(async ({ id }: { id: string }) => agentMap[id] ?? null);
|
||||
// B fails permission, C passes.
|
||||
const checkPermission = jest.fn(
|
||||
async ({ resourceId }: { resourceId: unknown }) => resourceId !== 'mongo-B',
|
||||
);
|
||||
|
||||
const result = await discoverConnectedAgents(
|
||||
{
|
||||
req: makeReq(),
|
||||
res: makeRes(),
|
||||
primaryConfig,
|
||||
allowedProviders: new Set(),
|
||||
modelsConfig: { openai: ['gpt-4o'] },
|
||||
loadTools: jest.fn(),
|
||||
},
|
||||
{
|
||||
getAgent,
|
||||
checkPermission,
|
||||
logViolation: jest.fn(),
|
||||
db: {} as never,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.skippedAgentIds.has('B')).toBe(true);
|
||||
expect(result.edges).toHaveLength(0);
|
||||
// C is unreachable once B's edges are filtered → must be pruned.
|
||||
expect(result.agentConfigs.has('C')).toBe(false);
|
||||
expect(result.agentConfigs.size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps sub-agents that remain reachable via surviving edges', async () => {
|
||||
// Graph: A -> [B, C]; B is skipped, C is kept. C must remain because
|
||||
// A -> C survives filtering.
|
||||
const edges: GraphEdge[] = [
|
||||
{ from: 'A', to: 'B', edgeType: 'handoff' },
|
||||
{ from: 'A', to: 'C', edgeType: 'handoff' },
|
||||
];
|
||||
const primaryConfig = makeConfig('A', edges);
|
||||
|
||||
const agentMap: Record<string, Agent> = {
|
||||
B: makeAgent('B', []),
|
||||
C: makeAgent('C', []),
|
||||
};
|
||||
const getAgent = jest.fn(async ({ id }: { id: string }) => agentMap[id] ?? null);
|
||||
const checkPermission = jest.fn(
|
||||
async ({ resourceId }: { resourceId: unknown }) => resourceId !== 'mongo-B',
|
||||
);
|
||||
|
||||
const result = await discoverConnectedAgents(
|
||||
{
|
||||
req: makeReq(),
|
||||
res: makeRes(),
|
||||
primaryConfig,
|
||||
allowedProviders: new Set(),
|
||||
modelsConfig: { openai: ['gpt-4o'] },
|
||||
loadTools: jest.fn(),
|
||||
},
|
||||
{
|
||||
getAgent,
|
||||
checkPermission,
|
||||
logViolation: jest.fn(),
|
||||
db: {} as never,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.agentConfigs.has('C')).toBe(true);
|
||||
expect(result.agentConfigs.has('B')).toBe(false);
|
||||
expect(result.edges).toHaveLength(1);
|
||||
expect(result.edges[0].to).toBe('C');
|
||||
});
|
||||
|
||||
it('drops edges whose `from` references a skipped agent (bidirectional graph)', async () => {
|
||||
// Orchestrator A with bidirectional handoffs A<->B. B is inaccessible,
|
||||
// so `A -> B` and `B -> A` must both be filtered — otherwise `B -> A`
|
||||
|
|
|
|||
|
|
@ -287,6 +287,40 @@ export async function discoverConnectedAgents(
|
|||
|
||||
const edges = filterOrphanedEdges(Array.from(edgeMap.values()), skippedAgentIds);
|
||||
|
||||
/**
|
||||
* Prune sub-agents that were initialized eagerly during BFS but whose
|
||||
* connecting edges were later dropped by `filterOrphanedEdges` (e.g. an
|
||||
* `A -> B -> C` chain where B is skipped, so both edges disappear but
|
||||
* C had already been loaded from the primary's edge scan). Without this,
|
||||
* those disconnected agents still flow into `createRun` — which flips
|
||||
* into multi-agent mode whenever `agents.length > 1` and turns the
|
||||
* stranded sub-agent into an unintended parallel start node.
|
||||
*/
|
||||
const reachable = new Set<string>([primaryConfig.id]);
|
||||
const frontier: string[] = [primaryConfig.id];
|
||||
while (frontier.length > 0) {
|
||||
const current = frontier.pop() as string;
|
||||
for (const edge of edges) {
|
||||
const sources = Array.isArray(edge.from) ? edge.from : [edge.from];
|
||||
if (!sources.includes(current)) {
|
||||
continue;
|
||||
}
|
||||
const dests = Array.isArray(edge.to) ? edge.to : [edge.to];
|
||||
for (const dest of dests) {
|
||||
if (typeof dest === 'string' && !reachable.has(dest)) {
|
||||
reachable.add(dest);
|
||||
frontier.push(dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const agentId of [...agentConfigs.keys()]) {
|
||||
if (!reachable.has(agentId)) {
|
||||
agentConfigs.delete(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agentConfigs,
|
||||
edges,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue