diff --git a/examples/starter/.env.example b/examples/starter/.env.example index 5e8733aa..7d045b75 100644 --- a/examples/starter/.env.example +++ b/examples/starter/.env.example @@ -21,3 +21,5 @@ OPENAI_BASE_URL=http://host.docker.internal:11434/v1 # Optional — read by the bank_name resolver. # BANK_NAME=Northwind Bank + +# CONTEXT_MAX_TOKENS=2000 diff --git a/package-lock.json b/package-lock.json index 5fa7f024..2aa4dc56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -835,7 +835,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -988,7 +987,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -1389,7 +1387,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3009,7 +3006,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3019,7 +3015,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, diff --git a/src/agent_engine/core/validator.py b/src/agent_engine/core/validator.py index f4fba741..6d5adfb1 100644 --- a/src/agent_engine/core/validator.py +++ b/src/agent_engine/core/validator.py @@ -32,4 +32,3 @@ def _validate_node( message="plugins/access.py is required for protected nodes", ) ) - diff --git a/src/agent_manager/api/routes.py b/src/agent_manager/api/routes.py index 445b2f9b..70e26c5e 100644 --- a/src/agent_manager/api/routes.py +++ b/src/agent_manager/api/routes.py @@ -13,8 +13,12 @@ from agent_engine.runtime.streaming import RunStreamEvent from agent_manager.api.deps import get_service from agent_manager.api.schemas import ( + ContextUsageResponse, + ConversationSummary, CreateConversationRequest, CreateConversationResponse, + FeedbackRequest, + FeedbackResponse, MessageOut, SendMessageRequest, SendMessageResponse, @@ -25,6 +29,7 @@ ConversationNotFound, ConversationService, ConversationTokenBudgetExceeded, + MessageNotFound, ) router = APIRouter() @@ -41,13 +46,49 @@ async def create_conversation( return CreateConversationResponse(conversation_id=session_id, session_id=session_id) +@router.get("/conversations", response_model=list[ConversationSummary]) +async def list_conversations(service: Service, user_id: str) -> list[ConversationSummary]: + sessions = await service.list_conversations(user_id) + return [ + ConversationSummary( + conversation_id=s.session_id, + title=s.title, + last_message_at=s.last_message_at, + ) + for s in sessions + ] + + @router.get("/conversations/{conversation_id}/messages", response_model=list[MessageOut]) async def list_messages(conversation_id: str, service: Service) -> list[MessageOut]: try: msgs = await service.history(conversation_id) except ConversationNotFound as exc: raise HTTPException(status_code=404, detail="conversation not found") from exc - return [MessageOut(role=m.role, content=m.content, created_at=m.created_at) for m in msgs] + return [ + MessageOut( + message_id=m.message_id, + role=m.role, + content=m.content, + created_at=m.created_at, + feedback=m.feedback, + ) + for m in msgs + ] + + +@router.get("/conversations/{conversation_id}/usage", response_model=ContextUsageResponse) +async def get_usage(conversation_id: str, service: Service) -> ContextUsageResponse: + try: + usage = await service.usage(conversation_id) + except ConversationNotFound as exc: + raise HTTPException(status_code=404, detail="conversation not found") from exc + return ContextUsageResponse( + used_tokens=usage.used_tokens, + max_tokens=usage.max_tokens, + percent=usage.percent, + severity=usage.severity, + ) @router.post("/conversations/{conversation_id}/messages", response_model=SendMessageResponse) @@ -55,7 +96,7 @@ async def send_message( conversation_id: str, body: SendMessageRequest, service: Service ) -> SendMessageResponse: try: - result = await service.send(conversation_id, body.message, user_id=body.user_id) + result, msg = await service.send(conversation_id, body.message, user_id=body.user_id) except ConversationNotFound as exc: raise HTTPException(status_code=404, detail="conversation not found") from exc except ConversationTokenBudgetExceeded: @@ -66,10 +107,30 @@ async def send_message( answer=result.answer, visited=list(result.visited), used_tools=[ToolRecord(**dataclasses.asdict(t)) for t in result.used_tools], + message_id=msg.message_id if msg else None, ) -def _to_stream_event(event: RunStreamEvent) -> StreamEventOut: +@router.post( + "/conversations/{conversation_id}/messages/{message_id}/feedback", + response_model=FeedbackResponse, +) +async def record_feedback( + conversation_id: str, + message_id: str, + body: FeedbackRequest, + service: Service, +) -> FeedbackResponse: + try: + msg = await service.record_feedback(conversation_id, message_id, body.feedback) + except ConversationNotFound as exc: + raise HTTPException(status_code=404, detail="conversation not found") from exc + except MessageNotFound as exc: + raise HTTPException(status_code=404, detail="message not found") from exc + return FeedbackResponse(message_id=msg.message_id, feedback=msg.feedback) + + +def _to_stream_event(event: RunStreamEvent, message_id: str | None = None) -> StreamEventOut: return StreamEventOut( type=event.type, content=event.content, @@ -85,6 +146,7 @@ def _to_stream_event(event: RunStreamEvent) -> StreamEventOut: if event.used_tools else None ), + message_id=message_id, ) @@ -106,10 +168,11 @@ async def stream_message( async def event_source() -> AsyncIterator[str]: try: if first is not None: - payload = _to_stream_event(first).model_dump(exclude_none=True) - yield f"event: {first.type}\ndata: {json.dumps(payload)}\n\n" - async for event in stream: - payload = _to_stream_event(event).model_dump(exclude_none=True) + first_event, first_msg_id = first + payload = _to_stream_event(first_event, first_msg_id).model_dump(exclude_none=True) + yield f"event: {first_event.type}\ndata: {json.dumps(payload)}\n\n" + async for event, msg_id in stream: + payload = _to_stream_event(event, msg_id).model_dump(exclude_none=True) yield f"event: {event.type}\ndata: {json.dumps(payload)}\n\n" except Exception as exc: yield f"event: error\ndata: {json.dumps({'type': 'error', 'error': str(exc)})}\n\n" diff --git a/src/agent_manager/api/schemas.py b/src/agent_manager/api/schemas.py index 3777ee5c..e454b342 100644 --- a/src/agent_manager/api/schemas.py +++ b/src/agent_manager/api/schemas.py @@ -10,7 +10,7 @@ from pydantic import BaseModel -from agent_manager.domain import Role +from agent_manager.domain import ContextSeverity, Role class CreateConversationRequest(BaseModel): @@ -23,10 +23,27 @@ class CreateConversationResponse(BaseModel): session_id: str +class ConversationSummary(BaseModel): + conversation_id: str + title: str | None = None + last_message_at: datetime | None = None + + class MessageOut(BaseModel): + message_id: str | None = None role: Role content: str created_at: datetime + feedback: str | None = None + + +class FeedbackRequest(BaseModel): + feedback: str | None = None + + +class FeedbackResponse(BaseModel): + message_id: str + feedback: str | None = None class SendMessageRequest(BaseModel): @@ -47,6 +64,14 @@ class SendMessageResponse(BaseModel): answer: str visited: list[str] used_tools: list[ToolRecord] + message_id: str | None = None + + +class ContextUsageResponse(BaseModel): + used_tokens: int + max_tokens: int | None = None + percent: float = 0.0 + severity: ContextSeverity = ContextSeverity.NORMAL class StreamEventOut(BaseModel): @@ -60,3 +85,4 @@ class StreamEventOut(BaseModel): error: str | None = None system_name: str | None = None used_tools: list[ToolRecord] | None = None + message_id: str | None = None diff --git a/src/agent_manager/api/static/demo.html b/src/agent_manager/api/static/demo.html index db9313bc..852e0161 100644 --- a/src/agent_manager/api/static/demo.html +++ b/src/agent_manager/api/static/demo.html @@ -21,16 +21,15 @@
<script type="module" src="https://your-backend/widget.js"></script> -<agent-chat title="Home Assistant" color="#2563eb"></agent-chat>+<agent-chat title="Support" color="#18181b"></agent-chat> -
↘ The launcher is in the bottom-right corner. Click it and try - “turn on the kitchen lights”, then “now turn it off”.
+↘ The launcher is in the bottom-right corner. Click it and ask a question.