Skip to content

Commit b246631

Browse files
authored
feat: Support conversation history directly in AI Provider model runners (#1371)
1 parent bb8f861 commit b246631

6 files changed

Lines changed: 311 additions & 46 deletions

File tree

packages/ai-providers/server-ai-langchain/__tests__/LangChainModelRunner.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,69 @@ describe('LangChainModelRunner', () => {
115115
it('returns the underlying chat model', () => {
116116
expect(runner.getChatModel()).toBe(mockLLM);
117117
});
118+
119+
describe('conversation history', () => {
120+
it('accumulates history across successful calls', async () => {
121+
mockLLM.invoke
122+
.mockResolvedValueOnce(new AIMessage('First response'))
123+
.mockResolvedValueOnce(new AIMessage('Second response'));
124+
125+
await runner.run('First question');
126+
await runner.run('Second question');
127+
128+
const secondCallMessages = mockLLM.invoke.mock.calls[1][0];
129+
const roles = secondCallMessages.map((m: any) => m.constructor.name);
130+
expect(roles).toEqual(['HumanMessage', 'AIMessage', 'HumanMessage']);
131+
expect(secondCallMessages[0].content).toBe('First question');
132+
expect(secondCallMessages[1].content).toBe('First response');
133+
expect(secondCallMessages[2].content).toBe('Second question');
134+
});
135+
136+
it('does not accumulate history when the call throws', async () => {
137+
mockLLM.invoke.mockRejectedValueOnce(new Error('Model error'));
138+
await runner.run('Hello');
139+
140+
mockLLM.invoke.mockResolvedValueOnce(new AIMessage('Recovery'));
141+
await runner.run('Try again');
142+
143+
const secondCallMessages = mockLLM.invoke.mock.calls[1][0];
144+
expect(secondCallMessages).toHaveLength(1);
145+
expect(secondCallMessages[0].content).toBe('Try again');
146+
});
147+
148+
it('does not accumulate history when content is empty (multimodal)', async () => {
149+
mockLLM.invoke.mockResolvedValueOnce(new AIMessage([{ type: 'image' }] as any));
150+
await runner.run('Hello');
151+
152+
mockLLM.invoke.mockResolvedValueOnce(new AIMessage('Recovery'));
153+
await runner.run('Try again');
154+
155+
const secondCallMessages = mockLLM.invoke.mock.calls[1][0];
156+
expect(secondCallMessages).toHaveLength(1);
157+
expect(secondCallMessages[0].content).toBe('Try again');
158+
});
159+
160+
it('keeps config messages prepended ahead of accumulated history on every call', async () => {
161+
const configWithMessages: LDAICompletionConfig = {
162+
...baseConfig,
163+
messages: [{ role: 'system', content: 'You are helpful.' }],
164+
};
165+
const r = new LangChainModelRunner(mockLLM, configWithMessages, mockLogger);
166+
167+
mockLLM.invoke
168+
.mockResolvedValueOnce(new AIMessage('Answer 1'))
169+
.mockResolvedValueOnce(new AIMessage('Answer 2'));
170+
171+
await r.run('Q1');
172+
await r.run('Q2');
173+
174+
const secondCallMessages = mockLLM.invoke.mock.calls[1][0];
175+
expect(secondCallMessages).toHaveLength(4);
176+
expect(secondCallMessages[0].constructor.name).toBe('SystemMessage');
177+
expect(secondCallMessages[0].content).toBe('You are helpful.');
178+
expect(secondCallMessages[1].content).toBe('Q1');
179+
expect(secondCallMessages[2].content).toBe('Answer 1');
180+
expect(secondCallMessages[3].content).toBe('Q2');
181+
});
182+
});
118183
});

packages/ai-providers/server-ai-langchain/src/LangChainModelRunner.ts

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1+
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
12
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
2-
import { AIMessage } from '@langchain/core/messages';
3+
import { AIMessage, BaseMessage, HumanMessage } from '@langchain/core/messages';
34

45
import type {
56
LDAICompletionConfig,
67
LDLogger,
7-
LDMessage,
88
Runner,
99
RunnerResult,
1010
} from '@launchdarkly/server-sdk-ai';
@@ -19,35 +19,49 @@ import { convertMessagesToLangChain, getAIMetricsFromResponse } from './LangChai
1919
*/
2020
export class LangChainModelRunner implements Runner {
2121
private _llm: BaseChatModel;
22-
private _config: LDAICompletionConfig;
22+
private _chatHistory: InMemoryChatMessageHistory;
2323
private _logger?: LDLogger;
2424

2525
constructor(llm: BaseChatModel, config: LDAICompletionConfig, logger?: LDLogger) {
2626
this._llm = llm;
27-
this._config = config;
27+
this._chatHistory = new InMemoryChatMessageHistory(
28+
convertMessagesToLangChain(config.messages ?? []),
29+
);
2830
this._logger = logger;
2931
}
3032

3133
/**
3234
* Run the LangChain model with the given user prompt.
3335
*
34-
* Prepends any messages defined in the AI config (system prompt, etc.) before
35-
* the user prompt.
36+
* The runner maintains a LangChain `InMemoryChatMessageHistory` that is
37+
* initialized from any messages on the AI config (system prompt, etc.) and
38+
* grows with each successful call. On every invocation the user prompt is
39+
* appended to the existing history before being sent to the model. When the
40+
* call succeeds and produces non-empty content, the user prompt and the
41+
* assistant's reply are persisted to the history; failed calls leave the
42+
* history unchanged so the next call can retry cleanly.
3643
*
3744
* @param input The user prompt string.
3845
* @param outputType Optional JSON schema for structured output. When provided,
3946
* the parsed result is exposed via {@link RunnerResult.parsed}.
4047
*/
4148
async run(input: string, outputType?: Record<string, unknown>): Promise<RunnerResult> {
42-
const messages: LDMessage[] = [
43-
...(this._config.messages ?? []),
44-
{ role: 'user', content: input },
49+
const langchainMessages: BaseMessage[] = [
50+
...(await this._chatHistory.getMessages()),
51+
new HumanMessage(input),
4552
];
4653

47-
if (outputType !== undefined) {
48-
return this._runStructured(messages, outputType);
54+
const result =
55+
outputType !== undefined
56+
? await this._runStructured(langchainMessages, outputType)
57+
: await this._runCompletion(langchainMessages);
58+
59+
if (result.metrics.success && result.content) {
60+
await this._chatHistory.addUserMessage(input);
61+
await this._chatHistory.addAIMessage(result.content);
4962
}
50-
return this._runCompletion(messages);
63+
64+
return result;
5165
}
5266

5367
/**
@@ -57,10 +71,9 @@ export class LangChainModelRunner implements Runner {
5771
return this._llm;
5872
}
5973

60-
private async _runCompletion(messages: LDMessage[]): Promise<RunnerResult> {
74+
private async _runCompletion(messages: BaseMessage[]): Promise<RunnerResult> {
6175
try {
62-
const langchainMessages = convertMessagesToLangChain(messages);
63-
const response: AIMessage = await this._llm.invoke(langchainMessages);
76+
const response: AIMessage = await this._llm.invoke(messages);
6477
const metrics = getAIMetricsFromResponse(response);
6578

6679
let content: string = '';
@@ -85,14 +98,13 @@ export class LangChainModelRunner implements Runner {
8598
}
8699

87100
private async _runStructured(
88-
messages: LDMessage[],
101+
messages: BaseMessage[],
89102
outputType: Record<string, unknown>,
90103
): Promise<RunnerResult> {
91104
try {
92-
const langchainMessages = convertMessagesToLangChain(messages);
93105
const response = (await this._llm
94106
.withStructuredOutput(outputType)
95-
.invoke(langchainMessages)) as Record<string, unknown>;
107+
.invoke(messages)) as Record<string, unknown>;
96108

97109
const metrics = {
98110
success: true,

packages/ai-providers/server-ai-openai/__tests__/OpenAIModelRunner.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,87 @@ describe('OpenAIModelRunner', () => {
148148
expect(runner.getClient()).toBe(mockOpenAI);
149149
});
150150
});
151+
152+
describe('conversation history', () => {
153+
it('accumulates history across successful calls', async () => {
154+
(mockOpenAI.chat.completions.create as jest.Mock)
155+
.mockResolvedValueOnce({
156+
choices: [{ message: { content: 'First response' } }],
157+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
158+
} as any)
159+
.mockResolvedValueOnce({
160+
choices: [{ message: { content: 'Second response' } }],
161+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
162+
} as any);
163+
164+
await runner.run('First question');
165+
await runner.run('Second question');
166+
167+
const secondCallArgs = (mockOpenAI.chat.completions.create as jest.Mock).mock.calls[1][0];
168+
expect(secondCallArgs.messages).toEqual([
169+
{ role: 'user', content: 'First question' },
170+
{ role: 'assistant', content: 'First response' },
171+
{ role: 'user', content: 'Second question' },
172+
]);
173+
});
174+
175+
it('does not accumulate history when the call throws', async () => {
176+
(mockOpenAI.chat.completions.create as jest.Mock).mockRejectedValueOnce(new Error('boom'));
177+
await runner.run('Hello!');
178+
179+
(mockOpenAI.chat.completions.create as jest.Mock).mockResolvedValueOnce({
180+
choices: [{ message: { content: 'Recovery' } }],
181+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
182+
} as any);
183+
await runner.run('Try again');
184+
185+
const secondCallArgs = (mockOpenAI.chat.completions.create as jest.Mock).mock.calls[1][0];
186+
expect(secondCallArgs.messages).toEqual([{ role: 'user', content: 'Try again' }]);
187+
});
188+
189+
it('does not accumulate history when content is empty', async () => {
190+
(mockOpenAI.chat.completions.create as jest.Mock).mockResolvedValueOnce({
191+
choices: [{ message: {} }],
192+
} as any);
193+
await runner.run('Hello!');
194+
195+
(mockOpenAI.chat.completions.create as jest.Mock).mockResolvedValueOnce({
196+
choices: [{ message: { content: 'Recovery' } }],
197+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
198+
} as any);
199+
await runner.run('Try again');
200+
201+
const secondCallArgs = (mockOpenAI.chat.completions.create as jest.Mock).mock.calls[1][0];
202+
expect(secondCallArgs.messages).toEqual([{ role: 'user', content: 'Try again' }]);
203+
});
204+
205+
it('keeps config messages prepended ahead of accumulated history on every call', async () => {
206+
const configWithMessages: LDAICompletionConfig = {
207+
...baseConfig,
208+
messages: [{ role: 'system', content: 'You are helpful.' }],
209+
};
210+
const r = new OpenAIModelRunner(mockOpenAI, configWithMessages);
211+
212+
(mockOpenAI.chat.completions.create as jest.Mock)
213+
.mockResolvedValueOnce({
214+
choices: [{ message: { content: 'Answer 1' } }],
215+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
216+
} as any)
217+
.mockResolvedValueOnce({
218+
choices: [{ message: { content: 'Answer 2' } }],
219+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
220+
} as any);
221+
222+
await r.run('Q1');
223+
await r.run('Q2');
224+
225+
const secondCallArgs = (mockOpenAI.chat.completions.create as jest.Mock).mock.calls[1][0];
226+
expect(secondCallArgs.messages).toEqual([
227+
{ role: 'system', content: 'You are helpful.' },
228+
{ role: 'user', content: 'Q1' },
229+
{ role: 'assistant', content: 'Answer 1' },
230+
{ role: 'user', content: 'Q2' },
231+
]);
232+
});
233+
});
151234
});

packages/ai-providers/server-ai-openai/src/OpenAIModelRunner.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,39 +18,49 @@ import { convertMessagesToOpenAI, getAIMetricsFromResponse } from './OpenAIHelpe
1818
*/
1919
export class OpenAIModelRunner implements Runner {
2020
private _client: OpenAI;
21-
private _config: LDAICompletionConfig;
2221
private _modelName: string;
2322
private _parameters: Record<string, unknown>;
23+
private _history: LDMessage[];
2424
private _logger?: LDLogger;
2525

2626
constructor(client: OpenAI, config: LDAICompletionConfig, logger?: LDLogger) {
2727
this._client = client;
28-
this._config = config;
2928
this._modelName = config.model?.name ?? '';
3029
this._parameters = { ...(config.model?.parameters ?? {}) };
30+
this._history = [...(config.messages ?? [])];
3131
this._logger = logger;
3232
}
3333

3434
/**
3535
* Run the OpenAI model with the given user prompt.
3636
*
37-
* Prepends any messages defined in the AI config (system prompt,
38-
* instructions, etc.) before the user prompt.
37+
* The runner maintains a conversation history that is initialized from any
38+
* messages on the AI config (system prompt, instructions, etc.) and grows
39+
* with each successful call. On every invocation the user prompt is appended
40+
* to the existing history before being sent to the model. When the call
41+
* succeeds and produces non-empty content, the user prompt and the
42+
* assistant's reply are persisted to the history; failed calls leave the
43+
* history unchanged so the next call can retry cleanly.
3944
*
4045
* @param input The user prompt string.
4146
* @param outputType Optional JSON schema for structured output. When provided,
4247
* the response is parsed and exposed via {@link RunnerResult.parsed}.
4348
*/
4449
async run(input: string, outputType?: Record<string, unknown>): Promise<RunnerResult> {
45-
const messages: LDMessage[] = [
46-
...(this._config.messages ?? []),
47-
{ role: 'user', content: input },
48-
];
50+
const userMessage: LDMessage = { role: 'user', content: input };
51+
const messages: LDMessage[] = [...this._history, userMessage];
4952

50-
if (outputType !== undefined) {
51-
return this._runStructured(messages, outputType);
53+
const result =
54+
outputType !== undefined
55+
? await this._runStructured(messages, outputType)
56+
: await this._runCompletion(messages);
57+
58+
if (result.metrics.success && result.content) {
59+
this._history.push(userMessage);
60+
this._history.push({ role: 'assistant', content: result.content });
5261
}
53-
return this._runCompletion(messages);
62+
63+
return result;
5464
}
5565

5666
/**

0 commit comments

Comments
 (0)