diff --git a/python/llm/agents/a2a_trading_agents/.env.example b/python/llm/agents/a2a_trading_agents/.env.example new file mode 100644 index 0000000..eed7ca0 --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/.env.example @@ -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 diff --git a/python/llm/agents/a2a_trading_agents/.gitignore b/python/llm/agents/a2a_trading_agents/.gitignore new file mode 100644 index 0000000..ac7d762 --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/.gitignore @@ -0,0 +1,5 @@ +.env +__pycache__/ +*.pyc +.venv/ +venv/ diff --git a/python/llm/agents/a2a_trading_agents/README.md b/python/llm/agents/a2a_trading_agents/README.md new file mode 100644 index 0000000..33e417c --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/README.md @@ -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 # 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: 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. diff --git a/python/llm/agents/a2a_trading_agents/a2a_servers.py b/python/llm/agents/a2a_trading_agents/a2a_servers.py new file mode 100644 index 0000000..0e205c8 --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/a2a_servers.py @@ -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() diff --git a/python/llm/agents/a2a_trading_agents/config.py b/python/llm/agents/a2a_trading_agents/config.py new file mode 100644 index 0000000..d12c4dc --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/config.py @@ -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] diff --git a/python/llm/agents/a2a_trading_agents/deploy_agent_engine.py b/python/llm/agents/a2a_trading_agents/deploy_agent_engine.py new file mode 100644 index 0000000..7141a77 --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/deploy_agent_engine.py @@ -0,0 +1,235 @@ +"""Deploy both agents to Vertex AI Agent Engine as managed A2A services. + +This is the production counterpart to a2a_servers.py: instead of two local uvicorn +processes, each agent becomes a managed Agent Engine service with an authenticated A2A +endpoint, and the orchestrator reaches them through Google-signed HTTP requests. + +Vertex AI only. Requires GOOGLE_CLOUD_PROJECT, a staging bucket, Application Default +Credentials, and permission to create Agent Engine resources. + + python deploy_agent_engine.py # deploy both, print resource names + python deploy_agent_engine.py --query "..." # deploy, then run one query + python deploy_agent_engine.py --delete [ ...] + +Deployment takes several minutes per agent and leaves billable resources running. Delete +them with --delete when you are finished. +""" + +import argparse +import asyncio +import os + +import httpx +import vertexai +from a2a.client.client import ClientConfig as A2AClientConfig +from a2a.client.client_factory import ClientFactory as A2AClientFactory +from a2a.types import TransportProtocol +from google.adk import Runner +from google.adk.agents import LlmAgent +from google.adk.agents.remote_a2a_agent import RemoteA2aAgent +from google.adk.sessions import InMemorySessionService +from google.adk.tools.agent_tool import AgentTool +from google.auth import default as google_auth_default +from google.auth.transport.requests import Request as AuthRequest +from google.genai import types +from vertexai.preview.reasoning_engines import A2aAgent + +import config +from trading_agents.bear_agent import BearAgentExecutor, create_bear_agent_card +from trading_agents.bull_agent import build_bull_executor, create_bull_agent_card + +# Arize AX settings are forwarded to the deployed services so the remote agents trace to +# the same project. They are read from the environment, never hardcoded. +ARIZE_ENV_VARS = ( + "ARIZE_API_KEY", + "ARIZE_SPACE_ID", + "ARIZE_PROJECT_NAME", + "ARIZE_COLLECTOR_ENDPOINT", +) + +BEAR_REQUIREMENTS = [ + "a2a-sdk>=0.3.4,<0.4", + "google-cloud-aiplatform[agent_engines,adk]", + "fastmcp", + "pydantic", + "pydantic-ai", + "numpy", + "arize-otel", + "openinference-instrumentation-pydantic-ai", + "opentelemetry-sdk", + "opentelemetry-exporter-otlp", + "opentelemetry-api", +] + +BULL_REQUIREMENTS = [ + "a2a-sdk>=0.3.4,<0.4", + "google-cloud-aiplatform[agent_engines,adk]", + "fastmcp", + "numpy", + "litellm", + "arize-otel", + "openinference-instrumentation-google-adk", +] + + +def arize_env() -> dict: + """Collect the Arize AX settings to forward to the deployed agents.""" + return {name: os.environ[name] for name in ARIZE_ENV_VARS if os.environ.get(name)} + + +def staging_bucket() -> str: + """Return the GCS staging bucket URI used to upload the agent packages.""" + explicit = os.environ.get("STAGING_BUCKET") + if explicit: + return explicit if explicit.startswith("gs://") else f"gs://{explicit}" + return f"gs://{config.GOOGLE_CLOUD_PROJECT}-agent" + + +def deploy(client, name: str, card, executor_builder, requirements: list[str]): + """Deploy one agent to Agent Engine and return the created resource.""" + # http_json is the transport Agent Engine serves; local runs use jsonrpc instead. + card.preferred_transport = TransportProtocol.http_json + + print(f"Deploying {name} (this takes several minutes)...") + created = client.agent_engines.create( + agent=A2aAgent(agent_card=card, agent_executor_builder=executor_builder), + config={ + "display_name": name, + "description": card.description, + "requirements": requirements, + # mcp_tools ships alongside the agent; the deployed copy spawns it over stdio. + "extra_packages": ["mcp_tools"], + "env_vars": arize_env(), + "staging_bucket": staging_bucket(), + }, + ) + print(f" {name} -> {created.api_resource.name}") + return created + + +class GoogleAuth(httpx.Auth): + """Sign every outgoing request with a Google Cloud access token.""" + + def __init__(self) -> None: + self.credentials, self.project = google_auth_default( + scopes=["https://www.googleapis.com/auth/cloud-platform"] + ) + self.auth_request = AuthRequest() + + def auth_flow(self, request: httpx.Request): + if not self.credentials.valid: + self.credentials.refresh(self.auth_request) + request.headers["Authorization"] = f"Bearer {self.credentials.token}" + yield request + + +def remote_proxies(bear_resource: str, bull_resource: str): + """Build RemoteA2aAgent proxies for the two deployed Agent Engine endpoints.""" + api_endpoint = f"https://{config.GOOGLE_CLOUD_LOCATION}-aiplatform.googleapis.com" + authenticated_client = httpx.AsyncClient(timeout=120, auth=GoogleAuth()) + factory = A2AClientFactory( + config=A2AClientConfig( + httpx_client=authenticated_client, + streaming=False, + polling=False, + supported_transports=[TransportProtocol.http_json], + ) + ) + + def proxy(name: str, description: str, resource: str) -> RemoteA2aAgent: + endpoint = f"{api_endpoint}/v1beta1/{resource}/a2a" + return RemoteA2aAgent( + name=name, + description=description, + agent_card=f"{endpoint}/v1/card", + httpx_client=authenticated_client, + a2a_client_factory=factory, + ) + + return ( + proxy("bear_risk_analyst", "Analyzes risks and warning signals", bear_resource), + proxy( + "bull_market_analyst", + "Identifies growth opportunities and bullish patterns", + bull_resource, + ), + ) + + +async def query_deployed(bear_resource: str, bull_resource: str, query: str): + """Run one query against the deployed agents through the orchestrator.""" + remote_bear, remote_bull = remote_proxies(bear_resource, bull_resource) + orchestrator = LlmAgent( + name="trading_strategy_orchestrator", + model=config.orchestrator_model(), + instruction=config.ORCHESTRATOR_INSTRUCTION, + tools=[AgentTool(agent=remote_bear), AgentTool(agent=remote_bull)], + ) + runner = Runner( + app_name=orchestrator.name, + agent=orchestrator, + session_service=InMemorySessionService(), + ) + session = await runner.session_service.create_session( + app_name=orchestrator.name, user_id="deploy_user", session_id="deploy_session" + ) + + content = types.Content(role="user", parts=[types.Part(text=query)]) + async for event in runner.run_async( + session_id=session.id, user_id="deploy_user", new_message=content + ): + if event.is_final_response(): + if event.content and event.content.parts: + return "".join( + p.text for p in event.content.parts if getattr(p, "text", None) + ) + break + return None + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--query", help="Run this query against the deployed agents") + parser.add_argument( + "--delete", nargs="+", metavar="RESOURCE", help="Delete deployed agents and exit" + ) + args = parser.parse_args() + + config.init_vertex() + client = vertexai.Client( + project=config.GOOGLE_CLOUD_PROJECT, location=config.GOOGLE_CLOUD_LOCATION + ) + + if args.delete: + for resource in args.delete: + print(f"Deleting {resource}") + client.agent_engines.delete(resource, force=True) + return + + bear = deploy( + client, + "Bear Risk Analyst", + create_bear_agent_card(), + BearAgentExecutor, + BEAR_REQUIREMENTS, + ) + bull = deploy( + client, + "Bull Market Analyst", + create_bull_agent_card(), + build_bull_executor, + BULL_REQUIREMENTS, + ) + + bear_resource = bear.api_resource.name + bull_resource = bull.api_resource.name + print(f"\nDelete them when finished:\n python deploy_agent_engine.py --delete " + f"{bear_resource} {bull_resource}") + + if args.query: + print(f"\nQuery: {args.query}") + print(asyncio.run(query_deployed(bear_resource, bull_resource, args.query))) + + +if __name__ == "__main__": + main() diff --git a/python/llm/agents/a2a_trading_agents/mcp_tools/__init__.py b/python/llm/agents/a2a_trading_agents/mcp_tools/__init__.py new file mode 100644 index 0000000..42b664f --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/mcp_tools/__init__.py @@ -0,0 +1,5 @@ +"""MCP tools package for the A2A trading agents.""" + +from mcp_tools.market_data import MarketDataGenerator + +__all__ = ["MarketDataGenerator"] diff --git a/python/llm/agents/a2a_trading_agents/mcp_tools/bear_mcp_server.py b/python/llm/agents/a2a_trading_agents/mcp_tools/bear_mcp_server.py new file mode 100644 index 0000000..7ff6bcc --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/mcp_tools/bear_mcp_server.py @@ -0,0 +1,13 @@ +"""Bear Agent MCP Server - Risk-focused market analysis tools. + +Run as a module from the project root so the `mcp_tools` package resolves: + + python -m mcp_tools.bear_mcp_server + +The agents spawn this themselves over stdio; you only run it by hand to debug. +""" + +from mcp_tools.bear_tools import mcp + +if __name__ == "__main__": + mcp.run(transport="stdio") diff --git a/python/llm/agents/a2a_trading_agents/mcp_tools/bear_tools.py b/python/llm/agents/a2a_trading_agents/mcp_tools/bear_tools.py new file mode 100644 index 0000000..2f63c88 --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/mcp_tools/bear_tools.py @@ -0,0 +1,120 @@ +"""Bear Agent MCP Tools - Risk analysis tools.""" + +import numpy as np +from mcp.server.fastmcp import FastMCP + +from mcp_tools.market_data import MarketDataGenerator + +# Initialize MCP server +mcp = FastMCP("bear-agent-tools") + +# Create global market data generator +market_generator = MarketDataGenerator() + +RSI_OVERBOUGHT_THRESHOLD = 70 +RISK_HIGH_THRESHOLD = 60 + + +@mcp.tool() +async def risk_scanner(symbol: str) -> str: + """Scan for potential downside risks and warning signals.""" + prices = market_generator.generate_price_series(symbol, days=30) + current_price = prices[-1]["close"] + closes = [p["close"] for p in prices] + rsi = market_generator.calculate_rsi(closes) + + risk_score = np.random.uniform(40, 75) + + risks = [] + if rsi > RSI_OVERBOUGHT_THRESHOLD: + risks.append( + { + "risk": "Overbought Conditions", + "severity": "HIGH", + "description": f"RSI at {rsi:.1f} indicates potential pullback", + "impact": "-5% to -10%", + } + ) + + if len(risks) == 0: + risks.append( + { + "risk": "Valuation Concerns", + "severity": "MEDIUM", + "description": "P/E ratio elevated vs historical average", + "impact": "-10% to -15%", + } + ) + + risk_level = "HIGH" if risk_score > RISK_HIGH_THRESHOLD else "MEDIUM" + separator = "=" * 40 + + result = f""" +RISK ANALYSIS FOR {symbol} +{separator} +Current Price: ${current_price} +Risk Score: {risk_score:.1f}/100 +Risk Level: {risk_level} + +Identified Risks: +""" + + for risk in risks: + result += f"\n[{risk['severity']}] {risk['risk']}" + result += f"\n {risk['description']}" + result += f"\n Potential Impact: {risk['impact']}\n" + + return result + + +@mcp.tool() +async def divergence_detector(symbol: str) -> str: + """Detect bearish divergences and technical weakness.""" + prices = market_generator.generate_price_series(symbol, days=30) + closes = [p["close"] for p in prices] + rsi = market_generator.calculate_rsi(closes) + + divergence_score = np.random.uniform(30, 70) + separator = "=" * 40 + + return f""" +DIVERGENCE ANALYSIS FOR {symbol} +{separator} +Divergence Score: {divergence_score:.1f}/100 +RSI: {rsi:.1f} + +Detected Divergences: +- RSI Bearish Divergence + Price making highs but RSI not confirming + Confidence: 75% + +- Volume Divergence + Declining volume on advances + Confidence: 70% +""" + + +@mcp.tool() +async def exit_signal_monitor(symbol: str) -> str: + """Monitor for distribution patterns and exit signals.""" + prices = market_generator.generate_price_series(symbol, days=30) + current_price = prices[-1]["close"] + + stop_aggressive = round(current_price * 0.95, 2) + stop_moderate = round(current_price * 0.93, 2) + separator = "=" * 40 + + return f""" +EXIT SIGNAL MONITOR FOR {symbol} +{separator} +Current Price: ${current_price} + +Exit Signals: +[MED] Distribution Pattern + Heavy selling on up days + Action: Reduce position size + +Stop Loss Recommendations: + Aggressive: ${stop_aggressive} (-5%) + Moderate: ${stop_moderate} (-7%) +""" diff --git a/python/llm/agents/a2a_trading_agents/mcp_tools/bull_mcp_server.py b/python/llm/agents/a2a_trading_agents/mcp_tools/bull_mcp_server.py new file mode 100644 index 0000000..a482807 --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/mcp_tools/bull_mcp_server.py @@ -0,0 +1,13 @@ +"""Bull Agent MCP Server - Opportunity-focused market analysis tools. + +Run as a module from the project root so the `mcp_tools` package resolves: + + python -m mcp_tools.bull_mcp_server + +The agents spawn this themselves over stdio; you only run it by hand to debug. +""" + +from mcp_tools.bull_tools import mcp + +if __name__ == "__main__": + mcp.run(transport="stdio") diff --git a/python/llm/agents/a2a_trading_agents/mcp_tools/bull_tools.py b/python/llm/agents/a2a_trading_agents/mcp_tools/bull_tools.py new file mode 100644 index 0000000..4ba0663 --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/mcp_tools/bull_tools.py @@ -0,0 +1,93 @@ +"""Bull Agent MCP Tools - Opportunity analysis tools.""" + +import numpy as np +from mcp.server.fastmcp import FastMCP + +from mcp_tools.market_data import MarketDataGenerator + +# Initialize MCP server +mcp = FastMCP("bull-agent-tools") + +# Create global market data generator +market_generator = MarketDataGenerator() + + +@mcp.tool() +async def find_breakout_patterns(symbol: str) -> str: + """Identify bullish breakout patterns and technical setups.""" + prices = market_generator.generate_price_series(symbol, days=30) + current_price = prices[-1]["close"] + + breakout_score = np.random.uniform(55, 85) + momentum = "STRONG" if breakout_score > 70 else "MODERATE" + separator = "=" * 40 + + return f""" +BREAKOUT PATTERN ANALYSIS FOR {symbol} +{separator} +Current Price: ${current_price} +Breakout Score: {breakout_score:.1f}/100 +Momentum: {momentum} + +Bullish Patterns: +[HIGH] Resistance Breakout + Price breaking above key resistance + Target: ${round(current_price * 1.08, 2)} (+8%) + +[MED] Ascending Triangle + Higher lows with resistance test + Target: ${round(current_price * 1.10, 2)} (+10%) +""" + + +@mcp.tool() +async def momentum_screener(symbol: str) -> str: + """Screen for stocks with strong upward momentum.""" + prices = market_generator.generate_price_series(symbol, days=30) + closes = [p["close"] for p in prices] + rsi = market_generator.calculate_rsi(closes) + + momentum_score = np.random.uniform(60, 90) + rating = "VERY STRONG" if momentum_score > 80 else "STRONG" + separator = "=" * 40 + + return f""" +MOMENTUM ANALYSIS FOR {symbol} +{separator} +Momentum Score: {momentum_score:.1f}/100 +Rating: {rating} +Trend: BULLISH + +Momentum Factors: +- Healthy RSI at {rsi:.1f} - room to run +- MACD bullish crossover confirmed +- Volume surge - institutions accumulating +- Uptrend pattern intact +""" + + +@mcp.tool() +async def entry_signal_detector(symbol: str) -> str: + """Detect optimal entry points for long positions.""" + prices = market_generator.generate_price_series(symbol, days=30) + current_price = prices[-1]["close"] + + entry_quality = np.random.uniform(60, 90) + sizing = "75-100%" if entry_quality > 80 else "50-75%" + separator = "=" * 40 + + return f""" +ENTRY SIGNAL ANALYSIS FOR {symbol} +{separator} +Current Price: ${current_price} +Entry Quality: {entry_quality:.1f}/100 + +Entry Signals: +[HIGH] Pullback to Support + Quality entry at ${round(current_price * 0.98, 2)} + Stop Loss: ${round(current_price * 0.95, 2)} + Risk/Reward: 1:3 + +Position Sizing: + Suggested: {sizing} of planned position +""" diff --git a/python/llm/agents/a2a_trading_agents/mcp_tools/market_data.py b/python/llm/agents/a2a_trading_agents/mcp_tools/market_data.py new file mode 100644 index 0000000..eb35ee2 --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/mcp_tools/market_data.py @@ -0,0 +1,90 @@ +"""Market Data Generator - Creates synthetic market data for testing.""" + +import random +import numpy as np +from datetime import datetime, timedelta +from typing import List, Dict + + +class MarketDataGenerator: + """Generate realistic synthetic market data.""" + + def __init__(self, seed: int = 42): + random.seed(seed) + np.random.seed(seed) + + # Base prices for common symbols + self.base_prices = { + "NVDA": 850.0, + "AAPL": 185.0, + "GOOGL": 155.0, + "MSFT": 420.0, + "TSLA": 245.0, + } + + def generate_price_series(self, symbol: str, days: int = 30) -> List[Dict]: + """Generate realistic OHLCV price series.""" + base_price = self.base_prices.get(symbol, 100.0) + + prices = [base_price] + for _ in range(days - 1): + drift = random.uniform(-0.005, 0.01) + shock = random.gauss(0, 0.02) + new_price = prices[-1] * (1 + drift + shock) + prices.append(max(new_price, 1.0)) + + # Generate OHLCV data + ohlcv_data = [] + start_date = datetime.now() - timedelta(days=days) + + for i, close in enumerate(prices): + date = start_date + timedelta(days=i) + intraday_range = close * random.uniform(0.01, 0.03) + open_price = close + random.uniform(-intraday_range/2, intraday_range/2) + high = max(open_price, close) + random.uniform(0, intraday_range) + low = min(open_price, close) - random.uniform(0, intraday_range) + volume = int(random.uniform(50_000_000, 150_000_000)) + + ohlcv_data.append({ + "date": date.strftime("%Y-%m-%d"), + "open": round(open_price, 2), + "high": round(high, 2), + "low": round(low, 2), + "close": round(close, 2), + "volume": volume + }) + + return ohlcv_data + + def calculate_rsi(self, prices: List[float], period: int = 14) -> float: + """Calculate RSI indicator.""" + if len(prices) < period + 1: + return 50.0 + + deltas = np.diff(prices[-period-1:]) + gains = deltas.copy() + losses = deltas.copy() + gains[gains < 0] = 0 + losses[losses > 0] = 0 + losses = abs(losses) + + avg_gain = np.mean(gains) if len(gains) > 0 else 0 + avg_loss = np.mean(losses) if len(losses) > 0 else 0.01 + + rs = avg_gain / avg_loss if avg_loss != 0 else 100 + rsi = 100 - (100 / (1 + rs)) + + return rsi + + def calculate_macd(self, prices: List[float]) -> tuple: + """Calculate MACD and signal line.""" + if len(prices) < 26: + return (0.0, 0.0) + + # Simplified MACD calculation + fast_ema = np.mean(prices[-12:]) + slow_ema = np.mean(prices[-26:]) + macd = fast_ema - slow_ema + signal = macd * 0.9 # Simplified signal + + return (macd, signal) diff --git a/python/llm/agents/a2a_trading_agents/orchestrator.py b/python/llm/agents/a2a_trading_agents/orchestrator.py new file mode 100644 index 0000000..23d466b --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/orchestrator.py @@ -0,0 +1,103 @@ +"""Orchestrator: coordinate the two A2A specialists and answer a question. + +The orchestrator never imports the specialists. It reaches them over A2A through +RemoteA2aAgent, which fetches each agent card and calls the remote agent as a tool. That +is what makes the two frameworks interchangeable behind the protocol. + +Run a2a_servers.py first, then: + + python orchestrator.py "Should I buy NVDA stock?" +""" + +import argparse +import asyncio + +from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH +from google.adk import Runner +from google.adk.agents import LlmAgent +from google.adk.agents.remote_a2a_agent import RemoteA2aAgent +from google.adk.sessions import InMemorySessionService +from google.adk.tools.agent_tool import AgentTool +from google.genai import types + +import config +import tracing + +USER_ID = "local_user" +SESSION_ID = "orchestrator_session" + + +def build_orchestrator() -> LlmAgent: + """Build the orchestrator with both remote specialists wrapped as tools.""" + config.init_vertex() + + remote_bear = RemoteA2aAgent( + name="bear_risk_analyst", + description="Analyzes downside risks and warning signals for a stock", + agent_card=f"http://localhost:{config.BEAR_PORT}{AGENT_CARD_WELL_KNOWN_PATH}", + ) + remote_bull = RemoteA2aAgent( + name="bull_market_analyst", + description="Identifies growth opportunities and bullish patterns for a stock", + agent_card=f"http://localhost:{config.BULL_PORT}{AGENT_CARD_WELL_KNOWN_PATH}", + ) + + return LlmAgent( + name="trading_strategy_orchestrator", + model=config.orchestrator_model(), + instruction=config.ORCHESTRATOR_INSTRUCTION, + tools=[AgentTool(agent=remote_bear), AgentTool(agent=remote_bull)], + ) + + +async def run_query(query: str) -> str | None: + """Send one query through the orchestrator and return its final answer.""" + orchestrator = build_orchestrator() + runner = Runner( + app_name=orchestrator.name, + agent=orchestrator, + session_service=InMemorySessionService(), + ) + session = await runner.session_service.create_session( + app_name=orchestrator.name, + user_id=USER_ID, + session_id=SESSION_ID, + ) + + content = types.Content(role="user", parts=[types.Part(text=query)]) + + final_result = None + async for event in runner.run_async( + session_id=session.id, user_id=USER_ID, new_message=content + ): + if event.is_final_response(): + if event.content and event.content.parts: + final_result = "".join( + part.text for part in event.content.parts if getattr(part, "text", None) + ) + break + + return final_result + + +async def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "query", + nargs="?", + default="Should I buy NVDA stock? Give me both the risk and the opportunity case.", + ) + args = parser.parse_args() + + tracing.setup_tracing() + + print(f"Query: {args.query}\n") + result = await run_query(args.query) + print(f"Result:\n{result}") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + finally: + tracing.flush() diff --git a/python/llm/agents/a2a_trading_agents/requirements.txt b/python/llm/agents/a2a_trading_agents/requirements.txt new file mode 100644 index 0000000..9ea0dbc --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/requirements.txt @@ -0,0 +1,24 @@ +# Python 3.10 or later. +# +# 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 nothing imports. +a2a-sdk[http-server]>=0.3.4,<0.4 + +google-cloud-aiplatform[agent_engines,adk]>=1.163.0 +google-adk[a2a]>=1.14.0 +pydantic-ai>=2.23.0 +litellm>=1.95.0 +fastmcp>=3.4.0 +numpy>=2.0 +uvicorn>=0.34 +httpx>=0.28 +python-dotenv>=1.0 + +# Arize AX tracing +arize-otel>=0.13.0 +openinference-instrumentation-google-adk>=0.1.18 +openinference-instrumentation-pydantic-ai>=0.1.18 +opentelemetry-sdk>=1.44.0 +opentelemetry-exporter-otlp>=1.44.0 +opentelemetry-api>=1.44.0 diff --git a/python/llm/agents/a2a_trading_agents/run_local.py b/python/llm/agents/a2a_trading_agents/run_local.py new file mode 100644 index 0000000..1333424 --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/run_local.py @@ -0,0 +1,83 @@ +"""Run the whole system with one command: start both A2A agents, then send one query. + + python run_local.py "Should I buy NVDA stock?" + +The two agents are started as a child process running a2a_servers.py, which is what they +are in production: independent services reached over HTTP. This script waits for both +agent cards to be served, sends one query through the orchestrator, prints the answer, +and stops the agents. + +Use a2a_servers.py and orchestrator.py in two terminals instead when you want the agents +to stay up across several queries. +""" + +import argparse +import asyncio +import subprocess +import sys + +import httpx + +import config +import tracing +from orchestrator import run_query + +STARTUP_TIMEOUT_S = 60 +SHUTDOWN_TIMEOUT_S = 20 + + +async def wait_for_agent_card(port: int) -> None: + """Poll an agent's card endpoint until it responds, so the query never races startup.""" + url = f"http://127.0.0.1:{port}/.well-known/agent-card.json" + loop = asyncio.get_running_loop() + deadline = loop.time() + STARTUP_TIMEOUT_S + async with httpx.AsyncClient(timeout=5) as client: + while True: + try: + if (await client.get(url)).status_code == 200: + return + except httpx.HTTPError: + pass + if loop.time() > deadline: + raise TimeoutError(f"agent on port {port} did not come up") + await asyncio.sleep(0.5) + + +async def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "query", + nargs="?", + default="Should I buy NVDA stock? Give me both the risk and the opportunity case.", + ) + args = parser.parse_args() + + tracing.setup_tracing() + print("Starting the Bear and Bull A2A agents...") + + agents = subprocess.Popen( + [sys.executable, "a2a_servers.py"], + cwd=str(config.PROJECT_ROOT), + ) + try: + await asyncio.gather( + wait_for_agent_card(config.BEAR_PORT), + wait_for_agent_card(config.BULL_PORT), + ) + print(f"Both agents up.\n\nQuery: {args.query}\n") + + result = await run_query(args.query) + print(f"Result:\n{result}") + return 0 if result else 1 + finally: + # SIGTERM lets uvicorn shut down gracefully and close each agent's MCP subprocess. + agents.terminate() + try: + agents.wait(timeout=SHUTDOWN_TIMEOUT_S) + except subprocess.TimeoutExpired: + agents.kill() + tracing.flush() + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/python/llm/agents/a2a_trading_agents/tracing.py b/python/llm/agents/a2a_trading_agents/tracing.py new file mode 100644 index 0000000..f20fb3e --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/tracing.py @@ -0,0 +1,61 @@ +"""Arize AX tracing for both agents. + +The two agents need different instrumentation because they are built on different +frameworks: + + Bear (Pydantic AI) emits OpenTelemetry GenAI spans. OpenInferenceSpanProcessor + translates those into OpenInference attributes so Arize AX reads them as LLM spans. + + Bull (Google ADK) is instrumented directly by GoogleADKInstrumentor. + +Both write into one tracer provider, so a single trace spans the orchestrator, the A2A +hop, and each specialist's tool calls. + +The processor is passed to register() through span_processors= rather than added +afterwards with add_span_processor(). That is deliberate: add_span_processor() on a +provider returned by register() shuts down and discards the default Arize exporter, and +since OpenInferenceSpanProcessor only translates spans and does not export them, adding +it that way would leave the process with no exporter and send nothing to Arize AX. +""" + +import os + +from arize.otel import register +from openinference.instrumentation.google_adk import GoogleADKInstrumentor +from openinference.instrumentation.pydantic_ai import OpenInferenceSpanProcessor +from pydantic_ai import Agent, InstrumentationSettings + +_tracer_provider = None + + +def setup_tracing(project_name: str | None = None): + """Configure tracing once per process and return the tracer provider.""" + global _tracer_provider + if _tracer_provider is not None: + return _tracer_provider + + project_name = project_name or os.environ.get( + "ARIZE_PROJECT_NAME", "a2a-trading-agents" + ) + + _tracer_provider = register( + space_id=os.environ["ARIZE_SPACE_ID"], + api_key=os.environ["ARIZE_API_KEY"], + project_name=project_name, + span_processors=[OpenInferenceSpanProcessor()], + set_global_tracer_provider=True, + ) + + GoogleADKInstrumentor().instrument(tracer_provider=_tracer_provider) + + # Pydantic AI 2.x sets instrumentation on the Agent class rather than per-agent + # (Agent(instrument=True) was removed), so point every agent at this provider. + Agent.instrument_all(InstrumentationSettings(tracer_provider=_tracer_provider)) + + return _tracer_provider + + +def flush() -> None: + """Force-flush pending spans. Arize AX ingests asynchronously, so call this before exit.""" + if _tracer_provider is not None: + _tracer_provider.force_flush() diff --git a/python/llm/agents/a2a_trading_agents/trading_agents/__init__.py b/python/llm/agents/a2a_trading_agents/trading_agents/__init__.py new file mode 100644 index 0000000..78c94fc --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/trading_agents/__init__.py @@ -0,0 +1 @@ +"""The Bear and Bull specialist agents, and their A2A wrappers.""" diff --git a/python/llm/agents/a2a_trading_agents/trading_agents/bear_agent.py b/python/llm/agents/a2a_trading_agents/trading_agents/bear_agent.py new file mode 100644 index 0000000..cca1064 --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/trading_agents/bear_agent.py @@ -0,0 +1,144 @@ +"""Bear Agent: risk analysis on Pydantic AI, exposed over A2A. + +Pydantic AI has no built-in A2A server, so this module supplies the AgentExecutor that +bridges it: BearAgentExecutor translates an A2A task into an agent run and reports +progress back through the A2A TaskUpdater. +""" + +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.server.tasks import TaskUpdater +from a2a.types import AgentSkill, TaskState, TextPart, UnsupportedOperationError +from a2a.utils import new_agent_text_message +from a2a.utils.errors import ServerError +from vertexai.preview.reasoning_engines.templates.a2a import create_agent_card + +import config + +BEAR_SKILLS = [ + AgentSkill( + id="risk_analysis", + name="Risk Factor Scanner", + description="Identifies potential downside catalysts and risk factors", + tags=["Risk-Analysis", "Market-Analysis"], + examples=[ + "What are the key risks for NVDA?", + "Analyze downside catalysts for tech stocks", + ], + ), + AgentSkill( + id="divergence_detection", + name="Divergence Detection", + description="Finds bearish divergences and technical weakness signals", + tags=["Technical-Analysis", "Divergence"], + examples=["Find bearish divergences in AAPL"], + ), + AgentSkill( + id="exit_signals", + name="Exit Signal Monitoring", + description="Tracks distribution patterns and exit signals", + tags=["Exit-Strategy", "Risk-Management"], + examples=["Monitor exit signals for NVDA"], + ), +] + + +def create_bear_agent_card(): + """Create the A2A Agent Card that advertises the Bear agent's skills.""" + return create_agent_card( + agent_name="Bear Risk Analyst (Pydantic AI + MCP)", + description=( + "A cautious risk analyst powered by Pydantic AI, " + "focused on identifying downside catalysts and warning signals." + ), + skills=BEAR_SKILLS, + ) + + +def build_bear_agent(): + """Build the Pydantic AI agent with its MCP toolset attached.""" + from pydantic_ai import Agent + from pydantic_ai.mcp import MCPToolset, StdioTransport + + config.init_vertex() + + command, args = config.mcp_server_command("mcp_tools.bear_mcp_server") + toolset = MCPToolset( + StdioTransport(command=command, args=args, cwd=str(config.PROJECT_ROOT)) + ) + + # Instrumentation is not set here: tracing.setup_tracing() calls + # Agent.instrument_all(), which covers every agent in the process. + return Agent( + model=config.bear_model(), + system_prompt=config.BEAR_SYSTEM_PROMPT, + toolsets=[toolset], + retries=3, + ) + + +class BearAgentExecutor(AgentExecutor): + """A2A executor for the Bear agent. + + The agent is built lazily rather than in __init__ because Agent Engine pickles the + executor to deploy it, and an initialized agent holding an MCP subprocess is not + picklable. Building on first execute() also means tracing is configured inside the + process that actually serves traffic. + """ + + def __init__(self): + self.agent = None + self._traced = False + + def _init_agent(self): + if not self._traced: + import tracing + + tracing.setup_tracing() + self._traced = True + + if self.agent is None: + self.agent = build_bear_agent() + + async def cancel(self, context: RequestContext, event_queue: EventQueue): + raise ServerError(error=UnsupportedOperationError()) + + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + """Run one A2A task: analyze the requested symbol's downside risk.""" + query = context.get_user_input() + updater = TaskUpdater(event_queue, context.task_id, context.context_id) + + if not getattr(context, "current_task", None): + await updater.submit() + await updater.start_work() + + try: + # Inside the try: a build failure (bad credentials, missing model access) is + # reported to the caller as a failed task rather than escaping as a + # server-level JSON-RPC error the orchestrator cannot interpret. + self._init_agent() + + await updater.update_status( + TaskState.working, + message=new_agent_text_message("Analyzing risks..."), + ) + + result = await self.agent.run(query) + result_text = getattr(result, "output", None) or str(result) + + separator = "=" * 50 + response = f""" +BEAR RISK ANALYSIS +{separator} + +{result_text} +""" + await updater.add_artifact([TextPart(text=response)], name="risk_analysis") + await updater.complete() + + except Exception as exc: # surface the failure to the A2A caller + await updater.update_status( + TaskState.failed, + message=new_agent_text_message(f"Analysis failed: {exc}"), + final=True, + ) diff --git a/python/llm/agents/a2a_trading_agents/trading_agents/bull_agent.py b/python/llm/agents/a2a_trading_agents/trading_agents/bull_agent.py new file mode 100644 index 0000000..4c5db36 --- /dev/null +++ b/python/llm/agents/a2a_trading_agents/trading_agents/bull_agent.py @@ -0,0 +1,100 @@ +"""Bull Agent: opportunity analysis on Google ADK, exposed over A2A. + +ADK ships its own A2A executor, so unlike the Bear agent this one needs no hand-written +bridge: wrap the agent in a Runner, hand that to A2aAgentExecutor, and ADK speaks A2A. +""" + +from a2a.types import AgentSkill +from google.adk import Runner +from google.adk.a2a.executor.a2a_agent_executor import ( + A2aAgentExecutor, + A2aAgentExecutorConfig, +) +from google.adk.agents import LlmAgent +from google.adk.sessions import InMemorySessionService +from google.adk.tools.mcp_tool import StdioConnectionParams +from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset, StdioServerParameters +from vertexai.preview.reasoning_engines.templates.a2a import create_agent_card + +import config + +BULL_SKILLS = [ + AgentSkill( + id="breakout_detection", + name="Breakout Pattern Detection", + description="Identify bullish breakout patterns", + tags=["technical-analysis", "breakouts"], + examples=["Find breakout patterns for NVDA"], + ), + AgentSkill( + id="momentum_screening", + name="Momentum Screening", + description="Screen for stocks with strong momentum", + tags=["momentum", "screening"], + examples=["Find high momentum tech stocks"], + ), + AgentSkill( + id="entry_signals", + name="Entry Signal Detection", + description="Detect optimal entry points", + tags=["entry-points", "timing"], + examples=["When should I buy AAPL?"], + ), +] + + +def create_bull_agent_card(): + """Create the A2A Agent Card that advertises the Bull agent's skills.""" + return create_agent_card( + agent_name="Bull Market Analyst (ADK + MCP)", + description=( + "An optimistic analyst powered by Google ADK, " + "focused on growth opportunities and bullish patterns." + ), + skills=BULL_SKILLS, + ) + + +def build_bull_agent() -> LlmAgent: + """Build the ADK agent with its MCP toolset attached.""" + config.init_vertex() + + command, args = config.mcp_server_command("mcp_tools.bull_mcp_server") + toolset = MCPToolset( + connection_params=StdioConnectionParams( + server_params=StdioServerParameters( + command=command, + args=args, + cwd=str(config.PROJECT_ROOT), + ), + timeout=60, + ), + ) + + return LlmAgent( + name="bull_market_analyst", + model=config.bull_model(), + description="Optimistic analyst focused on growth opportunities and bullish signals.", + instruction=config.BULL_SYSTEM_PROMPT, + tools=[toolset], + ) + + +def build_bull_executor() -> A2aAgentExecutor: + """Build the ADK A2A executor for the Bull agent. + + Called by a2a_servers.py locally and by Agent Engine after deployment. Tracing is + configured here rather than at import time so that a deployed copy instruments the + process it actually runs in. + """ + import tracing + + tracing.setup_tracing() + + agent = build_bull_agent() + runner = Runner( + app_name=agent.name, + agent=agent, + session_service=InMemorySessionService(), + ) + return A2aAgentExecutor(runner=runner, config=A2aAgentExecutorConfig())