Initial commit
This commit is contained in:
375
src/api/routes.py
Normal file
375
src/api/routes.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""REST API endpoints for the agentic microservice."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import pathlib
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..models.context import MemoryDocument, MemoryType
|
||||
from ..models.session import SessionState, SessionStatus
|
||||
from ..streaming.sse import EventType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Request / Response schemas
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
class CreateSessionRequest(BaseModel):
|
||||
project_profile: dict[str, Any] = Field(default_factory=dict)
|
||||
immutable_rules: list[str] = Field(default_factory=list)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CreateSessionResponse(BaseModel):
|
||||
session_id: str
|
||||
status: str
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
message: str
|
||||
stream: bool = False
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
session_id: str
|
||||
status: str
|
||||
turn_count: int
|
||||
current_task: dict[str, Any] | None = None
|
||||
completed_tasks: list[str] = Field(default_factory=list)
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dependency helpers (set by main.py at startup)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_deps: dict[str, Any] = {}
|
||||
|
||||
|
||||
def set_dependencies(
|
||||
storage: Any,
|
||||
orchestrator: Any,
|
||||
sse_emitter: Any,
|
||||
context_engine: Any = None,
|
||||
memory_store: Any = None,
|
||||
) -> None:
|
||||
_deps["storage"] = storage
|
||||
_deps["orchestrator"] = orchestrator
|
||||
_deps["sse"] = sse_emitter
|
||||
if context_engine:
|
||||
_deps["context_engine"] = context_engine
|
||||
if memory_store:
|
||||
_deps["memory_store"] = memory_store
|
||||
|
||||
|
||||
def _get_storage():
|
||||
return _deps["storage"]
|
||||
|
||||
|
||||
def _get_orchestrator():
|
||||
return _deps["orchestrator"]
|
||||
|
||||
|
||||
def _get_sse():
|
||||
return _deps["sse"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /sessions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions", response_model=CreateSessionResponse, status_code=201)
|
||||
async def create_session(body: CreateSessionRequest) -> CreateSessionResponse:
|
||||
storage = _get_storage()
|
||||
session = SessionState(
|
||||
project_profile=body.project_profile,
|
||||
immutable_rules=body.immutable_rules,
|
||||
metadata=body.metadata,
|
||||
)
|
||||
await storage.create_session(session)
|
||||
|
||||
sse = _get_sse()
|
||||
await sse.emit(
|
||||
EventType.SESSION_CREATED,
|
||||
{"session_id": session.session_id},
|
||||
session_id=session.session_id,
|
||||
)
|
||||
|
||||
logger.info("Session created: %s", session.session_id)
|
||||
return CreateSessionResponse(
|
||||
session_id=session.session_id,
|
||||
status=session.status.value,
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /sessions/{id}/messages
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/messages")
|
||||
async def send_message(
|
||||
session_id: str, body: SendMessageRequest
|
||||
) -> dict[str, Any]:
|
||||
storage = _get_storage()
|
||||
session = await storage.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
orchestrator = _get_orchestrator()
|
||||
|
||||
if body.stream:
|
||||
asyncio.create_task(_execute_and_persist(orchestrator, storage, session, body.message))
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "executing",
|
||||
"stream_url": f"/sessions/{session_id}/stream",
|
||||
}
|
||||
|
||||
result = await _execute_and_persist(orchestrator, storage, session, body.message)
|
||||
return result
|
||||
|
||||
|
||||
async def _execute_and_persist(orchestrator, storage, session, message) -> dict[str, Any]:
|
||||
# Acquire exclusive lock — prevents concurrent execution on same session
|
||||
async with storage.session_lock(session.session_id) as acquired:
|
||||
if not acquired:
|
||||
return {
|
||||
"session_id": session.session_id,
|
||||
"content": "Error: session is busy — another request is executing",
|
||||
"status": "busy",
|
||||
}
|
||||
|
||||
try:
|
||||
result = await orchestrator.process_message(session, message)
|
||||
return result
|
||||
except Exception as e:
|
||||
session.status = SessionStatus.ERROR
|
||||
logger.exception("Execution failed for session %s", session.session_id)
|
||||
return {
|
||||
"session_id": session.session_id,
|
||||
"content": f"Error: {e}",
|
||||
"status": "error",
|
||||
}
|
||||
finally:
|
||||
try:
|
||||
await storage.update_session(session)
|
||||
except Exception as e:
|
||||
logger.error("Failed to persist session state: %s", e)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /sessions/{id}/stream
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@router.get("/sessions/{session_id}/stream")
|
||||
async def stream_session(session_id: str) -> StreamingResponse:
|
||||
storage = _get_storage()
|
||||
session = await storage.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
sse = _get_sse()
|
||||
|
||||
return StreamingResponse(
|
||||
sse.subscribe(session_id),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /sessions/{id}
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@router.get("/sessions/{session_id}", response_model=SessionResponse)
|
||||
async def get_session(session_id: str) -> SessionResponse:
|
||||
storage = _get_storage()
|
||||
session = await storage.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
return SessionResponse(
|
||||
session_id=session.session_id,
|
||||
status=session.status.value,
|
||||
turn_count=session.turn_count,
|
||||
current_task=session.current_task.model_dump() if session.current_task else None,
|
||||
completed_tasks=session.completed_tasks,
|
||||
created_at=session.created_at.isoformat(),
|
||||
updated_at=session.updated_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /sessions/{id}
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def delete_session(session_id: str) -> dict[str, str]:
|
||||
storage = _get_storage()
|
||||
deleted = await storage.delete_session(session_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
sse = _get_sse()
|
||||
sse.cleanup_session(session_id)
|
||||
|
||||
return {"status": "deleted", "session_id": session_id}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /sessions/{id}/events
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@router.get("/sessions/{session_id}/events")
|
||||
async def get_session_events(session_id: str) -> list[dict[str, Any]]:
|
||||
sse = _get_sse()
|
||||
return await sse.get_history(session_id)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /sessions/{id}/context-debug
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@router.get("/sessions/{session_id}/context-debug")
|
||||
async def get_context_debug(session_id: str) -> dict[str, Any]:
|
||||
"""Returns the full context engine debug history for a session.
|
||||
|
||||
Shows exactly what each agent received: sections, token counts,
|
||||
priorities, compaction status, and content previews.
|
||||
"""
|
||||
ctx_engine = _deps.get("context_engine")
|
||||
if not ctx_engine:
|
||||
raise HTTPException(status_code=501, detail="Context engine not available")
|
||||
|
||||
history = ctx_engine.get_debug_history(session_id)
|
||||
last = ctx_engine.get_last_context_debug(session_id)
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"total_builds": len(history),
|
||||
"last_build": last,
|
||||
"history": history,
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Knowledge Base
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
class LoadKnowledgeRequest(BaseModel):
|
||||
docs_path: str = "docs"
|
||||
|
||||
|
||||
@router.post("/knowledge/load")
|
||||
async def load_knowledge(body: LoadKnowledgeRequest) -> dict[str, Any]:
|
||||
"""Load markdown docs from a directory into the knowledge base."""
|
||||
memory = _deps.get("memory_store")
|
||||
if not memory:
|
||||
raise HTTPException(status_code=501, detail="Memory store not available")
|
||||
|
||||
docs_dir = pathlib.Path(body.docs_path)
|
||||
if not docs_dir.is_absolute():
|
||||
# Resolve relative to project root
|
||||
docs_dir = pathlib.Path(__file__).resolve().parent.parent.parent / body.docs_path
|
||||
|
||||
if not docs_dir.is_dir():
|
||||
raise HTTPException(status_code=400, detail=f"Directory not found: {docs_dir}")
|
||||
|
||||
loaded = []
|
||||
for md_file in sorted(docs_dir.glob("*.md")):
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
doc_id = md_file.stem
|
||||
|
||||
# Build a summary from the first ~500 chars
|
||||
lines = content.strip().splitlines()
|
||||
title = lines[0].lstrip("#").strip() if lines else doc_id
|
||||
summary_lines = []
|
||||
for line in lines[:30]:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
summary_lines.append(line)
|
||||
if len(" ".join(summary_lines)) > 500:
|
||||
break
|
||||
summary = " ".join(summary_lines)[:500]
|
||||
|
||||
# Extract tags from headings
|
||||
tags = []
|
||||
for line in lines:
|
||||
if line.startswith("## "):
|
||||
tags.append(line.lstrip("#").strip().lower()[:30])
|
||||
|
||||
doc = MemoryDocument(
|
||||
memory_id=doc_id,
|
||||
memory_type=MemoryType.DOCUMENT,
|
||||
namespace="knowledge",
|
||||
title=title,
|
||||
content=content,
|
||||
summary=summary,
|
||||
tags=tags[:10],
|
||||
)
|
||||
await memory.store_document(doc)
|
||||
loaded.append({
|
||||
"id": doc_id,
|
||||
"title": title,
|
||||
"chars": len(content),
|
||||
"tags": tags[:5],
|
||||
})
|
||||
|
||||
logger.info("Loaded %d knowledge documents from %s", len(loaded), docs_dir)
|
||||
return {
|
||||
"status": "loaded",
|
||||
"count": len(loaded),
|
||||
"documents": loaded,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/knowledge")
|
||||
async def list_knowledge() -> dict[str, Any]:
|
||||
"""List all documents in the knowledge base."""
|
||||
memory = _deps.get("memory_store")
|
||||
if not memory:
|
||||
raise HTTPException(status_code=501, detail="Memory store not available")
|
||||
|
||||
docs = await memory.list_documents(namespace="knowledge")
|
||||
return {
|
||||
"count": len(docs),
|
||||
"documents": [
|
||||
{
|
||||
"id": d.memory_id,
|
||||
"title": d.title,
|
||||
"chars": len(d.content),
|
||||
"summary": d.summary[:200],
|
||||
"tags": d.tags,
|
||||
"updated_at": d.updated_at.isoformat(),
|
||||
}
|
||||
for d in docs
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/knowledge/{doc_id}")
|
||||
async def delete_knowledge(doc_id: str) -> dict[str, str]:
|
||||
"""Remove a document from the knowledge base."""
|
||||
memory = _deps.get("memory_store")
|
||||
if not memory:
|
||||
raise HTTPException(status_code=501, detail="Memory store not available")
|
||||
|
||||
deleted = await memory.delete_document(doc_id, namespace="knowledge")
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
return {"status": "deleted", "id": doc_id}
|
||||
Reference in New Issue
Block a user