Skip to content

Commit 288ac65

Browse files
committed
feat: Replace LangChainProvider with Runner protocol implementation (AIC-2388) (#1338)
1 parent 0ab910c commit 288ac65

11 files changed

Lines changed: 1075 additions & 517 deletions
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
2+
3+
import { CompiledAgent, LangChainAgentRunner } from '../src/LangChainAgentRunner';
4+
5+
const mockLogger = {
6+
warn: jest.fn(),
7+
info: jest.fn(),
8+
error: jest.fn(),
9+
debug: jest.fn(),
10+
};
11+
12+
function makeAgent(invoke: jest.Mock): CompiledAgent {
13+
return { invoke };
14+
}
15+
16+
it('returns content with no toolCalls when the agent returns a simple response', async () => {
17+
const finalMsg = new AIMessage('done');
18+
finalMsg.usage_metadata = { total_tokens: 6, input_tokens: 4, output_tokens: 2 };
19+
20+
const agent = makeAgent(jest.fn().mockResolvedValue({ messages: [finalMsg] }));
21+
const runner = new LangChainAgentRunner(agent, mockLogger);
22+
const result = await runner.run('hi');
23+
24+
expect(agent.invoke).toHaveBeenCalledWith({
25+
messages: [{ role: 'user', content: 'hi' }],
26+
});
27+
expect(result.content).toBe('done');
28+
expect(result.metrics.success).toBe(true);
29+
expect(result.metrics.toolCalls).toBeUndefined();
30+
expect(result.metrics.usage).toEqual({ total: 6, input: 4, output: 2 });
31+
});
32+
33+
it('extracts tool calls and aggregates usage from multi-step agent messages', async () => {
34+
const toolCallMsg = new AIMessage('');
35+
toolCallMsg.tool_calls = [{ id: 'call_1', name: 'lookup', args: { id: 42 } }];
36+
toolCallMsg.usage_metadata = { total_tokens: 14, input_tokens: 10, output_tokens: 4 };
37+
38+
const toolResultMsg = new ToolMessage({ tool_call_id: 'call_1', content: '{"value":42}' });
39+
40+
const finalMsg = new AIMessage('Answer is 42.');
41+
finalMsg.usage_metadata = { total_tokens: 14, input_tokens: 6, output_tokens: 8 };
42+
43+
const agent = makeAgent(
44+
jest.fn().mockResolvedValue({
45+
messages: [
46+
new HumanMessage('Look up 42'),
47+
toolCallMsg,
48+
toolResultMsg,
49+
finalMsg,
50+
],
51+
}),
52+
);
53+
54+
const runner = new LangChainAgentRunner(agent, mockLogger);
55+
const result = await runner.run('Look up 42');
56+
57+
expect(result.content).toBe('Answer is 42.');
58+
expect(result.metrics.toolCalls).toEqual(['lookup']);
59+
expect(result.metrics.usage).toEqual({ total: 28, input: 16, output: 12 });
60+
});
61+
62+
it('returns success=false when the agent throws', async () => {
63+
const agent = makeAgent(jest.fn().mockRejectedValue(new Error('boom')));
64+
const runner = new LangChainAgentRunner(agent, mockLogger);
65+
const result = await runner.run('hi');
66+
67+
expect(result.content).toBe('');
68+
expect(result.metrics.success).toBe(false);
69+
expect(mockLogger.warn).toHaveBeenCalled();
70+
});
71+
72+
it('returns the underlying agent via getAgent()', () => {
73+
const agent = makeAgent(jest.fn());
74+
const runner = new LangChainAgentRunner(agent, mockLogger);
75+
expect(runner.getAgent()).toBe(agent);
76+
});
77+
78+
it('handles empty messages array gracefully', async () => {
79+
const agent = makeAgent(jest.fn().mockResolvedValue({ messages: [] }));
80+
const runner = new LangChainAgentRunner(agent, mockLogger);
81+
const result = await runner.run('hi');
82+
83+
expect(result.content).toBe('');
84+
expect(result.metrics.success).toBe(true);
85+
expect(result.metrics.toolCalls).toBeUndefined();
86+
expect(result.metrics.usage).toBeUndefined();
87+
});
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { AIMessage, HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages';
2+
import { initChatModel } from 'langchain/chat_models/universal';
3+
4+
import {
5+
buildStructuredTools,
6+
convertMessagesToLangChain,
7+
createLangChainModel,
8+
extractLastMessageContent,
9+
extractToolCalls,
10+
getAIMetricsFromResponse,
11+
getAIUsageFromResponse,
12+
mapProviderName,
13+
sumTokenUsageFromMessages,
14+
} from '../src/LangChainHelper';
15+
16+
jest.mock('langchain/chat_models/universal', () => ({
17+
initChatModel: jest.fn(),
18+
}));
19+
20+
const mockInitChatModel = initChatModel as jest.MockedFunction<typeof initChatModel>;
21+
22+
describe('createLangChainModel', () => {
23+
const fakeLLM = { invoke: jest.fn() };
24+
25+
beforeEach(() => {
26+
mockInitChatModel.mockReset();
27+
mockInitChatModel.mockResolvedValue(fakeLLM as any);
28+
});
29+
30+
it('calls initChatModel with model name and mapped provider', async () => {
31+
await createLangChainModel({
32+
key: 'cfg',
33+
enabled: true,
34+
provider: { name: 'openai' },
35+
model: { name: 'gpt-4o', parameters: { temperature: 0.5 } },
36+
});
37+
38+
expect(mockInitChatModel).toHaveBeenCalledWith('gpt-4o', {
39+
temperature: 0.5,
40+
modelProvider: 'openai',
41+
});
42+
});
43+
44+
it('maps gemini to google-genai', async () => {
45+
await createLangChainModel({
46+
key: 'cfg',
47+
enabled: true,
48+
provider: { name: 'gemini' },
49+
model: { name: 'gemini-2.0' },
50+
});
51+
52+
expect(mockInitChatModel).toHaveBeenCalledWith('gemini-2.0', {
53+
modelProvider: 'google-genai',
54+
});
55+
});
56+
});
57+
58+
it('converts system, user, and assistant messages to LangChain instances', () => {
59+
const result = convertMessagesToLangChain([
60+
{ role: 'system', content: 'sys' },
61+
{ role: 'user', content: 'u' },
62+
{ role: 'assistant', content: 'a' },
63+
]);
64+
65+
expect(result).toHaveLength(3);
66+
expect(result[0]).toBeInstanceOf(SystemMessage);
67+
expect(result[1]).toBeInstanceOf(HumanMessage);
68+
expect(result[2]).toBeInstanceOf(AIMessage);
69+
});
70+
71+
it('throws on an unsupported role', () => {
72+
expect(() => convertMessagesToLangChain([{ role: 'tool' as any, content: 'x' }])).toThrow(
73+
'Unsupported message role: tool',
74+
);
75+
});
76+
77+
it('maps gemini to google-genai (case-insensitive)', () => {
78+
expect(mapProviderName('gemini')).toBe('google-genai');
79+
expect(mapProviderName('Gemini')).toBe('google-genai');
80+
expect(mapProviderName('GEMINI')).toBe('google-genai');
81+
});
82+
83+
it('returns the provider unchanged when no mapping exists', () => {
84+
expect(mapProviderName('openai')).toBe('openai');
85+
expect(mapProviderName('anthropic')).toBe('anthropic');
86+
});
87+
88+
it('returns undefined when usage_metadata is absent', () => {
89+
expect(getAIUsageFromResponse(new AIMessage('x'))).toBeUndefined();
90+
});
91+
92+
it('maps usage_metadata to LDTokenUsage', () => {
93+
const message = new AIMessage('x');
94+
message.usage_metadata = { total_tokens: 30, input_tokens: 10, output_tokens: 20 };
95+
expect(getAIUsageFromResponse(message)).toEqual({ total: 30, input: 10, output: 20 });
96+
});
97+
98+
it('returns success=true with usage from the response', () => {
99+
const message = new AIMessage('x');
100+
message.usage_metadata = { total_tokens: 3, input_tokens: 1, output_tokens: 2 };
101+
expect(getAIMetricsFromResponse(message)).toEqual({
102+
success: true,
103+
usage: { total: 3, input: 1, output: 2 },
104+
});
105+
});
106+
107+
describe('buildStructuredTools', () => {
108+
const mockLogger = { warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn() };
109+
110+
beforeEach(() => jest.clearAllMocks());
111+
112+
it('builds a StructuredTool from a valid tool definition', () => {
113+
const toolDefs = [{ name: 'lookup', description: 'looks up a value' }];
114+
const registry = { lookup: jest.fn().mockReturnValue('result') };
115+
116+
const result = buildStructuredTools(toolDefs, registry, mockLogger);
117+
118+
expect(result).toHaveLength(1);
119+
expect(result[0].name).toBe('lookup');
120+
expect(result[0].description).toBe('looks up a value');
121+
});
122+
123+
it('skips tools missing from the registry and logs a warning', () => {
124+
const toolDefs = [{ name: 'missing', description: 'not in registry' }];
125+
126+
const result = buildStructuredTools(toolDefs, {}, mockLogger);
127+
128+
expect(result).toHaveLength(0);
129+
expect(mockLogger.warn).toHaveBeenCalledWith(
130+
expect.stringContaining("Tool 'missing'"),
131+
);
132+
});
133+
134+
it('skips non-function built-in tools and logs a warning', () => {
135+
const toolDefs = [{ type: 'code_interpreter', name: 'ci' }];
136+
137+
const result = buildStructuredTools(toolDefs, { ci: jest.fn() }, mockLogger);
138+
139+
expect(result).toHaveLength(0);
140+
expect(mockLogger.warn).toHaveBeenCalledWith(
141+
expect.stringContaining("Built-in tool 'code_interpreter'"),
142+
);
143+
});
144+
145+
it('handles function-style tool definitions with nested function.name', () => {
146+
const toolDefs = [
147+
{ type: 'function', function: { name: 'search', description: 'searches' } },
148+
];
149+
const registry = { search: jest.fn() };
150+
151+
const result = buildStructuredTools(toolDefs, registry, mockLogger);
152+
153+
expect(result).toHaveLength(1);
154+
expect(result[0].name).toBe('search');
155+
});
156+
157+
it('uses a default description when none is provided', () => {
158+
const toolDefs = [{ name: 'mytool' }];
159+
const registry = { mytool: jest.fn() };
160+
161+
const result = buildStructuredTools(toolDefs, registry);
162+
163+
expect(result[0].description).toBe('Tool mytool');
164+
});
165+
});
166+
167+
it('extracts tool call names from AIMessages with tool_calls', () => {
168+
const msg1 = new AIMessage('');
169+
msg1.tool_calls = [
170+
{ id: 'c1', name: 'lookup', args: {} },
171+
{ id: 'c2', name: 'search', args: {} },
172+
];
173+
const msg2 = new AIMessage('done');
174+
175+
expect(extractToolCalls([msg1, msg2])).toEqual(['lookup', 'search']);
176+
});
177+
178+
it('returns an empty array when no tool calls are present', () => {
179+
expect(extractToolCalls([new AIMessage('done')])).toEqual([]);
180+
});
181+
182+
it('handles empty messages for extractToolCalls', () => {
183+
expect(extractToolCalls([])).toEqual([]);
184+
});
185+
186+
it('extracts string content from the last message', () => {
187+
expect(
188+
extractLastMessageContent([new HumanMessage('hi'), new AIMessage('hello')]),
189+
).toBe('hello');
190+
});
191+
192+
it('returns empty string for empty array', () => {
193+
expect(extractLastMessageContent([])).toBe('');
194+
});
195+
196+
it('returns empty string when last message content is not a string', () => {
197+
const msg = new AIMessage({ content: [{ type: 'text', text: 'hi' }] });
198+
expect(extractLastMessageContent([msg])).toBe('');
199+
});
200+
201+
it('sums usage across multiple messages', () => {
202+
const m1 = new AIMessage('');
203+
m1.usage_metadata = { total_tokens: 10, input_tokens: 6, output_tokens: 4 };
204+
const m2 = new AIMessage('done');
205+
m2.usage_metadata = { total_tokens: 8, input_tokens: 3, output_tokens: 5 };
206+
const toolMsg = new ToolMessage({ tool_call_id: 'x', content: 'res' });
207+
208+
expect(sumTokenUsageFromMessages([m1, toolMsg, m2])).toEqual({
209+
total: 18,
210+
input: 9,
211+
output: 9,
212+
});
213+
});
214+
215+
it('returns undefined when no messages have usage', () => {
216+
expect(sumTokenUsageFromMessages([new AIMessage('hi')])).toBeUndefined();
217+
});

0 commit comments

Comments
 (0)