Skip to content

Commit fb96c2c

Browse files
jsonbaileyclaude
andcommitted
feat: add conversation history to OpenAI and LangChain model runners
OpenAI runner maintains a List[LDMessage] history (Chat Completions has no built-in state). LangChain runner uses InMemoryChatMessageHistory to store native BaseMessage objects; config messages are converted once per call and joined with the history before sending to the model. History accumulates only on successful runs. Failed or empty responses leave history unchanged so the next call retries from clean state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e6942a6 commit fb96c2c

4 files changed

Lines changed: 148 additions & 17 deletions

File tree

packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_model_runner.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from typing import Any, Dict, List, Optional
22

3+
from langchain_core.chat_history import InMemoryChatMessageHistory
34
from langchain_core.language_models.chat_models import BaseChatModel
4-
from langchain_core.messages import BaseMessage
5+
from langchain_core.messages import BaseMessage, HumanMessage
56
from ldai import LDMessage, log
67
from ldai.providers.runner import Runner
78
from ldai.providers.types import LDAIMetrics, RunnerResult
@@ -27,6 +28,7 @@ class LangChainModelRunner(Runner):
2728
def __init__(self, llm: BaseChatModel, config_messages: Optional[List[LDMessage]] = None):
2829
self._llm = llm
2930
self._config_messages: List[LDMessage] = list(config_messages or [])
31+
self._chat_history = InMemoryChatMessageHistory()
3032

3133
def get_llm(self) -> BaseChatModel:
3234
"""
@@ -44,8 +46,10 @@ async def run(
4446
"""
4547
Run the LangChain model with the given input.
4648
47-
Prepends any config messages (system prompt, instructions, etc.) stored
48-
at construction time before the user message.
49+
Prepends config messages and accumulated conversation history (stored as
50+
native LangChain messages via InMemoryChatMessageHistory) before the user
51+
message. On success, appends the exchange to chat history so subsequent
52+
calls include prior context.
4953
5054
:param input: A string prompt
5155
:param output_type: Optional JSON schema dict requesting structured output.
@@ -54,16 +58,26 @@ async def run(
5458
:return: :class:`RunnerResult` containing ``content``, ``metrics``,
5559
``raw`` and (when ``output_type`` is set) ``parsed``.
5660
"""
57-
messages = self._config_messages + [LDMessage(role='user', content=input)]
61+
langchain_messages = (
62+
convert_messages_to_langchain(self._config_messages)
63+
+ self._chat_history.messages
64+
+ [HumanMessage(content=input)]
65+
)
5866

5967
if output_type is not None:
60-
return await self._run_structured(messages, output_type)
61-
return await self._run_completion(messages)
68+
result = await self._run_structured(langchain_messages, output_type)
69+
else:
70+
result = await self._run_completion(langchain_messages)
6271

63-
async def _run_completion(self, messages: List[LDMessage]) -> RunnerResult:
72+
if result.metrics.success and result.content:
73+
self._chat_history.add_user_message(input)
74+
self._chat_history.add_ai_message(result.content)
75+
76+
return result
77+
78+
async def _run_completion(self, messages: List[BaseMessage]) -> RunnerResult:
6479
try:
65-
langchain_messages = convert_messages_to_langchain(messages)
66-
response: BaseMessage = await self._llm.ainvoke(langchain_messages)
80+
response: BaseMessage = await self._llm.ainvoke(messages)
6781
metrics = get_ai_metrics_from_response(response)
6882

6983
content: str = ''
@@ -90,13 +104,12 @@ async def _run_completion(self, messages: List[LDMessage]) -> RunnerResult:
90104

91105
async def _run_structured(
92106
self,
93-
messages: List[LDMessage],
107+
messages: List[BaseMessage],
94108
output_type: Dict[str, Any],
95109
) -> RunnerResult:
96110
try:
97-
langchain_messages = convert_messages_to_langchain(messages)
98111
structured_llm = self._llm.with_structured_output(output_type, include_raw=True)
99-
response = await structured_llm.ainvoke(langchain_messages)
112+
response = await structured_llm.ainvoke(messages)
100113

101114
if not isinstance(response, dict):
102115
log.warning(f'Structured output did not return a dict. Got: {type(response)}')

packages/ai-providers/server-ai-langchain/tests/test_langchain_provider.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,59 @@ async def test_returns_success_false_when_model_invocation_throws_error(self, mo
262262
assert result.metrics.success is False
263263
assert result.content == ''
264264

265+
@pytest.mark.asyncio
266+
async def test_accumulates_history_across_successful_calls(self, mock_llm):
267+
"""Should include prior exchange in messages on subsequent calls."""
268+
mock_llm.ainvoke = AsyncMock(side_effect=[
269+
AIMessage(content='First response'),
270+
AIMessage(content='Second response'),
271+
])
272+
provider = LangChainModelRunner(mock_llm)
273+
274+
await provider.run('First question')
275+
await provider.run('Second question')
276+
277+
second_call_messages = mock_llm.ainvoke.call_args_list[1][0][0]
278+
roles = [type(m).__name__ for m in second_call_messages]
279+
assert roles == ['HumanMessage', 'AIMessage', 'HumanMessage']
280+
assert second_call_messages[0].content == 'First question'
281+
assert second_call_messages[1].content == 'First response'
282+
assert second_call_messages[2].content == 'Second question'
283+
284+
@pytest.mark.asyncio
285+
async def test_does_not_accumulate_history_on_failed_call(self, mock_llm):
286+
"""Should not add to history when the call fails."""
287+
mock_llm.ainvoke = AsyncMock(side_effect=Exception('Model error'))
288+
provider = LangChainModelRunner(mock_llm)
289+
290+
await provider.run('Hello')
291+
292+
mock_llm.ainvoke = AsyncMock(return_value=AIMessage(content='Recovery'))
293+
await provider.run('Try again')
294+
295+
second_call_messages = mock_llm.ainvoke.call_args_list[0][0][0]
296+
assert len(second_call_messages) == 1
297+
assert second_call_messages[0].content == 'Try again'
298+
299+
@pytest.mark.asyncio
300+
async def test_prepends_config_messages_before_history(self, mock_llm):
301+
"""Should send config messages before history on every call."""
302+
mock_llm.ainvoke = AsyncMock(side_effect=[
303+
AIMessage(content='Answer 1'),
304+
AIMessage(content='Answer 2'),
305+
])
306+
config_messages = [LDMessage(role='system', content='You are helpful.')]
307+
provider = LangChainModelRunner(mock_llm, config_messages=config_messages)
308+
309+
await provider.run('Q1')
310+
await provider.run('Q2')
311+
312+
second_call_messages = mock_llm.ainvoke.call_args_list[1][0][0]
313+
assert second_call_messages[0].content == 'You are helpful.'
314+
assert second_call_messages[1].content == 'Q1'
315+
assert second_call_messages[2].content == 'Answer 1'
316+
assert second_call_messages[3].content == 'Q2'
317+
265318

266319
class TestRunStructured:
267320
"""Tests for run() with structured output."""

packages/ai-providers/server-ai-openai/src/ldai_openai/openai_model_runner.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def __init__(
3434
self._model_name = model_name
3535
self._parameters = parameters
3636
self._config_messages: List[LDMessage] = list(config_messages or [])
37+
self._history: List[LDMessage] = []
3738

3839
async def run(
3940
self,
@@ -43,8 +44,9 @@ async def run(
4344
"""
4445
Run the OpenAI model with the given input.
4546
46-
Prepends any config messages (system prompt, instructions, etc.) stored
47-
at construction time before the user message.
47+
Prepends config messages and accumulated conversation history before the
48+
user message. On success, appends the user/assistant exchange to history
49+
so subsequent calls include prior context.
4850
4951
:param input: A string prompt
5052
:param output_type: Optional JSON schema dict requesting structured output.
@@ -53,11 +55,19 @@ async def run(
5355
:return: :class:`RunnerResult` containing ``content``, ``metrics``,
5456
``raw`` and (when ``output_type`` is set) ``parsed``.
5557
"""
56-
messages = self._config_messages + [LDMessage(role='user', content=input)]
58+
user_message = LDMessage(role='user', content=input)
59+
messages = self._config_messages + self._history + [user_message]
5760

5861
if output_type is not None:
59-
return await self._run_structured(messages, output_type)
60-
return await self._run_completion(messages)
62+
result = await self._run_structured(messages, output_type)
63+
else:
64+
result = await self._run_completion(messages)
65+
66+
if result.metrics.success and result.content:
67+
self._history.append(user_message)
68+
self._history.append(LDMessage(role='assistant', content=result.content))
69+
70+
return result
6171

6272
async def _run_completion(self, messages: List[LDMessage]) -> RunnerResult:
6373
try:

packages/ai-providers/server-ai-openai/tests/test_openai_provider.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,61 @@ async def test_returns_unsuccessful_response_when_exception_thrown(self, mock_cl
204204
assert result.content == ''
205205
assert result.metrics.success is False
206206

207+
@pytest.mark.asyncio
208+
async def test_accumulates_history_across_successful_calls(self, mock_client):
209+
"""Should include prior exchange in messages on subsequent calls."""
210+
def make_response(text: str):
211+
r = MagicMock()
212+
r.context_wrapper = None
213+
r.choices = [MagicMock()]
214+
r.choices[0].message = MagicMock()
215+
r.choices[0].message.content = text
216+
r.usage = None
217+
return r
218+
219+
mock_client.chat = MagicMock()
220+
mock_client.chat.completions = MagicMock()
221+
mock_client.chat.completions.create = AsyncMock(side_effect=[
222+
make_response('First response'),
223+
make_response('Second response'),
224+
])
225+
226+
provider = OpenAIModelRunner(mock_client, 'gpt-4o', {})
227+
await provider.run('First question')
228+
await provider.run('Second question')
229+
230+
second_call_messages = mock_client.chat.completions.create.call_args_list[1].kwargs['messages']
231+
assert second_call_messages == [
232+
{'role': 'user', 'content': 'First question'},
233+
{'role': 'assistant', 'content': 'First response'},
234+
{'role': 'user', 'content': 'Second question'},
235+
]
236+
237+
@pytest.mark.asyncio
238+
async def test_does_not_accumulate_history_on_failed_call(self, mock_client):
239+
"""Should not add to history when the call fails."""
240+
mock_client.chat = MagicMock()
241+
mock_client.chat.completions = MagicMock()
242+
mock_client.chat.completions.create = AsyncMock(side_effect=Exception('API Error'))
243+
244+
provider = OpenAIModelRunner(mock_client, 'gpt-4o', {})
245+
await provider.run('Hello!')
246+
247+
def make_ok_response():
248+
r = MagicMock()
249+
r.context_wrapper = None
250+
r.choices = [MagicMock()]
251+
r.choices[0].message = MagicMock()
252+
r.choices[0].message.content = 'Recovery'
253+
r.usage = None
254+
return r
255+
256+
mock_client.chat.completions.create = AsyncMock(return_value=make_ok_response())
257+
await provider.run('Try again')
258+
259+
second_call_messages = mock_client.chat.completions.create.call_args.kwargs['messages']
260+
assert second_call_messages == [{'role': 'user', 'content': 'Try again'}]
261+
207262

208263
class TestRunStructured:
209264
"""Tests for the unified run() method (structured-output path)."""

0 commit comments

Comments
 (0)