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
25 changes: 25 additions & 0 deletions python/llm/agents/a2a_trading_agents/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Copy to .env and fill in. Never commit .env.

# --- Arize AX (required) -------------------------------------------------------------
# Space ID and API key from the Arize AX UI: Settings -> API Keys.
ARIZE_SPACE_ID=
ARIZE_API_KEY=
ARIZE_PROJECT_NAME=a2a-trading-agents

# --- Vertex AI (required) ------------------------------------------------------------
# Vertex AI has no API key. Authenticate with Application Default Credentials:
# gcloud auth application-default login
# The project needs the Vertex AI API enabled, and Llama 3.3 accepted in Model Garden.
GOOGLE_CLOUD_PROJECT=
GOOGLE_CLOUD_LOCATION=us-central1

# Staging bucket for deploy_agent_engine.py. Defaults to gs://$GOOGLE_CLOUD_PROJECT-agent.
STAGING_BUCKET=

# --- Optional overrides -------------------------------------------------------------
# Point an agent at a different Vertex model without touching the agent code.
# BEAR_MODEL=gemini-2.5-flash
# BULL_MODEL=vertex_ai/meta/llama-3.3-70b-instruct-maas
# ORCHESTRATOR_MODEL=gemini-2.5-flash
# BEAR_PORT=8001
# BULL_PORT=8002
5 changes: 5 additions & 0 deletions python/llm/agents/a2a_trading_agents/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.env
__pycache__/
*.pyc
.venv/
venv/
134 changes: 134 additions & 0 deletions python/llm/agents/a2a_trading_agents/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# A2A Trading Agents: Google ADK, Pydantic AI, MCP, and Arize AX

A trading analysis system where two specialist agents built on **different frameworks**
collaborate over the **Agent-to-Agent (A2A) protocol**, each with its own **MCP** tools,
all traced to **Arize AX**.

The point of the example is that the orchestrator does not know or care what framework
either specialist is built on. It discovers them through their A2A agent cards and calls
them as tools, so a Pydantic AI agent and a Google ADK agent are interchangeable behind
the protocol.

Companion guide: [Tracing an A2A Agent](https://arize.com/docs/ax/cookbooks/advanced-workflows/tracing-a2a-agent)

## Architecture

| Component | Framework | Tools | Role |
| :--- | :--- | :--- | :--- |
| Bear Risk Analyst | Pydantic AI | `risk_scanner`, `divergence_detector`, `exit_signal_monitor` | Downside catalysts and warning signals |
| Bull Market Analyst | Google ADK | `find_breakout_patterns`, `momentum_screener`, `entry_signal_detector` | Growth opportunities and bullish patterns |
| Orchestrator | Google ADK | The two agents above, as A2A tools | Coordinates both and weighs the cases |

Each specialist runs as an A2A HTTP service that publishes an agent card at
`/.well-known/agent-card.json`. Market data is synthetic, so no market data feed or API
key is needed for the tools.

```
orchestrator.py
|
|-- A2A --> localhost:8001 Bear (Pydantic AI) --stdio--> mcp_tools/bear_mcp_server.py
|
'-- A2A --> localhost:8002 Bull (Google ADK) --stdio--> mcp_tools/bull_mcp_server.py
```

## Files

| File | What it does |
| :--- | :--- |
| `config.py` | Environment-driven configuration and model selection for all three agents |
| `tracing.py` | Arize AX tracing for both frameworks in one tracer provider |
| `mcp_tools/` | The MCP servers and their synthetic market-data generator |
| `trading_agents/bear_agent.py` | Pydantic AI agent, its agent card, and the A2A executor that bridges it |
| `trading_agents/bull_agent.py` | ADK agent, its agent card, and ADK's built-in A2A executor |
| `a2a_servers.py` | Serves both agents as A2A services |
| `orchestrator.py` | Discovers both agents over A2A and answers one question |
| `run_local.py` | Starts the agents and sends one query, in a single command |
| `deploy_agent_engine.py` | Deploys both agents to Vertex AI Agent Engine (Vertex only) |

## Prerequisites

Python 3.10 or later and an [Arize AX account](https://app.arize.com/auth/join).

The agents run on Vertex AI: Gemini 2.5 Flash for the Bear agent and orchestrator, Llama
3.3 70B for the Bull agent. Vertex AI has no API key, so it needs:

- A Google Cloud project with billing and the [Vertex AI API](https://console.cloud.google.com/flows/enableapi?apiid=aiplatform.googleapis.com) enabled
- Application Default Credentials: `gcloud auth application-default login`
- Llama 3.3 accepted in [Model Garden](https://console.cloud.google.com/vertex-ai/model-garden), which is a per-model license step
- For `deploy_agent_engine.py` only: a GCS staging bucket and permission to create Agent Engine resources

## Setup

```bash
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

cp .env.example .env
# Fill in ARIZE_SPACE_ID, ARIZE_API_KEY, and GOOGLE_CLOUD_PROJECT
```

## Run it

One command, which starts both agents and sends a single query:

```bash
python run_local.py "Should I buy NVDA stock?"
```

Or keep the agents up across several queries, in two terminals:

```bash
python a2a_servers.py # terminal 1
python orchestrator.py "What are the risks for TSLA?" # terminal 2
```

The answer comes back with both cases argued from the tools' output, for example a risk
score and stop-loss levels from the Bear agent alongside breakout targets and an entry
price from the Bull agent.

## What you see in Arize AX

Open the `a2a-trading-agents` project. A single query produces roughly 100 spans:

- **`CHAIN`** `invocation [trading_strategy_orchestrator]`, the orchestrator run
- **`AGENT`** and **`LLM`** spans for each agent's reasoning, with the model name attached
- **`TOOL`** spans for the A2A calls (`execute_tool bear_risk_analyst`) and for every MCP
tool the specialists invoke (`execute_tool risk_scanner`, `tools/call risk_scanner`)

Two things about this trace shape are worth knowing before you go looking for them:

**The agents' work lands in separate traces from the orchestrator's.** A2A does not
propagate trace context across the HTTP hop, so one query produces one orchestrator trace
plus one trace per agent that answered, rather than a single connected tree. Group them by
time or by the project rather than expecting one root span to cover the whole exchange.

**The a2a-sdk emits its own internal spans.** Event-queue plumbing
(`EventQueue.dequeue_event` and friends) accounts for most of the span count and carries no
OpenInference span kind, so those rows sit uncategorized in Arize AX. Filter on
`attributes.openinference.span.kind` to get to the agent behavior.

## Deploy to Vertex AI Agent Engine

Turns each agent into a managed service with an authenticated A2A endpoint. Vertex only.

```bash
python deploy_agent_engine.py # deploy both
python deploy_agent_engine.py --query "Analyze risks for TSLA" # deploy, then query
python deploy_agent_engine.py --delete <resource> <resource> # tear down
```

Deployment takes several minutes per agent and leaves billable resources running. Delete
them when you are finished.

## Notes

**`a2a-sdk` is pinned below 0.4.** `google-adk` requires `a2a-sdk>=0.3.4,<0.4.0`, and
`a2a-sdk` 1.x removed `a2a.server.apps`, `a2a.types.TextPart`,
`a2a.types.TransportProtocol`, and `a2a.utils.new_agent_text_message`. An unpinned install
resolves to 1.x, and then nothing imports. Install from `requirements.txt`.

**Shutdown prints OpenTelemetry warnings.** After the answer, you will see
`ValueError: <Token ...> was created in a different Context` from
`opentelemetry/context/contextvars_context.py`. It comes from the MCP client's async
generators being finalized as the event loop closes, it happens after all work and all
span exports are done, and the process still exits 0. It is noise, not a failure.
91 changes: 91 additions & 0 deletions python/llm/agents/a2a_trading_agents/a2a_servers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Serve the Bear and Bull agents as A2A servers.

Each agent becomes an HTTP service that publishes an agent card at
/.well-known/agent-card.json and accepts A2A task requests. Run this in one terminal,
then run orchestrator.py in another.

python a2a_servers.py
"""

import asyncio

import uvicorn
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import TransportProtocol

import config
import tracing
from trading_agents.bear_agent import BearAgentExecutor, create_bear_agent_card
from trading_agents.bull_agent import build_bull_executor, create_bull_agent_card


def build_bear_app() -> A2AStarletteApplication:
"""Wrap the Pydantic AI Bear agent in an A2A server using our own executor."""
card = create_bear_agent_card()
card.url = f"http://localhost:{config.BEAR_PORT}"
card.preferred_transport = TransportProtocol.jsonrpc

handler = DefaultRequestHandler(
agent_executor=BearAgentExecutor(),
task_store=InMemoryTaskStore(),
)
return A2AStarletteApplication(agent_card=card, http_handler=handler)


def build_bull_app() -> A2AStarletteApplication:
"""Wrap the ADK Bull agent in an A2A server using ADK's built-in executor."""
card = create_bull_agent_card()
card.url = f"http://localhost:{config.BULL_PORT}"
card.preferred_transport = TransportProtocol.jsonrpc

handler = DefaultRequestHandler(
agent_executor=build_bull_executor(),
task_store=InMemoryTaskStore(),
)
return A2AStarletteApplication(agent_card=card, http_handler=handler)


def make_server(app: A2AStarletteApplication, port: int) -> uvicorn.Server:
"""Build a uvicorn server for one A2A application.

Returned rather than served immediately so a caller can trigger a graceful shutdown
via server.should_exit. Cancelling the serve() task instead tears down the agents'
MCP subprocesses from the wrong task and produces anyio cancel-scope errors.
"""
return uvicorn.Server(
uvicorn.Config(
app.build(),
host="127.0.0.1",
port=port,
log_level="warning",
loop="none", # reuse the caller's event loop
)
)


async def serve(app: A2AStarletteApplication, port: int) -> None:
"""Serve one A2A application on the given port until the process is interrupted."""
await make_server(app, port).serve()


async def main() -> None:
tracing.setup_tracing()

print(f"Bear Agent (Pydantic AI, {config.BEAR_MODEL}) -> http://127.0.0.1:{config.BEAR_PORT}")
print(f"Bull Agent (ADK, {config.BULL_MODEL}) -> http://127.0.0.1:{config.BULL_PORT}")

await asyncio.gather(
serve(build_bear_app(), config.BEAR_PORT),
serve(build_bull_app(), config.BULL_PORT),
)


if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\nShutting down A2A servers.")
finally:
tracing.flush()
111 changes: 111 additions & 0 deletions python/llm/agents/a2a_trading_agents/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Shared configuration, read from the environment.

The three agents run on Vertex AI: Gemini 2.5 Flash for the Bear agent and the
orchestrator, and Llama 3.3 70B from Vertex AI Model-as-a-Service for the Bull agent.

Vertex AI has no API key. It authenticates with Application Default Credentials against
a Google Cloud project that has the Vertex AI API enabled, so set GOOGLE_CLOUD_PROJECT
and run `gcloud auth application-default login` before any of the scripts here.

Model ids are read from the environment so you can point an agent at a different Vertex
model without touching the agent code.
"""

import os
import sys
from pathlib import Path

from dotenv import load_dotenv

PROJECT_ROOT = Path(__file__).resolve().parent

# Load .env before reading anything, so the values below reflect it. Real environment
# variables win over the file.
load_dotenv(PROJECT_ROOT / ".env")

# --- Vertex AI ---------------------------------------------------------------------
GOOGLE_CLOUD_PROJECT = os.environ.get("GOOGLE_CLOUD_PROJECT", "")
GOOGLE_CLOUD_LOCATION = os.environ.get("GOOGLE_CLOUD_LOCATION", "us-central1")

BEAR_MODEL = os.environ.get("BEAR_MODEL", "gemini-2.5-flash")
BULL_MODEL = os.environ.get("BULL_MODEL", "vertex_ai/meta/llama-3.3-70b-instruct-maas")
ORCHESTRATOR_MODEL = os.environ.get("ORCHESTRATOR_MODEL", "gemini-2.5-flash")

# --- A2A server ports ---------------------------------------------------------------
BEAR_PORT = int(os.environ.get("BEAR_PORT", "8001"))
BULL_PORT = int(os.environ.get("BULL_PORT", "8002"))

BEAR_SYSTEM_PROMPT = (
"You are a cautious risk analyst focused on identifying potential downside catalysts, "
"warning signals, and protective strategies. You prioritize capital preservation. "
"Use the available MCP tools to analyze market risks comprehensively."
)

BULL_SYSTEM_PROMPT = (
"You are an optimistic market analyst focused on identifying growth opportunities, "
"bullish patterns, and upside catalysts. You emphasize potential gains and momentum. "
"Use the available tools to analyze market opportunities comprehensively."
)

ORCHESTRATOR_INSTRUCTION = (
"You coordinate two specialist analysts to produce a balanced view of a stock. "
"Call the bear analyst for downside risk and the bull analyst for upside "
"opportunity, then summarize both sides and state which case is stronger."
)


def bear_model():
"""Return the Pydantic AI model for the Bear agent."""
from pydantic_ai.models.google import GoogleModel
from pydantic_ai.providers.google import GoogleProvider

return GoogleModel(BEAR_MODEL, provider=GoogleProvider(vertexai=True))


def bull_model():
"""Return the ADK model for the Bull agent.

ADK reaches non-Gemini models through LiteLlm, which is how an ADK agent runs on
Llama hosted by Vertex AI.
"""
from google.adk.models.lite_llm import LiteLlm

return LiteLlm(BULL_MODEL)


def orchestrator_model() -> str:
"""Return the ADK model for the orchestrator.

ADK takes a bare model id string for Gemini models.
"""
return ORCHESTRATOR_MODEL


def init_vertex() -> None:
"""Initialize Vertex AI and point LiteLLM at the same project."""
import vertexai
from google.adk.models.lite_llm import litellm

if not GOOGLE_CLOUD_PROJECT:
raise RuntimeError(
"GOOGLE_CLOUD_PROJECT is required. Set it in .env or the environment, and "
"authenticate with `gcloud auth application-default login`."
)

os.environ["GOOGLE_CLOUD_PROJECT"] = GOOGLE_CLOUD_PROJECT
os.environ["GOOGLE_CLOUD_LOCATION"] = GOOGLE_CLOUD_LOCATION
os.environ["GOOGLE_GENAI_USE_VERTEXAI"] = "TRUE"

litellm.vertex_project = GOOGLE_CLOUD_PROJECT
litellm.vertex_location = GOOGLE_CLOUD_LOCATION

vertexai.init(project=GOOGLE_CLOUD_PROJECT, location=GOOGLE_CLOUD_LOCATION)


def mcp_server_command(module: str) -> tuple[str, list[str]]:
"""Command and args that launch an MCP server over stdio.

Uses sys.executable rather than "python" so the server runs in the same interpreter
as the agent, which matters inside a virtualenv where "python" may not exist at all.
"""
return sys.executable, ["-m", module]
Loading