Initial commit

This commit is contained in:
Jordan
2026-04-01 23:16:45 +01:00
commit 91cfdaee72
200 changed files with 25589 additions and 0 deletions

375
src/api/routes.py Normal file
View 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}