From 3b7045ff1ca46d509b26480db313a5ec159f94b9 Mon Sep 17 00:00:00 2001 From: Al Duncanson Date: Thu, 4 Dec 2025 21:54:50 -0500 Subject: [PATCH 01/13] feat(cli): add --verbose option and defer imports --- src/a2a_handler/cli.py | 42 ++++++++++++++++-------------------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/src/a2a_handler/cli.py b/src/a2a_handler/cli.py index df0289b..997c9b0 100644 --- a/src/a2a_handler/cli.py +++ b/src/a2a_handler/cli.py @@ -1,15 +1,11 @@ import asyncio import json +import logging from typing import Optional import click import httpx -from a2a_handler.client import ( - build_http_client, - fetch_agent_card, - send_message_to_agent, -) from a2a_handler.common import ( console, get_logger, @@ -20,16 +16,29 @@ setup_logging, ) +setup_logging(level="WARNING") + +from a2a_handler.client import ( # noqa: E402 + build_http_client, + fetch_agent_card, + send_message_to_agent, +) +from a2a_handler.server import run_server # noqa: E402 +from a2a_handler.tui import HandlerTUI # noqa: E402 -setup_logging(level="INFO") log = get_logger(__name__) @click.group() +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging output") @click.pass_context -def cli(ctx) -> None: +def cli(ctx, verbose: bool) -> None: """Handler - A2A protocol client CLI""" ctx.ensure_object(dict) + if verbose: + setup_logging(level="INFO") + else: + setup_logging(level="WARNING") @cli.command() @@ -170,19 +179,8 @@ async def send_msg() -> None: @cli.command() def tui() -> None: """Launch the Handler TUI interface.""" - import logging - 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() @@ -195,14 +193,6 @@ def server(host: str, port: int) -> None: 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() - run_server(host, port) From b526293df8a36bc08ac30c8362edc7106c0d2aab Mon Sep 17 00:00:00 2001 From: Al Duncanson Date: Thu, 4 Dec 2025 22:22:24 -0500 Subject: [PATCH 02/13] feat(cli): add version command --- src/a2a_handler/cli.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/a2a_handler/cli.py b/src/a2a_handler/cli.py index 997c9b0..2bb1add 100644 --- a/src/a2a_handler/cli.py +++ b/src/a2a_handler/cli.py @@ -6,6 +6,7 @@ import click import httpx +from a2a_handler import __version__ from a2a_handler.common import ( console, get_logger, @@ -185,6 +186,12 @@ def tui() -> None: app.run() +@cli.command() +def version() -> None: + """Display Handler's version.""" + click.echo(__version__) + + @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") From 32b5063d5cfd264fba94599e9f17ef2c0875c65b Mon Sep 17 00:00:00 2001 From: Al Duncanson Date: Fri, 5 Dec 2025 00:02:02 -0500 Subject: [PATCH 03/13] feat(cli): add custom -h/--help option --- src/a2a_handler/cli.py | 74 +++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/src/a2a_handler/cli.py b/src/a2a_handler/cli.py index 2bb1add..07df35d 100644 --- a/src/a2a_handler/cli.py +++ b/src/a2a_handler/cli.py @@ -30,11 +30,37 @@ log = get_logger(__name__) -@click.group() +class CustomHelpOption(click.Option): + """Custom help option with a better help message.""" + + def __init__(self, *args, **kwargs): + kwargs.setdefault("is_flag", True) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("is_eager", True) + kwargs.setdefault("help", "Show this help message.") + super().__init__(*args, **kwargs) + + def handle_parse_result(self, ctx, opts, args): + if self.name in opts: + click.echo(ctx.get_help()) + ctx.exit() + return super().handle_parse_result(ctx, opts, args) + + +def add_help_option(f): + """Decorator to add a custom help option to a command.""" + return click.option("-h", "--help", cls=CustomHelpOption)(f) + + +CONTEXT_SETTINGS = {"help_option_names": []} + + +@click.group(context_settings=CONTEXT_SETTINGS) @click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging output") +@add_help_option @click.pass_context def cli(ctx, verbose: bool) -> None: - """Handler - A2A protocol client CLI""" + """Handler - A2A protocol client CLI.""" ctx.ensure_object(dict) if verbose: setup_logging(level="INFO") @@ -42,7 +68,7 @@ def cli(ctx, verbose: bool) -> None: setup_logging(level="WARNING") -@cli.command() +@cli.command(context_settings=CONTEXT_SETTINGS) @click.argument("agent_url") @click.option( "--output", @@ -51,13 +77,9 @@ def cli(ctx, verbose: bool) -> None: default="text", help="Output format", ) +@add_help_option 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.""" async def fetch() -> None: try: @@ -103,7 +125,7 @@ async def fetch() -> None: asyncio.run(fetch()) -@cli.command() +@cli.command(context_settings=CONTEXT_SETTINGS) @click.argument("agent_url") @click.argument("message") @click.option("--context-id", help="Context ID for conversation continuity") @@ -115,6 +137,7 @@ async def fetch() -> None: default="text", help="Output format", ) +@add_help_option def send( agent_url: str, message: str, @@ -122,15 +145,7 @@ 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.""" async def send_msg() -> None: try: @@ -177,28 +192,33 @@ async def send_msg() -> None: asyncio.run(send_msg()) -@cli.command() +@cli.command(context_settings=CONTEXT_SETTINGS) +@add_help_option def tui() -> None: - """Launch the Handler TUI interface.""" + """Launch the interactive TUI interface.""" log.info("Launching TUI") logging.getLogger().handlers = [] app = HandlerTUI() app.run() -@cli.command() +@cli.command(context_settings=CONTEXT_SETTINGS) +@add_help_option def version() -> None: - """Display Handler's version.""" + """Display the current version.""" click.echo(__version__) -@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") +@cli.command(context_settings=CONTEXT_SETTINGS) +@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) +@add_help_option def server(host: str, port: int) -> None: """Start the A2A server agent backed by Ollama. - Requires Ollama to be running (default: http://localhost:11434) with the qwen3 model (configurable via OLLAMA_API_BASE and OLLAMA_MODEL). + Requires Ollama to be running with the qwen3 model. Configure with + OLLAMA_API_BASE (default: http://localhost:11434) and OLLAMA_MODEL + environment variables. """ run_server(host, port) From 15f7ea6b9894699a0e87f6308555af460d50b61f Mon Sep 17 00:00:00 2001 From: Al Duncanson Date: Fri, 5 Dec 2025 12:57:05 -0500 Subject: [PATCH 04/13] feat(cli): add debug flag, logging, and response parser --- src/a2a_handler/cli.py | 73 ++++++++++++++++++++++++++++----------- src/a2a_handler/client.py | 44 +++++++++++++++++++++++ 2 files changed, 97 insertions(+), 20 deletions(-) diff --git a/src/a2a_handler/cli.py b/src/a2a_handler/cli.py index 07df35d..1079e0e 100644 --- a/src/a2a_handler/cli.py +++ b/src/a2a_handler/cli.py @@ -22,6 +22,7 @@ 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 @@ -57,12 +58,17 @@ def add_help_option(f): @click.group(context_settings=CONTEXT_SETTINGS) @click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging output") +@click.option("--debug", "-d", is_flag=True, help="Enable debug logging output") @add_help_option @click.pass_context -def cli(ctx, verbose: bool) -> None: +def cli(ctx, verbose: bool, debug: bool) -> None: """Handler - A2A protocol client CLI.""" ctx.ensure_object(dict) - if verbose: + 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") @@ -80,19 +86,26 @@ def cli(ctx, verbose: bool) -> None: @add_help_option def card(agent_url: str, output: str) -> None: """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: + log.debug("Outputting card as formatted text") 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: + log.debug("Card has %d skills", len(card_data.skills)) content += "\n\n[bold]Skills:[/bold]" for skill in card_data.skills: content += ( @@ -100,6 +113,7 @@ async def fetch() -> None: ) if card_data.capabilities: + log.debug("Card has capabilities defined") content += "\n\n[bold]Capabilities:[/bold]" if hasattr(card_data.capabilities, "pushNotifications"): status = ( @@ -112,13 +126,20 @@ async def fetch() -> None: print_panel(content, title=title) 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() @@ -146,46 +167,56 @@ def send( output: str, ) -> None: """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"]) + log.debug("Parsing response for text output") + parsed = parse_response(response) - 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" - - 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 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() @@ -206,6 +237,7 @@ def tui() -> None: @add_help_option def version() -> None: """Display the current version.""" + log.debug("Displaying version: %s", __version__) click.echo(__version__) @@ -220,6 +252,7 @@ def server(host: str, port: int) -> None: OLLAMA_API_BASE (default: http://localhost:11434) and OLLAMA_MODEL environment variables. """ + 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) From af9919f5d49d84d258e0cfbff6e9dde33ec721f5 Mon Sep 17 00:00:00 2001 From: Al Duncanson Date: Fri, 5 Dec 2025 14:59:23 -0500 Subject: [PATCH 05/13] chore: handle A2A client errors and connection failures --- src/a2a_handler/cli.py | 44 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/a2a_handler/cli.py b/src/a2a_handler/cli.py index 1079e0e..994e42b 100644 --- a/src/a2a_handler/cli.py +++ b/src/a2a_handler/cli.py @@ -19,6 +19,12 @@ 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, @@ -125,6 +131,25 @@ async def fetch() -> None: print_panel(content, 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") @@ -202,6 +227,25 @@ async def send_msg() -> None: 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") From d2380a558344c33a0e6e94b4480ab62005cbeacd Mon Sep 17 00:00:00 2001 From: Al Duncanson Date: Fri, 5 Dec 2025 15:01:26 -0500 Subject: [PATCH 06/13] fix(cli): package version in card title --- src/a2a_handler/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/a2a_handler/cli.py b/src/a2a_handler/cli.py index 994e42b..646ebcf 100644 --- a/src/a2a_handler/cli.py +++ b/src/a2a_handler/cli.py @@ -107,7 +107,7 @@ async def fetch() -> None: print_json(card_data.model_dump_json(indent=2)) else: log.debug("Outputting card as formatted text") - title = f"[bold green]{card_data.name}[/bold green] [dim]({card_data.version})[/dim]" + title = f"[bold green]{card_data.name}[/bold green] [dim]v{__version__}[/dim]" content = f"[italic]{card_data.description}[/italic]\n\n[bold]URL:[/bold] {card_data.url}" if card_data.skills: From 11d61df4bec99062b9d240e37f4cf75f79773569 Mon Sep 17 00:00:00 2001 From: Al Duncanson Date: Fri, 5 Dec 2025 16:43:05 -0500 Subject: [PATCH 07/13] feat(cli): integrate rich-click to enhance command help --- pyproject.toml | 1 + src/a2a_handler/cli.py | 106 +++++++++++++++++++++++------------------ uv.lock | 16 +++++++ 3 files changed, 77 insertions(+), 46 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d001919..982cc42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 646ebcf..73801fc 100644 --- a/src/a2a_handler/cli.py +++ b/src/a2a_handler/cli.py @@ -3,8 +3,8 @@ import logging from typing import Optional -import click import httpx +import rich_click as click from a2a_handler import __version__ from a2a_handler.common import ( @@ -17,6 +17,56 @@ 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"], + }, + { + "name": "Interface Commands", + "commands": ["tui", "server"], + }, + { + "name": "Utility Commands", + "commands": ["version"], + }, + ], +} + setup_logging(level="WARNING") from a2a.client.errors import ( # noqa: E402 @@ -37,38 +87,12 @@ log = get_logger(__name__) -class CustomHelpOption(click.Option): - """Custom help option with a better help message.""" - - def __init__(self, *args, **kwargs): - kwargs.setdefault("is_flag", True) - kwargs.setdefault("expose_value", False) - kwargs.setdefault("is_eager", True) - kwargs.setdefault("help", "Show this help message.") - super().__init__(*args, **kwargs) - - def handle_parse_result(self, ctx, opts, args): - if self.name in opts: - click.echo(ctx.get_help()) - ctx.exit() - return super().handle_parse_result(ctx, opts, args) - - -def add_help_option(f): - """Decorator to add a custom help option to a command.""" - return click.option("-h", "--help", cls=CustomHelpOption)(f) - - -CONTEXT_SETTINGS = {"help_option_names": []} - - -@click.group(context_settings=CONTEXT_SETTINGS) +@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") -@add_help_option @click.pass_context def cli(ctx, verbose: bool, debug: bool) -> None: - """Handler - A2A protocol client CLI.""" + """Handler A2A protocol client CLI.""" ctx.ensure_object(dict) if debug: log.debug("Debug logging enabled") @@ -80,7 +104,7 @@ def cli(ctx, verbose: bool, debug: bool) -> None: setup_logging(level="WARNING") -@cli.command(context_settings=CONTEXT_SETTINGS) +@cli.command() @click.argument("agent_url") @click.option( "--output", @@ -89,7 +113,6 @@ def cli(ctx, verbose: bool, debug: bool) -> None: default="text", help="Output format", ) -@add_help_option def card(agent_url: str, output: str) -> None: """Fetch and display an agent card from AGENT_URL.""" log.info("Fetching agent card from %s", agent_url) @@ -171,7 +194,7 @@ async def fetch() -> None: asyncio.run(fetch()) -@cli.command(context_settings=CONTEXT_SETTINGS) +@cli.command() @click.argument("agent_url") @click.argument("message") @click.option("--context-id", help="Context ID for conversation continuity") @@ -183,7 +206,6 @@ async def fetch() -> None: default="text", help="Output format", ) -@add_help_option def send( agent_url: str, message: str, @@ -267,35 +289,27 @@ async def send_msg() -> None: asyncio.run(send_msg()) -@cli.command(context_settings=CONTEXT_SETTINGS) -@add_help_option +@cli.command() def tui() -> None: - """Launch the interactive TUI interface.""" + """Launch the TUI.""" log.info("Launching TUI") logging.getLogger().handlers = [] app = HandlerTUI() app.run() -@cli.command(context_settings=CONTEXT_SETTINGS) -@add_help_option +@cli.command() def version() -> None: """Display the current version.""" log.debug("Displaying version: %s", __version__) click.echo(__version__) -@cli.command(context_settings=CONTEXT_SETTINGS) +@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) -@add_help_option def server(host: str, port: int) -> None: - """Start the A2A server agent backed by Ollama. - - Requires Ollama to be running with the qwen3 model. Configure with - OLLAMA_API_BASE (default: http://localhost:11434) and OLLAMA_MODEL - environment variables. - """ + """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/uv.lock b/uv.lock index 8ad0249..a42bd1d 100644 --- a/uv.lock +++ b/uv.lock @@ -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" From 84cb7078e3f3b1f2b740d7c7026f23fe3f6c9061 Mon Sep 17 00:00:00 2001 From: Al Duncanson Date: Fri, 5 Dec 2025 16:51:53 -0500 Subject: [PATCH 08/13] docs: for new rich cli and flags --- AGENTS.md | 2 +- CONTRIBUTING.md | 2 +- README.md | 20 ++++++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cf6cbfa..46d2fde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,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..ebc6126 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ 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` | | `common/` | Shared utilities (logging, printing with `rich`) | | `server.py` | Reference A2A agent using `google-adk` + `litellm` | diff --git a/README.md b/README.md index f6cfb2e..baa0ebe 100644 --- a/README.md +++ b/README.md @@ -54,16 +54,36 @@ 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 ``` 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 From 5f87dedaae8d4b7b84ab139917ddba36117efed1 Mon Sep 17 00:00:00 2001 From: Al Duncanson Date: Fri, 5 Dec 2025 17:19:17 -0500 Subject: [PATCH 09/13] feat(cli): refactor card output --- src/a2a_handler/cli.py | 128 +++++++++++++++++++++++++++++++++-------- 1 file changed, 104 insertions(+), 24 deletions(-) diff --git a/src/a2a_handler/cli.py b/src/a2a_handler/cli.py index 73801fc..d6c84df 100644 --- a/src/a2a_handler/cli.py +++ b/src/a2a_handler/cli.py @@ -1,7 +1,7 @@ import asyncio import json import logging -from typing import Optional +from typing import Any, Optional import httpx import rich_click as click @@ -104,6 +104,82 @@ def cli(ctx, verbose: bool, debug: bool) -> None: 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() @click.argument("agent_url") @click.option( @@ -130,29 +206,33 @@ async def fetch() -> None: print_json(card_data.model_dump_json(indent=2)) else: log.debug("Outputting card as formatted text") - title = f"[bold green]{card_data.name}[/bold green] [dim]v{__version__}[/dim]" - content = f"[italic]{card_data.description}[/italic]\n\n[bold]URL:[/bold] {card_data.url}" - - if card_data.skills: - log.debug("Card has %d skills", len(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: - log.debug("Card has capabilities defined") - 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) + 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) From a59f000e7384404048432a9fff08691b31e46cda Mon Sep 17 00:00:00 2001 From: Al Duncanson Date: Fri, 5 Dec 2025 19:42:03 -0500 Subject: [PATCH 10/13] feat(cli): add agent card validation utilities and tests --- src/a2a_handler/cli.py | 102 +++++++++- src/a2a_handler/validation.py | 374 ++++++++++++++++++++++++++++++++++ tests/test_validation.py | 176 ++++++++++++++++ 3 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 src/a2a_handler/validation.py create mode 100644 tests/test_validation.py diff --git a/src/a2a_handler/cli.py b/src/a2a_handler/cli.py index d6c84df..1b3facc 100644 --- a/src/a2a_handler/cli.py +++ b/src/a2a_handler/cli.py @@ -54,7 +54,7 @@ "handler": [ { "name": "Agent Commands", - "commands": ["card", "send"], + "commands": ["card", "send", "validate"], }, { "name": "Interface Commands", @@ -83,6 +83,11 @@ ) 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, +) log = get_logger(__name__) @@ -274,6 +279,101 @@ async def fetch() -> None: 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") 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" From f294b4856200bb2e97f203680e227df82dbbe775 Mon Sep 17 00:00:00 2001 From: Al Duncanson Date: Fri, 5 Dec 2025 19:51:19 -0500 Subject: [PATCH 11/13] refactor: match json rendering to text output --- src/a2a_handler/common/printing.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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( From eb3bc7590e53fe10d52f0c321633cc7fd22c157d Mon Sep 17 00:00:00 2001 From: Al Duncanson Date: Fri, 5 Dec 2025 20:14:57 -0500 Subject: [PATCH 12/13] chore: bump version to 0.1.8 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 982cc42..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" diff --git a/uv.lock b/uv.lock index a42bd1d..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" }, From 6a3a5abee9ddaca9550a0574782bf8a5d424d1bb Mon Sep 17 00:00:00 2001 From: Al Duncanson Date: Fri, 5 Dec 2025 22:04:46 -0500 Subject: [PATCH 13/13] docs: for new agent card validation command --- AGENTS.md | 4 +++- CONTRIBUTING.md | 2 ++ README.md | 8 ++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 46d2fde..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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ebc6126..d9c1d34 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,7 @@ Handler is a single Python package (`a2a-handler`) with all modules under `src/a |--------|-------------| | `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 baa0ebe..245884d 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,14 @@ 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