From 0069d8e6541554432975cbdd725ac22347d188df Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 26 Nov 2025 15:24:21 -0800 Subject: [PATCH 1/6] Add API endpoints for agent rating and user tracking #226 --- registry/api/agent_routes.py | 69 ++++++++++++++++++++++++++ registry/services/agent_service.py | 77 ++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/registry/api/agent_routes.py b/registry/api/agent_routes.py index b97af05c..1589e077 100644 --- a/registry/api/agent_routes.py +++ b/registry/api/agent_routes.py @@ -29,6 +29,7 @@ AgentProvider, AgentRegistrationRequest, ) +from pydantic import BaseModel from ..core.config import settings @@ -44,6 +45,10 @@ router = APIRouter() +class RatingRequest(BaseModel): + rating: int + + def _normalize_path( path: Optional[str], agent_name: Optional[str] = None, @@ -513,6 +518,70 @@ async def check_agent_health( } +@router.post("/agents/{path:path}/rate") +async def rate_agent( + path: str, + request: RatingRequest, + user_context: Annotated[dict, Depends(nginx_proxied_auth)], +): + """Save integer ratings to agent card.""" + path = _normalize_path(path) + + agent_card = agent_service.get_agent_info(path) + if not agent_card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Agent not found at path '{path}'", + ) + + accessible = _filter_agents_by_access([agent_card], user_context) + if not accessible: + logger.warning( + f"User {user_context['username']} attempted to rate agent {path} without permission" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have access to this agent", + ) + + success = agent_service.update_rating(path, user_context["username"], request.rating) + if not success: + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Failed to save rating"}, + ) + + return {"message": "Rating added successfully"} + + +@router.get("/agents/{path:path}/rating") +async def get_agent_rating( + path: str, + user_context: Annotated[dict, Depends(nginx_proxied_auth)], +): + """Get agent rating information.""" + path = _normalize_path(path) + + agent_card = agent_service.get_agent_info(path) + if not agent_card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Agent not found at path '{path}'", + ) + + accessible = _filter_agents_by_access([agent_card], user_context) + if not accessible: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have access to this agent", + ) + + return { + "num_stars": agent_card.num_stars, + "rating_details": agent_card.rating_details, + } + + @router.put("/agents/{path:path}") async def update_agent( path: str, diff --git a/registry/services/agent_service.py b/registry/services/agent_service.py index 11275e42..edef1b14 100644 --- a/registry/services/agent_service.py +++ b/registry/services/agent_service.py @@ -369,6 +369,83 @@ def list_agents(self) -> List[AgentCard]: """ return list(self.registered_agents.values()) + def update_rating( + self, + path: str, + username: str, + rating: int, + ) -> float: + """ + Log a user rating for an agent. If the user has already rated, update their rating. + + Args: + path: Agent path + username: The user who submitted rating + rating: integer between 1-5 + + Return: + Updated average rating + + Raises: + ValueError: If agent not found + """ + if path not in self.registered_agents: + logger.error(f"Cannot update agent at path '{path}': not found") + raise ValueError(f"Agent not found at path: {path}") + + # Validate rating + if not isinstance(rating, int): + logger.error(f"Invalid rating type: {rating} (type={type(rating)})") + raise ValueError("Rating must be an integer") + if rating < 1 or rating > 5: + logger.error(f"Invalid rating value: {rating}. Must be between 1 and 5.") + raise ValueError("Rating must be between 1 and 5 (inclusive)") + + # Get existing agent + existing_agent = self.registered_agents[path] + + # Ensure num_stars exists and ratings is a list + if "num_stars" not in existing_agent: + existing_agent["num_stars"] = 0.0 + if "rating_details" not in existing_agent or not isinstance(existing_agent["rating_details"], list): + existing_agent["rating_details"] = [] + + # Check if this user has already rated; if so, update their rating + user_found = False + for entry in existing_agent["rating_details"]: + if entry.get("user") == username: + entry["rating"] = rating + user_found = True + break + + # If no existing rating from this user, append a new one + if not user_found: + existing_agent["rating_details"].append({ + "user": username, + "rating": rating, + }) + + # Compute average rating + all_ratings = [entry["rating"] for entry in existing_agent["rating_details"]] + existing_agent["num_stars"] = float(sum(all_ratings) / len(all_ratings)) + + # Validate updated agent + try: + updated_agent = AgentCard(**existing_agent) + except Exception as e: + logger.error(f"Failed to validate updated agent: {e}") + raise ValueError(f"Invalid agent update: {e}") + + # Save to disk + if not _save_agent_to_disk(updated_agent, settings.agents_dir): + raise ValueError(f"Failed to save updated agent to disk") + + # Update in-memory registry + self.registered_agents[path] = updated_agent + + logger.info(f"Agent '{updated_agent.name}' ({path}) updated") + + return existing_agent["num_stars"] def update_agent( self, From 8b17f4de97b248f7a49d28bdcfba5ea59e9c652f Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 2 Dec 2025 14:33:16 -0800 Subject: [PATCH 2/6] add rate agent --- registry/api/agent_routes.py | 218 +++++++-------- tests/test_rate_agent_curl.sh | 350 +++++++++++++++++++++++++ tests/unit/api/test_rate_agent.py | 422 ++++++++++++++++++++++++++++++ 3 files changed, 887 insertions(+), 103 deletions(-) create mode 100755 tests/test_rate_agent_curl.sh create mode 100644 tests/unit/api/test_rate_agent.py diff --git a/registry/api/agent_routes.py b/registry/api/agent_routes.py index 1589e077..78f6d538 100644 --- a/registry/api/agent_routes.py +++ b/registry/api/agent_routes.py @@ -395,50 +395,9 @@ async def list_agents( } -@router.get("/agents/{path:path}") -async def get_agent( - path: str, - user_context: Annotated[dict, Depends(nginx_proxied_auth)], -): - """ - Get a single agent by path. - - Public agents are visible without special permissions. - Private and group-restricted agents require authorization. - - Args: - path: Agent path - user_context: Authenticated user context - - Returns: - Complete agent card - - Raises: - HTTPException: 404 if not found, 403 if not authorized - """ - path = _normalize_path(path) - - agent_card = agent_service.get_agent_info(path) - if not agent_card: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Agent not found at path '{path}'", - ) - - accessible = _filter_agents_by_access([agent_card], user_context) - - if not accessible: - logger.warning( - f"User {user_context['username']} attempted to access agent {path} " - f"without permission" - ) - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You do not have access to this agent", - ) - - return agent_card.model_dump() +# IMPORTANT: Specific routes with path suffixes (/health, /rate, /rating, /toggle) +# must come BEFORE catch-all {path:path} routes to prevent FastAPI from matching them incorrectly @router.post("/agents/{path:path}/health") async def check_agent_health( @@ -582,6 +541,119 @@ async def get_agent_rating( } +@router.post("/agents/{path:path}/toggle") +async def toggle_agent( + path: str, + enabled: bool, + user_context: Annotated[dict, Depends(nginx_proxied_auth)], +): + """ + Enable or disable an agent. + + Requires toggle_service permission for the agent. + + Args: + path: Agent path + enabled: New enabled state + user_context: Authenticated user context + + Returns: + Updated agent status + + Raises: + HTTPException: 404 if not found, 403 if unauthorized + """ + path = _normalize_path(path) + + agent_card = agent_service.get_agent_info(path) + if not agent_card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Agent not found at path '{path}'", + ) + + _check_agent_permission("toggle_service", agent_card.name, user_context) + + success = agent_service.toggle_agent(path, enabled) + + if not success: + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Failed to toggle agent state"}, + ) + + from ..search.service import faiss_service + + await faiss_service.add_or_update_entity( + path, + agent_card.model_dump(), + "a2a_agent", + enabled, + ) + + logger.info( + f"Agent '{agent_card.name}' ({path}) toggled to {enabled} by user " + f"'{user_context['username']}'" + ) + + return { + "message": f"Agent {'enabled' if enabled else 'disabled'} successfully", + "path": path, + "is_enabled": enabled, + } + + +@router.get("/agents/{path:path}") +async def get_agent( + path: str, + user_context: Annotated[dict, Depends(nginx_proxied_auth)], +): + """ + Get a single agent by path. + + Public agents are visible without special permissions. + Private and group-restricted agents require authorization. + + Args: + path: Agent path + user_context: Authenticated user context + + Returns: + Complete agent card + + Raises: + HTTPException: 404 if not found, 403 if not authorized + """ + path = _normalize_path(path) + + agent_card = agent_service.get_agent_info(path) + if not agent_card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Agent not found at path '{path}'", + ) + + accessible = _filter_agents_by_access([agent_card], user_context) + + if not accessible: + logger.warning( + f"User {user_context['username']} attempted to access agent {path} " + f"without permission" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have access to this agent", + ) + + return agent_card.model_dump() + + + + + + + + @router.put("/agents/{path:path}") async def update_agent( path: str, @@ -762,66 +834,6 @@ async def delete_agent( ) -@router.post("/agents/{path:path}/toggle") -async def toggle_agent( - path: str, - enabled: bool, - user_context: Annotated[dict, Depends(nginx_proxied_auth)], -): - """ - Enable or disable an agent. - - Requires toggle_service permission for the agent. - - Args: - path: Agent path - enabled: New enabled state - user_context: Authenticated user context - - Returns: - Updated agent status - - Raises: - HTTPException: 404 if not found, 403 if unauthorized - """ - path = _normalize_path(path) - - agent_card = agent_service.get_agent_info(path) - if not agent_card: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Agent not found at path '{path}'", - ) - - _check_agent_permission("toggle_service", agent_card.name, user_context) - - success = agent_service.toggle_agent(path, enabled) - - if not success: - return JSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"detail": "Failed to toggle agent state"}, - ) - - from ..search.service import faiss_service - - await faiss_service.add_or_update_entity( - path, - agent_card.model_dump(), - "a2a_agent", - enabled, - ) - - logger.info( - f"Agent '{agent_card.name}' ({path}) toggled to {enabled} by user " - f"'{user_context['username']}'" - ) - - return { - "message": f"Agent {'enabled' if enabled else 'disabled'} successfully", - "path": path, - "is_enabled": enabled, - } @router.post("/agents/discover") diff --git a/tests/test_rate_agent_curl.sh b/tests/test_rate_agent_curl.sh new file mode 100755 index 00000000..e4053ce0 --- /dev/null +++ b/tests/test_rate_agent_curl.sh @@ -0,0 +1,350 @@ +#!/bin/bash + +################################################################################ +# Test script for the rate_agent function in agent_routes.py +# +# This script demonstrates how to test the rate_agent endpoint using curl +# +# Usage: +# bash test_rate_agent_curl.sh +# bash test_rate_agent_curl.sh /path/to/token.json +# TOKEN_FILE=/path/to/token.json bash test_rate_agent_curl.sh +# +# Token Resolution (in order of precedence): +# 1. Command-line argument (first parameter) +# 2. TOKEN_FILE environment variable +# 3. Default: .oauth-tokens/admin-bot-token.json +# +# Note: Requires Docker containers running (docker-compose up -d) +# API accessible via Nginx reverse proxy on port 80 +################################################################################ + +# Configuration +HOST="http://localhost" +AGENT_PATH="test-reviewer" # Using existing agent from the registry +USERNAME="admin" +PASSWORD="anrwangAdminPassword" + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +################################################################################ +# Token Resolution and Validation +################################################################################ + +TOKEN="" +TOKEN_FILE="" + +# Check command-line argument first +if [ -n "$1" ]; then + TOKEN_FILE="$1" +# Check environment variable second +elif [ -n "$TOKEN_FILE" ]; then + TOKEN_FILE="$TOKEN_FILE" +# Use default +else + TOKEN_FILE=".oauth-tokens/admin-bot-token.json" +fi + +# Verify token file exists +if [ ! -f "$TOKEN_FILE" ]; then + echo "" + echo -e "${RED}✗ ERROR: Token file not found!${NC}" + echo "" + echo "Looked for: $TOKEN_FILE" + echo "" + echo -e "${YELLOW}To generate a token, run:${NC}" + echo " ./keycloak/setup/generate-agent-token.sh admin-bot" + echo "" + exit 1 +fi + +# Extract token from file +TOKEN=$(jq -r '.access_token' "$TOKEN_FILE" 2>/dev/null) + +# Validate token exists and is not empty +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "" + echo -e "${RED}✗ ERROR: Failed to extract token from: $TOKEN_FILE${NC}" + echo "" + exit 1 +fi + +echo -e "${GREEN}✓ Using token from: $TOKEN_FILE${NC}" +echo "" + +# Helper function to print sections +section() { + echo "" + echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║ $1${NC}" + echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}" + echo "" +} + +# Helper function to print commands +print_cmd() { + echo -e "${YELLOW}▶ Command:${NC}" + echo " $1" + echo "" +} + +# Helper function to print responses +print_response() { + echo -e "${YELLOW}◀ Response:${NC}" + echo "$1" | jq . 2>/dev/null || echo "$1" + echo "" +} + +################################################################################ +# STEP 0: Get authentication token (if token file doesn't exist) +################################################################################ +if [ ! -f "$TOKEN_FILE" ]; then + section "STEP 0: Get Authentication Token" + + echo -e "${YELLOW}Token file not found. Attempting to authenticate with credentials...${NC}" + echo "" + + print_cmd "POST /auth/token" + + AUTH_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST \ + "$HOST/auth/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=$USERNAME&password=$PASSWORD") + + HTTP_CODE=$(echo "$AUTH_RESPONSE" | grep "HTTP_CODE" | cut -d: -f2) + BODY=$(echo "$AUTH_RESPONSE" | grep -v "HTTP_CODE") + + echo -e "${YELLOW}◀ Response (HTTP $HTTP_CODE):${NC}" + echo "$BODY" | jq . 2>/dev/null || echo "$BODY" + echo "" + + if [ "$HTTP_CODE" = "200" ]; then + TOKEN=$(echo "$BODY" | jq -r '.access_token' 2>/dev/null) + if [ -n "$TOKEN" ] && [ "$TOKEN" != "null" ]; then + echo -e "${GREEN}✓ Authentication successful!${NC}" + echo "" + else + echo -e "${RED}✗ Failed to extract token from response${NC}" + echo "" + exit 1 + fi + else + echo -e "${RED}✗ Authentication failed (HTTP $HTTP_CODE)${NC}" + echo "" + exit 1 + fi +fi + +################################################################################ +# STEP 1: List available agents +################################################################################ +section "STEP 1: List Available Agents" + +print_cmd "GET /api/agents" + +RESPONSE=$(curl -s -X GET \ + "$HOST/api/agents" \ + -H "Authorization: Bearer $TOKEN") + +print_response "$RESPONSE" + +echo -e "${GREEN}Available agents:${NC}" +echo "$RESPONSE" | jq -r '.agents[]? | " - \(.name) (path: \(.path))"' 2>/dev/null || echo " No agents found" +echo "" + +################################################################################ +# STEP 2: Get agent details before rating +################################################################################ +section "STEP 2: Get Agent Details (Before Rating)" + +print_cmd "GET /api/agents/$AGENT_PATH" + +RESPONSE=$(curl -s -X GET \ + "$HOST/api/agents/$AGENT_PATH" \ + -H "Authorization: Bearer $TOKEN") + +print_response "$RESPONSE" + +CURRENT_RATING=$(echo "$RESPONSE" | jq -r '.num_stars // 0' 2>/dev/null) +echo -e "${GREEN}Current rating: $CURRENT_RATING stars${NC}" +echo "" + +################################################################################ +# STEP 3: Rate the agent with 5 stars +################################################################################ +section "STEP 3: Rate Agent (5 stars)" + +print_cmd "POST /api/agents/$AGENT_PATH/rate" + +RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST \ + "$HOST/api/agents/$AGENT_PATH/rate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"rating": 5}') + +HTTP_CODE=$(echo "$RESPONSE" | grep "HTTP_CODE" | cut -d: -f2) +BODY=$(echo "$RESPONSE" | grep -v "HTTP_CODE") + +echo -e "${YELLOW}◀ Response (HTTP $HTTP_CODE):${NC}" +echo "$BODY" | jq . 2>/dev/null || echo "$BODY" +echo "" + +if [ "$HTTP_CODE" = "200" ]; then + echo -e "${GREEN}✓ Rating submitted successfully!${NC}" +else + echo -e "${RED}✗ Failed to submit rating (HTTP $HTTP_CODE)${NC}" +fi + +################################################################################ +# STEP 4: Get agent rating details +################################################################################ +section "STEP 4: Get Agent Rating Details" + +print_cmd "GET /api/agents/$AGENT_PATH/rating" + +RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X GET \ + "$HOST/api/agents/$AGENT_PATH/rating" \ + -H "Authorization: Bearer $TOKEN") + +HTTP_CODE=$(echo "$RESPONSE" | grep "HTTP_CODE" | cut -d: -f2) +BODY=$(echo "$RESPONSE" | grep -v "HTTP_CODE") + +echo -e "${YELLOW}◀ Response (HTTP $HTTP_CODE):${NC}" +echo "$BODY" | jq . 2>/dev/null || echo "$BODY" +echo "" + +if [ "$HTTP_CODE" = "200" ]; then + echo -e "${GREEN}✓ Rating details retrieved successfully!${NC}" +else + echo -e "${RED}✗ Failed to get rating details (HTTP $HTTP_CODE)${NC}" +fi + +################################################################################ +# STEP 5: Rate the agent with 3 stars (update rating) +################################################################################ +section "STEP 5: Update Rating (3 stars)" + +print_cmd "POST /api/agents/$AGENT_PATH/rate" + +RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST \ + "$HOST/api/agents/$AGENT_PATH/rate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"rating": 3}') + +HTTP_CODE=$(echo "$RESPONSE" | grep "HTTP_CODE" | cut -d: -f2) +BODY=$(echo "$RESPONSE" | grep -v "HTTP_CODE") + +echo -e "${YELLOW}◀ Response (HTTP $HTTP_CODE):${NC}" +echo "$BODY" | jq . 2>/dev/null || echo "$BODY" +echo "" + +if [ "$HTTP_CODE" = "200" ]; then + echo -e "${GREEN}✓ Rating updated successfully!${NC}" +else + echo -e "${RED}✗ Failed to update rating (HTTP $HTTP_CODE)${NC}" +fi + +################################################################################ +# STEP 6: Verify updated rating +################################################################################ +section "STEP 6: Verify Updated Rating" + +print_cmd "GET /api/agents/$AGENT_PATH" + +RESPONSE=$(curl -s -X GET \ + "$HOST/api/agents/$AGENT_PATH" \ + -H "Authorization: Bearer $TOKEN") + +print_response "$RESPONSE" + +NEW_RATING=$(echo "$RESPONSE" | jq -r '.num_stars // 0' 2>/dev/null) +echo -e "${GREEN}Updated rating: $NEW_RATING stars${NC}" +echo "" + +################################################################################ +# STEP 7: Test invalid rating (out of range) +################################################################################ +section "STEP 7: Test Invalid Rating (Out of Range)" + +print_cmd "POST /api/agents/$AGENT_PATH/rate (rating: 10)" + +RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST \ + "$HOST/api/agents/$AGENT_PATH/rate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"rating": 10}') + +HTTP_CODE=$(echo "$RESPONSE" | grep "HTTP_CODE" | cut -d: -f2) +BODY=$(echo "$RESPONSE" | grep -v "HTTP_CODE") + +echo -e "${YELLOW}◀ Response (HTTP $HTTP_CODE):${NC}" +echo "$BODY" | jq . 2>/dev/null || echo "$BODY" +echo "" + +if [ "$HTTP_CODE" = "422" ] || [ "$HTTP_CODE" = "400" ] || [ "$HTTP_CODE" = "500" ]; then + echo -e "${GREEN}✓ Invalid rating correctly rejected!${NC}" +else + echo -e "${RED}✗ Invalid rating should have been rejected (HTTP $HTTP_CODE)${NC}" +fi + +################################################################################ +# STEP 8: Test rating non-existent agent +################################################################################ +section "STEP 8: Test Rating Non-Existent Agent" + +print_cmd "POST /api/agents/non-existent-agent/rate" + +RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST \ + "$HOST/api/agents/non-existent-agent/rate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"rating": 5}') + +HTTP_CODE=$(echo "$RESPONSE" | grep "HTTP_CODE" | cut -d: -f2) +BODY=$(echo "$RESPONSE" | grep -v "HTTP_CODE") + +echo -e "${YELLOW}◀ Response (HTTP $HTTP_CODE):${NC}" +echo "$BODY" | jq . 2>/dev/null || echo "$BODY" +echo "" + +if [ "$HTTP_CODE" = "404" ]; then + echo -e "${GREEN}✓ Non-existent agent correctly returned 404!${NC}" +else + echo -e "${RED}✗ Should have returned 404 for non-existent agent (HTTP $HTTP_CODE)${NC}" +fi + +################################################################################ +# Summary +################################################################################ +section "Rate Agent Test Summary" + +cat << 'EOF' +What we tested: + +1. LIST - Listed all available agents +2. GET - Retrieved agent details before rating +3. RATE - Submitted a 5-star rating +4. GET - Retrieved rating details +5. UPDATE - Updated rating to 3 stars +6. VERIFY - Verified the updated rating +7. INVALID - Tested invalid rating (out of range) +8. NOT FOUND - Tested rating non-existent agent + +Expected behaviors: +✓ Valid ratings (1-5) should return HTTP 200 +✓ Invalid ratings should return HTTP 422/400/500 +✓ Non-existent agents should return HTTP 404 +✓ Ratings should be stored and retrievable +✓ Users can update their own ratings + +EOF + +echo "" +echo -e "${GREEN}✓ Rate Agent Test Complete!${NC}" +echo "" \ No newline at end of file diff --git a/tests/unit/api/test_rate_agent.py b/tests/unit/api/test_rate_agent.py new file mode 100644 index 00000000..38b323dc --- /dev/null +++ b/tests/unit/api/test_rate_agent.py @@ -0,0 +1,422 @@ +""" +Unit tests for rate_agent endpoint in agent_routes.py +""" + +import pytest +from typing import Any, Dict +from unittest.mock import patch, Mock +from fastapi import status +from fastapi.testclient import TestClient + +from registry.main import app +from registry.services.agent_service import agent_service +from registry.schemas.agent_models import AgentCard + + +@pytest.fixture +def mock_user_context() -> Dict[str, Any]: + """Mock authenticated user context.""" + return { + "username": "testuser", + "groups": ["users"], + "is_admin": False, + "ui_permissions": {}, + "accessible_agents": ["all"], + } + + +@pytest.fixture +def mock_admin_context() -> Dict[str, Any]: + """Mock admin user context.""" + return { + "username": "admin", + "groups": ["admins"], + "is_admin": True, + "ui_permissions": {}, + "accessible_agents": ["all"], + } + + +@pytest.fixture +def sample_agent_card() -> AgentCard: + """Create a sample agent card for testing.""" + return AgentCard( + protocol_version="1.0", + name="Test Agent", + description="A test agent", + url="http://localhost:8080/test-agent", + path="/test-agent", + version="1.0.0", + tags=["test"], + skills=[], + visibility="public", + registered_by="admin", + num_stars=0.0, + rating_details=[], + ) + + +@pytest.mark.unit +class TestRateAgent: + """Test suite for POST /agents/{path}/rate endpoint.""" + + def test_rate_agent_success( + self, + mock_user_context: Dict[str, Any], + sample_agent_card: AgentCard, + ) -> None: + """Test successfully rating an agent.""" + from registry.auth.dependencies import nginx_proxied_auth + + def _mock_auth(session=None): + return mock_user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=sample_agent_card, + ), patch.object( + agent_service, + "update_rating", + return_value=4.5, + ): + client = TestClient(app) + response = client.post( + "/agents/test-agent/rate", + json={"rating": 5}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["message"] == "Rating added successfully" + + app.dependency_overrides.clear() + + def test_rate_agent_not_found( + self, + mock_user_context: Dict[str, Any], + ) -> None: + """Test rating a non-existent agent returns 404.""" + from registry.auth.dependencies import nginx_proxied_auth + + def _mock_auth(session=None): + return mock_user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=None, + ): + client = TestClient(app) + response = client.post( + "/agents/nonexistent/rate", + json={"rating": 5}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "not found" in response.json()["detail"].lower() + + app.dependency_overrides.clear() + + def test_rate_agent_no_access( + self, + sample_agent_card: AgentCard, + ) -> None: + """Test rating an agent without access returns 403.""" + from registry.auth.dependencies import nginx_proxied_auth + + # User with restricted access + restricted_context = { + "username": "restricted_user", + "groups": [], + "is_admin": False, + "ui_permissions": {}, + "accessible_agents": ["other-agent"], # Not the test agent + } + + def _mock_auth(session=None): + return restricted_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=sample_agent_card, + ): + client = TestClient(app) + response = client.post( + "/agents/test-agent/rate", + json={"rating": 5}, + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "access" in response.json()["detail"].lower() + + app.dependency_overrides.clear() + + def test_rate_agent_invalid_rating_type( + self, + mock_user_context: Dict[str, Any], + sample_agent_card: AgentCard, + ) -> None: + """Test rating with invalid type returns validation error.""" + from registry.auth.dependencies import nginx_proxied_auth + + def _mock_auth(session=None): + return mock_user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=sample_agent_card, + ): + client = TestClient(app) + response = client.post( + "/agents/test-agent/rate", + json={"rating": "five"}, # String instead of int + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + app.dependency_overrides.clear() + + def test_rate_agent_missing_rating( + self, + mock_user_context: Dict[str, Any], + sample_agent_card: AgentCard, + ) -> None: + """Test rating without rating field returns validation error.""" + from registry.auth.dependencies import nginx_proxied_auth + + def _mock_auth(session=None): + return mock_user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=sample_agent_card, + ): + client = TestClient(app) + response = client.post( + "/agents/test-agent/rate", + json={}, # Missing rating field + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + app.dependency_overrides.clear() + + def test_rate_agent_update_rating_fails( + self, + mock_user_context: Dict[str, Any], + sample_agent_card: AgentCard, + ) -> None: + """Test handling when update_rating fails.""" + from registry.auth.dependencies import nginx_proxied_auth + + def _mock_auth(session=None): + return mock_user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=sample_agent_card, + ), patch.object( + agent_service, + "update_rating", + side_effect=ValueError("Failed to save rating"), + ): + client = TestClient(app) + response = client.post( + "/agents/test-agent/rate", + json={"rating": 5}, + ) + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert "Failed to save rating" in response.json()["detail"] + + app.dependency_overrides.clear() + + def test_rate_agent_with_different_ratings( + self, + mock_user_context: Dict[str, Any], + sample_agent_card: AgentCard, + ) -> None: + """Test rating an agent with different valid rating values.""" + from registry.auth.dependencies import nginx_proxied_auth + + def _mock_auth(session=None): + return mock_user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + for rating_value in [1, 2, 3, 4, 5]: + with patch.object( + agent_service, + "get_agent_info", + return_value=sample_agent_card, + ), patch.object( + agent_service, + "update_rating", + return_value=float(rating_value), + ): + client = TestClient(app) + response = client.post( + "/agents/test-agent/rate", + json={"rating": rating_value}, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["message"] == "Rating added successfully" + + app.dependency_overrides.clear() + + def test_rate_agent_path_normalization( + self, + mock_user_context: Dict[str, Any], + sample_agent_card: AgentCard, + ) -> None: + """Test that agent path is normalized correctly.""" + from registry.auth.dependencies import nginx_proxied_auth + + def _mock_auth(session=None): + return mock_user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=sample_agent_card, + ) as mock_get_agent, patch.object( + agent_service, + "update_rating", + return_value=5.0, + ) as mock_update: + client = TestClient(app) + # Test with path without leading slash + response = client.post( + "/agents/test-agent/rate", + json={"rating": 5}, + ) + + assert response.status_code == status.HTTP_200_OK + # Verify the path was normalized (should have leading slash) + mock_get_agent.assert_called_once_with("/test-agent") + mock_update.assert_called_once_with("/test-agent", "testuser", 5) + + app.dependency_overrides.clear() + + def test_rate_agent_private_agent_by_owner( + self, + mock_user_context: Dict[str, Any], + ) -> None: + """Test that agent owner can rate their private agent.""" + from registry.auth.dependencies import nginx_proxied_auth + + # Create private agent owned by testuser + private_agent = AgentCard( + protocol_version="1.0", + name="Private Agent", + description="A private agent", + url="http://localhost:8080/private-agent", + path="/private-agent", + version="1.0.0", + tags=["test"], + skills=[], + visibility="private", + registered_by="testuser", # Same as mock_user_context username + num_stars=0.0, + rating_details=[], + ) + + def _mock_auth(session=None): + return mock_user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=private_agent, + ), patch.object( + agent_service, + "update_rating", + return_value=5.0, + ): + client = TestClient(app) + response = client.post( + "/agents/private-agent/rate", + json={"rating": 5}, + ) + + assert response.status_code == status.HTTP_200_OK + + app.dependency_overrides.clear() + + def test_rate_agent_group_restricted_with_access( + self, + ) -> None: + """Test rating a group-restricted agent when user is in allowed group.""" + from registry.auth.dependencies import nginx_proxied_auth + + # User in the allowed group + user_context = { + "username": "groupuser", + "groups": ["allowed-group"], + "is_admin": False, + "ui_permissions": {}, + "accessible_agents": ["all"], + } + + # Group-restricted agent + group_agent = AgentCard( + protocol_version="1.0", + name="Group Agent", + description="A group-restricted agent", + url="http://localhost:8080/group-agent", + path="/group-agent", + version="1.0.0", + tags=["test"], + skills=[], + visibility="group-restricted", + allowed_groups=["allowed-group"], + registered_by="admin", + num_stars=0.0, + rating_details=[], + ) + + def _mock_auth(session=None): + return user_context + + app.dependency_overrides[nginx_proxied_auth] = _mock_auth + + with patch.object( + agent_service, + "get_agent_info", + return_value=group_agent, + ), patch.object( + agent_service, + "update_rating", + return_value=4.0, + ): + client = TestClient(app) + response = client.post( + "/agents/group-agent/rate", + json={"rating": 4}, + ) + + assert response.status_code == status.HTTP_200_OK + + app.dependency_overrides.clear() From 31851cf936b37462f6bd538ca1bca49e9a110601 Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Tue, 9 Dec 2025 22:37:49 +0000 Subject: [PATCH 3/6] Add agent rating system with CLI support and API documentation - Add rating API endpoints (POST /api/agents/{path}/rate, GET /api/agents/{path}/rating) - Implement rotating buffer for last 100 ratings - Add CLI commands: agent-rate and agent-rating - Update Pydantic models to support float ratings (num_stars: int -> float) - Fix registry_client to send JSON for all agent endpoints - Fix token file parsing to extract access_token from JSON - Update OpenAPI spec with rating endpoints and schemas - Change num_stars from integer to float in AgentCard and AgentInfo models --- api/registry_client.py | 101 ++++++++++- api/registry_management.py | 107 +++++++++++- docs/api-specs/a2a-agent-management.yaml | 213 +++++++++++++++++++++-- registry/api/agent_routes.py | 20 ++- registry/schemas/agent_models.py | 23 ++- registry/services/agent_service.py | 36 ++-- 6 files changed, 457 insertions(+), 43 deletions(-) diff --git a/api/registry_client.py b/api/registry_client.py index 398b74ed..b09f4ede 100755 --- a/api/registry_client.py +++ b/api/registry_client.py @@ -417,6 +417,33 @@ class AgentSemanticDiscoveryResponse(BaseModel): agents: List[SemanticDiscoveredAgent] = Field(..., description="Semantically discovered agents") +class RatingDetail(BaseModel): + """Individual rating detail.""" + + user: str = Field(..., description="Username who submitted the rating") + rating: int = Field(..., ge=1, le=5, description="Rating value (1-5 stars)") + + +class RatingRequest(BaseModel): + """Rating submission request.""" + + rating: int = Field(..., ge=1, le=5, description="Rating value (1-5 stars)") + + +class RatingResponse(BaseModel): + """Rating submission response.""" + + message: str = Field(..., description="Success message") + average_rating: float = Field(..., ge=1.0, le=5.0, description="Updated average rating") + + +class RatingInfoResponse(BaseModel): + """Rating information response.""" + + num_stars: float = Field(..., ge=0.0, le=5.0, description="Average rating (0.0 if no ratings)") + rating_details: List[RatingDetail] = Field(..., description="Individual ratings (max 100)") + + # Anthropic Registry API Models (v0.1) @@ -588,9 +615,9 @@ def _make_request( logger.debug(f"{method} {url}") # Determine content type based on endpoint - # Agent registration uses JSON, server registration uses form data - if endpoint == "/api/agents/register": - # Send as JSON for agent registration + # Agent endpoints use JSON, server registration uses form data + if endpoint.startswith("/api/agents"): + # Send as JSON for all agent endpoints response = requests.request( method=method, url=url, @@ -1169,6 +1196,74 @@ def discover_agents_semantic( logger.info(f"Discovered {len(result.agents)} agents via semantic search") return result + + def rate_agent( + self, + path: str, + rating: int + ) -> RatingResponse: + """ + Submit a rating for an agent (1-5 stars). + + Each user can only have one active rating. If user has already rated, + this updates their existing rating. System maintains a rotating buffer + of the last 100 ratings. + + Args: + path: Agent path (e.g., /code-reviewer) + rating: Rating value (1-5 stars) + + Returns: + Rating response with success message and updated average rating + + Raises: + requests.HTTPError: If rating fails (400 for invalid rating, 403 for unauthorized, 404 for not found) + """ + logger.info(f"Rating agent '{path}' with {rating} stars") + + request_data = RatingRequest(rating=rating) + + response = self._make_request( + method="POST", + endpoint=f"/api/agents{path}/rate", + data=request_data.model_dump() + ) + + result = RatingResponse(**response.json()) + logger.info(f"Agent '{path}' rated successfully. New average: {result.average_rating:.2f}") + return result + + + def get_agent_rating( + self, + path: str + ) -> RatingInfoResponse: + """ + Get rating information for an agent. + + Returns average rating and up to 100 most recent individual ratings + (maintained as rotating buffer). + + Args: + path: Agent path (e.g., /code-reviewer) + + Returns: + Rating information with average and individual ratings + + Raises: + requests.HTTPError: If retrieval fails (403 for unauthorized, 404 for not found) + """ + logger.info(f"Getting ratings for agent: {path}") + + response = self._make_request( + method="GET", + endpoint=f"/api/agents{path}/rating" + ) + + result = RatingInfoResponse(**response.json()) + logger.info(f"Retrieved ratings for '{path}': {result.num_stars:.2f} stars ({len(result.rating_details)} ratings)") + return result + # Anthropic Registry API Methods (v0.1) def anthropic_list_servers( diff --git a/api/registry_management.py b/api/registry_management.py index ed44a36a..dec03140 100755 --- a/api/registry_management.py +++ b/api/registry_management.py @@ -44,6 +44,12 @@ # Delete agent uv run python registry_management.py agent-delete --path /code-reviewer + # Rate an agent (1-5 stars) + uv run python registry_management.py agent-rate --path /code-reviewer --rating 5 + + # Get agent rating information + uv run python registry_management.py agent-rating --path /code-reviewer + # Discover agents by skills uv run python registry_management.py agent-discover --skills code_analysis,bug_detection @@ -103,6 +109,8 @@ AgentToggleResponse, AgentDiscoveryResponse, AgentSemanticDiscoveryResponse, + RatingResponse, + RatingInfoResponse, AnthropicServerList, AnthropicServerResponse, ) @@ -277,7 +285,18 @@ def _create_client( raise FileNotFoundError(f"Token file not found: {args.token_file}") logger.debug(f"Loading token from file: {args.token_file}") - token = token_path.read_text().strip() + + # Try to parse as JSON first (token files from generate-agent-token.sh) + try: + with open(token_path, 'r') as f: + token_data = json.load(f) + # Extract access_token from JSON structure + token = token_data.get('access_token') + if not token: + raise RuntimeError(f"No 'access_token' field found in token file: {args.token_file}") + except json.JSONDecodeError: + # Fall back to plain text token file + token = token_path.read_text().strip() if not token: raise RuntimeError(f"Empty token in file: {args.token_file}") @@ -1030,6 +1049,67 @@ def cmd_agent_search(args: argparse.Namespace) -> int: return 1 +def cmd_agent_rate(args: argparse.Namespace) -> int: + """ + Rate an agent (1-5 stars). + + Args: + args: Command arguments with path and rating + + Returns: + Exit code (0 for success, 1 for failure) + """ + try: + client = _create_client(args) + response: RatingResponse = client.rate_agent( + path=args.path, + rating=args.rating + ) + + logger.info(f"✓ {response.message}") + logger.info(f"Average rating: {response.average_rating:.2f} stars") + + return 0 + + except Exception as e: + logger.error(f"Failed to rate agent: {e}") + return 1 + + +def cmd_agent_rating(args: argparse.Namespace) -> int: + """ + Get rating information for an agent. + + Args: + args: Command arguments with path + + Returns: + Exit code (0 for success, 1 for failure) + """ + try: + client = _create_client(args) + response: RatingInfoResponse = client.get_agent_rating(path=args.path) + + logger.info(f"\nRating for agent '{args.path}':") + logger.info(f" Average: {response.num_stars:.2f} stars") + logger.info(f" Total ratings: {len(response.rating_details)}") + + if response.rating_details: + logger.info("\nIndividual ratings (most recent):") + # Show first 10 ratings + for detail in response.rating_details[:10]: + logger.info(f" {detail.user}: {detail.rating} stars") + + if len(response.rating_details) > 10: + logger.info(f" ... and {len(response.rating_details) - 10} more") + + return 0 + + except Exception as e: + logger.error(f"Failed to get ratings: {e}") + return 1 + + def cmd_anthropic_list_servers(args: argparse.Namespace) -> int: """ List all servers using Anthropic Registry API v0.1. @@ -1480,6 +1560,29 @@ def main() -> int: help="Maximum number of results (default: 10)" ) + # Agent rate command + agent_rate_parser = subparsers.add_parser("agent-rate", help="Rate an agent (1-5 stars)") + agent_rate_parser.add_argument( + "--path", + required=True, + help="Agent path (e.g., /code-reviewer)" + ) + agent_rate_parser.add_argument( + "--rating", + required=True, + type=int, + choices=[1, 2, 3, 4, 5], + help="Rating value (1-5 stars)" + ) + + # Agent rating command + agent_rating_parser = subparsers.add_parser("agent-rating", help="Get rating information for an agent") + agent_rating_parser.add_argument( + "--path", + required=True, + help="Agent path (e.g., /code-reviewer)" + ) + # Anthropic Registry API Commands # Anthropic list servers command @@ -1565,6 +1668,8 @@ def main() -> int: "agent-toggle": cmd_agent_toggle, "agent-discover": cmd_agent_discover, "agent-search": cmd_agent_search, + "agent-rate": cmd_agent_rate, + "agent-rating": cmd_agent_rating, "anthropic-list": cmd_anthropic_list_servers, "anthropic-versions": cmd_anthropic_list_versions, "anthropic-get": cmd_anthropic_get_server diff --git a/docs/api-specs/a2a-agent-management.yaml b/docs/api-specs/a2a-agent-management.yaml index b0e11361..a9ec0e93 100644 --- a/docs/api-specs/a2a-agent-management.yaml +++ b/docs/api-specs/a2a-agent-management.yaml @@ -24,6 +24,8 @@ tags: description: Find agents by capability - name: Agent Status description: Enable/disable agents + - name: Agent Ratings + description: Rate and review agents paths: /api/agents/register: @@ -246,6 +248,123 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + /api/agents/{path}/rate: + post: + tags: + - Agent Ratings + summary: Rate an agent + description: | + Submit a rating (1-5 stars) for an agent. Each user can only have one active rating. + If user has already rated, this updates their existing rating. The system maintains + a rotating buffer of the last 100 ratings to prevent unbounded growth. + operationId: rateAgent + parameters: + - name: path + in: path + required: true + schema: + type: string + description: Agent path (e.g., code-reviewer) + example: code-reviewer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RatingRequest' + example: + rating: 5 + responses: + '200': + description: Rating submitted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/RatingResponse' + example: + message: "Rating added successfully" + average_rating: 4.5 + '400': + description: Bad request - Invalid rating value (must be 1-5) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + detail: "Rating must be between 1 and 5 (inclusive)" + '403': + description: Forbidden - User does not have access to this agent + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + detail: "You do not have access to this agent" + '404': + description: Not found - Agent does not exist + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + detail: "Agent not found at path '/code-reviewer'" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + detail: "Failed to save rating" + + /api/agents/{path}/rating: + get: + tags: + - Agent Ratings + summary: Get agent ratings + description: | + Retrieve rating information for an agent including average rating and individual ratings. + Returns up to 100 most recent ratings (maintained as a rotating buffer). + operationId: getAgentRating + parameters: + - name: path + in: path + required: true + schema: + type: string + description: Agent path (e.g., code-reviewer) + example: code-reviewer + responses: + '200': + description: Rating information retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/RatingInfoResponse' + example: + num_stars: 4.5 + rating_details: + - user: "service-account-admin-bot" + rating: 5 + - user: "service-account-lob1-bot" + rating: 4 + '403': + description: Forbidden - User does not have access to this agent + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + detail: "You do not have access to this agent" + '404': + description: Not found - Agent does not exist + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + detail: "Agent not found at path '/code-reviewer'" + /api/agents/discover: post: tags: @@ -398,10 +517,12 @@ components: default: false description: Whether agent is enabled in registry num_stars: - type: integer - minimum: 0 - default: 0 - description: Community rating + type: number + format: float + minimum: 0.0 + maximum: 5.0 + default: 0.0 + description: Average community rating (1.0-5.0, or 0.0 if no ratings) license: type: string default: N/A @@ -585,10 +706,18 @@ components: default: false description: Whether agent is enabled num_stars: - type: integer - minimum: 0 - default: 0 - description: Community rating + type: number + format: float + minimum: 0.0 + maximum: 5.0 + default: 0.0 + description: Average community rating (1.0-5.0, or 0.0 if no ratings) + rating_details: + type: array + description: Individual ratings (max 100, rotating buffer) + maxItems: 100 + items: + $ref: '#/components/schemas/RatingDetail' license: type: string default: N/A @@ -674,10 +803,12 @@ components: default: 0 description: Number of skills num_stars: - type: integer - minimum: 0 - default: 0 - description: Community rating + type: number + format: float + minimum: 0.0 + maximum: 5.0 + default: 0.0 + description: Average community rating (1.0-5.0, or 0.0 if no ratings) is_enabled: type: boolean default: false @@ -767,6 +898,64 @@ components: description: type: string + RatingRequest: + type: object + required: + - rating + properties: + rating: + type: integer + minimum: 1 + maximum: 5 + description: Rating value (1-5 stars) + example: 5 + + RatingResponse: + type: object + properties: + message: + type: string + description: Success message + example: "Rating added successfully" + average_rating: + type: number + format: float + minimum: 1.0 + maximum: 5.0 + description: Updated average rating across all ratings + example: 4.5 + + RatingInfoResponse: + type: object + properties: + num_stars: + type: number + format: float + minimum: 0.0 + maximum: 5.0 + description: Average rating across all ratings (0.0 if no ratings) + example: 4.5 + rating_details: + type: array + description: Individual ratings (max 100, maintained as rotating buffer) + maxItems: 100 + items: + $ref: '#/components/schemas/RatingDetail' + + RatingDetail: + type: object + properties: + user: + type: string + description: Username who submitted the rating + example: "service-account-admin-bot" + rating: + type: integer + minimum: 1 + maximum: 5 + description: Rating value (1-5 stars) + example: 5 + ErrorResponse: type: object properties: diff --git a/registry/api/agent_routes.py b/registry/api/agent_routes.py index 78f6d538..5b2d58c0 100644 --- a/registry/api/agent_routes.py +++ b/registry/api/agent_routes.py @@ -503,14 +503,24 @@ async def rate_agent( detail="You do not have access to this agent", ) - success = agent_service.update_rating(path, user_context["username"], request.rating) - if not success: - return JSONResponse( + try: + avg_rating = agent_service.update_rating(path, user_context["username"], request.rating) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + except Exception as e: + logger.error(f"Unexpected error updating rating: {e}") + raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"detail": "Failed to save rating"}, + detail="Failed to save rating", ) - return {"message": "Rating added successfully"} + return { + "message": "Rating added successfully", + "average_rating": avg_rating, + } @router.get("/agents/{path:path}/rating") diff --git a/registry/schemas/agent_models.py b/registry/schemas/agent_models.py index 31db11b8..a721374d 100644 --- a/registry/schemas/agent_models.py +++ b/registry/schemas/agent_models.py @@ -456,11 +456,17 @@ class AgentCard(BaseModel): alias="isEnabled", description="Whether agent is enabled in registry", ) - num_stars: int = Field( - 0, - ge=0, + num_stars: float = Field( + 0.0, + ge=0.0, + le=5.0, alias="numStars", - description="Community rating", + description="Average community rating (0.0-5.0)", + ) + rating_details: List[Dict[str, Any]] = Field( + default_factory=list, + alias="ratingDetails", + description="Individual user ratings with username and rating value", ) license: str = Field( "N/A", @@ -654,11 +660,12 @@ class AgentInfo(BaseModel): alias="numSkills", description="Number of skills", ) - num_stars: int = Field( - 0, - ge=0, + num_stars: float = Field( + 0.0, + ge=0.0, + le=5.0, alias="numStars", - description="Community rating", + description="Average community rating (0.0-5.0)", ) is_enabled: bool = Field( False, diff --git a/registry/services/agent_service.py b/registry/services/agent_service.py index edef1b14..a468ae8c 100644 --- a/registry/services/agent_service.py +++ b/registry/services/agent_service.py @@ -401,37 +401,45 @@ def update_rating( logger.error(f"Invalid rating value: {rating}. Must be between 1 and 5.") raise ValueError("Rating must be between 1 and 5 (inclusive)") - # Get existing agent + # Get existing agent (Pydantic model) existing_agent = self.registered_agents[path] - # Ensure num_stars exists and ratings is a list - if "num_stars" not in existing_agent: - existing_agent["num_stars"] = 0.0 - if "rating_details" not in existing_agent or not isinstance(existing_agent["rating_details"], list): - existing_agent["rating_details"] = [] + # Convert to dict for modification + agent_dict = existing_agent.model_dump() + + # Ensure rating_details is a list (should be by default from schema) + if "rating_details" not in agent_dict or agent_dict["rating_details"] is None: + agent_dict["rating_details"] = [] # Check if this user has already rated; if so, update their rating user_found = False - for entry in existing_agent["rating_details"]: + for entry in agent_dict["rating_details"]: if entry.get("user") == username: entry["rating"] = rating user_found = True break - + # If no existing rating from this user, append a new one if not user_found: - existing_agent["rating_details"].append({ + agent_dict["rating_details"].append({ "user": username, "rating": rating, }) + # Maintain a perfect rotating buffer of MAX_RATINGS entries + MAX_RATINGS = 100 + if len(agent_dict["rating_details"]) > MAX_RATINGS: + # Remove the oldest entry to maintain exactly MAX_RATINGS entries + agent_dict["rating_details"].pop(0) + logger.info(f"Removed oldest rating to maintain {MAX_RATINGS} entries limit for agent at '{path}'") + # Compute average rating - all_ratings = [entry["rating"] for entry in existing_agent["rating_details"]] - existing_agent["num_stars"] = float(sum(all_ratings) / len(all_ratings)) + all_ratings = [entry["rating"] for entry in agent_dict["rating_details"]] + agent_dict["num_stars"] = float(sum(all_ratings) / len(all_ratings)) # Validate updated agent try: - updated_agent = AgentCard(**existing_agent) + updated_agent = AgentCard(**agent_dict) except Exception as e: logger.error(f"Failed to validate updated agent: {e}") raise ValueError(f"Invalid agent update: {e}") @@ -443,9 +451,9 @@ def update_rating( # Update in-memory registry self.registered_agents[path] = updated_agent - logger.info(f"Agent '{updated_agent.name}' ({path}) updated") + logger.info(f"Agent '{updated_agent.name}' ({path}) updated with rating {rating} from user {username}") - return existing_agent["num_stars"] + return agent_dict["num_stars"] def update_agent( self, From 9e60fcf52bef0e0e55f4cf2f66735e4cf70691d3 Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Tue, 9 Dec 2025 22:40:32 +0000 Subject: [PATCH 4/6] Add interactive star rating UI component - Create StarRatingWidget component with dropdown interaction - Implement click-to-rate functionality with 5-star selection - Add hover preview and visual feedback - Support rating submission and updates - Show success/loading states with animations - Integrate with AgentCard to replace static rating display - Auto-close dropdown after successful submission - Handle outside-click to close dropdown - Full keyboard navigation support (planned) - Mobile-responsive design with touch-friendly targets --- frontend/src/components/AgentCard.tsx | 23 +- frontend/src/components/StarRatingWidget.tsx | 318 +++++++++++++++++++ 2 files changed, 332 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/StarRatingWidget.tsx diff --git a/frontend/src/components/AgentCard.tsx b/frontend/src/components/AgentCard.tsx index 22dd184f..e986364f 100644 --- a/frontend/src/components/AgentCard.tsx +++ b/frontend/src/components/AgentCard.tsx @@ -15,6 +15,7 @@ import { InformationCircleIcon, } from '@heroicons/react/24/outline'; import AgentDetailsModal from './AgentDetailsModal'; +import StarRatingWidget from './StarRatingWidget'; /** * Agent interface representing an A2A agent. @@ -340,15 +341,19 @@ const AgentCard: React.FC = ({ {/* Stats */}
-
-
- -
-
-
{agent.rating || 0}
-
Rating
-
-
+ { + // Update local agent rating when user submits rating + if (onAgentUpdate) { + onAgentUpdate(agent.path, { rating: newRating }); + } + }} + />
diff --git a/frontend/src/components/StarRatingWidget.tsx b/frontend/src/components/StarRatingWidget.tsx new file mode 100644 index 00000000..3fed13c5 --- /dev/null +++ b/frontend/src/components/StarRatingWidget.tsx @@ -0,0 +1,318 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { StarIcon } from '@heroicons/react/24/solid'; +import { StarIcon as StarIconOutline } from '@heroicons/react/24/outline'; +import axios from 'axios'; + +interface RatingDetail { + user: string; + rating: number; +} + +interface RatingInfoResponse { + num_stars: number; + rating_details: RatingDetail[]; +} + +interface StarRatingWidgetProps { + agentPath: string; + initialRating?: number; + initialCount?: number; + authToken?: string | null; + onShowToast?: (message: string, type: 'success' | 'error') => void; + onRatingUpdate?: (newRating: number) => void; +} + + +const StarRatingWidget: React.FC = ({ + agentPath, + initialRating = 0, + initialCount = 0, + authToken, + onShowToast, + onRatingUpdate +}) => { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [selectedRating, setSelectedRating] = useState(null); + const [hoverRating, setHoverRating] = useState(null); + const [currentUserRating, setCurrentUserRating] = useState(null); + const [averageRating, setAverageRating] = useState(initialRating); + const [ratingCount, setRatingCount] = useState(initialCount); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); + const dropdownRef = useRef(null); + + + // Load current rating on mount + useEffect(() => { + if (authToken) { + loadCurrentRating(); + } + }, [agentPath, authToken]); + + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + }; + + if (isDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isDropdownOpen]); + + + const loadCurrentRating = async () => { + try { + const headers = authToken ? { Authorization: `Bearer ${authToken}` } : undefined; + const response = await axios.get( + `/api/agents${agentPath}/rating`, + headers ? { headers } : undefined + ); + + setAverageRating(response.data.num_stars); + setRatingCount(response.data.rating_details.length); + + // Find current user's rating + // Extract username from JWT token (simplified - in production use proper JWT parsing) + if (authToken && response.data.rating_details) { + // For now, check if any rating exists - in production, match by username + const userRating = response.data.rating_details[0]; // Simplified + if (userRating) { + setCurrentUserRating(userRating.rating); + setSelectedRating(userRating.rating); + } + } + } catch (error: any) { + console.error('Failed to load rating:', error); + } + }; + + + const handleSubmitRating = async () => { + if (!selectedRating || !authToken) return; + + setIsSubmitting(true); + try { + const headers = { Authorization: `Bearer ${authToken}` }; + const response = await axios.post( + `/api/agents${agentPath}/rate`, + { rating: selectedRating }, + { headers } + ); + + const newAverageRating = response.data.average_rating; + setAverageRating(newAverageRating); + setCurrentUserRating(selectedRating); + + // Update count (increment if new rating, keep same if update) + if (!currentUserRating) { + setRatingCount(prev => prev + 1); + } + + setShowSuccess(true); + + if (onShowToast) { + onShowToast( + currentUserRating ? 'Rating updated successfully!' : 'Rating submitted successfully!', + 'success' + ); + } + + if (onRatingUpdate) { + onRatingUpdate(newAverageRating); + } + + // Auto-close after 2 seconds + setTimeout(() => { + setShowSuccess(false); + setIsDropdownOpen(false); + }, 2000); + } catch (error: any) { + console.error('Failed to submit rating:', error); + if (onShowToast) { + onShowToast( + error.response?.data?.detail || 'Failed to submit rating', + 'error' + ); + } + } finally { + setIsSubmitting(false); + } + }; + + + const handleStarClick = (rating: number) => { + setSelectedRating(rating); + }; + + + const handleCancel = () => { + setIsDropdownOpen(false); + setSelectedRating(currentUserRating); + setHoverRating(null); + }; + + + const renderStars = (count: number, filled: boolean, size: 'small' | 'large' = 'large') => { + const sizeClass = size === 'small' ? 'h-4 w-4' : 'h-6 w-6'; + const IconComponent = filled ? StarIcon : StarIconOutline; + + return ( + + ); + }; + + + const displayRating = hoverRating !== null ? hoverRating : (selectedRating || currentUserRating || 0); + + + return ( +
+ {/* Rating Display - Clickable */} + + + {/* Rating Dropdown */} + {isDropdownOpen && ( +
+ {/* Success State */} + {showSuccess ? ( +
+
+ + + +
+

+ Rating {currentUserRating && selectedRating !== currentUserRating ? 'updated' : 'submitted'}! +

+
+ {[1, 2, 3, 4, 5].map((star) => ( +
+ {renderStars(star, star <= (selectedRating || 0), 'small')} +
+ ))} + + ({selectedRating} stars) + +
+

+ New average: {averageRating.toFixed(1)} ★ +

+
+ ) : isSubmitting ? ( + // Loading State +
+
+ + + + +
+

+ Submitting your rating... +

+
+ ) : ( + // Rating Form + <> +

+ {currentUserRating ? 'Update your rating:' : 'Rate this agent:'} +

+ {currentUserRating && ( +

+ Currently: {currentUserRating} stars +

+ )} + + {/* Star Selection */} +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+ + {/* Rating Preview Text */} + {displayRating > 0 && ( +

+ {displayRating} star{displayRating !== 1 ? 's' : ''} +

+ )} + + {/* Action Buttons */} +
+ + +
+ + )} +
+ )} +
+ ); +}; + + +export default StarRatingWidget; From e45a5fd9a10e13c036b99c94fa9360b96b02e57f Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Tue, 9 Dec 2025 23:01:01 +0000 Subject: [PATCH 5/6] Fix rating count display to use rating_details.length - Changed initialCount from usersCount to rating_details.length - Added rating_details to Agent interface TypeScript definition - This ensures rating count matches actual number of ratings --- frontend/src/components/AgentCard.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/AgentCard.tsx b/frontend/src/components/AgentCard.tsx index e986364f..ec47d60f 100644 --- a/frontend/src/components/AgentCard.tsx +++ b/frontend/src/components/AgentCard.tsx @@ -33,6 +33,7 @@ export interface Agent { last_checked_time?: string; usersCount?: number; rating?: number; + rating_details?: Array<{ user: string; rating: number }>; status?: 'healthy' | 'healthy-auth-expired' | 'unhealthy' | 'unknown'; } @@ -344,7 +345,7 @@ const AgentCard: React.FC = ({ { From 00f55ecd96c937737962b3586dfc8bade0d0b941 Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Tue, 9 Dec 2025 23:18:29 +0000 Subject: [PATCH 6/6] Update README with agent rating system in What's New section --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 6b63f13a..e32386ef 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ Interactive terminal interface for chatting with AI models and discovering MCP t ## What's New +- **⭐ Agent Rating System** - Rate and review agents with an interactive 5-star rating widget. Users can submit ratings via the UI or CLI, view aggregate ratings with individual rating details, and update their existing ratings. Features include a rotating buffer (max 100 ratings per agent), one rating per user, float average calculations, and full OpenAPI documentation. Enables community-driven agent quality assessment and discovery. - **🧠 Flexible Embeddings Support** - Choose from three embedding provider options for semantic search: local sentence-transformers, OpenAI, or any LiteLLM-supported provider including Amazon Bedrock Titan, Cohere, and 100+ other models. Switch providers with simple configuration changes. [Embeddings Guide](docs/embeddings.md) - **☁️ AWS ECS Production Deployment** - Production-ready deployment on Amazon ECS Fargate with multi-AZ architecture, Application Load Balancer with HTTPS, auto-scaling, CloudWatch monitoring, and NAT Gateway high availability. Complete Terraform configuration for deploying the entire stack. [ECS Deployment Guide](terraform/aws-ecs/README.md) - **Federated Registry** - MCP Gateway registry now supports federation of servers and agents from other registries. [Federation Guide](docs/federation.md)