feat: add M365 AgentsSDK A2A patterns Python sample#474
feat: add M365 AgentsSDK A2A patterns Python sample#474sgshaji wants to merge 1 commit intomicrosoft:mainfrom
Conversation
|
@microsoft-github-policy-service agree company="Microsoft" |
There was a problem hiding this comment.
Pull request overview
Adds a new end-to-end Python sample under samples/python/m365-agents-sdk-a2a-patterns/ demonstrating M365 Agents SDK agent orchestration with Semantic Kernel, plus A2A protocol integrations (ping/send, SSE streaming, push/webhook) against a Google ADK “producer” agent.
Changes:
- Introduces a Google ADK A2A producer agent (BigQuery + SerpAPI + optional Selenium scraping) with scripts, eval datasets, and containerization.
- Adds an M365 Agents SDK “consumer” agent that uses Semantic Kernel for tool-driven orchestration and provides CLIs to exercise ping/stream/push patterns.
- Adds documentation and sample scaffolding (docs, env templates, ignore rules) and links the sample from
samples/python/README.md.
Reviewed changes
Copilot reviewed 51 out of 51 changed files in this pull request and generated 19 comments.
Show a summary per file
| File | Description |
|---|---|
| samples/python/m365-agents-sdk-a2a-patterns/env.example | Environment variable template for the sample. |
| samples/python/m365-agents-sdk-a2a-patterns/docs/ARCHITECTURE.md | Architecture write-up and component diagrams. |
| samples/python/m365-agents-sdk-a2a-patterns/docs/A2A_PATTERNS.md | Protocol/pattern deep dive with JSON-RPC and sequence diagrams. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/tests/unit/test_tools.py | Adds unit test(s) for ADK tool layer. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/run_a2a.py | ADK A2A server entrypoint (Starlette + A2A handler + push). |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/pyproject.toml | ADK agent dependencies and dev tooling config. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/eval/test_eval.py | ADK evaluation runner using ADK evaluator. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/eval/data/test_config.json | Evaluation scoring configuration. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/eval/data/eval_data1.evalset.json | Evaluation dataset for multi-turn/tool-trajectory behavior. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/eval/init.py | Eval package initializer. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/deployment/test_deployment.py | Script to test deployed agent on Vertex AI Agent Engine. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/deployment/test.sh | Local unit test runner script. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/deployment/run.sh | Local setup script (install + BigQuery populate). |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/deployment/eval.sh | Shell wrapper to run ADK eval. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/deployment/deploy.py | Vertex AI Agent Engine deployment helper. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/deployment/bq_populate_data.py | Helper to create/populate BigQuery dataset/table with sample rows. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/deployment/bq_data_setup.sql | SQL alternative for BigQuery table setup. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/deployment/init.py | Deployment package initializer. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/brand_search_optimization/tools/serp_connector.py | SerpAPI-based competitor product retrieval. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/brand_search_optimization/tools/bq_connector.py | BigQuery tools for categories + product lookup. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/brand_search_optimization/sub_agents/search_results/prompt.py | Prompt for web/search-results sub-agent. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/brand_search_optimization/sub_agents/search_results/agent.py | Search-results agent tools (SerpAPI + scraping fallback) and Selenium helpers. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/brand_search_optimization/sub_agents/keyword_finding/prompt.py | Prompt for keyword-finding sub-agent. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/brand_search_optimization/sub_agents/keyword_finding/agent.py | Keyword-finding agent wiring. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/brand_search_optimization/sub_agents/comparison/prompt.py | Prompts for comparison generator/critic/root. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/brand_search_optimization/sub_agents/comparison/agent.py | Comparison sub-agent wiring. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/brand_search_optimization/sub_agents/init.py | Sub-agents package initializer. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/brand_search_optimization/shared_libraries/constants.py | Shared configuration/env constants for ADK agent. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/brand_search_optimization/prompt.py | Root agent orchestration prompt. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/brand_search_optimization/agent.py | Root ADK agent wiring and tool exposure. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/brand_search_optimization/init.py | Package initializer. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/README.md | ADK agent setup/run/deploy docs. |
| samples/python/m365-agents-sdk-a2a-patterns/adk-agent/Dockerfile | Container image for ADK A2A server mode. |
| samples/python/m365-agents-sdk-a2a-patterns/a2a-client-agent/test_demo.py | Interactive demo CLI for ping/stream/push experiences. |
| samples/python/m365-agents-sdk-a2a-patterns/a2a-client-agent/run_server.py | Entry point for M365 agent aiohttp server. |
| samples/python/m365-agents-sdk-a2a-patterns/a2a-client-agent/requirements.txt | Client agent Python dependencies. |
| samples/python/m365-agents-sdk-a2a-patterns/a2a-client-agent/env.TEMPLATE | Client agent environment template. |
| samples/python/m365-agents-sdk-a2a-patterns/a2a-client-agent/cli_test.py | Direct A2A protocol CLI (discover/ping/stream/push/status). |
| samples/python/m365-agents-sdk-a2a-patterns/a2a-client-agent/brand_intelligence_advisor/tools/brand_advisor.py | Local parsing/domain knowledge + formatting utilities. |
| samples/python/m365-agents-sdk-a2a-patterns/a2a-client-agent/brand_intelligence_advisor/tools/a2a_client.py | Async A2A client implementation (send/stream/push). |
| samples/python/m365-agents-sdk-a2a-patterns/a2a-client-agent/brand_intelligence_advisor/tools/init.py | Tools package docs. |
| samples/python/m365-agents-sdk-a2a-patterns/a2a-client-agent/brand_intelligence_advisor/server.py | aiohttp server with M365 /api/messages + webhook endpoints. |
| samples/python/m365-agents-sdk-a2a-patterns/a2a-client-agent/brand_intelligence_advisor/prompt.py | System prompt for Semantic Kernel orchestration. |
| samples/python/m365-agents-sdk-a2a-patterns/a2a-client-agent/brand_intelligence_advisor/orchestrator.py | Semantic Kernel ChatCompletionAgent + tool plugin. |
| samples/python/m365-agents-sdk-a2a-patterns/a2a-client-agent/brand_intelligence_advisor/agent.py | M365 Agents SDK agent handlers + LLM/regex fallback routing. |
| samples/python/m365-agents-sdk-a2a-patterns/a2a-client-agent/brand_intelligence_advisor/init.py | Package docs. |
| samples/python/m365-agents-sdk-a2a-patterns/a2a-client-agent/README.md | Client agent setup and explanation. |
| samples/python/m365-agents-sdk-a2a-patterns/README.md | Root sample README tying together all tracks/patterns. |
| samples/python/m365-agents-sdk-a2a-patterns/LICENSE | Sample-level MIT license. |
| samples/python/m365-agents-sdk-a2a-patterns/.gitignore | Sample-local ignore rules for env/log/artifacts. |
| samples/python/README.md | Adds the new sample to the Python samples index. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def click_at_coordinates(x: int, y: int) -> str: | ||
| """Clicks at the specified coordinates on the screen.""" | ||
| driver.execute_script(f"window.scrollTo({x}, {y});") | ||
| driver.find_element(By.TAG_NAME, "body").click() | ||
|
|
||
|
|
||
| def find_element_with_text(text: str) -> str: | ||
| """Finds an element on the page with the given text.""" | ||
| print(f"🔍 Finding element with text: '{text}'") # Added print statement | ||
|
|
||
| try: | ||
| element = driver.find_element(By.XPATH, f"//*[text()='{text}']") | ||
| if element: |
There was a problem hiding this comment.
Several Selenium tools (click_at_coordinates, find_element_with_text, etc.) use the module-global driver without calling _ensure_driver() or checking for None. If go_to_url hasn’t been called (or driver init fails / is disabled), these tools will raise. Also click_at_coordinates doesn’t return a str on success, despite its annotation and being registered as a tool.
| SEARCH_RESULT_AGENT_PROMPT = """ | ||
| You are an autonomous web search agent that extracts competitor product data. | ||
|
|
||
| <Process> | ||
| 1. When you receive a request like "Find competing products for moisture management socks", extract the keyword | ||
| - The keyword is everything after "for" (e.g., "moisture wicking socks") | ||
|
|
||
| 2. IMMEDIATELY call extract_google_shopping_products(keyword="[extracted_keyword]") | ||
| - Pass the keyword as a parameter | ||
| - Example: extract_google_shopping_products(keyword="moisture wicking socks") | ||
| - DO NOT ask for confirmation | ||
| - DO NOT say "I will search" | ||
| - JUST call the tool right away | ||
| - This tool automatically tries multiple sources: | ||
| * Amazon (primary - most stable) | ||
| * Google Shopping (fallback) | ||
| - Returns real product titles and prices from whichever source succeeds | ||
| - Always gets real data - no mock results | ||
|
|
There was a problem hiding this comment.
The search-results agent prompt is internally inconsistent: step numbering skips from 2 → 4, and it claims the tool’s primary source is Amazon with Google Shopping fallback, but the implementation now tries SerpAPI first (then Amazon, then Google Shopping). This can lead the LLM to describe the wrong behavior or make incorrect assumptions during execution.
| class TestBrandSearchOptimization: | ||
| @patch("brand_search_optimization.tools.bq_connector.client") | ||
| def test_get_product_details_for_brand_success(self, mock_client): | ||
| # Mock ToolContext | ||
| mock_tool_context = MagicMock(spec=ToolContext) | ||
| mock_tool_context.user_content.parts = [MagicMock(text="cymbal")] | ||
|
|
||
| # Mock BigQuery results | ||
| mock_row1 = MagicMock( | ||
| title="cymbal Air Max", | ||
| description="Comfortable running shoes", | ||
| attribute="Size: 10, Color: Blue", | ||
| brand="cymbal", | ||
| ) | ||
| mock_row2 = MagicMock( | ||
| title="cymbal Sportswear T-Shirt", | ||
| description="Cotton blend, short sleeve", | ||
| attribute="Size: L, Color: Black", | ||
| brand="cymbal", | ||
| ) | ||
| mock_row3 = MagicMock( | ||
| title="neuravibe Pro Training Shorts", | ||
| description="Moisture-wicking fabric", | ||
| attribute="Size: M, Color: Gray", | ||
| brand="neuravibe", | ||
| ) | ||
| mock_results = [mock_row1, mock_row2, mock_row3] | ||
|
|
||
| # Mock QueryJob and its result | ||
| mock_query_job = MagicMock() | ||
| mock_query_job.result.return_value = mock_results | ||
| mock_client.query.return_value = mock_query_job | ||
|
|
||
| # Mock constants | ||
| with patch.object(constants, "PROJECT", "test_project"): | ||
| with patch.object(constants, "TABLE_ID", "test_table"): | ||
| # Call the function | ||
| markdown_output = bq_connector.get_product_details_for_brand( | ||
| mock_tool_context | ||
| ) | ||
| assert "neuravibe Pro" not in markdown_output |
There was a problem hiding this comment.
This unit test doesn’t match the implementation it targets: bq_connector.get_product_details_for_brand expects (brand: str, category: str | None), but the test passes a ToolContext mock. It also patches constants.TABLE_ID, which doesn’t exist in shared_libraries/constants.py. As written, this test will fail at runtime and won’t validate the intended behavior.
| bucket = ( | ||
| FLAGS.bucket | ||
| if FLAGS.bucket | ||
| else os.getenv("GOOGLE_CLOUD_STORAGE_BUCKET") | ||
| ) | ||
|
|
||
| project_id = os.getenv("GOOGLE_CLOUD_PROJECT") | ||
| location = os.getenv("GOOGLE_CLOUD_LOCATION") | ||
| bucket = os.getenv("GOOGLE_CLOUD_STORAGE_BUCKET") | ||
|
|
||
| if not project_id: | ||
| print("Missing required environment variable: GOOGLE_CLOUD_PROJECT") | ||
| return | ||
| elif not location: | ||
| print("Missing required environment variable: GOOGLE_CLOUD_LOCATION") | ||
| return | ||
| elif not bucket: | ||
| print( | ||
| "Missing required environment variable: GOOGLE_CLOUD_STORAGE_BUCKET" | ||
| ) |
There was a problem hiding this comment.
The bucket environment variable name here is GOOGLE_CLOUD_STORAGE_BUCKET, but elsewhere in this sample the staging bucket is STAGING_BUCKET (see shared_libraries/constants.py / env.example). This inconsistency will cause setup confusion; align on one variable name and update the error messages accordingly.
| # Build query with optional category filter | ||
| where_clause = f"WHERE LOWER(brand) = LOWER('{brand}')" | ||
| if category: | ||
| category = category.strip() | ||
| where_clause += f" AND LOWER(category) = LOWER('{category}')" | ||
|
|
There was a problem hiding this comment.
get_product_details_for_brand builds the SQL WHERE clause via f-string interpolation of brand/category, unlike get_categories_for_brand which correctly uses query parameters. This is vulnerable to SQL injection and can also break on quotes in brand/category names. Use QueryJobConfig with ScalarQueryParameter (or at least parameterized @brand/@category) instead of interpolating user input into the query string.
| comparsion_critic_agent = Agent( | ||
| model=constants.MODEL, | ||
| name="comparison_critic_agent", | ||
| description="A helpful agent to critique comparison.", | ||
| instruction=prompt.COMPARISON_CRITIC_AGENT_PROMPT, | ||
| ) |
There was a problem hiding this comment.
Typo in variable name comparsion_critic_agent (should be comparison_critic_agent). While it doesn’t break execution (it’s referenced consistently), it makes the code harder to read/search and is easy to propagate elsewhere.
| print( | ||
| "\n--- Instructions on how to add permissions to BQ Table are in the customiztion.md file ---" | ||
| ) |
There was a problem hiding this comment.
Typo in the printed message: customiztion.md → customization.md (or update to the correct filename).
| """Test deployment of FOMC Research Agent to Agent Engine.""" | ||
|
|
||
| import asyncio | ||
| import os | ||
|
|
||
| import vertexai | ||
| from absl import app, flags | ||
| from dotenv import load_dotenv | ||
| from google.adk.sessions import VertexAiSessionService | ||
| from vertexai import agent_engines | ||
|
|
||
| FLAGS = flags.FLAGS | ||
|
|
||
| flags.DEFINE_string("project_id", None, "GCP project ID.") | ||
| flags.DEFINE_string("location", None, "GCP location.") | ||
| flags.DEFINE_string("bucket", None, "GCP bucket.") | ||
| flags.DEFINE_string( | ||
| "resource_id", | ||
| None, | ||
| "ReasoningEngine resource ID (returned after deploying the agent)", | ||
| ) | ||
| flags.DEFINE_string("user_id", None, "User ID (can be any string).") | ||
| flags.mark_flag_as_required("resource_id") | ||
| flags.mark_flag_as_required("user_id") | ||
|
|
||
|
|
||
| def main(argv: list[str]) -> None: # pylint: disable=unused-argument | ||
| load_dotenv() | ||
|
|
||
| project_id = ( | ||
| FLAGS.project_id | ||
| if FLAGS.project_id | ||
| else os.getenv("GOOGLE_CLOUD_PROJECT") | ||
| ) | ||
| location = ( | ||
| FLAGS.location if FLAGS.location else os.getenv("GOOGLE_CLOUD_LOCATION") | ||
| ) | ||
| bucket = ( | ||
| FLAGS.bucket | ||
| if FLAGS.bucket | ||
| else os.getenv("GOOGLE_CLOUD_STORAGE_BUCKET") | ||
| ) | ||
|
|
||
| project_id = os.getenv("GOOGLE_CLOUD_PROJECT") | ||
| location = os.getenv("GOOGLE_CLOUD_LOCATION") | ||
| bucket = os.getenv("GOOGLE_CLOUD_STORAGE_BUCKET") |
There was a problem hiding this comment.
This script ignores CLI flags because project_id, location, and bucket are re-assigned from environment variables immediately after computing the flag/env fallback (lines 44–60). Remove the second assignment block so flags actually work, or intentionally drop the flags. Also the module docstring mentions “FOMC Research Agent”, which doesn’t match this sample.
| payload = { | ||
| "jsonrpc": "2.0", | ||
| "id": request_id, | ||
| "method": "pushNotificationConfig/set", | ||
| "params": { | ||
| "taskId": task_id, | ||
| "pushNotificationConfig": { | ||
| "url": webhook_url, | ||
| "token": token, | ||
| }, | ||
| }, | ||
| } |
There was a problem hiding this comment.
register_push() uses JSON-RPC method pushNotificationConfig/set, but the rest of this sample (docs + cli_test.py) uses tasks/pushNotificationConfig/set and explicitly notes the tasks/ prefix requirement. If the server expects the tasks/-prefixed method, push registration from the SDK client/orchestrator will fail. Align the method name (and any required param shape) with the documented/working pattern.
| # 3b. Push notification sender — POSTs task updates to registered webhook URLs | ||
| httpx_client = httpx.AsyncClient(timeout=30.0) | ||
| push_sender = BasePushNotificationSender( | ||
| httpx_client=httpx_client, | ||
| config_store=push_config_store, | ||
| ) |
There was a problem hiding this comment.
httpx.AsyncClient is created at module scope (httpx_client = httpx.AsyncClient(...)) but never closed. This can leak connections and emit warnings on shutdown. Consider creating it during app startup and closing it during shutdown (Starlette lifespan events), or using an async context manager around the server lifecycle.
Summary
This PR adds a new Python sample — M365 AgentsSDK A2A Patterns — demonstrating how to build intelligent agents using the M365 Agents SDK with Agent-to-Agent (A2A) protocol integration.
What's Included
Track 1 — Intelligent Agent Orchestration with Semantic Kernel
AzureChatCompletionwired into aChatCompletionAgentinside the M365 Agents SDK hosting model@kernel_functiontoolsTrack 2 — Agent-to-Agent Communication via A2A Protocol
Track 3 — A2A Transmission Patterns (Ping / Stream / Push)
message/send): blocking request/responsemessage/stream): Server-Sent Events for real-time resultsmessage/send+ webhook): fire-and-forget with async callbackSample Structure
samples/python/m365-agents-sdk-a2a-patterns/
├── a2a-client-agent/ # M365 Agents SDK + Semantic Kernel
├── adk-agent/ # Google ADK + Gemini 2.0 Flash (A2A producer)
└── docs/ # Architecture diagrams and A2A pattern deep-dives
Notes
microsoft_agentsimport structure (underscore) per current SDK conventionpip install -r requirements.txtcd-samplesCI workflow