diff --git a/AGENTS.md b/AGENTS.md index cf6cbfa..1ce6b2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ Use `just` for all development tasks: | `just console` | Run Textual devtools console | | `just get-card` | Fetch agent card (CLI) | | `just send` | Send message to agent (CLI) | +| `just validate` | Validate agent card (CLI) | | `just version` | Show current version | | `just bump` | Bump version (patch, minor, major) | | `just tag` | Create git tag for current version | @@ -27,8 +28,9 @@ Use `just` for all development tasks: handler/ ├── src/a2a_handler/ # Main package │ ├── _version.py # Version string -│ ├── cli.py # CLI (click) +│ ├── cli.py # CLI (rich-click) │ ├── client.py # A2A protocol client (a2a-sdk) +│ ├── validation.py # Agent card validation utilities │ ├── server.py # A2A server agent (google-adk, litellm) │ ├── tui.py # TUI application (textual) │ ├── common/ # Shared utilities (rich, logging) @@ -61,7 +63,7 @@ The `a2a_handler.client` module provides A2A protocol logic: ## Key Dependencies -- **CLI**: `click` +- **CLI**: `rich-click` (enhanced `click` with rich formatting) - **Client**: `a2a-sdk`, `httpx` - **Server**: `google-adk`, `litellm`, `uvicorn` - **TUI**: `textual` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0d4a308..d9c1d34 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,8 +6,9 @@ Handler is a single Python package (`a2a-handler`) with all modules under `src/a | Module | Description | |--------|-------------| -| `cli.py` | CLI built with `click`. Entry point: `handler` | +| `cli.py` | CLI built with `rich-click`. Entry point: `handler` | | `client.py` | A2A protocol client library using `a2a-sdk` | +| `validation.py` | Agent card validation utilities | | `common/` | Shared utilities (logging, printing with `rich`) | | `server.py` | Reference A2A agent using `google-adk` + `litellm` | | `tui.py` | TUI application built with `textual` | @@ -43,6 +44,7 @@ just install # or: uv sync | `just console` | Run Textual devtools console | | `just get-card [url]` | Fetch agent card from URL | | `just send [url] [msg]` | Send message to agent | +| `just validate [source]` | Validate agent card from URL or file | | `just version` | Show current version | | `just bump [level]` | Bump version (patch, minor, major) | | `just tag` | Create git tag for current version | diff --git a/README.md b/README.md index f6cfb2e..245884d 100644 --- a/README.md +++ b/README.md @@ -54,16 +54,44 @@ handler tui ### CLI +#### Global Options + +```bash +handler --verbose # Enable verbose logging output +handler --debug # Enable debug logging output +handler --help # Show help for any command +``` + +#### Commands + Fetch agent card from A2A server: ```bash handler card http://localhost:8000 +handler card http://localhost:8000 --output json # JSON output +``` + +Validate an agent card from a URL or file: + +```bash +handler validate http://localhost:8000 # Validate from URL +handler validate ./agent-card.json # Validate from file +handler validate http://localhost:8000 --output json # JSON output ``` Send a message to an A2A agent: ```bash handler send http://localhost:8000 "Hello World" +handler send http://localhost:8000 "Hello" --output json # JSON output +handler send http://localhost:8000 "Hello" --context-id abc # Conversation continuity +handler send http://localhost:8000 "Hello" --task-id xyz # Reference existing task +``` + +Show version: + +```bash +handler version ``` ## Contributing diff --git a/pyproject.toml b/pyproject.toml index d001919..a3305ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "a2a-handler" -version = "0.1.7" +version = "0.1.8" description = "An A2A Protocol client TUI and CLI." readme = "README.md" requires-python = ">=3.11" @@ -34,6 +34,7 @@ classifiers = [ dependencies = [ "a2a-sdk>=0.2.5", "click>=8.0.0", + "rich-click>=1.8.0", "google-adk>=0.5.0", "httpx>=0.27.0", "litellm>=1.0.0", diff --git a/src/a2a_handler/cli.py b/src/a2a_handler/cli.py index df0289b..1b3facc 100644 --- a/src/a2a_handler/cli.py +++ b/src/a2a_handler/cli.py @@ -1,15 +1,12 @@ import asyncio import json -from typing import Optional +import logging +from typing import Any, Optional -import click import httpx +import rich_click as click -from a2a_handler.client import ( - build_http_client, - fetch_agent_card, - send_message_to_agent, -) +from a2a_handler import __version__ from a2a_handler.common import ( console, get_logger, @@ -20,16 +17,172 @@ setup_logging, ) +click.rich_click.USE_RICH_MARKUP = True +click.rich_click.USE_MARKDOWN = True +click.rich_click.SHOW_ARGUMENTS = True +click.rich_click.GROUP_ARGUMENTS_OPTIONS = True +click.rich_click.STYLE_HELPTEXT = "" +click.rich_click.STYLE_OPTION = "cyan" +click.rich_click.STYLE_ARGUMENT = "cyan" +click.rich_click.STYLE_COMMAND = "green" +click.rich_click.STYLE_SWITCH = "bold green" +click.rich_click.OPTION_GROUPS = { + "handler": [ + { + "name": "Global Options", + "options": ["--verbose", "--debug", "--help"], + }, + ], + "handler send": [ + { + "name": "Conversation Options", + "options": ["--context-id", "--task-id"], + }, + { + "name": "Output Options", + "options": ["--output", "--help"], + }, + ], + "handler server": [ + { + "name": "Server Options", + "options": ["--host", "--port", "--help"], + }, + ], +} +click.rich_click.COMMAND_GROUPS = { + "handler": [ + { + "name": "Agent Commands", + "commands": ["card", "send", "validate"], + }, + { + "name": "Interface Commands", + "commands": ["tui", "server"], + }, + { + "name": "Utility Commands", + "commands": ["version"], + }, + ], +} + +setup_logging(level="WARNING") + +from a2a.client.errors import ( # noqa: E402 + A2AClientError, + A2AClientHTTPError, + A2AClientTimeoutError, +) + +from a2a_handler.client import ( # noqa: E402 + build_http_client, + fetch_agent_card, + parse_response, + send_message_to_agent, +) +from a2a_handler.server import run_server # noqa: E402 +from a2a_handler.tui import HandlerTUI # noqa: E402 +from a2a_handler.validation import ( # noqa: E402 + ValidationResult, + validate_agent_card_from_file, + validate_agent_card_from_url, +) -setup_logging(level="INFO") log = get_logger(__name__) @click.group() +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging output") +@click.option("--debug", "-d", is_flag=True, help="Enable debug logging output") @click.pass_context -def cli(ctx) -> None: - """Handler - A2A protocol client CLI""" +def cli(ctx, verbose: bool, debug: bool) -> None: + """Handler A2A protocol client CLI.""" ctx.ensure_object(dict) + if debug: + log.debug("Debug logging enabled") + setup_logging(level="DEBUG") + elif verbose: + log.debug("Verbose logging enabled") + setup_logging(level="INFO") + else: + setup_logging(level="WARNING") + + +def _format_field_name(name: str) -> str: + """Convert snake_case or camelCase to Title Case.""" + import re + + name = re.sub(r"([a-z])([A-Z])", r"\1 \2", name) + name = name.replace("_", " ") + return name.title() + + +def _format_value(value: Any, indent: int = 0) -> str: + """Recursively format a value for display, returning only truthy content.""" + prefix = " " * indent + + if value is None or value == "" or value == [] or value == {}: + return "" + + if isinstance(value, bool): + return "✓" if value else "✗" + + if isinstance(value, str): + return value + + if isinstance(value, int | float): + return str(value) + + if isinstance(value, list): + lines: list[str] = [] + for item in value: + if hasattr(item, "model_dump"): + item_dict: dict[str, Any] = item.model_dump() + name = item_dict.get("name") or item_dict.get("id") or "Item" + desc = item_dict.get("description") or "" + if desc: + desc_prefix = " " * (indent + 1) + lines.append(f"{prefix} • [cyan]{name}[/cyan]") + lines.append(f"{desc_prefix} {desc}") + else: + lines.append(f"{prefix} • [cyan]{name}[/cyan]") + elif isinstance(item, dict): + item_d: dict[str, Any] = item + name = item_d.get("name") or item_d.get("id") or "Item" + desc = item_d.get("description") or "" + if desc: + desc_prefix = " " * (indent + 1) + lines.append(f"{prefix} • [cyan]{name}[/cyan]") + lines.append(f"{desc_prefix} {desc}") + else: + lines.append(f"{prefix} • [cyan]{name}[/cyan]") + else: + formatted = _format_value(item, indent) + if formatted: + lines.append(f"{prefix} • {formatted}") + return "\n" + "\n".join(lines) if lines else "" + + if hasattr(value, "model_dump"): + value = value.model_dump() + + if isinstance(value, dict): + dict_lines: list[str] = [] + for k, v in value.items(): + if isinstance(k, str) and k.startswith("_"): + continue + formatted = _format_value(v, indent + 1) + if formatted: + field_name = _format_field_name(str(k)) + if "\n" in formatted: + dict_lines.append( + f"{prefix}[bold]{field_name}:[/bold]\n{formatted}" + ) + else: + dict_lines.append(f"{prefix}[bold]{field_name}:[/bold] {formatted}") + return "\n".join(dict_lines) if dict_lines else "" + + return str(value) if value else "" @cli.command() @@ -42,57 +195,185 @@ def cli(ctx) -> None: help="Output format", ) def card(agent_url: str, output: str) -> None: - """Fetch and display an agent card. - - Args: - agent_url: The URL of the agent - output: Output format (json or text) - """ + """Fetch and display an agent card from AGENT_URL.""" + log.info("Fetching agent card from %s", agent_url) async def fetch() -> None: try: + log.debug("Building HTTP client") async with build_http_client() as client: + log.debug("Requesting agent card") card_data = await fetch_agent_card(agent_url, client) + log.info("Retrieved card for agent: %s", card_data.name) if output == "json": + log.debug("Outputting card as JSON") print_json(card_data.model_dump_json(indent=2)) else: - title = f"[bold green]{card_data.name}[/bold green] [dim]({card_data.version})[/dim]" - content = f"[italic]{card_data.description}[/italic]\n\n[bold]URL:[/bold] {card_data.url}" - - if card_data.skills: - content += "\n\n[bold]Skills:[/bold]" - for skill in card_data.skills: - content += ( - f"\n• [cyan]{skill.name}[/cyan]: {skill.description}" - ) - - if card_data.capabilities: - content += "\n\n[bold]Capabilities:[/bold]" - if hasattr(card_data.capabilities, "pushNotifications"): - status = ( - "✅" - if card_data.capabilities.push_notifications - else "❌" - ) - content += f"\n• Push Notifications: {status}" - - print_panel(content, title=title) - + log.debug("Outputting card as formatted text") + card_dict = card_data.model_dump() + + name = card_dict.pop("name", "Unknown Agent") + description = card_dict.pop("description", "") + + title = f"[bold green]{name}[/bold green] [dim]v{__version__}[/dim]" + content_parts = [] + + if description: + content_parts.append(f"[italic]{description}[/italic]") + + for key, value in card_dict.items(): + if key.startswith("_"): + continue + formatted = _format_value(value) + if formatted: + field_name = _format_field_name(key) + if "\n" in formatted: + content_parts.append( + f"[bold]{field_name}:[/bold]\n{formatted}" + ) + else: + content_parts.append( + f"[bold]{field_name}:[/bold] {formatted}" + ) + + print_panel("\n\n".join(content_parts), title=title) + + except A2AClientTimeoutError: + log.error("Request to %s timed out", agent_url) + print_error("Request timed out") + raise click.Abort() + except A2AClientHTTPError as e: + log.error("A2A client error: %s", e) + if "connection" in str(e).lower(): + print_error(f"Connection failed: Is the server running at {agent_url}?") + else: + print_error(str(e)) + raise click.Abort() + except A2AClientError as e: + log.error("A2A client error: %s", e) + print_error(str(e)) + raise click.Abort() + except httpx.ConnectError: + log.error("Connection refused to %s", agent_url) + print_error(f"Connection refused: Is the server running at {agent_url}?") + raise click.Abort() except httpx.TimeoutException: + log.error("Request to %s timed out", agent_url) print_error("Request timed out") raise click.Abort() except httpx.HTTPStatusError as e: + log.error( + "HTTP error %d from %s: %s", + e.response.status_code, + agent_url, + e.response.text, + ) print_error(f"HTTP {e.response.status_code} - {e.response.text}") raise click.Abort() except Exception as e: - log.exception("Failed to fetch agent card") + log.exception("Failed to fetch agent card from %s", agent_url) print_error(str(e)) raise click.Abort() asyncio.run(fetch()) +def _format_validation_result(result: ValidationResult, output: str) -> None: + """Format and print validation result.""" + if output == "json": + import json + + output_data = { + "valid": result.valid, + "source": result.source, + "sourceType": result.source_type.value, + "agentName": result.agent_name, + "protocolVersion": result.protocol_version, + "issues": [ + {"field": i.field, "message": i.message, "type": i.issue_type} + for i in result.issues + ], + "warnings": [ + {"field": w.field, "message": w.message, "type": w.issue_type} + for w in result.warnings + ], + } + print_json(json.dumps(output_data, indent=2)) + return + + if result.valid: + title = "[bold green]✓ Valid Agent Card[/bold green]" + content_parts = [ + f"[bold]Agent:[/bold] {result.agent_name}", + f"[bold]Protocol Version:[/bold] {result.protocol_version}", + f"[bold]Source:[/bold] {result.source}", + ] + + if result.warnings: + content_parts.append("") + content_parts.append( + f"[bold yellow]Warnings ({len(result.warnings)}):[/bold yellow]" + ) + for warning in result.warnings: + content_parts.append( + f" [yellow]⚠[/yellow] {warning.field}: {warning.message}" + ) + + print_panel("\n".join(content_parts), title=title) + else: + title = "[bold red]✗ Invalid Agent Card[/bold red]" + content_parts = [ + f"[bold]Source:[/bold] {result.source}", + "", + f"[bold red]Errors ({len(result.issues)}):[/bold red]", + ] + + for issue in result.issues: + content_parts.append(f" [red]✗[/red] {issue.field}: {issue.message}") + + print_panel("\n".join(content_parts), title=title) + + +@cli.command() +@click.argument("source") +@click.option( + "--output", + "-o", + type=click.Choice(["json", "text"]), + default="text", + help="Output format", +) +def validate(source: str, output: str) -> None: + """Validate an agent card from a URL or file path. + + SOURCE can be either: + - A URL (e.g., http://localhost:8000) + - A file path (e.g., ./agent-card.json) + + The command will automatically detect whether the source is a URL or file. + """ + log.info("Validating agent card from %s", source) + + is_url = source.startswith(("http://", "https://")) + + async def do_validate() -> None: + if is_url: + log.debug("Detected URL source") + async with build_http_client() as client: + result = await validate_agent_card_from_url(source, client) + else: + log.debug("Detected file source") + result = validate_agent_card_from_file(source) + + _format_validation_result(result, output) + + if not result.valid: + raise click.Abort() + + asyncio.run(do_validate()) + + @cli.command() @click.argument("agent_url") @click.argument("message") @@ -112,55 +393,76 @@ def send( task_id: Optional[str], output: str, ) -> None: - """Send a message to an agent. - - Args: - agent_url: The URL of the agent - message: The message to send - context_id: Optional context ID for conversation continuity - task_id: Optional task ID to reference - output: Output format (json or text) - """ + """Send MESSAGE to an agent at AGENT_URL.""" + log.info("Sending message to %s", agent_url) + log.debug("Message: %s", message[:100] if len(message) > 100 else message) + + if context_id: + log.debug("Using context ID: %s", context_id) + if task_id: + log.debug("Using task ID: %s", task_id) async def send_msg() -> None: try: + log.debug("Building HTTP client") async with build_http_client() as client: - log.debug("Sending message to %s", agent_url) - if output == "text": console.print(f"[dim]Sending message to {agent_url}...[/dim]") + log.debug("Sending message via A2A client") response = await send_message_to_agent( agent_url, message, client, context_id, task_id ) + log.debug("Received response from agent") if output == "json": + log.debug("Outputting response as JSON") print_json(json.dumps(response, indent=2)) else: - if not response: - text = "Error: No result in response" - else: - texts = [] - if "parts" in response: - texts.extend(p.get("text", "") for p in response["parts"]) - - for artifact in response.get("artifacts", []): - texts.extend( - p.get("text", "") for p in artifact.get("parts", []) - ) - - text = "\n".join(t for t in texts if t) or "No text in response" + log.debug("Parsing response for text output") + parsed = parse_response(response) - print_markdown(text, title="Response") + if parsed.has_content: + log.debug("Response contains %d characters", len(parsed.text)) + print_markdown(parsed.text, title="Response") + else: + log.warning("Response contained no text content") + print_markdown("No text in response", title="Response") + except A2AClientTimeoutError: + log.error("Request to %s timed out", agent_url) + print_error("Request timed out") + raise click.Abort() + except A2AClientHTTPError as e: + log.error("A2A client error: %s", e) + if "connection" in str(e).lower(): + print_error(f"Connection failed: Is the server running at {agent_url}?") + else: + print_error(str(e)) + raise click.Abort() + except A2AClientError as e: + log.error("A2A client error: %s", e) + print_error(str(e)) + raise click.Abort() + except httpx.ConnectError: + log.error("Connection refused to %s", agent_url) + print_error(f"Connection refused: Is the server running at {agent_url}?") + raise click.Abort() except httpx.TimeoutException: + log.error("Request to %s timed out", agent_url) print_error("Request timed out") raise click.Abort() except httpx.HTTPStatusError as e: + log.error( + "HTTP error %d from %s: %s", + e.response.status_code, + agent_url, + e.response.text, + ) print_error(f"HTTP {e.response.status_code} - {e.response.text}") raise click.Abort() except Exception as e: - log.exception("Failed to send message") + log.exception("Failed to send message to %s", agent_url) print_error(str(e)) raise click.Abort() @@ -169,40 +471,26 @@ async def send_msg() -> None: @cli.command() def tui() -> None: - """Launch the Handler TUI interface.""" - import logging - + """Launch the TUI.""" log.info("Launching TUI") - try: - from .tui import HandlerTUI - except ImportError as e: - print_error( - f"Failed to import TUI dependencies: {e}\n\nMake sure handler-tui is installed." - ) - raise click.Abort() - logging.getLogger().handlers = [] - app = HandlerTUI() app.run() @cli.command() -@click.option("--host", default="0.0.0.0", help="Host to bind to") -@click.option("--port", default=8000, help="Port to bind to") -def server(host: str, port: int) -> None: - """Start the A2A server agent backed by Ollama. +def version() -> None: + """Display the current version.""" + log.debug("Displaying version: %s", __version__) + click.echo(__version__) - Requires Ollama to be running (default: http://localhost:11434) with the qwen3 model (configurable via OLLAMA_API_BASE and OLLAMA_MODEL). - """ - try: - from a2a_handler.server import run_server - except ImportError as e: - print_error( - f"Failed to import server dependencies: {e}\n\nMake sure handler-server is installed." - ) - raise click.Abort() +@cli.command() +@click.option("--host", default="0.0.0.0", help="Host to bind to", show_default=True) +@click.option("--port", default=8000, help="Port to bind to", show_default=True) +def server(host: str, port: int) -> None: + """Start the A2A server agent backed by Ollama.""" + log.info("Starting A2A server on %s:%d", host, port) run_server(host, port) diff --git a/src/a2a_handler/client.py b/src/a2a_handler/client.py index 9fb89bc..4fa9768 100644 --- a/src/a2a_handler/client.py +++ b/src/a2a_handler/client.py @@ -1,6 +1,7 @@ """A2A protocol client utilities.""" import uuid +from dataclasses import dataclass from typing import Any import httpx @@ -127,3 +128,46 @@ async def send_message_to_agent( return last_response[0].model_dump() return last_response.model_dump() if hasattr(last_response, "model_dump") else {} + + +@dataclass +class ParsedResponse: + """Parsed A2A response with extracted text content.""" + + text: str + raw: dict[str, Any] + + @property + def has_content(self) -> bool: + """Check if the response has meaningful content.""" + return bool(self.text) + + +def parse_response(response: dict[str, Any]) -> ParsedResponse: + """Parse an A2A response and extract text content. + + Args: + response: Raw response dictionary from send_message_to_agent + + Returns: + ParsedResponse with extracted text and raw data + """ + if not response: + log.debug("Empty response received") + return ParsedResponse(text="", raw=response) + + texts: list[str] = [] + + if "parts" in response: + texts.extend(p.get("text", "") for p in response["parts"]) + log.debug("Extracted %d parts from response", len(response["parts"])) + + for artifact in response.get("artifacts", []): + artifact_parts = artifact.get("parts", []) + texts.extend(p.get("text", "") for p in artifact_parts) + log.debug("Extracted %d parts from artifact", len(artifact_parts)) + + text = "\n".join(t for t in texts if t) + log.debug("Parsed response with %d characters", len(text)) + + return ParsedResponse(text=text, raw=response) diff --git a/src/a2a_handler/common/printing.py b/src/a2a_handler/common/printing.py index 152ad54..06a9c43 100644 --- a/src/a2a_handler/common/printing.py +++ b/src/a2a_handler/common/printing.py @@ -4,7 +4,7 @@ from rich.markdown import Markdown from rich.panel import Panel -from rich.syntax import Syntax +from rich.json import JSON from .logging import console @@ -53,16 +53,17 @@ def print_error(content: str, title: str | None = None) -> None: print_panel(content, title=title, border_style="red") -def print_json(data: str, title: str | None = None, theme: str = "monokai") -> None: - """Print JSON with syntax highlighting in a panel. +def print_json(data: str, title: str | None = None) -> None: + """Print JSON in a panel with structural highlighting. Args: data: JSON string to display title: Optional panel title - theme: Syntax highlighting theme """ - syntax = Syntax(data, "json", theme=theme) - console.print(Panel(syntax, title=title, border_style="dim", expand=False)) + json_renderable = JSON(data, highlight=False) + console.print( + Panel(json_renderable, title=title, border_style="green", expand=False) + ) def print_markdown( diff --git a/src/a2a_handler/validation.py b/src/a2a_handler/validation.py new file mode 100644 index 0000000..292c48f --- /dev/null +++ b/src/a2a_handler/validation.py @@ -0,0 +1,374 @@ +"""A2A protocol validation utilities.""" + +import json +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any + +import httpx +from a2a.types import AgentCard +from pydantic import ValidationError + +from a2a_handler.common import get_logger + +log = get_logger(__name__) + + +class ValidationSource(Enum): + """Source type for agent card validation.""" + + URL = "url" + FILE = "file" + + +@dataclass +class ValidationIssue: + """Represents a single validation issue.""" + + field: str + message: str + issue_type: str = "error" + + def __str__(self) -> str: + return f"[{self.issue_type}] {self.field}: {self.message}" + + +@dataclass +class ValidationResult: + """Result of validating an agent card.""" + + valid: bool + source: str + source_type: ValidationSource + agent_card: AgentCard | None = None + issues: list[ValidationIssue] = field(default_factory=list) + warnings: list[ValidationIssue] = field(default_factory=list) + raw_data: dict[str, Any] | None = None + + @property + def agent_name(self) -> str: + """Get the agent name if available.""" + if self.agent_card: + return self.agent_card.name + if self.raw_data: + return self.raw_data.get("name", "Unknown") + return "Unknown" + + @property + def protocol_version(self) -> str: + """Get the protocol version if available.""" + if self.agent_card: + return self.agent_card.protocol_version or "1.0" + if self.raw_data: + return self.raw_data.get("protocolVersion", "1.0") + return "Unknown" + + +def _parse_pydantic_error(error: ValidationError) -> list[ValidationIssue]: + """Parse Pydantic validation errors into ValidationIssues.""" + issues = [] + for err in error.errors(): + field_path = ".".join(str(loc) for loc in err["loc"]) + message = err["msg"] + issue_type = err["type"] + issues.append( + ValidationIssue( + field=field_path or "root", + message=message, + issue_type=issue_type, + ) + ) + return issues + + +def _check_best_practices(card: AgentCard) -> list[ValidationIssue]: + """Check for best practices and generate warnings. + + Note: In A2A v0.3.0, the following are REQUIRED fields and validated by Pydantic: + - name, description, url, version + - capabilities, defaultInputModes, defaultOutputModes, skills + - preferredTransport (defaults to JSONRPC in SDK) + + This function only warns about optional fields that improve agent discoverability. + """ + warnings = [] + + if not card.provider: + warnings.append( + ValidationIssue( + field="provider", + message="Agent card should specify a provider for better discoverability", + issue_type="warning", + ) + ) + + if not card.documentation_url: + warnings.append( + ValidationIssue( + field="documentationUrl", + message="Agent card should include documentation URL", + issue_type="warning", + ) + ) + + if not card.icon_url: + warnings.append( + ValidationIssue( + field="iconUrl", + message="Agent card should include an icon URL for UI display", + issue_type="warning", + ) + ) + + if card.skills: + for i, skill in enumerate(card.skills): + if not skill.description: + warnings.append( + ValidationIssue( + field=f"skills[{i}].description", + message=f"Skill '{skill.name}' should have a description", + issue_type="warning", + ) + ) + if not skill.examples or len(skill.examples) == 0: + warnings.append( + ValidationIssue( + field=f"skills[{i}].examples", + message=f"Skill '{skill.name}' should include example prompts", + issue_type="warning", + ) + ) + + if not card.additional_interfaces or len(card.additional_interfaces) == 0: + warnings.append( + ValidationIssue( + field="additionalInterfaces", + message="Consider declaring additional transport interfaces for flexibility", + issue_type="warning", + ) + ) + + return warnings + + +def validate_agent_card_data( + data: dict[str, Any], source: str, source_type: ValidationSource +) -> ValidationResult: + """Validate agent card data against the A2A protocol schema. + + Args: + data: Raw agent card data as a dictionary + source: The source (URL or file path) of the data + source_type: Whether the source is a URL or file + + Returns: + ValidationResult with validation status and any issues + """ + log.debug("Validating agent card data from %s", source) + + try: + card = AgentCard.model_validate(data) + log.info("Agent card validation successful for %s", card.name) + + warnings = _check_best_practices(card) + + return ValidationResult( + valid=True, + source=source, + source_type=source_type, + agent_card=card, + warnings=warnings, + raw_data=data, + ) + + except ValidationError as e: + log.warning("Agent card validation failed: %s", e) + issues = _parse_pydantic_error(e) + + return ValidationResult( + valid=False, + source=source, + source_type=source_type, + issues=issues, + raw_data=data, + ) + + +async def validate_agent_card_from_url( + url: str, + client: httpx.AsyncClient | None = None, + card_path: str | None = None, +) -> ValidationResult: + """Fetch and validate an agent card from a URL. + + Args: + url: The base URL of the agent + client: Optional HTTP client to use + card_path: Optional custom path to the agent card (default: /.well-known/agent.json) + + Returns: + ValidationResult with validation status and any issues + """ + log.info("Validating agent card from URL: %s", url) + + should_close = client is None + if client is None: + client = httpx.AsyncClient(timeout=30) + + try: + base_url = url.rstrip("/") + if card_path: + full_url = f"{base_url}/{card_path.lstrip('/')}" + else: + full_url = f"{base_url}/.well-known/agent-card.json" + + log.debug("Fetching agent card from %s", full_url) + response = await client.get(full_url) + response.raise_for_status() + + data = response.json() + return validate_agent_card_data(data, url, ValidationSource.URL) + + except httpx.HTTPStatusError as e: + log.error("HTTP error fetching agent card: %s", e) + return ValidationResult( + valid=False, + source=url, + source_type=ValidationSource.URL, + issues=[ + ValidationIssue( + field="http", + message=f"HTTP {e.response.status_code}: {e.response.text[:200]}", + issue_type="http_error", + ) + ], + ) + + except httpx.RequestError as e: + log.error("Request error fetching agent card: %s", e) + return ValidationResult( + valid=False, + source=url, + source_type=ValidationSource.URL, + issues=[ + ValidationIssue( + field="connection", + message=str(e), + issue_type="connection_error", + ) + ], + ) + + except json.JSONDecodeError as e: + log.error("JSON decode error: %s", e) + return ValidationResult( + valid=False, + source=url, + source_type=ValidationSource.URL, + issues=[ + ValidationIssue( + field="json", + message=f"Invalid JSON: {e}", + issue_type="json_error", + ) + ], + ) + + finally: + if should_close: + await client.aclose() + + +def validate_agent_card_from_file(file_path: str | Path) -> ValidationResult: + """Validate an agent card from a local file. + + Args: + file_path: Path to the agent card JSON file + + Returns: + ValidationResult with validation status and any issues + """ + path = Path(file_path) + log.info("Validating agent card from file: %s", path) + + if not path.exists(): + log.error("File not found: %s", path) + return ValidationResult( + valid=False, + source=str(path), + source_type=ValidationSource.FILE, + issues=[ + ValidationIssue( + field="file", + message=f"File not found: {path}", + issue_type="file_error", + ) + ], + ) + + if not path.is_file(): + log.error("Path is not a file: %s", path) + return ValidationResult( + valid=False, + source=str(path), + source_type=ValidationSource.FILE, + issues=[ + ValidationIssue( + field="file", + message=f"Path is not a file: {path}", + issue_type="file_error", + ) + ], + ) + + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + + return validate_agent_card_data(data, str(path), ValidationSource.FILE) + + except json.JSONDecodeError as e: + log.error("JSON decode error: %s", e) + return ValidationResult( + valid=False, + source=str(path), + source_type=ValidationSource.FILE, + issues=[ + ValidationIssue( + field="json", + message=f"Invalid JSON at line {e.lineno}, column {e.colno}: {e.msg}", + issue_type="json_error", + ) + ], + ) + + except PermissionError: + log.error("Permission denied reading file: %s", path) + return ValidationResult( + valid=False, + source=str(path), + source_type=ValidationSource.FILE, + issues=[ + ValidationIssue( + field="file", + message=f"Permission denied: {path}", + issue_type="file_error", + ) + ], + ) + + except OSError as e: + log.error("Error reading file: %s", e) + return ValidationResult( + valid=False, + source=str(path), + source_type=ValidationSource.FILE, + issues=[ + ValidationIssue( + field="file", + message=str(e), + issue_type="file_error", + ) + ], + ) diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..49736f9 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,176 @@ +"""Tests for A2A protocol validation.""" + +import json +import tempfile +from pathlib import Path + +from a2a_handler.validation import ( + ValidationSource, + validate_agent_card_data, + validate_agent_card_from_file, +) + + +def _minimal_valid_agent_card() -> dict: + """Return a minimal valid agent card per A2A v0.3.0 spec. + + Required fields: name, description, url, version, capabilities, + defaultInputModes, defaultOutputModes, skills (with id, name, tags). + """ + return { + "name": "Test Agent", + "description": "A test agent", + "url": "http://localhost:8000", + "version": "1.0.0", + "capabilities": {}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [ + { + "id": "test_skill", + "name": "Test Skill", + "description": "A test skill", + "tags": ["test"], + } + ], + } + + +class TestValidateAgentCardData: + """Tests for validate_agent_card_data function.""" + + def test_valid_minimal_card(self): + """Test validation of a minimal valid agent card.""" + data = _minimal_valid_agent_card() + result = validate_agent_card_data(data, "test", ValidationSource.FILE) + + assert result.valid is True + assert result.agent_card is not None + assert result.agent_card.name == "Test Agent" + assert len(result.issues) == 0 + + def test_missing_required_field(self): + """Test validation fails when required field is missing.""" + data = {"url": "http://localhost:8000"} + result = validate_agent_card_data(data, "test", ValidationSource.FILE) + + assert result.valid is False + assert len(result.issues) > 0 + field_names = [i.field for i in result.issues] + assert "name" in field_names + + def test_warnings_for_optional_fields(self): + """Test warnings are generated for missing optional fields.""" + data = _minimal_valid_agent_card() + result = validate_agent_card_data(data, "test", ValidationSource.FILE) + + assert result.valid is True + warning_fields = [w.field for w in result.warnings] + assert "provider" in warning_fields + assert "documentationUrl" in warning_fields + assert "iconUrl" in warning_fields + + def test_skill_without_tags_fails_validation(self): + """Test that skills without tags fail validation (tags are required in v0.3.0).""" + data = _minimal_valid_agent_card() + data["skills"] = [{"id": "test", "name": "Test", "description": "Test desc"}] + result = validate_agent_card_data(data, "test", ValidationSource.FILE) + + assert result.valid is False + issue_fields = [i.field for i in result.issues] + assert any("skills" in f and "tags" in f for f in issue_fields) + + def test_skill_without_examples_generates_warning(self): + """Test that skills without examples generate warnings.""" + data = _minimal_valid_agent_card() + result = validate_agent_card_data(data, "test", ValidationSource.FILE) + + assert result.valid is True + warning_fields = [w.field for w in result.warnings] + assert any("examples" in f for f in warning_fields) + + +class TestValidateAgentCardFromFile: + """Tests for validate_agent_card_from_file function.""" + + def test_valid_file(self): + """Test validation of a valid agent card file.""" + data = _minimal_valid_agent_card() + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + f.flush() + + result = validate_agent_card_from_file(f.name) + + assert result.valid is True + assert result.source_type == ValidationSource.FILE + assert result.agent_card is not None + + Path(f.name).unlink() + + def test_nonexistent_file(self): + """Test validation fails for nonexistent file.""" + result = validate_agent_card_from_file("/nonexistent/path/agent.json") + + assert result.valid is False + assert len(result.issues) == 1 + assert result.issues[0].issue_type == "file_error" + + def test_invalid_json_file(self): + """Test validation fails for invalid JSON file.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("not valid json {{{") + f.flush() + + result = validate_agent_card_from_file(f.name) + + assert result.valid is False + assert len(result.issues) == 1 + assert result.issues[0].issue_type == "json_error" + + Path(f.name).unlink() + + def test_directory_path(self): + """Test validation fails when path is a directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + result = validate_agent_card_from_file(tmpdir) + + assert result.valid is False + assert len(result.issues) == 1 + assert result.issues[0].issue_type == "file_error" + + +class TestValidationResult: + """Tests for ValidationResult properties.""" + + def test_agent_name_from_card(self): + """Test agent_name property returns name from agent card.""" + data = _minimal_valid_agent_card() + result = validate_agent_card_data(data, "test", ValidationSource.FILE) + + assert result.agent_name == "Test Agent" + + def test_agent_name_from_raw_data(self): + """Test agent_name property returns name from raw data when card is None.""" + data = {"name": "Raw Agent", "url": "invalid"} + result = validate_agent_card_data(data, "test", ValidationSource.FILE) + + assert result.valid is False + assert result.agent_name == "Raw Agent" + + def test_protocol_version_from_sdk(self): + """Test protocol_version returns the SDK default version.""" + data = _minimal_valid_agent_card() + result = validate_agent_card_data(data, "test", ValidationSource.FILE) + + assert result.protocol_version is not None + assert len(result.protocol_version) > 0 + + def test_protocol_version_explicit(self): + """Test protocol_version returns explicit version when set.""" + data = _minimal_valid_agent_card() + data["protocolVersion"] = "2.0" + result = validate_agent_card_data(data, "test", ValidationSource.FILE) + + assert result.protocol_version == "2.0" diff --git a/uv.lock b/uv.lock index 8ad0249..c93e750 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ [[package]] name = "a2a-handler" -version = "0.1.7" +version = "0.1.8" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -19,6 +19,7 @@ dependencies = [ { name = "litellm" }, { name = "python-dotenv" }, { name = "rich" }, + { name = "rich-click" }, { name = "textual" }, { name = "uvicorn" }, ] @@ -41,6 +42,7 @@ requires-dist = [ { name = "litellm", specifier = ">=1.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "rich", specifier = ">=13.0.0" }, + { name = "rich-click", specifier = ">=1.8.0" }, { name = "textual", specifier = ">=0.47.0" }, { name = "uvicorn", specifier = ">=0.30.0" }, ] @@ -2806,6 +2808,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] +[[package]] +name = "rich-click" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/d8/f2c1b7e9a645ba40f756d7a5b195fc104729bc6b19061ba3ab385f342931/rich_click-1.9.4.tar.gz", hash = "sha256:af73dc68e85f3bebb80ce302a642b9fe3b65f3df0ceb42eb9a27c467c1b678c8", size = 73632, upload-time = "2025-10-25T01:08:49.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/6a/1f03adcb3cc7beb6f63aecc21565e9d515ccee653187fc4619cd0b42713b/rich_click-1.9.4-py3-none-any.whl", hash = "sha256:d70f39938bcecaf5543e8750828cbea94ef51853f7d0e174cda1e10543767389", size = 70245, upload-time = "2025-10-25T01:08:47.939Z" }, +] + [[package]] name = "rpds-py" version = "0.29.0"