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:
2026-08-29 17:27:04 -04:00
parent 6bf7f456d4
commit 1a60ecbd8b
186 changed files with 1738 additions and 7796 deletions
+22 -10
View File
@@ -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
View File
@@ -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: