Implementing CurrencyAgent with A2A Python SDK

The official a2a-python SDK from Google has been frequently updated, and our tutorial needs to keep up. In this article, we'll implement a simple CurrencyAgent using version 0.2.3
of the a2a-python SDK.
Table of Contents
- Source Code
- Prerequisites
- Detailed Process
Source Code
The project source code is available at a2a-python-currency. Stars are welcome!
Prerequisites
- uv 0.7.2, for project management
- Python 3.13+, required by a2a-python
- openai/openrouter apiKey and baseURL. I'm using OpenRouter, which offers more model choices.
Detailed Process
Create Project
uv init a2a-python-currency
cd a2a-python-currency
Create Virtual Environment
uv venv
source .venv/bin/activate
Add Dependencies
uv add a2a-sdk uvicorn dotenv click
Configure Environment Variables
echo OPENROUTER_API_KEY=your_api_key >> .env
echo OPENROUTER_BASE_URL=your_base_url >> .env
# example
OPENROUTER_API_KEY=your_OpenRouter_API_key
OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"
Create Agent
Here's the complete code:
import logging
import json
from typing import Any, Dict, List, Optional
import httpx
from os import getenv
from dotenv import load_dotenv
from collections.abc import AsyncIterable
load_dotenv()
logger = logging.getLogger(__name__)
class CurrencyAgent:
"""Currency Conversion Agent using OpenAI API."""
SYSTEM_PROMPT = """You are a specialized assistant for currency conversions.
Your sole purpose is to use the 'get_exchange_rate' tool to answer questions about currency exchange rates.
If the user asks about anything other than currency conversion or exchange rates,
politely state that you cannot help with that topic and can only assist with currency-related queries.
Do not attempt to answer unrelated questions or use tools for other purposes.
You have access to the following tool:
- get_exchange_rate: Get current exchange rate between two currencies
When using the tool, respond in the following JSON format:
{
"status": "completed" | "input_required" | "error",
"message": "your response message"
}
If you need to use the tool, respond with:
{
"status": "tool_use",
"tool": "get_exchange_rate",
"parameters": {
"currency_from": "USD",
"currency_to": "EUR",
"currency_date": "latest"
}
}
Note: Return the response in the JSON format, only json is allowed.
"""
def __init__(self):
self.api_key = getenv("OPENROUTER_API_KEY")
self.api_base = getenv("OPENROUTER_BASE_URL")
self.model = "anthropic/claude-3.7-sonnet"
self.conversation_history: List[Dict[str, str]] = []
async def get_exchange_rate(
self,
currency_from: str = 'USD',
currency_to: str = 'EUR',
currency_date: str = 'latest',
) -> Dict[str, Any]:
"""Get current exchange rate between currencies."""
try:
response = httpx.get(
f'https://api.frankfurter.app/{currency_date}',
params={'from': currency_from, 'to': currency_to},
)
response.raise_for_status()
data = response.json()
if 'rates' not in data:
logger.error(f'rates not found in response: {data}')
return {'error': 'Invalid API response format.'}
logger.info(f'API response: {data}')
return data
except httpx.HTTPError as e:
logger.error(f'API request failed: {e}')
return {'error': f'API request failed: {e}'}
except ValueError:
logger.error('Invalid JSON response from API')
return {'error': 'Invalid JSON response from API.'}
async def _call_openai(self, messages: List[Dict[str, str]]) -> Dict[str, Any]:
"""Call OpenAI API through OpenRouter."""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.api_base}/chat/completions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json={
"model": self.model,
"messages": messages,
"temperature": 0.7,
"stream": False,
},
)
response.raise_for_status()
return response.json()
async def stream(self, query: str, session_id: str) -> AsyncIterable[Dict[str, Any]]:
"""Stream the response for a given query."""
# Add user message to conversation history
self.conversation_history.append({"role": "user", "content": query})
# Prepare messages for API call
messages = [{"role": "system", "content": self.SYSTEM_PROMPT}] + self.conversation_history
# Get response from OpenAI
response = await self._call_openai(messages)
assistant_message = response["choices"][0]["message"]["content"]
print(assistant_message)
try:
# Try to parse the response as JSON
parsed_response = json.loads(assistant_message)
# If it's a tool use request
if parsed_response.get("status") == "tool_use":
tool_name = parsed_response["tool"]
parameters = parsed_response["parameters"]
# Yield tool usage status
yield {
"is_task_complete": False,
"require_user_input": False,
"content": "Looking up the exchange rates..."
}
if tool_name == "get_exchange_rate":
# Yield processing status
yield {
"is_task_complete": False,
"require_user_input": False,
"content": "Processing the exchange rates..."
}
tool_result = await self.get_exchange_rate(**parameters)
# Add tool result to conversation history
self.conversation_history.append({
"role": "assistant",
"content": json.dumps({"tool_result": tool_result})
})
# Get final response after tool use
final_response = await self._call_openai(messages)
final_message = final_response["choices"][0]["message"]["content"]
parsed_response = json.loads(final_message)
# Add assistant response to conversation history
self.conversation_history.append({"role": "assistant", "content": assistant_message})
# Yield final response
if parsed_response["status"] == "completed":
yield {
"is_task_complete": True,
"require_user_input": False,
"content": parsed_response["message"]
}
elif parsed_response["status"] in ["input_required", "error"]:
yield {
"is_task_complete": False,
"require_user_input": True,
"content": parsed_response["message"]
}
else:
yield {
"is_task_complete": False,
"require_user_input": True,
"content": "We are unable to process your request at the moment. Please try again."
}
except json.JSONDecodeError:
# If response is not valid JSON, return error
yield {
"is_task_complete": False,
"require_user_input": True,
"content": "Invalid response format from the model."
}
Let's analyze its main functionality and implementation logic:
1. Core Functionality
- Specialized in handling currency conversion and exchange rate queries
- Uses Frankfurter API to get real-time exchange rate data
- Processes conversations through OpenRouter using Claude 3.7 Sonnet model
2. System Architecture
The Agent consists of several main components:
2.1 System Prompt
- Defines the Agent's specialized purpose: handling only currency conversion queries
- Specifies response format: must use JSON format
- Defines tool usage: uses
get_exchange_rate
tool to get exchange rate information
2.2 Main Methods
-
Initialization Method
__init__
- Configures API key and base URL
- Initializes conversation history
-
Exchange Rate Query Method
get_exchange_rate
- Parameters: source currency, target currency, date (defaults to latest)
- Calls Frankfurter API to get exchange rate data
- Returns exchange rate information in JSON format
-
Streaming Method
stream
- Provides streaming response functionality
- Returns processing status and results in real-time
- Supports intermediate state feedback for tool calls
3. Workflow
-
Receive User Query
- Add user message to conversation history
-
Model Processing
- Send system prompt and conversation history to model
- Model analyzes if tool usage is needed
-
Tool Call (if needed)
- If model decides to use tool, returns tool call request
- Execute exchange rate query
- Add query results to conversation history
-
Generate Final Response
- Generate final answer based on tool call results
- Return formatted JSON response
4. Response Format
Agent responses always use JSON format with the following states:
completed
: Task completedinput_required
: User input needederror
: Error occurredtool_use
: Tool usage needed
5. Error Handling
- Includes comprehensive error handling mechanism
- Handles API call failures
- Handles JSON parsing errors
- Handles invalid response formats
Test Agent
Here's the test code:
import asyncio
import logging
from currency_agent import CurrencyAgent
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def main():
agent = CurrencyAgent()
# Test cases
test_queries = [
"What is the current exchange rate from USD to EUR?",
"Can you tell me the exchange rate between GBP and JPY?",
"What's the weather like today?", # This should be rejected as it's not currency-related
]
for query in test_queries:
logger.info(f"\nTesting query: {query}")
async for response in agent.stream(query, "test_session"):
logger.info(f"Response: {response}")
if __name__ == "__main__":
asyncio.run(main())
If everything is set up correctly, especially the environment configuration, you should see output similar to this:
uv run python test_currency_agent.py
INFO:__main__:
Testing query: What is the current exchange rate from USD to EUR?
INFO:httpx:HTTP Request: POST https://openrouter.ai/api/v1/chat/completions "HTTP/1.1 200 OK"
INFO:__main__:Response: {'is_task_complete': False, 'require_user_input': False, 'content': 'Looking up the exchange rates...'}
INFO:__main__:Response: {'is_task_complete': False, 'require_user_input': False, 'content': 'Processing the exchange rates...'}
INFO:httpx:HTTP Request: GET https://api.frankfurter.app/latest?from=USD&to=EUR "HTTP/1.1 200 OK"
INFO:currency_agent:API response: {'amount': 1.0, 'base': 'USD', 'date': '2025-05-20', 'rates': {'EUR': 0.8896}}
INFO:httpx:HTTP Request: POST https://openrouter.ai/api/v1/chat/completions "HTTP/1.1 200 OK"
INFO:currency_agent:Final message: {'role': 'assistant', 'content': '{\n "status": "completed",\n "message": "The current exchange rate from USD to EUR is 0.8896. This means that 1 US Dollar equals 0.8896 Euros as of May 20, 2025."\n}', 'refusal': None, 'reasoning': None}
INFO:__main__:Response: {'is_task_complete': True, 'require_user_input': False, 'content': 'The current exchange rate from USD to EUR is 0.8896. This means that 1 US Dollar equals 0.8896 Euros as of May 20, 2025.'}
Implement AgentExecutor
from currency_agent import CurrencyAgent # type: ignore[import-untyped]
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events.event_queue import EventQueue
from a2a.types import (
TaskArtifactUpdateEvent,
TaskState,
TaskStatus,
TaskStatusUpdateEvent,
)
from a2a.utils import new_agent_text_message, new_task, new_text_artifact
class CurrencyAgentExecutor(AgentExecutor):
"""Currency AgentExecutor Example."""
def __init__(self):
self.agent = CurrencyAgent()
async def execute(
self,
context: RequestContext,
event_queue: EventQueue,
) -> None:
query = context.get_user_input()
task = context.current_task
if not context.message:
raise Exception('No message provided')
if not task:
task = new_task(context.message)
event_queue.enqueue_event(task)
# invoke the underlying agent, using streaming results
async for event in self.agent.stream(query, task.contextId):
if event['is_task_complete']:
event_queue.enqueue_event(
TaskArtifactUpdateEvent(
append=False,
contextId=task.contextId,
taskId=task.id,
lastChunk=True,
artifact=new_text_artifact(
name='current_result',
description='Result of request to agent.',
text=event['content'],
),
)
)
event_queue.enqueue_event(
TaskStatusUpdateEvent(
status=TaskStatus(state=TaskState.completed),
final=True,
contextId=task.contextId,
taskId=task.id,
)
)
elif event['require_user_input']:
event_queue.enqueue_event(
TaskStatusUpdateEvent(
status=TaskStatus(
state=TaskState.input_required,
message=new_agent_text_message(
event['content'],
task.contextId,
task.id,
),
),
final=True,
contextId=task.contextId,
taskId=task.id,
)
)
else:
event_queue.enqueue_event(
TaskStatusUpdateEvent(
status=TaskStatus(
state=TaskState.working,
message=new_agent_text_message(
event['content'],
task.contextId,
task.id,
),
),
final=False,
contextId=task.contextId,
taskId=task.id,
)
)
async def cancel(
self, context: RequestContext, event_queue: EventQueue
) -> None:
raise Exception('cancel not supported')
Let me analyze the logic of this code:
This is an AgentExecutor class named CurrencyAgentExecutor
that primarily handles currency-related agent operations. Let me analyze its structure and functionality in detail:
The core logic for handling requests and generating responses/events in A2A agents is implemented by AgentExecutor. The A2A Python SDK provides an abstract base class a2a.server.agent_execution.AgentExecutor that you need to implement.
The AgentExecutor class defines two main methods:
async def execute(self, context: RequestContext, event_queue: EventQueue)
: Handles incoming requests that need responses or event streams. It processes user input (obtained through context) and usesevent_queue
to send Message, Task, TaskStatusUpdateEvent, or TaskArtifactUpdateEvent objects.async def cancel(self, context: RequestContext, event_queue: EventQueue)
: Handles requests to cancel ongoing tasks.
RequestContext provides information about the incoming request, such as the user's message and any existing task details. EventQueue is used by the executor to send events to the client.
Implement AgentServer
Code:
import os
import sys
import click
import httpx
from currency_agent import CurrencyAgent # type: ignore[import-untyped]
from agent_executor import CurrencyAgentExecutor # type: ignore[import-untyped]
from dotenv import load_dotenv
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryPushNotifier, InMemoryTaskStore
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
load_dotenv()
@click.command()
@click.option('--host', 'host', default='localhost')
@click.option('--port', 'port', default=10000)
def main(host: str, port: int):
client = httpx.AsyncClient()
request_handler = DefaultRequestHandler(
agent_executor=CurrencyAgentExecutor(),
task_store=InMemoryTaskStore(),
push_notifier=InMemoryPushNotifier(client),
)
server = A2AStarletteApplication(
agent_card=get_agent_card(host, port), http_handler=request_handler
)
import uvicorn
uvicorn.run(server.build(), host=host, port=port)
def get_agent_card(host: str, port: int):
"""Returns the Agent Card for the Currency Agent."""
capabilities = AgentCapabilities(streaming=True, pushNotifications=True)
skill = AgentSkill(
id='convert_currency',
name='Currency Exchange Rates Tool',
description='Helps with exchange values between various currencies',
tags=['currency conversion', 'currency exchange'],
examples=['What is exchange rate between USD and GBP?'],
)
return AgentCard(
name='Currency Agent',
description='Helps with exchange rates for currencies',
url=f'http://{host}:{port}/',
version='1.0.0',
defaultInputModes=CurrencyAgent.SUPPORTED_CONTENT_TYPES,
defaultOutputModes=CurrencyAgent.SUPPORTED_CONTENT_TYPES,
capabilities=capabilities,
skills=[skill],
)
if __name__ == '__main__':
main()
AgentSkill
AgentSkill describes specific capabilities or functions that the agent can perform. It's a building block that tells clients what types of tasks the agent is suitable for. Key attributes of AgentSkill (defined in a2a.types):
- id: Unique identifier for the skill
- name: Human-readable name
- description: More detailed explanation of the skill's functionality
- tags: Keywords for categorization and discovery
- examples: Example prompts or use cases
- inputModes / outputModes: Supported input and output MIME types (e.g., "text/plain", "application/json")
This skill is very simple: handling currency conversion, with both input and output being text
, defined in the AgentCard.
AgentCard
AgentCard is a JSON document provided by the A2A server, typically located at the .well-known/agent.json
endpoint. It's like a digital business card for the agent.
Key attributes of AgentCard (defined in a2a.types):
- name, description, version: Basic identity information
- url: Endpoint where the A2A service can be accessed
- capabilities: Specifies supported A2A features, such as streaming or pushNotifications
- defaultInputModes / defaultOutputModes: Default MIME types for the agent
- skills: List of AgentSkill objects provided by the agent
AgentServer
-
DefaultRequestHandler: The SDK provides DefaultRequestHandler. This handler takes an AgentExecutor implementation (here CurrencyAgentExecutor) and a TaskStore (here InMemoryTaskStore). It routes incoming A2A RPC calls to the appropriate methods on the executor (like execute or cancel). TaskStore is used by DefaultRequestHandler to manage task lifecycles, especially for stateful interactions, streaming, and resubscriptions. Even if the AgentExecutor is simple, the handler needs a task store.
-
A2AStarletteApplication: The A2AStarletteApplication class is instantiated with agent_card and request_handler (called http_handler in its constructor). agent_card is crucial because the server will expose it by default at the
/.well-known/agent.json
endpoint. request_handler is responsible for handling all incoming A2A method calls by interacting with your AgentExecutor. -
uvicorn.run(server_app_builder.build(), ...): A2AStarletteApplication has a build() method that builds the actual Starlette application. Then use
uvicorn.run()
to run this application, making your agent accessible via HTTP. host='0.0.0.0' makes the server accessible on all network interfaces of your machine. port=9999 specifies the port to listen on. This matches the url in the AgentCard.
Running
Run Server
uv run python main.py
Output:
INFO: Started server process [70842]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://localhost:10000 (Press CTRL+C to quit)
Run Client
Here's the client code:
from a2a.client import A2AClient
from typing import Any
from uuid import uuid4
from a2a.types import (
SendMessageResponse,
GetTaskResponse,
SendMessageSuccessResponse,
Task,
TaskState,
SendMessageRequest,
MessageSendParams,
GetTaskRequest,
TaskQueryParams,
SendStreamingMessageRequest,
)
import httpx
import traceback
AGENT_URL = 'http://localhost:10000'
def create_send_message_payload(
text: str, task_id: str | None = None, context_id: str | None = None
) -> dict[str, Any]:
"""Helper function to create the payload for sending a task."""
payload: dict[str, Any] = {
'message': {
'role': 'user',
'parts': [{'kind': 'text', 'text': text}],
'messageId': uuid4().hex,
},
}
if task_id:
payload['message']['taskId'] = task_id
if context_id:
payload['message']['contextId'] = context_id
return payload
def print_json_response(response: Any, description: str) -> None:
"""Helper function to print the JSON representation of a response."""
print(f'--- {description} ---')
if hasattr(response, 'root'):
print(f'{response.root.model_dump_json(exclude_none=True)}\n')
else:
print(f'{response.model_dump(mode="json", exclude_none=True)}\n')
async def run_single_turn_test(client: A2AClient) -> None:
"""Runs a single-turn non-streaming test."""
send_payload = create_send_message_payload(
text='how much is 100 USD in CAD?'
)
request = SendMessageRequest(params=MessageSendParams(**send_payload))
print('--- Single Turn Request ---')
# Send Message
send_response: SendMessageResponse = await client.send_message(request)
print_json_response(send_response, 'Single Turn Request Response')
if not isinstance(send_response.root, SendMessageSuccessResponse):
print('received non-success response. Aborting get task ')
return
if not isinstance(send_response.root.result, Task):
print('received non-task response. Aborting get task ')
return
task_id: str = send_response.root.result.id
print('---Query Task---')
# query the task
get_request = GetTaskRequest(params=TaskQueryParams(id=task_id))
get_response: GetTaskResponse = await client.get_task(get_request)
print_json_response(get_response, 'Query Task Response')
async def run_streaming_test(client: A2AClient) -> None:
"""Runs a single-turn streaming test."""
send_payload = create_send_message_payload(
text='how much is 50 EUR in JPY?'
)
request = SendStreamingMessageRequest(
params=MessageSendParams(**send_payload)
)
print('--- Single Turn Streaming Request ---')
stream_response = client.send_message_streaming(request)
async for chunk in stream_response:
print_json_response(chunk, 'Streaming Chunk')
async def run_multi_turn_test(client: A2AClient) -> None:
"""Runs a multi-turn non-streaming test."""
print('--- Multi-Turn Request ---')
# --- First Turn ---
first_turn_payload = create_send_message_payload(
text='how much is 100 USD?'
)
request1 = SendMessageRequest(
params=MessageSendParams(**first_turn_payload)
)
first_turn_response: SendMessageResponse = await client.send_message(
request1
)
print_json_response(first_turn_response, 'Multi-Turn: First Turn Response')
context_id: str | None = None
if isinstance(
first_turn_response.root, SendMessageSuccessResponse
) and isinstance(first_turn_response.root.result, Task):
task: Task = first_turn_response.root.result
context_id = task.contextId # Capture context ID
# --- Second Turn (if input required) ---
if task.status.state == TaskState.input_required and context_id:
print('--- Multi-Turn: Second Turn (Input Required) ---')
second_turn_payload = create_send_message_payload(
'in GBP', task.id, context_id
)
request2 = SendMessageRequest(
params=MessageSendParams(**second_turn_payload)
)
second_turn_response = await client.send_message(request2)
print_json_response(
second_turn_response, 'Multi-Turn: Second Turn Response'
)
elif not context_id:
print('Warning: Could not get context ID from first turn response.')
else:
print(
'First turn completed, no further input required for this test case.'
)
async def main() -> None:
"""Main function to run the tests."""
print(f'Connecting to agent at {AGENT_URL}...')
try:
async with httpx.AsyncClient(timeout=100) as httpx_client:
client = await A2AClient.get_client_from_agent_card_url(
httpx_client, AGENT_URL
)
print('Connection successful.')
await run_single_turn_test(client)
await run_streaming_test(client)
await run_multi_turn_test(client)
except Exception as e:
traceback.print_exc()
print(f'An error occurred: {e}')
print('Ensure the agent server is running.')
if __name__ == '__main__':
import asyncio
asyncio.run(main())
Run it like this:
uv run python test_client.py
End of tutorial.