""" A2A server for the coding agent. """ from __future__ import annotations import json import os import secrets import uuid from contextlib import asynccontextmanager from datetime import datetime from typing import Any, AsyncGenerator, Optional from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel, Field, ValidationError from coding_a2a_agent.common.agent_callback_utils import AgentCallbackHandler, CallbackContextManager from coding_a2a_agent.agent import CodingA2ARuntime, CodingRuntimeError from coding_a2a_agent.config import AgentMetadata, CodingRequestConfig, LiteLLMConfig SERVICE_HOST = os.getenv("SERVICE_HOST", "0.0.0.0") SERVICE_PORT = int(os.getenv("SERVICE_PORT", "8000")) POD_NAME = os.getenv("POD_NAME", "coding-a2a-agent") TEMPLATE_TYPE = os.getenv("TEMPLATE_TYPE", "coding_a2a_agent") USER_ID = os.getenv("USER_ID", "") HEICODE_AGENT_ID = os.getenv("HEICODE_AGENT_ID", "") AGENT_ACCESS_TOKEN = os.getenv("AGENT_ACCESS_TOKEN", "") AGENT_ACCESS_HEADER = "X-Agent-Access-Token" class A2APart(BaseModel): kind: str = "text" text: Optional[str] = None data: Optional[dict[str, Any]] = None mime_type: Optional[str] = None class A2AMessage(BaseModel): role: str parts: list[A2APart] messageId: str = Field(default_factory=lambda: uuid.uuid4().hex) class A2ARequest(BaseModel): jsonrpc: str = "2.0" id: str method: str params: Optional[dict[str, Any]] = None class A2AArtifact(BaseModel): artifactId: str = Field(default_factory=lambda: uuid.uuid4().hex) name: str = "coding-result" parts: list[A2APart] class A2ATaskStatus(BaseModel): state: str timestamp: str = Field(default_factory=lambda: datetime.utcnow().isoformat() + "Z") message: Optional[str] = None class A2ATask(BaseModel): kind: str = "task" id: str = Field(default_factory=lambda: uuid.uuid4().hex) contextId: str = Field(default_factory=lambda: uuid.uuid4().hex) status: A2ATaskStatus artifacts: Optional[list[A2AArtifact]] = None metadata: Optional[dict[str, Any]] = None class AgentSkill(BaseModel): id: str name: str description: str class AgentCapabilities(BaseModel): text: bool = True streaming: bool = True push_notifications: bool = False forms: bool = False files: bool = True class AgentCard(BaseModel): name: str description: str version: str url: str capabilities: AgentCapabilities skills: list[AgentSkill] authentication: Optional[dict[str, Any]] = None class CodingA2AServer: def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None): self.llm_config = LiteLLMConfig(api_key=api_key, model=model or LiteLLMConfig().model) self.metadata = AgentMetadata() self.runtime = CodingA2ARuntime(self.llm_config, self.metadata) self.callback_handler = AgentCallbackHandler(agent_name=POD_NAME, user_id=USER_ID) self.tasks: dict[str, A2ATask] = {} self.app = self._create_app() def _agent_access_required(self) -> bool: return bool(AGENT_ACCESS_TOKEN) def _agent_authentication_card(self) -> Optional[dict[str, Any]]: if not self._agent_access_required(): return None return { "type": "header", "header": AGENT_ACCESS_HEADER, "required": True, "agent_id": HEICODE_AGENT_ID or POD_NAME, } def _authorize_agent_request(self, request: Request) -> Optional[JSONResponse]: expected_token = AGENT_ACCESS_TOKEN if not expected_token: return None provided_token = request.headers.get(AGENT_ACCESS_HEADER, "") if not provided_token: return JSONResponse( status_code=401, content={"detail": f"missing {AGENT_ACCESS_HEADER}"}, ) if not secrets.compare_digest(expected_token, provided_token): return JSONResponse( status_code=403, content={"detail": "agent access denied"}, ) return None def _jsonrpc_error( self, request_id: str, code: int, message: str, *, data: Optional[dict[str, Any]] = None, status_code: int = 200, ) -> JSONResponse: payload: dict[str, Any] = { "jsonrpc": "2.0", "id": request_id, "error": { "code": code, "message": message, }, } if data is not None: payload["error"]["data"] = data return JSONResponse(payload, status_code=status_code) def _create_app(self) -> FastAPI: @asynccontextmanager async def lifespan(app: FastAPI): yield app = FastAPI( title=f"{self.metadata.name} - A2A", version=self.metadata.version, lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) self._register_routes(app) return app def _register_routes(self, app: FastAPI): @app.get("/") async def root(): runtime_defaults = CodingRequestConfig() return { "name": self.metadata.name, "version": self.metadata.version, "protocol": "A2A", "status": "running", "pod_name": POD_NAME, "template_type": TEMPLATE_TYPE, "role_name": self.metadata.role_name, "instruction_source": self.metadata.instruction_source, "enabled_resources": runtime_defaults.resources.enabled_resource_names, "auth_required": self._agent_access_required(), "agent_id": HEICODE_AGENT_ID or POD_NAME, } @app.get("/health") async def health(): runtime_defaults = CodingRequestConfig() return { "status": "healthy", "template_type": TEMPLATE_TYPE, "role_name": self.metadata.role_name, "instruction_source": self.metadata.instruction_source, "enabled_resources": runtime_defaults.resources.enabled_resource_names, "auth_required": self._agent_access_required(), "agent_id": HEICODE_AGENT_ID or POD_NAME, "timestamp": datetime.utcnow().isoformat() + "Z", } @app.get("/.well-known/agent.json") async def agent_card(request: Request): base_url = str(request.base_url).rstrip("/") card = AgentCard( name=self.metadata.name, description=self.metadata.description, version=self.metadata.version, url=base_url, capabilities=AgentCapabilities(streaming=self.metadata.enable_streaming), authentication=self._agent_authentication_card(), skills=[ AgentSkill(id="coding", name="Coding", description="Inspect, edit, and verify repositories like a Claude Code style coding agent."), AgentSkill(id="git", name="Git", description="Prepare workspaces, inspect git state, branch, commit, and push for Gitea, GitHub, and GitLab."), AgentSkill(id="data", name="Data Resources", description="Inspect MySQL/PostgreSQL schemas and Azure Blob artifacts when configured."), ], ) return card.model_dump() @app.post("/message/send") async def message_send(request: Request): auth_error = self._authorize_agent_request(request) if auth_error: return auth_error body = await request.json() try: rpc_request = A2ARequest(**body) except Exception as exc: return self._jsonrpc_error( str(body.get("id", "unknown")), -32600, "Invalid Request", data={"detail": str(exc)}, ) if rpc_request.method != "message/send": return self._jsonrpc_error( rpc_request.id, -32601, f"Method not found: {rpc_request.method}", ) return await self._handle_message_send(rpc_request) @app.post("/message/stream") async def message_stream(request: Request): auth_error = self._authorize_agent_request(request) if auth_error: return auth_error body = await request.json() try: rpc_request = A2ARequest(**body) except Exception as exc: return self._jsonrpc_error( str(body.get("id", "unknown")), -32600, "Invalid Request", data={"detail": str(exc)}, ) if rpc_request.method != "message/stream": return self._jsonrpc_error( rpc_request.id, -32601, f"Method not found: {rpc_request.method}", ) return await self._handle_message_stream(rpc_request) @app.get("/tasks/{task_id}") async def get_task(task_id: str, request: Request): auth_error = self._authorize_agent_request(request) if auth_error: return auth_error if task_id not in self.tasks: raise HTTPException(status_code=404, detail="Task not found") return self.tasks[task_id].model_dump() async def _handle_message_send(self, request: A2ARequest) -> JSONResponse: task: Optional[A2ATask] = None try: params = request.params or {} message_text = self._extract_message_text(params.get("message", {})) if not message_text: return self._jsonrpc_error( request.id, -32602, "Invalid params: no text content found", ) task_id = uuid.uuid4().hex context_id = params.get("contextId", uuid.uuid4().hex) task = A2ATask(id=task_id, contextId=context_id, status=A2ATaskStatus(state="working")) self.tasks[task_id] = task runtime_config = CodingRequestConfig.model_validate(params.get("configuration") or {}) api_key = params.get("api_key") or runtime_config.api_key or self.llm_config.api_key model = params.get("model") or runtime_config.model or self.llm_config.model with CallbackContextManager( handler=self.callback_handler, user_id=params.get("user_id") or USER_ID, request_id=task_id, ) as callback: callback.add_tool("a2a_message_send") result = await self.runtime.run_task( message_text, runtime_config, api_key=api_key, model=model, ) task.status = A2ATaskStatus(state="completed") task.artifacts = [ A2AArtifact( name="coding-result", parts=[A2APart(kind="text", text=result.response_text)], ) ] task.metadata = { "summary": result.summary, "workspace_root": result.workspace_root, "files_changed": result.files_changed, "tool_log": [entry.model_dump() for entry in result.tool_log], "resources_used": result.resources_used, "role_name": self.metadata.role_name, "instruction_source": self.metadata.instruction_source, } self.tasks[task.id] = task return JSONResponse({"jsonrpc": "2.0", "id": request.id, "result": task.model_dump()}) except ValidationError as exc: if task: task.status = A2ATaskStatus(state="failed", message="Invalid configuration") self.tasks[task.id] = task return self._jsonrpc_error( request.id, -32602, "Invalid params: configuration validation failed", data={"stage": "configuration_validation", "errors": exc.errors()}, ) except CodingRuntimeError as exc: if task: task.status = A2ATaskStatus(state="failed", message=str(exc)) self.tasks[task.id] = task return self._jsonrpc_error( request.id, -32010, str(exc), data=exc.to_payload(), ) except Exception as exc: if task: task.status = A2ATaskStatus(state="failed", message=str(exc)) self.tasks[task.id] = task return self._jsonrpc_error( request.id, -32000, f"Agent error: {exc}", data={"stage": "run_task"}, ) async def _handle_message_stream(self, request: A2ARequest) -> StreamingResponse | JSONResponse: params = request.params or {} message_text = self._extract_message_text(params.get("message", {})) if not message_text: return self._jsonrpc_error( request.id, -32602, "Invalid params: no text content found", ) try: runtime_config = CodingRequestConfig.model_validate(params.get("configuration") or {}) except ValidationError as exc: return self._jsonrpc_error( request.id, -32602, "Invalid params: configuration validation failed", data={"stage": "configuration_validation", "errors": exc.errors()}, ) api_key = params.get("api_key") or runtime_config.api_key or self.llm_config.api_key model = params.get("model") or runtime_config.model or self.llm_config.model task_id = uuid.uuid4().hex context_id = params.get("contextId", uuid.uuid4().hex) async def event_stream() -> AsyncGenerator[str, None]: start_event = {"kind": "task-start", "taskId": task_id, "contextId": context_id} yield f"data: {json.dumps(start_event, ensure_ascii=False)}\n\n" try: result = await self.runtime.run_task( message_text, runtime_config, api_key=api_key, model=model, ) artifact_event = { "kind": "artifact", "taskId": task_id, "contextId": context_id, "data": { "text": result.response_text, "summary": result.summary, "files_changed": result.files_changed, }, } yield f"data: {json.dumps(artifact_event, ensure_ascii=False)}\n\n" finish_event = {"kind": "task-complete", "taskId": task_id, "contextId": context_id} yield f"data: {json.dumps(finish_event, ensure_ascii=False)}\n\n" except CodingRuntimeError as exc: error_event = { "kind": "task-failed", "taskId": task_id, "contextId": context_id, "data": { "message": str(exc), **exc.to_payload(), }, } yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n" except Exception as exc: error_event = { "kind": "task-failed", "taskId": task_id, "contextId": context_id, "data": {"message": str(exc)}, } yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n" return StreamingResponse(event_stream(), media_type="text/event-stream") def _extract_message_text(self, message: dict[str, Any]) -> str: parts = message.get("parts", []) return "".join(part.get("text", "") for part in parts if part.get("kind") == "text") def create_app(api_key: Optional[str] = None, model: Optional[str] = None) -> FastAPI: return CodingA2AServer(api_key=api_key, model=model).app