diff --git a/samples/python/agents/ag2/README.md b/samples/python/agents/ag2/README.md index f32578983..3a7e3da91 100644 --- a/samples/python/agents/ag2/README.md +++ b/samples/python/agents/ag2/README.md @@ -38,54 +38,35 @@ sequenceDiagram ## The demo -Here we have a simple demo that shows how to use the A2A protocol to communicate with an AG2 agent. We have -- one A2A-served remote agent `a2a_python_reviewer.py` -- two different A2A clients, which communicate with the remote agent using the A2A protocol: - CLI code generator `cli_codegen_a2a_client.py` and FastAPI code generator `fastapi_codegen_a2a_client.py` +This demo shows how to use the A2A protocol to communicate with an AG2 agent using a simple server/client architecture. -## Prerequisites - -- Python 3.12 or higher -- UV package manager -- OpenAI API Key (for default configuration) - -## Setup & Running - -1. Navigate to the samples directory: - - ```bash - cd samples/python/agents/ag2 - ``` +4. Run the remote agent: -2. Create an environment file with your API key (uses `openai gpt-4o`): +```bash +uv run a2a_python_reviewer.py +``` - ```bash - echo "OPENAI_API_KEY=your_api_key_here" > .env - ``` +5. In a new terminal, start an A2A client to interact with the remote `ReviewerAgent`. Choose one of the following: -3. Install the dependencies: - ```bash - uv sync - ``` +- CLI client (generates CLI scripts): -4. Run the remote agent: - ```bash - uv run a2a_python_reviewer.py - ``` +```bash +uv run cli_codegen_a2a_client.py +``` -5. In a new terminal, start an A2AClient interface to interact with the remote (ag2) agent. You can use one of the following clients: +- FastAPI client (generates FastAPI apps): - - **Method A: Run the CLI client** +```bash +uv run fastapi_codegen_a2a_client.py +``` - ```bash - uv run cli_codegen_a2a_client.py - ``` +- Optional: WebSocket demo UI (visual demo only): - - **Method B: Run the FastAPI client** +```bash +uv run websocket.py +``` - ```bash - uv run fastapi_codegen_a2a_client.py - ``` +Then open [http://127.0.0.1:9000](http://127.0.0.1:9000) in your browser for the pixel-art themed interactive demo. ## Learn More diff --git a/samples/python/agents/ag2/a2a_python_reviewer.py b/samples/python/agents/ag2/a2a_python_reviewer.py index 8053d3670..4ed276d10 100644 --- a/samples/python/agents/ag2/a2a_python_reviewer.py +++ b/samples/python/agents/ag2/a2a_python_reviewer.py @@ -3,6 +3,14 @@ from typing import Annotated +# Load .env file if python-dotenv is available +try: + from dotenv import load_dotenv + + load_dotenv() +except ImportError: + pass + from autogen import ConversableAgent, LLMConfig from autogen.a2a import A2aAgentServer from mypy import api @@ -11,48 +19,60 @@ # create regular AG2 agent config = LLMConfig( { - 'model': 'gpt-4o-mini', - 'api_key': os.getenv('OPENAI_API_KEY'), + "model": "gpt-4o-mini", + "api_key": os.getenv("OPENAI_API_KEY"), } ) reviewer_agent = ConversableAgent( - name='ReviewerAgent', - description='An agent that reviews the code for the user', + name="ReviewerAgent", + description="An agent that reviews the code for the user", system_message=( - 'You are an expert in code review pretty strict and focused on typing. ' - 'Please, use mypy tool to validate the code.' - 'If mypy has no issues with the code, return "No issues found."' + "You are an expert in code review: strict and focused on typing. " + "Please use the mypy tool to validate the code. " + "If mypy has no issues with the code, return exactly: No issues found." ), llm_config=config, - human_input_mode='NEVER', + human_input_mode="NEVER", ) # Add mypy tool to validate the code @reviewer_agent.register_for_llm( - name='mypy-checker', - description='Check the code with mypy tool', + name="mypy-checker", + description="Check the code with mypy tool", ) def review_code_with_mypy( code: Annotated[ str, - 'Raw code content to review. Code should be formatted as single file.', + "Raw code content to review. Code should be formatted as single file.", ], ) -> str: - with tempfile.NamedTemporaryFile('w', suffix='.py') as tmp: + # Windows-safe: create a temp file, close it, run mypy, then remove the file. + with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as tmp: tmp.write(code) - stdout, stderr, exit_status = api.run([tmp.name]) - if exit_status != 0: - return stderr - return stdout or 'No issues found.' + tmp_path = tmp.name + try: + stdout, stderr, exit_status = api.run([tmp_path, "--ignore-missing-imports"]) + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + # When mypy exits with 0, return the exact agreed success token. + if exit_status == 0: + return "No issues found." + + # Otherwise return any output we got (stdout preferred, then stderr). + return (stdout or stderr) or "mypy reported issues." # wrap agent to A2A server server = A2aAgentServer(reviewer_agent).build() -if __name__ == '__main__': +if __name__ == "__main__": # run server as regular ASGI application import uvicorn - uvicorn.run(server, host='0.0.0.0', port=8000) + uvicorn.run(server, host="0.0.0.0", port=8000) diff --git a/samples/python/agents/ag2/demo/README.md b/samples/python/agents/ag2/demo/README.md new file mode 100644 index 000000000..f232f2d1f --- /dev/null +++ b/samples/python/agents/ag2/demo/README.md @@ -0,0 +1,42 @@ +# AG2 Demo UI + +This folder contains the browser-based WebSocket demo interface for the AG2 A2A sample. + +## Files + +- **`ui.html`** - Main HTML/CSS/JavaScript for the pixel-art themed demo UI +- **`assets/`** - Visual assets for the interface + +## Assets + +- `banner.png` - Top title banner (AG2 × A2A) +- `background-clouds.png` - Sky background +- `code-box.png` - Stone frame for code/log panels +- `grass-bottom.png` - Grass strip at the bottom + +## How it works + +1. The parent folder's `websocket.py` serves this UI at `http://127.0.0.1:9000/` +2. The browser connects via WebSocket to interact with the AG2 agents +3. The UI displays: + - **Prompt box** - Enter code generation requests + - **Live Backend Logs** - Real-time status updates and agent conversations + - **Generated Code** - Final code output after review iterations + +## Running the demo + +From the parent directory: + +```bash +uv run websocket.py +``` + +Then open `http://127.0.0.1:9000/` in your browser. + +## Design notes + +The UI uses a retro pixel-art aesthetic inspired by classic RPG games: +- 16-bit style graphics +- Stone-framed panels for content +- Pixel-rendered fonts +- Sky background with grass footer diff --git a/samples/python/agents/ag2/demo/assets/background-clouds.png b/samples/python/agents/ag2/demo/assets/background-clouds.png new file mode 100644 index 000000000..65ef131f3 Binary files /dev/null and b/samples/python/agents/ag2/demo/assets/background-clouds.png differ diff --git a/samples/python/agents/ag2/demo/assets/banner.png b/samples/python/agents/ag2/demo/assets/banner.png new file mode 100644 index 000000000..d56fb902a Binary files /dev/null and b/samples/python/agents/ag2/demo/assets/banner.png differ diff --git a/samples/python/agents/ag2/demo/assets/code-box.png b/samples/python/agents/ag2/demo/assets/code-box.png new file mode 100644 index 000000000..e2567cc0e Binary files /dev/null and b/samples/python/agents/ag2/demo/assets/code-box.png differ diff --git a/samples/python/agents/ag2/demo/assets/grass-bottom.png b/samples/python/agents/ag2/demo/assets/grass-bottom.png new file mode 100644 index 000000000..ea507e70c Binary files /dev/null and b/samples/python/agents/ag2/demo/assets/grass-bottom.png differ diff --git a/samples/python/agents/ag2/demo/ui.html b/samples/python/agents/ag2/demo/ui.html new file mode 100644 index 000000000..6e4bb3961 --- /dev/null +++ b/samples/python/agents/ag2/demo/ui.html @@ -0,0 +1,453 @@ + + + + + + AG2 & A2A Demo + + + + +
+
+ +
+ +
+

Prompt

+ +
+ +
+
+ +
+
+
Live Backend Logs
+
+ +
+ (type a prompt, then click "Run Demo") +
+
+
+ +
+
Generated Code
+
+ +
+(waiting)
+
+
+
+
+ +
+ + + + diff --git a/samples/python/agents/ag2/demo/websocket.py b/samples/python/agents/ag2/demo/websocket.py new file mode 100644 index 000000000..b96f19029 --- /dev/null +++ b/samples/python/agents/ag2/demo/websocket.py @@ -0,0 +1,217 @@ +import json +import os +from pathlib import Path +import socket +import sys +from urllib.parse import urlparse + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles + +try: + # Load .env from current directory or any parent directory (e.g., repo root) + # so OPENAI_API_KEY is available without manually setting it per terminal. + from dotenv import find_dotenv, load_dotenv + + load_dotenv(find_dotenv(usecwd=True)) +except ImportError: + pass + +# Add parent directory to path to import AG2 core modules +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# Import the AG2 sample client (reused without modification) +import fastapi_codegen_a2a_client as demo +from autogen.a2a import A2aRemoteAgent + + +APP_HOST = os.getenv("AG2_WS_UI_HOST", "127.0.0.1") +APP_PORT = int(os.getenv("AG2_WS_UI_PORT", "9000")) +REVIEWER_URL = os.getenv("A2A_REVIEWER_URL", "http://localhost:8000") +UI_HTML_PATH = Path(__file__).parent / "ui.html" +ASSETS_DIR = Path(__file__).parent / "assets" + +app = FastAPI(title="AG2 A2A WebSocket UI") + +# Static UI assets (PNG frames, backgrounds). If you add images into ./assets, +# they will be available at http://:/assets/ +app.mount("/assets", StaticFiles(directory=str(ASSETS_DIR), check_dir=False), name="assets") + + +def _probe_reviewer(reviewer_url: str) -> tuple[bool, str]: + """Best-effort connectivity check (TCP) to help demos/debugging. + + Some A2A servers may return 404 for GET /, so HTTP-based checks can be + misleading. A TCP connection is enough to confirm the service is up. + """ + + parsed = urlparse(reviewer_url) + host = parsed.hostname + if not host: + return False, "Invalid reviewer URL" + + if parsed.port: + port = parsed.port + else: + port = 443 if parsed.scheme == "https" else 80 + + try: + with socket.create_connection((host, port), timeout=2): + return True, f"TCP OK to {host}:{port}" + except Exception as e: + return False, f"TCP connect failed to {host}:{port} ({e})" + + +def _reply_to_text(reply: object) -> str: + if isinstance(reply, dict): + return str(reply.get("content") or "") + return str(reply or "") + + +async def _run_codegen_then_review_loop( + *, + codegen_agent: object, + reviewer_url: str, + prompt: str, + send: callable, +) -> str: + max_rounds = int(os.getenv("AG2_MAX_ROUNDS", "3")) + reviewer_agent = A2aRemoteAgent(url=reviewer_url, name="ReviewerAgent") + + await send({"type": "chat", "name": "User", "role": "user", "content": prompt}) + + current_prompt = prompt + last_code = "" + for round_num in range(1, max_rounds + 1): + await send({"type": "status", "message": f"Round {round_num}/{max_rounds}: generating code…"}) + + # Generate code locally using the CodeGenAgent. + code_reply = await codegen_agent.a_generate_reply(messages=[{"role": "user", "content": current_prompt}]) + code_text = _reply_to_text(code_reply).strip() + last_code = code_text + await send({"type": "chat", "name": "CodeGenAgent", "role": "assistant", "content": code_text}) + + await send({"type": "status", "message": "Sending code to ReviewerAgent over A2A (mypy)…"}) + review_prompt = ( + "Please run mypy on the following single-file FastAPI app. " + "Return ONLY the mypy output. If there are no issues, return exactly: No issues found.\n\n" + "```python\n" + f"{code_text}\n" + "```" + ) + + review_reply = await reviewer_agent.a_generate_reply(messages=[{"role": "user", "content": review_prompt}]) + review_text = _reply_to_text(review_reply).strip() + await send({"type": "chat", "name": "ReviewerAgent", "role": "assistant", "content": review_text}) + + if "No issues found." in review_text: + await send({"type": "status", "message": "Reviewer reports no issues. Done."}) + return code_text + + current_prompt = ( + "Please fix the code to address these mypy issues. " + "Return the full corrected code as a single file, code only:\n\n" + f"{review_text}" + ) + + await send({"type": "status", "message": "Reached max rounds; returning last generated code."}) + return last_code + + +@app.get("/", response_class=HTMLResponse) +async def index() -> HTMLResponse: + html = UI_HTML_PATH.read_text(encoding="utf-8") + + # Inject the reviewer URL into the page as a JS global so the UI can + # discover which ReviewerAgent to call. Replacing a literal token in the + # HTML was unreliable, so we append a small script before . + script = f"" + if "" in html: + html = html.replace("", script + "") + else: + html = html + script + + return HTMLResponse( + html, + headers={ + "Cache-Control": "no-store, max-age=0", + "Pragma": "no-cache", + }, + ) + + +@app.websocket("/ws") +async def ws(websocket: WebSocket) -> None: + await websocket.accept() + + async def send(payload: dict) -> None: + await websocket.send_text(json.dumps(payload, ensure_ascii=False)) + + try: + raw = await websocket.receive_text() + payload = json.loads(raw) + prompt = (payload.get("prompt") or "").strip() + + if not prompt: + await send({"type": "error", "error": "No prompt provided."}) + return + + if not os.getenv("OPENAI_API_KEY"): + await send( + { + "type": "error", + "error": "OPENAI_API_KEY is not set. Create a .env with OPENAI_API_KEY=... and run again.", + } + ) + return + + await send({"type": "status", "message": f"Reviewer (A2A): {REVIEWER_URL}"}) + ok, detail = _probe_reviewer(REVIEWER_URL) + await send( + { + "type": "status", + "message": f"Reviewer reachable: {'YES' if ok else 'NO'} — {detail}", + } + ) + if not ok: + await send( + { + "type": "error", + "error": "Reviewer agent is not reachable. Start a2a_python_reviewer.py on :8000 and try again.", + } + ) + return + await send( + { + "type": "status", + "message": "Using existing sample: fastapi_codegen_a2a_client.py", + } + ) + await send( + { + "type": "status", + "message": "Starting A2A chat (CodeGenAgent ↔ ReviewerAgent)…", + } + ) + + # Run multi-round code generation + review loop with WebSocket updates + code = await _run_codegen_then_review_loop( + codegen_agent=demo.codegen_agent, + reviewer_url=REVIEWER_URL, + prompt=prompt, + send=send, + ) + + await send({"type": "result", "code": code or "(no code found)"}) + + except WebSocketDisconnect: + return + except Exception as e: + await send({"type": "error", "error": str(e)}) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host=APP_HOST, port=APP_PORT) diff --git a/samples/python/agents/ag2/pyproject.toml b/samples/python/agents/ag2/pyproject.toml index 9d21f071a..ac693afb2 100644 --- a/samples/python/agents/ag2/pyproject.toml +++ b/samples/python/agents/ag2/pyproject.toml @@ -5,6 +5,8 @@ description = "MCP Mypy agent using A2A and AG2" readme = "README.md" requires-python = ">=3.12" dependencies = [ - "ag2[mcp,openai,a2a]>=0.10.0", + "ag2[mcp,openai,a2a,websockets]>=0.10.0", + "fastapi>=0.110.0", "mypy>=1.10.0", + "uvicorn[standard]>=0.30.0", ]