|
1 | 1 | import { describe, it, expect } from 'vitest'; |
2 | 2 | import { Annotation, MessagesAnnotation, StateGraph, END } from '@langchain/langgraph'; |
| 3 | +import { ToolNode } from '@langchain/langgraph/prebuilt'; |
3 | 4 | import { AIMessage, ToolMessage, HumanMessage } from '@langchain/core/messages'; |
| 5 | +import { tool } from '@langchain/core/tools'; |
| 6 | +import { z } from 'zod'; |
4 | 7 | import { bindClientTools, clientToolsChannel, clientToolsRouter } from './langgraph'; |
5 | 8 |
|
6 | 9 | // A scripted fake chat model exposing the bindTools + invoke surface the graph uses. |
@@ -51,3 +54,43 @@ describe('client-tools loop (in-process)', () => { |
51 | 54 | expect((r2.messages[r2.messages.length - 1] as AIMessage).content).toBe('It is 65F in SF.'); |
52 | 55 | }); |
53 | 56 | }); |
| 57 | + |
| 58 | +describe("the router's default toolsNode", () => { |
| 59 | + it('dispatches a server tool call to a node named by the default, with no override', async () => { |
| 60 | + const echo = tool(async ({ text }: { text: string }) => `echoed:${text}`, { |
| 61 | + name: 'echo', |
| 62 | + description: 'Echo the input.', |
| 63 | + schema: z.object({ text: z.string() }), |
| 64 | + }); |
| 65 | + |
| 66 | + let agentTurns = 0; |
| 67 | + const graph = new StateGraph(State) |
| 68 | + .addNode('agent', async () => { |
| 69 | + agentTurns += 1; |
| 70 | + if (agentTurns > 1) return { messages: [new AIMessage({ content: 'done' })] }; |
| 71 | + return { |
| 72 | + messages: [ |
| 73 | + new AIMessage({ content: '', tool_calls: [{ name: 'echo', args: { text: 'hi' }, id: 'call_1' }] }), |
| 74 | + ], |
| 75 | + }; |
| 76 | + }) |
| 77 | + .addNode('server_tools', new ToolNode([echo])) |
| 78 | + .addEdge('__start__', 'agent') |
| 79 | + .addEdge('server_tools', 'agent') |
| 80 | + .addConditionalEdges('agent', (s) => clientToolsRouter(['echo'])(s), ['server_tools', END]) |
| 81 | + .compile(); |
| 82 | + |
| 83 | + const result = await graph.invoke({ messages: [new HumanMessage('echo hi')] }); |
| 84 | + const toolMessage = result.messages.find((m): m is ToolMessage => m instanceof ToolMessage); |
| 85 | + expect(toolMessage?.content).toBe('echoed:hi'); |
| 86 | + }); |
| 87 | + |
| 88 | + it("records why 'tools' cannot be a node name on a client-tools graph", () => { |
| 89 | + // clientToolsChannel() declares a `tools` state channel, and LangGraph JS |
| 90 | + // shares one namespace between channel names and node names — which is why |
| 91 | + // the router's default destination is 'server_tools', not 'tools'. |
| 92 | + expect(() => new StateGraph(State).addNode('tools', async () => ({ messages: [] }))).toThrow( |
| 93 | + /tools is already being used as a state attribute/, |
| 94 | + ); |
| 95 | + }); |
| 96 | +}); |
0 commit comments