Files
group_fqcd_jr/app/api/controllers/agent_runs.py
T

113 lines
4.3 KiB
Python
Raw Normal View History

2026-09-09 21:55:37 +08:00
from collections.abc import AsyncIterator
from dataclasses import asdict
from fastapi import APIRouter, Depends, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import StreamingResponse
from app.api.dependencies.auth import build_request_context
from app.api.dependencies.database import get_session
2026-09-11 09:47:40 +08:00
from app.api.dependencies.rate_limit import enforce_rate_limit
2026-09-09 21:55:37 +08:00
from app.api.schemas.agent_runs import (
2026-09-11 09:47:40 +08:00
AgentRunAcceptedEnvelope,
2026-09-09 21:55:37 +08:00
AgentRunAcceptedResponse,
AgentRunCreateRequest,
2026-09-11 09:47:40 +08:00
AgentRunStatusEnvelope,
2026-09-09 21:55:37 +08:00
AgentRunStatusResponse,
)
from app.api.views.agent_run_sse import encode_events, recovery_events
from app.core.config import get_settings
from app.core.contracts import AgentRequest, RequestContext
2026-09-11 09:47:40 +08:00
from app.core.errors import SseNotAcceptableError
2026-09-09 21:55:37 +08:00
from app.service.agent_run_application_service import AgentRunApplicationService
from app.service.run_query_service import RunQueryService
2026-09-11 09:47:40 +08:00
router = APIRouter(
prefix="/api/v1/agent-runs",
tags=["agent-runs"],
dependencies=[Depends(enforce_rate_limit)],
)
2026-09-09 21:55:37 +08:00
2026-09-11 09:47:40 +08:00
def accepts_event_stream(accept: str | None) -> bool:
if accept is None or not accept.strip():
return True
for entry in accept.split(","):
parts = entry.split(";")
if parts[0].strip().lower() not in {"text/event-stream", "text/*", "*/*"}:
continue
quality = 1.0
for parameter in parts[1:]:
name, _, value = parameter.partition("=")
if name.strip().lower() == "q":
try:
quality = float(value.strip())
except ValueError:
quality = 0.0
if quality > 0:
return True
return False
@router.post("", response_model=AgentRunAcceptedEnvelope, status_code=status.HTTP_202_ACCEPTED)
2026-09-09 21:55:37 +08:00
async def create_agent_run(
payload: AgentRunCreateRequest,
request: Request,
context: RequestContext = Depends(build_request_context), # noqa: B008
session: AsyncSession = Depends(get_session), # noqa: B008
2026-09-11 09:47:40 +08:00
) -> AgentRunAcceptedEnvelope:
2026-09-09 21:55:37 +08:00
request.state.request_context = context
accepted = await AgentRunApplicationService(session).accept(
AgentRequest(**payload.model_dump()), context)
2026-09-11 09:47:40 +08:00
return AgentRunAcceptedEnvelope(
data=AgentRunAcceptedResponse(
run_id=accepted.run_id, trace_id=accepted.trace_id, status=accepted.status,
status_url=f"/api/v1/agent-runs/{accepted.run_id}",
events_url=f"/api/v1/agent-runs/{accepted.run_id}/events",
),
meta={"trace_id": context.trace_id},
2026-09-09 21:55:37 +08:00
)
2026-09-11 09:47:40 +08:00
@router.get("/{run_id}", response_model=AgentRunStatusEnvelope)
2026-09-09 21:55:37 +08:00
async def get_agent_run(
run_id: str, context: RequestContext = Depends(build_request_context), # noqa: B008
2026-09-11 09:47:40 +08:00
) -> AgentRunStatusEnvelope:
return AgentRunStatusEnvelope(
data=AgentRunStatusResponse(**asdict(await RunQueryService().get(run_id, context))),
meta={"trace_id": context.trace_id},
)
2026-09-09 21:55:37 +08:00
@router.get("/{run_id}/events")
async def stream_agent_run_events(
2026-09-11 09:47:40 +08:00
run_id: str,
request: Request,
context: RequestContext = Depends(build_request_context), # noqa: B008
2026-09-09 21:55:37 +08:00
) -> StreamingResponse:
query = RunQueryService()
initial = await query.get(run_id, context)
2026-09-11 09:47:40 +08:00
if not accepts_event_stream(request.headers.get("Accept")):
raise SseNotAcceptableError("Accept must allow text/event-stream")
2026-09-09 21:55:37 +08:00
async def generate() -> AsyncIterator[str]:
start_sent = False
async for snapshot in query.watch(initial, context):
if snapshot is None:
yield ": heartbeat\n\n"
continue
result = snapshot.result or {}
events = recovery_events(
run_id=snapshot.run_id, trace_id=snapshot.trace_id, status=snapshot.status,
error_code=snapshot.error_code, content=result.get("content"),
tool_calls=result.get("tool_calls"),
replay=initial.status in {"succeeded", "failed", "cancelled"},
chunk_size=get_settings().sse_chunk_characters,
)
for encoded in encode_events(run_id, events[1:] if start_sent else events):
yield encoded
start_sent = True
return StreamingResponse(generate(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache"})