✂️ 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:
Danny Avila 2026-04-18 11:55:52 -04:00
parent 7ec1ba246f
commit 4cbfffc9ee
2 changed files with 122 additions and 0 deletions

View file

@ -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`

View file

@ -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,