feat(chat): stop an in-flight answer — Send becomes Stop, the partial is kept and persisted, the model stream is torn down
This commit is contained in:
+22
-10
@@ -246,12 +246,19 @@ async def run_agent(
|
||||
rounds = 0
|
||||
while True:
|
||||
calls: list[ToolCallPiece] = []
|
||||
async for piece in llm.chat_stream(
|
||||
cast("list[dict[str, str]]", messages), tools=tools
|
||||
):
|
||||
if isinstance(piece, ToolCallPiece):
|
||||
calls.append(piece)
|
||||
yield piece
|
||||
# Phase 48: bind the round's stream so a consumer abandon
|
||||
# (GeneratorExit into the yield below) tears down the in-flight
|
||||
# model stream deterministically — not GC-dependent. Awaiting
|
||||
# ``aclose()`` in the ``finally`` is safe because it does not
|
||||
# yield; on a fully consumed round it is a quiet no-op.
|
||||
stream = llm.chat_stream(cast("list[dict[str, str]]", messages), tools=tools)
|
||||
try:
|
||||
async for piece in stream:
|
||||
if isinstance(piece, ToolCallPiece):
|
||||
calls.append(piece)
|
||||
yield piece
|
||||
finally:
|
||||
await stream.aclose()
|
||||
if not calls:
|
||||
return # the answer was streamed
|
||||
call = calls[0] # a stream can carry several calls; run the first
|
||||
@@ -287,8 +294,13 @@ async def run_agent(
|
||||
"no-tools answer",
|
||||
rounds,
|
||||
)
|
||||
async for piece in llm.chat_stream(
|
||||
cast("list[dict[str, str]]", messages), tools=None
|
||||
):
|
||||
yield piece
|
||||
# Phase 48: the forced final answer gets the same explicit
|
||||
# teardown as the loop rounds (consumer abandon mid-final
|
||||
# answer must still close the model's stream).
|
||||
final = llm.chat_stream(cast("list[dict[str, str]]", messages), tools=None)
|
||||
try:
|
||||
async for piece in final:
|
||||
yield piece
|
||||
finally:
|
||||
await final.aclose()
|
||||
return
|
||||
|
||||
+28
-5
@@ -20,12 +20,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncGenerator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
from openai.types.chat import ChatCompletionMessageParam
|
||||
from openai import AsyncOpenAI, AsyncStream
|
||||
from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
|
||||
@@ -318,7 +318,7 @@ class LLMClient:
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
||||
) -> AsyncGenerator[StreamPiece | ToolCallPiece, None]:
|
||||
"""Stream assistant pieces from the chat model (PLAN A5/A15, phase 17).
|
||||
|
||||
``stream=True`` against the OpenAI-compatible endpoint, yielding
|
||||
@@ -355,6 +355,17 @@ class LLMClient:
|
||||
Any failure (network, HTTP, malformed stream) surfaces as
|
||||
:class:`LLMError` so the API layer can turn it into an SSE
|
||||
``error`` event instead of a hung request.
|
||||
|
||||
Teardown (phase 48, 2026-08-29, ``TODO.md`` L3): once
|
||||
``create()`` succeeded, the endpoint stream's lifetime is
|
||||
explicit — it is closed on **every** exit: normal exhaustion
|
||||
(a quiet no-op on the already-ended SDK stream, so the
|
||||
completed path stays byte-identical), a wrapped mid-stream
|
||||
failure, and consumer abandon (stop/cancel — ``GeneratorExit``;
|
||||
awaiting in the ``finally`` is safe because it does not yield).
|
||||
The SDK's ``close()`` awaits the underlying httpx response's
|
||||
``aclose()``, so the local model stops generating as soon as
|
||||
the SSE consumer goes away.
|
||||
"""
|
||||
kwargs: dict[str, Any] = {
|
||||
# ``{role, content}`` dicts are exactly what the message params
|
||||
@@ -367,8 +378,12 @@ class LLMClient:
|
||||
}
|
||||
if tools is not None:
|
||||
kwargs["tools"] = tools
|
||||
stream: AsyncStream[ChatCompletionChunk] | None = None
|
||||
try:
|
||||
stream = await self._client.chat.completions.create(**kwargs)
|
||||
stream = cast(
|
||||
"AsyncStream[ChatCompletionChunk]",
|
||||
await self._client.chat.completions.create(**kwargs),
|
||||
)
|
||||
calls: dict[int, _ToolCallSlot] = {}
|
||||
emitted = False
|
||||
async for chunk in stream:
|
||||
@@ -417,6 +432,14 @@ class LLMClient:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 — wrap transport-level failures
|
||||
raise LLMError(f"chat stream from {self.settings.llm_base_url} failed: {e}") from e
|
||||
finally:
|
||||
# Phase 48: deterministic teardown — whenever ``create()``
|
||||
# succeeded, close the endpoint's stream on every subsequent
|
||||
# exit (normal exhaustion, wrapped failures, and consumer
|
||||
# abandon). A failure of ``create()`` itself never sets
|
||||
# ``stream``, so it stays the plain wrap above.
|
||||
if stream is not None:
|
||||
await stream.close()
|
||||
|
||||
|
||||
async def check_models(llm: LLMClient) -> None:
|
||||
|
||||
Reference in New Issue
Block a user