Mix and Match: Serving an ADK Agent to AWS and Azure
This article provides a step by step look at running a Google ADK agent on Cloud Run, and serving it over the A2A protocol to callers that are not ADK. The code is here: github.com/xbill9/multicloud-a2a-subagent What is this project trying to Do? This project aims to answer one research brief with three agents on three clouds, over one protocol, with no stored credentials between them. Google runs an ADK agent on Cloud Run. AWS runs a Strands agent on Bedrock AgentCore. Azure runs an Agent Framework agent on Container Apps. A coordinator asks all three the same question and scores what comes back. Everything below is the Google side of it, and none of it is visible from a laptop. Aren't Those Clients on the Wrong Clouds? Mix and Match. That is the entire point of the exercise. An ADK agent that only ever answers ADK clients is not interoperating with anything. The findings below all needed two things at once: a deployment, and a caller that was not Google's. Google ADK The Agent Development Kit is Google's open source framework for building and deploying AI agents. It is model agnostic and deployment agnostic, it runs Gemini through Vertex AI or an API key, and to_a2a() turns an agent into an A2A server in one line. More information is available here: What is A2A A2A (Agent2Agent) is an open protocol for agents built by different teams, on different frameworks, to call each other. An agent publishes a card at /.well-known/agent-card.json describing what it does and how to reach it, and speaks JSON-RPC over HTTP. This project runs A2A v1.0. More details are available here: Serving ADK Over A2A This is the whole server: from google.adk.agents import LlmAgent from google.adk.a2a.utils.agent_to_a2a import to_a2a agent = LlmAgent( model="gemini-2.5-flash", name=..., description=..., instruction=INSTRUCTION, tools=[web_search], ) app = to_a2a(agent, host=HOST, port=PORT) That is genuinely the shortest path from an LlmAgent to something another vendor's agent can call, and it is the reason I started here. Then deploy it to Cloud Run from source, no Dockerfile: PUBLIC=1 MODEL_MODE=llm ./infra/deploy_gcp.sh deploy Verify The Agent Card First step after any deploy - fetch your own card: $ curl -s https:// .run.app/.well-known/agent-card.json {"url": null, "additionalInterfaces": [ {"url": "http://0.0.0.0:8080", "protocolBinding": "JSONRPC"} ]} A public HTTPS endpoint advertising unroutable plaintext. to_a2a(agent, host, port) writes host:port straight into the card's interface URL. On Cloud Run the process binds 0.0.0.0:8080 , so that is what the card says. Note- it cannot reproduce locally. On a laptop the bind address and the dial address are the same string, which is exactly how it survives into a deployment. The other two agents take a PUBLIC_URL and advertise that - the behaviour ADK is missing, not anything clever. Which clients survive it is the part worth knowing: | client | against the deployed ADK server | why | |---|---|---| a2a-sdk | ok | rewrites the interfaces after card resolution | agent-framework A2AAgent | ok | never routes by card, so a bad card is inert | google-adk RemoteA2aAgent | fails | routes by card, dials 0.0.0.0:8080 | ADK's own client cannot reach ADK's own server once hosted. Both halves pass Google's own tests, because locally the two addresses are identical. The one pairing that is entirely first-party code is the one that cannot complete a hop. And the failure is reported at the wrong layer. Having dialled 0.0.0.0:8080 and failed, RemoteA2aAgent raises: AttributeError: 'A2AClientError' object has no attribute 'status_code' The error handler assumes any A2AClientError carries a status code, which a transport failure does not. The real cause - All connection attempts failed - goes to a separate log line. Two defects compounding: the first sends the client somewhere unroutable, the second deletes the evidence of where it went. So if you serve with to_a2a() today: fetch your own card after deploying, and if you cannot fix the card, make sure your callers rewrite the interface URL after resolution rather than routing by it. The Same Reply, Delivered Twice ADK's executor attaches the reply as a task artifact - and also leaves a copy in task history. That only matters when you talk to someone else. Microsoft's A2AExecutor drives the full task lifecycle and leaves the reply only in history, with artifacts empty. So a client that reads artifacts alone - the obvious implementation, and the one that works perfectly against ADK - returns an empty string against Agent Framework. Not an error, not a timeout: a successful call with no content. Fix that by reading every carrier the spec allows, and ADK's reply now arrives twice. In an earlier version of this project the agents returned an exchange rate, and the duplicate was invisible: the parser indexed quotes by target currency, so the second copy overwrote the first and the answer was correct. Change the payload to a written draft and the body doubles, the word count doubles, and the scorer downstream marks a compliant draft as a 100% length overrun. I found it by reading output - one cloud returned 202 words of text the other two returned in 98. Note- no test caught it. The suite was green throughout. There is a live test now asserting all three serving stacks return the same canned text at the same length, which is the cheapest detector I know for the whole class. There is No Function Seam Strands hands you async (prompt) -> reply . You can wrap that from outside and be done. to_a2a() takes an agent and serialises its event stream, so anything you need to do between the model and the wire has to happen inside ADK's object model - in my case a BaseAgent that consumes inner.run_async(ctx) and yields one final event. Why bother: every draft in this system carries one line, written by the server and never by the model. It carries the two things the coordinator cannot reconstruct from its side of the wire - which model actually answered, and whether a model answered at all. Ask Gemini to emit its own metadata and a model that gets it wrong misattributes a draft in the audit, which is the one error an audit cannot detect from the inside. That wrapper became load-bearing the moment I attached a tool. The first version concatenated the text of every event in the stream, which is correct while the stream holds exactly one event. With a web_search tool bound, the stream also carries the model's commentary around each tool call - "Let me look that up", a summary of what it found - and concatenating those produces a draft that opens with Gemini narrating its own research. The rubric then scores the narration. async for event in inner.run_async(ctx): if not event.is_final_response(): continue # <- the whole fix ... Function calls and their results have no text and were already skipped by a part.text filter. What has to be excluded explicitly is the model text that accompanies them. The Tool is Deliberately Not google_search ADK gives you google_search as native hosted grounding, and I did not use it. The other two clouds cannot match it. Microsoft's Agent Framework exports SupportsWebSearchTool , which is a protocol a chat client may declare rather than a tool you can hand an agent, and Foundry's own grounding wants a Bing resource connection created out of band. Strands bundles no search at all. Using each vendor's native option would have meant Gemini grounded against Google's index and Bedrock grounded against nothing - three retrieval products, and a comparison that reports the difference between them as a difference between models. So all three get the same plain function against the same backend. What stays native is the part worth measuring: ADK wraps the plain callable itself and runs its own tool-calling loop, which is a genuinely different implementation from Strands' @tool decorator and Agent Framework's function machinery. Calling ADK From a Program As a client, ADK is the heaviest of the three stacks by a wide margin. RemoteA2aAgent is a BaseAgent meant to sit inside an agent tree, so using it as a plain client means standing up a Runner , an InMemorySessionService and a session - per request. Local, direct-brain, no model in the path, every client against every server: $ python3 -m matrix.runner client \ server gcp aws azure ------------------------------------------------- a2a-sdk ok 134ms ok 8ms ok 8ms agent-framework ok 129ms ok 7ms ok 8ms google-adk ok 920ms ok 9ms ok 10ms 9/9 attempted cells succeeded Single loopback runs, so read it as an ordering and nothing more. The shape holds anyway: against the ADK server the ADK client costs about seven times what the other two do, and it still emits [EXPERIMENTAL] warnings on every call. If you are writing a program that calls an agent rather than an agent that calls an agent, the reference a2a-sdk client is the lighter object. What Cloud Run Brings Two Cloud Run properties shaped this whole project. It mints workload OIDC tokens for an audience you choose. That is what makes the mesh keyless: the coordinator takes a Google-minted token to AWS STS for AssumeRoleWithWebIdentity and to Entra as a client assertion, so there is no stored secret on any leg. It is also why the coordinator lives on Google rather than anywhere else. The other two runtimes' minting ability was never confirmed, so hosting it there would have meant storing a credential. Where your coordinator runs decides how many secrets your system has. And One Thing It Costs A cold start is visible in the wrong place. On a scale-to-zero service the first thing a caller touches is not your agent, it is your agent card: +792ms gcp D research-gcp-...run.app/.well-known/ 200 5849ms +6643ms gcp I research-gcp-...run.app/ 200 55ms 5849ms on discovery, 55ms on the actual invocation once warm. A single per-leg number would have recorded that run as "Gemini is slow." The Buildpack Entrypoint Trap This one cost a deploy cycle. The GCP side is built o
Comments
No comments yet. Start the discussion.