88a1dab810
---ci--- phase: 7 milestone: v0.2 status: complete requirements: covered: [REQ-2-001, REQ-2-002, REQ-2-003, REQ-2-004, REQ-2-005, REQ-2-006, REQ-2-007, REQ-2-008, REQ-2-009, REQ-2-010, REQ-2-011, REQ-2-012] partial: [] ---/ci--- Milestone v0.2 (ai-tutor-architecture) merged to main. Escalation record (audit remediation, durable): P1 executor delegation failed twice (empty subagent results, zero files created); auto-resolved at full autonomy to inline execution with identical plan fidelity (commit 3271373, reflog-only after phase branch squash-delete).
129 lines
4.9 KiB
Python
129 lines
4.9 KiB
Python
"""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",
|
|
},
|
|
)
|