1010deltas must be replayed with the matching suffix so pydantic_ai can append the
1111streamed ``tool_args`` content to the correct part.
1212
13+ When compaction omits the ``conversation`` parameter from inference requests,
14+ ``LlamaStackResponsesModel`` appends completed turns to the conversation via
15+ ``CompactionTurnContext`` (in :meth:`_responses_create` for non-streaming rounds,
16+ and on ``response.completed`` for streaming rounds in :meth:`request_stream`).
17+
1318This module provides ``LlamaStackResponsesModel`` which wraps the event stream to
1419buffer those early delta events and replay them correctly once the item is announced.
1520"""
1621
1722from __future__ import annotations as _annotations
1823
1924from collections import defaultdict
20- from collections .abc import AsyncIterator
25+ from collections .abc import AsyncIterator , Sequence
2126from contextlib import asynccontextmanager
22- from typing import Any , cast
27+ from dataclasses import dataclass
28+ from typing import Any , Literal , Optional , cast , overload
2329
30+ from llama_stack_client import AsyncLlamaStackClient
2431from openai import AsyncStream
2532from openai .types import responses
2633from pydantic_ai import UnexpectedModelBehavior
2734from pydantic_ai ._run_context import RunContext
2835from pydantic_ai ._utils import PeekableAsyncStream , Unset , number_to_datetime
29- from pydantic_ai .messages import ModelMessage , ModelResponse
36+ from pydantic_ai .messages import ModelMessage , ModelRequest , ModelResponse
3037from pydantic_ai .models import (
3138 ModelRequestParameters ,
3239 StreamedResponse ,
3845 OpenAIResponsesStreamedResponse ,
3946 _map_api_errors ,
4047)
48+ from pydantic_ai .profiles import ModelProfileSpec
4149from pydantic_ai .settings import ModelSettings
4250
4351from log import get_logger
52+ from models .common .responses .types import ResponseInput
53+ from pydantic_ai_lightspeed .llamastack ._provider import LlamaStackProvider
54+ from utils .conversations import append_turn_items_to_conversation
4455
4556logger = get_logger (__name__ )
4657
4758
59+ @dataclass
60+ class CompactionTurnContext :
61+ """Mutable state for manually persisting compacted agent turns.
62+
63+ ``latest_round_input`` is initialized to the real user query. The create patch
64+ leaves it unchanged on the first LLM round, then records pydantic-ai input
65+ for follow-up rounds after that turn is persisted.
66+
67+ Attributes:
68+ client: Llama Stack client used to append conversation items.
69+ conversation_id: Conversation to store turns against.
70+ latest_round_input: Input stored for the current or next inference round.
71+ original_input_persisted: Whether the first compacted round was appended.
72+ """
73+
74+ client : AsyncLlamaStackClient
75+ conversation_id : str
76+ latest_round_input : ResponseInput
77+ original_input_persisted : bool = False
78+
79+
4880class _FilteredResponseStream :
4981 """Wraps an OpenAI AsyncStream to reorder spurious events from Llama Stack.
5082
@@ -58,13 +90,19 @@ class _FilteredResponseStream:
5890 a closing ``}`` to complete the outer JSON object that pydantic_ai opens.
5991 """
6092
61- def __init__ (self , source : AsyncStream [responses .ResponseStreamEvent ]) -> None :
93+ def __init__ (
94+ self ,
95+ source : AsyncStream [responses .ResponseStreamEvent ],
96+ compaction : Optional [CompactionTurnContext ] = None ,
97+ ) -> None :
6298 """Wrap an existing stream with reordering logic.
6399
64100 Args:
65101 source: The raw OpenAI AsyncStream to reorder.
102+ compaction: Compaction state for turn persistence, if active.
66103 """
67104 self ._source = source
105+ self ._compaction = compaction
68106 self ._announced_item_ids : set [str ] = set ()
69107 self ._buffered_deltas : dict [
70108 str , list [responses .ResponseFunctionCallArgumentsDeltaEvent ]
@@ -112,6 +150,19 @@ async def _filtered_iter(
112150 self ._buffered_deltas [event .item_id ].append (event )
113151 continue
114152
153+ if (
154+ isinstance (event , responses .ResponseCompletedEvent )
155+ and self ._compaction is not None
156+ ):
157+ compaction = self ._compaction
158+ await append_turn_items_to_conversation (
159+ compaction .client ,
160+ compaction .conversation_id ,
161+ compaction .latest_round_input ,
162+ cast (Sequence [Any ], event .response .output ),
163+ )
164+ compaction .original_input_persisted = True
165+
115166 yield event
116167
117168 def _replay_buffered_deltas (
@@ -179,8 +230,108 @@ class LlamaStackResponsesModel(OpenAIResponsesModel):
179230 Overrides the streaming response processing to buffer and replay
180231 ``ResponseFunctionCallArgumentsDeltaEvent`` events that Llama Stack emits
181232 before the corresponding ``McpCall`` or ``ResponseFunctionToolCall`` item.
233+
234+ When ``compaction`` is set, completed inference rounds are appended to the
235+ conversation because compacted mode omits the ``conversation`` parameter.
182236 """
183237
238+ def __init__ ( # pylint: disable=too-many-arguments
239+ self ,
240+ model_name : str ,
241+ provider : LlamaStackProvider ,
242+ profile : ModelProfileSpec | None = None ,
243+ settings : ModelSettings | None = None ,
244+ compaction : Optional [CompactionTurnContext ] = None ,
245+ ) -> None :
246+ """Initialize the model.
247+
248+ Args:
249+ model_name: Model identifier passed to pydantic-ai.
250+ provider: Pydantic AI provider or provider name.
251+ profile: Optional model profile override.
252+ settings: Optional pydantic-ai model settings.
253+ compaction: Compaction state when turns must be stored manually.
254+ """
255+ super ().__init__ (
256+ model_name ,
257+ provider = provider ,
258+ profile = profile ,
259+ settings = settings ,
260+ )
261+ self .compaction = compaction
262+
263+ @overload
264+ async def _responses_create (
265+ self ,
266+ messages : list [ModelRequest | ModelResponse ],
267+ stream : Literal [False ],
268+ model_settings : OpenAIResponsesModelSettings ,
269+ model_request_parameters : ModelRequestParameters ,
270+ ) -> responses .Response : ...
271+
272+ @overload
273+ async def _responses_create (
274+ self ,
275+ messages : list [ModelRequest | ModelResponse ],
276+ stream : Literal [True ],
277+ model_settings : OpenAIResponsesModelSettings ,
278+ model_request_parameters : ModelRequestParameters ,
279+ ) -> AsyncStream [responses .ResponseStreamEvent ]: ...
280+
281+ async def _responses_create (
282+ self ,
283+ messages : list [ModelRequest | ModelResponse ],
284+ stream : bool ,
285+ model_settings : OpenAIResponsesModelSettings ,
286+ model_request_parameters : ModelRequestParameters ,
287+ ) -> (
288+ responses .Response | AsyncStream [responses .ResponseStreamEvent ] | ModelResponse
289+ ):
290+ """Create a Responses API request with compacted turn persistence.
291+
292+ After the first compacted round is persisted, records pydantic-ai input
293+ for follow-up tool-loop rounds. Non-streaming responses are appended
294+ immediately; streaming persistence is handled in :meth:`request_stream`.
295+ """
296+ compaction = self .compaction
297+ if compaction is not None and compaction .original_input_persisted :
298+ request_params = await self ._build_responses_request_params (
299+ messages ,
300+ model_settings ,
301+ model_request_parameters ,
302+ self .profile ,
303+ )
304+ compaction .latest_round_input = cast (ResponseInput , request_params .input )
305+
306+ result : (
307+ responses .Response
308+ | AsyncStream [responses .ResponseStreamEvent ]
309+ | ModelResponse
310+ )
311+ if stream :
312+ result = await super ()._responses_create (
313+ messages , True , model_settings , model_request_parameters
314+ )
315+ else :
316+ result = await super ()._responses_create (
317+ messages , False , model_settings , model_request_parameters
318+ )
319+
320+ if (
321+ compaction is not None
322+ and not stream
323+ and isinstance (result , responses .Response )
324+ ):
325+ await append_turn_items_to_conversation (
326+ compaction .client ,
327+ compaction .conversation_id ,
328+ compaction .latest_round_input ,
329+ cast (Sequence [Any ], result .output ),
330+ )
331+ compaction .original_input_persisted = True
332+
333+ return result
334+
184335 async def request ( # pylint: disable=unused-argument
185336 self ,
186337 messages : list [ModelMessage ],
@@ -274,7 +425,7 @@ async def request_stream( # pylint: disable=unused-argument
274425 messages , True , model_settings_cast , model_request_parameters
275426 )
276427
277- filtered_stream = _FilteredResponseStream (response )
428+ filtered_stream = _FilteredResponseStream (response , self . compaction )
278429
279430 async with response :
280431 peekable : PeekableAsyncStream [
0 commit comments