Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ just run --help # Show CLI help

Run `just` to see all available commands.

## Environment And Package Management

- This project is managed with `uv`; use `uv` and `uv run` for Python and dependency operations.
- Do not use system `python`, `python3`, `pip`, or `pip3` directly for project tasks.
- Prefer `just` commands first; when running tools manually, use `uv run <tool>`.
- For dependency updates, edit `pyproject.toml` and regenerate the lockfile with `uv lock`.

## Project Structure

```
Expand Down Expand Up @@ -85,6 +92,12 @@ just bump patch # Bump version (major|minor|patch)
just release # Tag and push release
```

## Commit Messages

- Use **Conventional Commits** for all commit messages.
- Preferred format: `type(scope): short imperative summary` (example: `fix(auth): apply credentials during card discovery`).
- Keep the subject concise and lowercase after the colon.

## Code Style

- **Python 3.11+** with full type hints
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"a2a-sdk>=0.3.22",
"a2a-sdk>=0.3.24",
"click>=8.0.0",
"rich-click>=1.8.0",
"google-adk>=0.5.0",
"google-adk>=1.26.0",
"httpx>=0.27.0",
"litellm>=1.0.0",
"python-dotenv>=1.0.0",
Expand Down
5 changes: 3 additions & 2 deletions src/a2a_handler/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,12 @@ def version() -> None:


@cli.command()
def tui() -> None:
@click.option("--bearer", "-b", "bearer_token", help="Bearer token for agent auth")
def tui(bearer_token: str | None) -> None:
"""Launch the interactive terminal interface."""
log.info("Launching TUI")
logging.getLogger().handlers = []
app = HandlerTUI()
app = HandlerTUI(initial_bearer_token=bearer_token)
app.run()


Expand Down
6 changes: 5 additions & 1 deletion src/a2a_handler/cli/card.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from a2a_handler.common import Output, get_logger
from a2a_handler.service import A2AService
from a2a_handler.session import get_credentials
from a2a_handler.validation import (
ValidationResult,
validate_agent_card_from_file,
Expand All @@ -34,12 +35,15 @@ def card() -> None:
def card_get(agent_url: str, authenticated: bool) -> None:
"""Retrieve an agent's card."""
log.info("Fetching agent card from %s", agent_url)
credentials = get_credentials(agent_url) if authenticated else None
if authenticated and credentials is None:
log.warning("No saved credentials found for %s", agent_url)

async def do_get() -> None:
output = Output()
try:
async with build_http_client() as http_client:
service = A2AService(http_client, agent_url)
service = A2AService(http_client, agent_url, credentials=credentials)
card_data = await service.get_card()
log.info("Retrieved card for agent: %s", card_data.name)

Expand Down
1 change: 1 addition & 0 deletions src/a2a_handler/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class APIKeyAuthMiddleware(BaseHTTPMiddleware):

OPEN_PATHS = {
"/.well-known/agent-card.json",
"/.well-known/agent.json",
"/health",
}

Expand Down
37 changes: 36 additions & 1 deletion src/a2a_handler/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import httpx
from a2a.client import A2ACardResolver, Client, ClientConfig, ClientFactory
from a2a.client.errors import A2AClientHTTPError
from a2a.types import (
AgentCard,
GetTaskPushNotificationConfigParams,
Expand All @@ -27,6 +28,11 @@
TransportProtocol,
)

from a2a.utils.constants import (
AGENT_CARD_WELL_KNOWN_PATH,
PREV_AGENT_CARD_WELL_KNOWN_PATH,
)

from a2a_handler.auth import AuthCredentials
from a2a_handler.common import get_logger

Expand Down Expand Up @@ -255,18 +261,47 @@ def set_credentials(self, credentials: AuthCredentials) -> None:
auth_headers = credentials.to_headers()
self.http_client.headers.update(auth_headers)
self._applied_auth_headers = set(auth_headers.keys())
# Rebuild the SDK client so updated headers are guaranteed to be used.
self._cached_client = None
logger.debug("Applied authentication headers: %s", list(auth_headers.keys()))

def clear_credentials(self) -> None:
"""Clear authentication credentials from the service and HTTP client."""
for header_name in self._applied_auth_headers:
self.http_client.headers.pop(header_name, None)
self._applied_auth_headers.clear()
self.credentials = None
# Rebuild the SDK client so cleared headers are guaranteed to be used.
self._cached_client = None
logger.debug("Cleared authentication headers")

async def get_card(self) -> AgentCard:
"""Fetch and cache the agent card.

Tries the standard well-known path first (``agent-card.json``), then
falls back to the previous path (``agent.json``) used by older ADK
versions.

Returns:
The agent's card with metadata and capabilities
"""
if self._cached_agent_card is None:
logger.info("Fetching agent card from %s", self.agent_url)
card_resolver = A2ACardResolver(self.http_client, self.agent_url)
self._cached_agent_card = await card_resolver.get_agent_card()
try:
self._cached_agent_card = await card_resolver.get_agent_card()
except (A2AClientHTTPError, httpx.HTTPStatusError):
logger.info(
"Agent card not found at %s, trying %s",
AGENT_CARD_WELL_KNOWN_PATH,
PREV_AGENT_CARD_WELL_KNOWN_PATH,
)
fallback_resolver = A2ACardResolver(
self.http_client,
self.agent_url,
agent_card_path=PREV_AGENT_CARD_WELL_KNOWN_PATH,
)
self._cached_agent_card = await fallback_resolver.get_agent_card()
logger.info("Connected to agent: %s", self._cached_agent_card.name)
return self._cached_agent_card

Expand Down
23 changes: 19 additions & 4 deletions src/a2a_handler/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from textual.screen import Screen
from textual.widgets import Button, Footer, Input

from a2a_handler.auth import AuthCredentials
from a2a_handler.common import get_theme, install_tui_log_handler, save_theme
from a2a_handler.service import A2AService
from a2a_handler.tui.components import (
Expand Down Expand Up @@ -72,14 +73,15 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | No
return False
return True

def __init__(self, **kwargs: Any) -> None:
def __init__(self, initial_bearer_token: str | None = None, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.current_agent_card: AgentCard | None = None
self.http_client: httpx.AsyncClient | None = None
self.current_context_id: str | None = None
self.current_agent_url: str | None = None
self._agent_service: A2AService | None = None
self._is_maximized: bool = False
self._initial_bearer_token = initial_bearer_token

def compose(self) -> ComposeResult:
with Container(id="root-container"):
Expand All @@ -102,6 +104,8 @@ async def on_mount(self) -> None:

messages_panel = self.query_one("#messages-container", TabbedMessagesPanel)
messages_panel.load_logs(tui_log_handler.get_lines())
if self._initial_bearer_token:
messages_panel.set_bearer_token(self._initial_bearer_token)

contact_panel = self.query_one("#contact-container", ContactPanel)
contact_panel.set_version(__version__)
Expand All @@ -125,12 +129,20 @@ def watch_theme(self, new_theme: str) -> None:
agent_card_panel = self.query_one("#agent-card-container", AgentCardPanel)
agent_card_panel.refresh_theme()

async def _connect_to_agent(self, agent_url: str) -> AgentCard:
async def _connect_to_agent(
self,
agent_url: str,
credentials: AuthCredentials | None = None,
) -> AgentCard:
if not self.http_client:
raise RuntimeError("HTTP client not initialized")

logger.info("Connecting to agent at %s", agent_url)
self._agent_service = A2AService(self.http_client, agent_url)
self._agent_service = A2AService(
self.http_client,
agent_url,
credentials=credentials,
)
return await self._agent_service.get_card()

def _update_ui_for_connected_state(self, agent_card: AgentCard) -> None:
Expand All @@ -155,7 +167,8 @@ async def handle_connect_button(self) -> None:
messages_panel.add_system_message(f"Connecting to {agent_url}...")

try:
agent_card = await self._connect_to_agent(agent_url)
credentials = messages_panel.get_auth_credentials()
agent_card = await self._connect_to_agent(agent_url, credentials)

self.current_agent_card = agent_card
self.current_agent_url = agent_url
Expand Down Expand Up @@ -214,6 +227,8 @@ async def _send_message(self) -> None:
credentials = messages_panel.get_auth_credentials()
if credentials:
self._agent_service.set_credentials(credentials)
else:
self._agent_service.clear_credentials()

send_result = await self._agent_service.send(
message_text,
Expand Down
10 changes: 10 additions & 0 deletions src/a2a_handler/tui/components/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,13 @@ def get_auth_type(self) -> AuthType | None:
elif pressed.id == "auth-bearer":
return AuthType.BEARER
return None

def set_bearer_token(self, token: str) -> None:
"""Preconfigure bearer token authentication."""
self.query_one("#bearer-token-input", Input).value = token
self.query_one("#auth-bearer", RadioButton).value = True

# Ensure fields are visible even if no RadioSet event is emitted.
self.query_one("#api-key-fields", Vertical).add_class("hidden")
self.query_one("#bearer-fields", Vertical).remove_class("hidden")
logger.debug("Preconfigured bearer token authentication")
5 changes: 5 additions & 0 deletions src/a2a_handler/tui/components/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,11 @@ def get_auth_credentials(self) -> "AuthCredentials | None":
auth_panel = self._get_auth_panel()
return auth_panel.get_credentials()

def set_bearer_token(self, token: str) -> None:
"""Preconfigure bearer token authentication in the auth panel."""
auth_panel = self._get_auth_panel()
auth_panel.set_bearer_token(token)

def add_task(self, task: "Task") -> None:
"""Add a task to the tasks panel."""
tasks_panel = self._get_tasks_panel()
Expand Down
20 changes: 19 additions & 1 deletion src/a2a_handler/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@

import httpx
from a2a.client import A2ACardResolver
from a2a.client.errors import A2AClientHTTPError
from a2a.types import AgentCard
from a2a.utils.constants import (
AGENT_CARD_WELL_KNOWN_PATH,
PREV_AGENT_CARD_WELL_KNOWN_PATH,
)
from pydantic import ValidationError

from a2a_handler.common import get_logger
Expand Down Expand Up @@ -101,7 +106,20 @@ async def validate_agent_card_from_url(

try:
resolver = A2ACardResolver(http_client, agent_url)
agent_card = await resolver.get_agent_card()
try:
agent_card = await resolver.get_agent_card()
except (A2AClientHTTPError, httpx.HTTPStatusError):
logger.info(
"Agent card not found at %s, trying %s",
AGENT_CARD_WELL_KNOWN_PATH,
PREV_AGENT_CARD_WELL_KNOWN_PATH,
)
fallback_resolver = A2ACardResolver(
http_client,
agent_url,
agent_card_path=PREV_AGENT_CARD_WELL_KNOWN_PATH,
)
agent_card = await fallback_resolver.get_agent_card()

logger.info("Agent card validation successful for %s", agent_card.name)
return ValidationResult(
Expand Down
31 changes: 31 additions & 0 deletions tests/test_cli_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from click.testing import CliRunner
from a2a.types import AgentCard, AgentSkill, AgentCapabilities

from a2a_handler.auth import create_bearer_auth
from a2a_handler.cli.card import card, _format_agent_card, _format_validation_result
from a2a_handler.common import Output
from a2a_handler.validation import ValidationResult, ValidationIssue, ValidationSource
Expand Down Expand Up @@ -88,6 +89,36 @@ def test_card_get_connection_error(self, runner):

assert result.exit_code == 1

def test_card_get_authenticated_uses_saved_credentials(self, runner):
"""Test authenticated card get passes stored credentials to service."""
mock_card = _make_agent_card()
credentials = create_bearer_auth("test-token")

with (
patch("a2a_handler.cli.card.build_http_client") as mock_client,
patch("a2a_handler.cli.card.get_credentials") as mock_get_credentials,
patch("a2a_handler.cli.card.A2AService") as mock_service_cls,
):
mock_http = AsyncMock()
mock_http.__aenter__.return_value = mock_http
mock_http.__aexit__.return_value = None
mock_client.return_value = mock_http
mock_get_credentials.return_value = credentials

mock_service = AsyncMock()
mock_service.get_card.return_value = mock_card
mock_service_cls.return_value = mock_service

result = runner.invoke(card, ["get", "http://localhost:8000", "-a"])

assert result.exit_code == 0
mock_get_credentials.assert_called_once_with("http://localhost:8000")
mock_service_cls.assert_called_once_with(
mock_http,
"http://localhost:8000",
credentials=credentials,
)


class TestCardValidate:
"""Tests for card validate command."""
Expand Down
24 changes: 24 additions & 0 deletions tests/test_cli_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Tests for top-level CLI commands."""

from unittest.mock import patch

import pytest
from click.testing import CliRunner

from a2a_handler.cli import cli


@pytest.fixture
def runner() -> CliRunner:
"""Create a CLI runner."""
return CliRunner()


def test_tui_passes_bearer_token_to_app(runner: CliRunner) -> None:
"""TUI command passes bearer token through to app initialization."""
with patch("a2a_handler.cli.HandlerTUI") as mock_tui_cls:
result = runner.invoke(cli, ["tui", "--bearer", "token-123"])

assert result.exit_code == 0
mock_tui_cls.assert_called_once_with(initial_bearer_token="token-123")
mock_tui_cls.return_value.run.assert_called_once()
Loading