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
5 changes: 5 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,10 @@ jobs:
NODE_OPTIONS: '--experimental-vm-modules'
NODE_ENV: test
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENAI_BASE_URL: ${{ vars.OPENAI_BASE_URL || secrets.OPENAI_BASE_URL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_BASE_URL: ${{ secrets.ANTHROPIC_BASE_URL }}
ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL || secrets.ANTHROPIC_BASE_URL }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
AZURE_MODEL_NAME: ${{ secrets.AZURE_MODEL_NAME }}
Expand Down Expand Up @@ -166,8 +168,10 @@ jobs:
NODE_OPTIONS: '--experimental-vm-modules'
NODE_ENV: test
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENAI_BASE_URL: ${{ vars.OPENAI_BASE_URL || secrets.OPENAI_BASE_URL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_BASE_URL: ${{ secrets.ANTHROPIC_BASE_URL }}
ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL || secrets.ANTHROPIC_BASE_URL }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
AZURE_MODEL_NAME: ${{ secrets.AZURE_MODEL_NAME }}
Expand Down Expand Up @@ -270,6 +274,7 @@ jobs:
NODE_OPTIONS: '--experimental-vm-modules'
NODE_ENV: test
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_BASE_URL: ${{ secrets.ANTHROPIC_BASE_URL }}
ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL || secrets.ANTHROPIC_BASE_URL }}

- name: Surface Anthropic LLM soft failure
Expand Down
76 changes: 74 additions & 2 deletions src/specs/graph-subagent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { FakeListChatModel } from '@langchain/core/utils/testing';
import type { RunnableConfig } from '@langchain/core/runnables';
import type { GraphSubagentConfig } from '@/types';
import type * as t from '@/types';
import { MultiAgentGraph } from '@/graphs/MultiAgentGraph';
import { createFakeStreamingLLM } from '@/llm/fake';
import { Constants, Providers } from '@/common';
import { StandardGraph } from '@/graphs/Graph';
Expand Down Expand Up @@ -112,7 +113,8 @@ const makeGraphConfig = (): GraphSubagentConfig => ({

const createRun = async (
graphConfig: GraphSubagentConfig,
overrides: Partial<t.RunConfig> = {}
overrides: Partial<t.RunConfig> = {},
maxSubagentDepth = 2
): Promise<Run<t.IState>> =>
Run.create<t.IState>({
runId: `graph-subagent-${Date.now()}-${Math.random()}`,
Expand All @@ -121,7 +123,7 @@ const createRun = async (
agents: [
{
...makeAgent('parent'),
maxSubagentDepth: 2,
maxSubagentDepth,
subagentConfigs: [graphConfig],
},
],
Expand Down Expand Up @@ -169,6 +171,76 @@ describe('Graph subagent integration', () => {
expect(result).toBe('synthesized answer');
});

it('runs a depth-one parallel team without giving any member a grandchild tool', async () => {
const config = makeGraphConfig();
config.agents = config.agents.map((agent) => ({
...agent,
maxSubagentDepth: 99,
subagentConfigs: [
{
type: 'grandchild',
name: 'Grandchild',
description: 'Must not be spawned.',
agentInputs: makeAgent('grandchild'),
},
],
}));
const originalCreateWorkflow = MultiAgentGraph.prototype.createWorkflow;
const observedMembers: Array<{
agentId: string;
maxSubagentDepth?: number;
nestedConfigs?: t.SubagentConfigEntry[];
hasSubagentTool: boolean;
}> = [];
const createWorkflowSpy = jest
.spyOn(MultiAgentGraph.prototype, 'createWorkflow')
.mockImplementation(function (this: MultiAgentGraph) {
const workflow = originalCreateWorkflow.call(this);
for (const [agentId, context] of this.agentContexts) {
observedMembers.push({
agentId,
maxSubagentDepth: context.maxSubagentDepth,
nestedConfigs: context.subagentConfigs,
hasSubagentTool:
(context.graphTools as t.GenericTool[] | undefined)?.some(
(memberTool) =>
'name' in memberTool && memberTool.name === Constants.SUBAGENT
) ?? false,
});
}
return workflow;
});

try {
const run = await createRun(config, {}, 1);
(run.Graph as StandardGraph).setSubagentModelOverride(
createFakeStreamingLLM({
responses: ['plan', 'left work', 'right work', 'team answer'],
})
);

const result = await getGraphSubagentTool(run).invoke(
{
description: 'Complete the parallel team.',
subagent_type: config.type,
},
invokeConfig
);

expect(result).toBe('team answer');
expect(observedMembers).toEqual(
config.agents.map(({ agentId }) => ({
agentId,
maxSubagentDepth: undefined,
nestedConfigs: undefined,
hasSubagentTool: false,
}))
);
} finally {
createWorkflowSpy.mockRestore();
}
});

it('does not fall back to worker text when the result member is textless', async () => {
const graphConfig: GraphSubagentConfig = {
...makeGraphConfig(),
Expand Down
119 changes: 119 additions & 0 deletions src/specs/subagent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
AIMessage,
AIMessageChunk,
HumanMessage,
ToolMessage,
} from '@langchain/core/messages';
import type { UsageMetadata, BaseMessage } from '@langchain/core/messages';
import type { RunnableConfig } from '@langchain/core/runnables';
Expand Down Expand Up @@ -662,6 +663,124 @@ describe('Subagent Integration', () => {
}
});

it('keeps both fan-out children at depth zero even when they request more nesting', async () => {
const originalCreateWorkflow = StandardGraph.prototype.createWorkflow;
const observedChildren: Array<{
agentId: string;
maxSubagentDepth?: number;
nestedConfigs: number;
hasSubagentTool: boolean;
}> = [];
const createWorkflowSpy = jest
.spyOn(StandardGraph.prototype, 'createWorkflow')
.mockImplementation(function (this: StandardGraph) {
const workflow = originalCreateWorkflow.call(this);
const agentId = this.defaultAgentId;
if (agentId === 'left-child' || agentId === 'right-child') {
const context = this.agentContexts.get(agentId);
observedChildren.push({
agentId,
maxSubagentDepth: context?.maxSubagentDepth,
nestedConfigs: context?.subagentConfigs?.length ?? 0,
hasSubagentTool:
(context?.graphTools as t.GenericTool[] | undefined)?.some(
(tool) => 'name' in tool && tool.name === Constants.SUBAGENT
) ?? false,
});
}
return workflow;
});
const childTypes = ['left', 'right'];
const parent: t.AgentInputs = {
...createParentAgent(),
maxSubagentDepth: 1,
subagentConfigs: childTypes.map((type) => ({
type,
name: `${type} child`,
description: `Handle the ${type} task.`,
allowNested: true,
agentInputs: {
...createParentAgent(),
agentId: `${type}-child`,
maxSubagentDepth: 99,
subagentConfigs: [
{
type: `${type}-grandchild`,
name: `${type} grandchild`,
description: 'Must not be spawned.',
agentInputs: {
...createParentAgent(),
agentId: `${type}-grandchild`,
subagentConfigs: undefined,
},
},
],
},
})),
};

try {
const run = await Run.create<t.IState>({
runId: `depth-one-fan-out-${Date.now()}`,
graphConfig: { type: 'standard', agents: [parent] },
returnContent: true,
skipCleanup: true,
});
const toolCalls: ToolCall[] = childTypes.map((type) => ({
id: `call_${type}`,
name: Constants.SUBAGENT,
args: { description: `Handle ${type}.`, subagent_type: type },
type: 'tool_call',
}));
run.Graph?.overrideTestModel(
['Delegate both tasks.', 'Both tasks completed.'],
10,
toolCalls
);

await run.processStream(
{ messages: [new HumanMessage('Handle both tasks.')] },
callerConfig
);

const results = run
.getRunMessages()
?.filter(
(message): message is ToolMessage =>
message instanceof ToolMessage &&
message.name === Constants.SUBAGENT
);
expect(results?.map((message) => message.tool_call_id).sort()).toEqual([
'call_left',
'call_right',
]);
expect(results?.map((message) => message.content)).toEqual([
CHILD_RESPONSE,
CHILD_RESPONSE,
]);
expect(
observedChildren.sort((left, right) =>
left.agentId.localeCompare(right.agentId)
)
).toEqual([
{
agentId: 'left-child',
maxSubagentDepth: 0,
nestedConfigs: 1,
hasSubagentTool: false,
},
{
agentId: 'right-child',
maxSubagentDepth: 0,
nestedConfigs: 1,
hasSubagentTool: false,
},
]);
} finally {
createWorkflowSpy.mockRestore();
}
});

it('should not create subagent tool when maxSubagentDepth is 0', async () => {
const agentWithZeroDepth: t.AgentInputs = {
...createParentAgent(),
Expand Down
Loading