"""POST /v1/chat/stream — SSE chat with the D-016 envelope + agent routing. Envelope: meta event first (flushed before first token), then raw content deltas, then done; error event before [DONE] on mid-stream failure. Pre-first-byte provider failures surface as in-band `provider_unavailable` error events (SSE 200 headers are already committed once meta flushes). Agent routing (A-007): the request names its agent; unknown agents are rejected with 422. No autonomous routing in v0.2. """ import json from collections.abc import AsyncIterator from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sse_starlette.sse import EventSourceResponse from ..agents.registry import AgentRegistry, UnknownAgentError from ..agents.session import SessionStore from ..config import Settings from ..corpus.learner_context import get_learner_context from ..llm.base import LLMProvider from ..llm.types import Message from .deps import get_agent_registry, get_provider, get_session_store, get_settings router = APIRouter(prefix="/v1") class ChatStreamRequest(BaseModel): agent: str = Field(min_length=1) session_id: str = Field(min_length=1) learner_id: str | None = None messages: list[Message] = Field(min_length=1) @router.post("/chat/stream") async def chat_stream( body: ChatStreamRequest, registry: AgentRegistry = Depends(get_agent_registry), sessions: SessionStore = Depends(get_session_store), settings: Settings = Depends(get_settings), provider: LLMProvider = Depends(get_provider), ) -> EventSourceResponse: # Route to the named agent (A-007); unknown → 422 before any streaming. try: agent = registry.get(provider, settings, body.agent) except UnknownAgentError as exc: raise HTTPException( status_code=422, detail=str(exc) ) from None learner_context = get_learner_context(body.learner_id) # Agent-scoped session (A-007/G-4): persisted turn history, windowed replay. session = await sessions.get(body.session_id) if session is None: session = await sessions.create( body.session_id, agent=body.agent, learner_id=body.learner_id or "learner-001" ) # The new user turn is the last message of the request. user_turn = body.messages[-1] history = await sessions.history_window(body.session_id) # Retry dedupe (P1 from final review): a client retry resends the same # turn after a provider failure — don't double-append it to history. last_stored = history[-1] if history else None is_retry = ( last_stored is not None and last_stored.role == "user" and last_stored.content == user_turn.content ) if not is_retry: await sessions.append(body.session_id, user_turn) else: # On retry the history replay should exclude the stored duplicate. history = history[:-1] async def event_stream() -> AsyncIterator[dict]: yield {"event": "message", "data": json.dumps({ "type": "meta", "agent": body.agent, "session_id": body.session_id, "model": settings.model, })} first_byte = True reply_parts: list[str] = [] try: async for token in agent.stream_reply( history=history, user_input=user_turn.content, learner_context=learner_context, ): first_byte = False reply_parts.append(token) yield {"event": "message", "data": json.dumps({ "type": "delta", "content": token })} full_reply = "".join(reply_parts) if full_reply: await sessions.append( body.session_id, Message(role="assistant", content=full_reply) ) yield {"event": "message", "data": json.dumps({ "type": "done", "finish_reason": "stop" })} yield {"event": "message", "data": "[DONE]"} except Exception as exc: # CancelledError is BaseException — passes through message = str(exc) if first_byte: yield {"event": "message", "data": json.dumps({ "type": "error", "code": "provider_unavailable", "message": message })} else: yield {"event": "message", "data": json.dumps({ "type": "error", "code": "provider_error", "message": message })} # [DONE] is yielded from the except branch, NEVER from finally: # a yield inside finally would re-raise after GeneratorExit when the # client disconnects ("async generator ignored GeneratorExit"). yield {"event": "message", "data": "[DONE]"} return EventSourceResponse( event_stream(), headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", }, )