|
| 1 | +""" |
| 2 | +Minimal A2A (Agent-to-Agent) Protocol Server |
| 3 | +
|
| 4 | +A lightweight implementation of the A2A protocol specification (https://a2a-protocol.org) |
| 5 | +for testing Azure AI Foundry's RemoteA2AConnector through the Data Proxy. |
| 6 | +
|
| 7 | +Endpoints: |
| 8 | + GET /.well-known/agent.json - Agent card (A2A spec standard) |
| 9 | + GET /.well-known/agent-card.json - Agent card (Azure SDK default path) |
| 10 | + POST / - JSON-RPC 2.0 task endpoint |
| 11 | + GET /healthz - Container health check |
| 12 | +""" |
| 13 | + |
| 14 | +import json |
| 15 | +import logging |
| 16 | +import os |
| 17 | +import uuid |
| 18 | + |
| 19 | +from fastapi import FastAPI, Request |
| 20 | +from fastapi.responses import JSONResponse |
| 21 | + |
| 22 | +logging.basicConfig(level=logging.INFO) |
| 23 | +logger = logging.getLogger(__name__) |
| 24 | + |
| 25 | +app = FastAPI(title="A2A Calculator Agent") |
| 26 | + |
| 27 | +# ============================================================================ |
| 28 | +# Agent Card — describes this agent's identity and capabilities |
| 29 | +# ============================================================================ |
| 30 | + |
| 31 | +AGENT_CARD = { |
| 32 | + "name": "Calculator Agent", |
| 33 | + "description": "A simple calculator agent for A2A protocol testing. " |
| 34 | + "Performs basic arithmetic (add, subtract, multiply, divide).", |
| 35 | + "url": "/", |
| 36 | + "version": "1.0.0", |
| 37 | + "protocolVersion": "0.2.6", |
| 38 | + "preferredTransport": "jsonrpc", |
| 39 | + "capabilities": { |
| 40 | + "streaming": False, |
| 41 | + "pushNotifications": False, |
| 42 | + }, |
| 43 | + "skills": [ |
| 44 | + { |
| 45 | + "id": "calculate", |
| 46 | + "name": "Calculate", |
| 47 | + "description": "Performs basic arithmetic operations (add, subtract, multiply, divide)", |
| 48 | + "tags": ["math", "calculator", "arithmetic"], |
| 49 | + "examples": ["add 5 and 3", "multiply 7 by 8"], |
| 50 | + } |
| 51 | + ], |
| 52 | + "defaultInputModes": ["text"], |
| 53 | + "defaultOutputModes": ["text"], |
| 54 | +} |
| 55 | + |
| 56 | + |
| 57 | +# ============================================================================ |
| 58 | +# Agent Card Endpoints |
| 59 | +# ============================================================================ |
| 60 | + |
| 61 | + |
| 62 | +@app.get("/.well-known/agent.json") |
| 63 | +@app.get("/.well-known/agent-card.json") |
| 64 | +async def get_agent_card(request: Request): |
| 65 | + """Serves agent card at both A2A spec and Azure SDK default paths. |
| 66 | + Dynamically sets the url field to the absolute URL of this server.""" |
| 67 | + logger.info("Agent card requested") |
| 68 | + # Build absolute URL from the incoming request |
| 69 | + base_url = os.environ.get("A2A_BASE_URL", "") |
| 70 | + if not base_url: |
| 71 | + scheme = request.headers.get("x-forwarded-proto", request.url.scheme) |
| 72 | + host = request.headers.get("x-forwarded-host", request.headers.get("host", "")) |
| 73 | + base_url = f"{scheme}://{host}" |
| 74 | + card = dict(AGENT_CARD) |
| 75 | + card["url"] = base_url + "/" |
| 76 | + return card |
| 77 | + |
| 78 | + |
| 79 | +# ============================================================================ |
| 80 | +# JSON-RPC 2.0 Task Endpoint |
| 81 | +# ============================================================================ |
| 82 | + |
| 83 | + |
| 84 | +def _jsonrpc_error(request_id: str, code: int, message: str, status: int = 200): |
| 85 | + return JSONResponse( |
| 86 | + status_code=status, |
| 87 | + content={ |
| 88 | + "jsonrpc": "2.0", |
| 89 | + "id": request_id, |
| 90 | + "error": {"code": code, "message": message}, |
| 91 | + }, |
| 92 | + ) |
| 93 | + |
| 94 | + |
| 95 | +@app.get("/") |
| 96 | +async def root_get(): |
| 97 | + """GET on root — return basic info (some A2A clients probe this).""" |
| 98 | + return {"name": "Calculator Agent", "protocol": "a2a", "version": "1.0.0"} |
| 99 | + |
| 100 | + |
| 101 | +def _extract_user_text(params: dict) -> str: |
| 102 | + """Extract text content from an A2A message's parts.""" |
| 103 | + message = params.get("message", {}) |
| 104 | + parts = message.get("parts", []) |
| 105 | + for part in parts: |
| 106 | + if part.get("kind") == "text" or part.get("type") == "text": |
| 107 | + return part.get("text", "") |
| 108 | + return "" |
| 109 | + |
| 110 | + |
| 111 | +def _process_message(text: str) -> str: |
| 112 | + """Simple calculator logic — returns a text response.""" |
| 113 | + lower = text.lower().strip() |
| 114 | + |
| 115 | + # Try to detect arithmetic from natural language |
| 116 | + import re |
| 117 | + |
| 118 | + # Match patterns like "add 5 and 3", "multiply 4 by 7", "15 + 3" |
| 119 | + ops = { |
| 120 | + "add": "+", |
| 121 | + "plus": "+", |
| 122 | + "sum": "+", |
| 123 | + "subtract": "-", |
| 124 | + "minus": "-", |
| 125 | + "multiply": "*", |
| 126 | + "times": "*", |
| 127 | + "divide": "/", |
| 128 | + "divided": "/", |
| 129 | + } |
| 130 | + |
| 131 | + # Check for "X op Y" patterns |
| 132 | + num_pattern = r"(-?\d+(?:\.\d+)?)" |
| 133 | + for word, op in ops.items(): |
| 134 | + pattern = rf"{word}\s+{num_pattern}\s+(?:and|by|from|with)?\s*{num_pattern}" |
| 135 | + match = re.search(pattern, lower) |
| 136 | + if match: |
| 137 | + a, b = float(match.group(1)), float(match.group(2)) |
| 138 | + return _compute(op, a, b) |
| 139 | + |
| 140 | + # Check for "X + Y" style |
| 141 | + arith_match = re.search( |
| 142 | + rf"{num_pattern}\s*([+\-*/])\s*{num_pattern}", text |
| 143 | + ) |
| 144 | + if arith_match: |
| 145 | + a = float(arith_match.group(1)) |
| 146 | + op = arith_match.group(2) |
| 147 | + b = float(arith_match.group(3)) |
| 148 | + return _compute(op, a, b) |
| 149 | + |
| 150 | + # Capability question |
| 151 | + if "what" in lower and ("do" in lower or "can" in lower or "capabilities" in lower): |
| 152 | + return ( |
| 153 | + "I'm a calculator agent. I can perform basic arithmetic: " |
| 154 | + "add, subtract, multiply, and divide. " |
| 155 | + "Try asking me something like 'add 5 and 3' or 'multiply 7 by 8'." |
| 156 | + ) |
| 157 | + |
| 158 | + return f"I received your message: '{text}'. I'm a calculator agent — ask me to do math!" |
| 159 | + |
| 160 | + |
| 161 | +def _compute(op: str, a: float, b: float) -> str: |
| 162 | + if op == "+": |
| 163 | + return f"{a} + {b} = {a + b}" |
| 164 | + elif op == "-": |
| 165 | + return f"{a} - {b} = {a - b}" |
| 166 | + elif op == "*": |
| 167 | + return f"{a} * {b} = {a * b}" |
| 168 | + elif op == "/": |
| 169 | + if b == 0: |
| 170 | + return "Error: Division by zero" |
| 171 | + return f"{a} / {b} = {a / b}" |
| 172 | + return "Unknown operation" |
| 173 | + |
| 174 | + |
| 175 | +@app.post("/") |
| 176 | +async def handle_jsonrpc(request: Request): |
| 177 | + """Handle A2A JSON-RPC 2.0 requests.""" |
| 178 | + try: |
| 179 | + body = await request.json() |
| 180 | + except json.JSONDecodeError: |
| 181 | + return _jsonrpc_error("unknown", -32700, "Parse error", status=400) |
| 182 | + |
| 183 | + request_id = body.get("id", str(uuid.uuid4())) |
| 184 | + |
| 185 | + if body.get("jsonrpc") != "2.0": |
| 186 | + return _jsonrpc_error(request_id, -32600, "Invalid Request: jsonrpc must be '2.0'") |
| 187 | + |
| 188 | + method = body.get("method", "") |
| 189 | + # Support both A2A v1.0 (message/send) and older (tasks/send) |
| 190 | + supported_methods = {"message/send", "tasks/send", "message/stream"} |
| 191 | + |
| 192 | + if method not in supported_methods: |
| 193 | + return _jsonrpc_error(request_id, -32601, f"Method not found: {method}") |
| 194 | + |
| 195 | + params = body.get("params", {}) |
| 196 | + task_id = params.get("id", str(uuid.uuid4())) |
| 197 | + user_text = _extract_user_text(params) |
| 198 | + |
| 199 | + logger.info(f"A2A {method}: task={task_id}, text='{user_text[:100]}'") |
| 200 | + |
| 201 | + response_text = _process_message(user_text) |
| 202 | + |
| 203 | + # Build A2A response as a direct Message (with kind discriminator) |
| 204 | + # The A2A SDK uses "kind" to distinguish Message vs Task responses |
| 205 | + result = { |
| 206 | + "kind": "message", |
| 207 | + "messageId": str(uuid.uuid4()), |
| 208 | + "role": "agent", |
| 209 | + "parts": [{"kind": "text", "text": response_text}], |
| 210 | + } |
| 211 | + |
| 212 | + return JSONResponse( |
| 213 | + content={"jsonrpc": "2.0", "id": request_id, "result": result} |
| 214 | + ) |
| 215 | + |
| 216 | + |
| 217 | +# ============================================================================ |
| 218 | +# Health Check |
| 219 | +# ============================================================================ |
| 220 | + |
| 221 | + |
| 222 | +@app.get("/healthz") |
| 223 | +async def healthz(): |
| 224 | + return {"status": "ok"} |
| 225 | + |
| 226 | + |
| 227 | +if __name__ == "__main__": |
| 228 | + import uvicorn |
| 229 | + |
| 230 | + uvicorn.run(app, host="0.0.0.0", port=8080) |
0 commit comments