Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/graphs/MultiAgentGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,50 @@ export class MultiAgentGraph extends StandardGraph {
constructor(input: t.MultiAgentGraphInput) {
super(input);
this.edges = input.edges;
this.validateEdgeAgents();
this.categorizeEdges();
this.analyzeGraph();
this.createHandoffTools();
}

/**
* Fails fast when an edge references an agent that is not in
* `agentContexts`. Without this check, the underlying LangGraph
* `StateGraph.compile()` would throw the opaque
* `Found edge ending at unknown node "<id>"` error after graph
* construction — far from the true root cause.
*
* This catches the common misuse of passing `edges` into a multi-agent
* config without also passing the corresponding sub-agent configs in
* `agents` (e.g. a host that forgot to pre-load handoff targets).
*/
private validateEdgeAgents(): void {
const known = new Set(this.agentContexts.keys());
const unknown = new Set<string>();
for (const edge of this.edges) {
const participants = [
...(Array.isArray(edge.from) ? edge.from : [edge.from]),
...(Array.isArray(edge.to) ? edge.to : [edge.to]),
];
for (const id of participants) {
if (typeof id === 'string' && !known.has(id)) {
unknown.add(id);
}
}
}
if (unknown.size === 0) {
return;
}
const missing = Array.from(unknown)
.map((id) => `"${id}"`)
.join(', ');
throw new Error(
`MultiAgentGraph: edges reference agent(s) not present in agents: [${missing}]. ` +
'Ensure every agent referenced by an edge is also included in the `agents` array, ' +
'or filter orphaned edges before constructing the graph.'
);
}

/**
* Categorize edges into handoff and direct types
*/
Expand Down
91 changes: 91 additions & 0 deletions src/graphs/__tests__/MultiAgentGraph.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// src/graphs/__tests__/MultiAgentGraph.test.ts
import { MultiAgentGraph } from '../MultiAgentGraph';
import { Providers } from '@/common';
import type * as t from '@/types';

describe('MultiAgentGraph.validateEdgeAgents', () => {
const makeAgent = (agentId: string): t.AgentInputs => ({
agentId,
provider: Providers.OPENAI,
instructions: 'test',
});

it('constructs without error when every edge endpoint has a matching agent', () => {
const input: t.MultiAgentGraphInput = {
runId: 'r1',
agents: [makeAgent('A'), makeAgent('B')],
edges: [{ from: 'A', to: 'B', edgeType: 'handoff' }],
};

expect(() => new MultiAgentGraph(input)).not.toThrow();
});

it('throws a descriptive error when an edge `to` points at an unknown agent', () => {
const input: t.MultiAgentGraphInput = {
runId: 'r1',
agents: [makeAgent('A')],
edges: [{ from: 'A', to: 'MISSING', edgeType: 'handoff' }],
};

expect(() => new MultiAgentGraph(input)).toThrow(/MISSING/);
expect(() => new MultiAgentGraph(input)).toThrow(
/edges reference agent\(s\) not present in agents/
);
});

it('throws when an edge `from` points at an unknown agent', () => {
const input: t.MultiAgentGraphInput = {
runId: 'r1',
agents: [makeAgent('A')],
edges: [{ from: 'MISSING', to: 'A', edgeType: 'handoff' }],
};

expect(() => new MultiAgentGraph(input)).toThrow(/MISSING/);
});

it('reports all unknown agent ids in a single error', () => {
const input: t.MultiAgentGraphInput = {
runId: 'r1',
agents: [makeAgent('A')],
edges: [
{ from: 'A', to: 'B', edgeType: 'handoff' },
{ from: 'A', to: 'C', edgeType: 'handoff' },
],
};

let thrown: Error | undefined;
try {
new MultiAgentGraph(input);
} catch (err) {
thrown = err as Error;
}
expect(thrown).toBeDefined();
expect(thrown!.message).toMatch(/"B"/);
expect(thrown!.message).toMatch(/"C"/);
});

it('handles array `from` / `to` fields', () => {
const valid: t.MultiAgentGraphInput = {
runId: 'r1',
agents: [makeAgent('A'), makeAgent('B'), makeAgent('C')],
edges: [{ from: ['A'], to: ['B', 'C'], edgeType: 'direct' }],
};
expect(() => new MultiAgentGraph(valid)).not.toThrow();

const invalid: t.MultiAgentGraphInput = {
runId: 'r1',
agents: [makeAgent('A'), makeAgent('B')],
edges: [{ from: ['A'], to: ['B', 'C'], edgeType: 'direct' }],
};
expect(() => new MultiAgentGraph(invalid)).toThrow(/"C"/);
});

it('accepts an empty edges array (single-agent case with no handoffs)', () => {
const input: t.MultiAgentGraphInput = {
runId: 'r1',
agents: [makeAgent('A')],
edges: [],
};
expect(() => new MultiAgentGraph(input)).not.toThrow();
});
});
Loading