From 2093eda5a00e6f81bdb65cde3759fa8d1233501d Mon Sep 17 00:00:00 2001
From: Amit Avital
Date: Sun, 26 Jul 2026 18:19:04 +0300
Subject: [PATCH 1/6] feat(widget): context-usage meter backed by the agent
manager
Surface per-conversation token usage against the configured
context_max_tokens budget so users see how full the context is before
they hit the 429 wall.
Backend (source of truth):
- ContextUsage domain value object with a ContextSeverity StrEnum and a
from_totals factory that owns the warning/critical thresholds
- GET /conversations/{id}/usage returns used_tokens, max_tokens, percent
and severity; ConversationService.usage sums stored token counts
- schema reuses the domain ContextSeverity enum (no duplicated literals)
Widget (stateless renderer):
- AgentChatClient.getUsage + useConversation.loadUsage fetch usage on
open and after each turn; no client-side token math or thresholds
- ContextMeter ring (severity-coloured, hover popover) shown only when a
budget is set; hidden and non-breaking against backends without /usage
Also: neutral demo copy and a documented CONTEXT_MAX_TOKENS in the
starter example.
Tests: usage endpoint, severity thresholds, and two widget e2e cases.
Co-Authored-By: Claude Opus 4.8
---
examples/starter/.env.example | 2 +
src/agent_manager/api/routes.py | 15 ++
src/agent_manager/api/schemas.py | 9 +-
src/agent_manager/api/static/demo.html | 11 +-
src/agent_manager/api/static/widget.js | 136 +++++++++++++++++-
.../api/static/widget/api/AgentChatClient.ts | 16 ++-
.../api/static/widget/react/AgentChatApp.tsx | 75 +++++++++-
.../static/widget/react/useConversation.ts | 18 ++-
.../api/static/widget/styles/styles.ts | 29 ++++
src/agent_manager/api/static/widget/types.ts | 9 ++
src/agent_manager/application/service.py | 13 +-
src/agent_manager/domain/__init__.py | 4 +
src/agent_manager/domain/models.py | 30 ++++
tests/agent_manager/test_api.py | 55 +++++++
tests/e2e/widget.spec.ts | 44 ++++++
15 files changed, 448 insertions(+), 18 deletions(-)
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/src/agent_manager/api/routes.py b/src/agent_manager/api/routes.py
index 445b2f9b..0e7fc015 100644
--- a/src/agent_manager/api/routes.py
+++ b/src/agent_manager/api/routes.py
@@ -13,6 +13,7 @@
from agent_engine.runtime.streaming import RunStreamEvent
from agent_manager.api.deps import get_service
from agent_manager.api.schemas import (
+ ContextUsageResponse,
CreateConversationRequest,
CreateConversationResponse,
MessageOut,
@@ -50,6 +51,20 @@ async def list_messages(conversation_id: str, service: Service) -> list[MessageO
return [MessageOut(role=m.role, content=m.content, created_at=m.created_at) 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)
async def send_message(
conversation_id: str, body: SendMessageRequest, service: Service
diff --git a/src/agent_manager/api/schemas.py b/src/agent_manager/api/schemas.py
index 3777ee5c..69f7ec3b 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):
@@ -49,6 +49,13 @@ class SendMessageResponse(BaseModel):
used_tools: list[ToolRecord]
+class ContextUsageResponse(BaseModel):
+ used_tokens: int
+ max_tokens: int | None = None
+ percent: float = 0.0
+ severity: ContextSeverity = ContextSeverity.NORMAL
+
+
class StreamEventOut(BaseModel):
type: str
content: 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 @@ Drop-in chat widget
your product, paste these two lines — pointing at your backend:
<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.