DEV Community

Three Clouds, One Brief: What Actually Differs Between ADK, Strands and Agent Framework

All three hyperscalers now ship an agent framework, and all three speak A2A. The protocol page will tell you that is the interoperability story finished: In a world where agents are built using diverse frameworks and by different vendors, A2A provides the definitive common language for agent interoperability. That is true on the wire, and the wire is not the whole job. So I built the same agent three times - one research agent, one instruction, one search tool, one word budget - on Google ADK, on AWS Strands and on Microsoft Agent Framework, hosted on each vendor's own runtime, and had one coordinator fan the same brief out to all three and score what came back. | AWS | Azure | || |---|---|---|---| | framework | ADK LlmAgent | Strands Agent | Agent Framework Agent | | model | gemini-2.5-flash | us.amazon.nova-micro-v1:0 | gpt-5-mini on Foundry | | served by | to_a2a() | a2a-sdk reference routes | A2AExecutor | | hosted on | Cloud Run, us-central1 | Bedrock AgentCore, us-west-2 | Container Apps, westus2 | The code is all here: github.com/xbill9/multicloud-a2a-subagent. Nothing below is about A2A being broken. A2A worked. This is about the nine other things that differ once it does - and about the two questions worth separating, which almost nobody separates: what differs because of the platform, and what differs because of the model. What actually has to be the same The first version of this was a demo: three agents, three SDKs, three green ticks. It told me nothing. When three columns differ in nine ways, you cannot attribute any result to any of them. So the rule became one line: share everything that is not the variable under test. | shared, exactly one implementation | different, on purpose | |---|---| | the brief and its focus questions | the agent framework | | the instruction, versioned | the model | | the search tool and its six-call budget | the serving stack | | the scoring rubric, versioned | the hosting platform | | the wire format - markdown, one stamped header | the credential mechanism | | the failure taxonomy | the tool-binding API | The right column is the article. The left column is what makes it evidence instead of an anecdote. The one people argue with is the search tool. I gave all three clouds the same search function rather than each vendor's own, and it is the decision I would defend hardest. Only Google ships a ready search tool. Microsoft's Agent Framework exports SupportsWebSearchTool , which is a protocol a chat client may declare - not a tool you can hand an agent - and Foundry's own grounding wants a Bing resource connection created out of band. Strands bundles none at all. "Native search everywhere" would have meant Gemini grounded against Google's index, a Foundry model against Bing, and Bedrock against nothing: three retrieval products, and a comparison that reports the gap between them as a gap between models. What is still native is the part I wanted to see anyway - how each framework binds and drives a tool. That part is now the only part that varies. Three frameworks, three shapes for the same agent Here is the entire model-side construction on each cloud. Not excerpts - this is all of it. Google, ADK: from google.adk.agents import LlmAgent LlmAgent( model="gemini-2.5-flash", # a model id string name=..., description=..., instruction=INSTRUCTION, # instruction tools=[web_search], # a plain callable ) AWS, Strands: from strands import Agent, tool from strands.models import BedrockModel Agent( model=BedrockModel(model_id="us.amazon.nova-micro-v1:0"), # a model object system_prompt=INSTRUCTION, # system_prompt tools=[tool(web_search)], # explicitly decorated ) Azure, Agent Framework: from agent_framework import Agent from agent_framework.foundry import FoundryChatClient from azure.identity import DefaultAzureCredential Agent( client=FoundryChatClient( # a chat client, not a model project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], model=model_id(), credential=DefaultAzureCredential(), ), instructions=INSTRUCTION, # instructions, plural tools=[web_search], default_options={"store": False}, ) Three names for the system prompt. Three levels at which the model is named: a string, a model object, a client holding an endpoint and a credential. Three tool conventions - ADK wraps the plain callable itself, Strands wants an explicit @tool , Agent Framework takes the callable and runs it through its own function machinery. None of that is hard. All of it is untranslatable. There is no adapter that turns these into one object, and every hour I have seen spent trying to build one produced a fourth thing to maintain that then became what was actually under test. Share the prompt, the tool and the wire format. Do not try to share the agent. Two of the three give you nowhere to stand Strands hands you a function. Everything else can wrap it from outside: async def respond(prompt: str) -> str: return str(await agent.invoke_async(prompt)) ADK and Agent Framework do not. to_a2a() takes an agent and serialises its event stream, and Agent Framework's A2AExecutor calls the agent too. Neither gives you an (prompt) -> reply boundary, so anything you need to do between the model and the wire has to be done inside that framework's own object model - a BaseAgent wrapping the first agent on one cloud, a delegating class implementing run on the other. This is not a style complaint. It decides where a fact can be recorded. Every draft in this system carries one line: That line is written by the server, never by the model. It carries the two things the coordinator cannot reconstruct from its own side of the wire - which model actually answered, and whether a model answered at all. Ask the model 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. And on ADK that wrapper became load-bearing the moment I added a tool. The first version concatenated the text of every event in the stream, which was correct while the stream held exactly one event. With web_search attached 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 the model narrating its own research. The scorer downstream then grades the narration. Keep only event.is_final_response() . A completed task does not mean you got the answer ADK and Agent Framework both return a Task in TASK_STATE_COMPLETED . Both are spec-conformant. They disagree about where the reply goes: - ADK attaches it as an artifact - and also leaves a copy in history. - Agent Framework's A2AExecutor drives the full lifecycle (submit β†’start_work β†’complete ) and leaves the reply as aROLE_AGENT message in history, withartifacts empty. - The a2a-sdk reference executor, which is what my AWS agent sits on, enqueues a singleMessage and runs no task lifecycle at all. So the obvious client - read task.artifacts - works perfectly against Google and returns an empty string against Microsoft. Not an error. Not a timeout. A successful call with no content, which then fails somewhere downstream as a parse error pointing at the wrong layer. Read every carrier the spec allows and you get the mirror-image bug: ADK's reply arrives twice, once per envelope. That one is worth dwelling on, because of how it stayed hidden. In the predecessor version of this project the agents returned an exchange rate, and the parser indexed quotes by target currency - so a duplicate object quietly overwrote its twin and the answer was correct. Change the domain to a written draft and the body doubles, the word count doubles, and the scorer 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. No test caught it, and the suite was green throughout. There is now a live test 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. "The call succeeded" and "you received the answer" are different claims in A2A. A client written against one vendor's server will pass that vendor's tests while silently dropping another vendor's replies. The agent card advertises an address you cannot dial to_a2a(agent, host, port) writes the bind address straight into the 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. My AWS and Azure agents take a PUBLIC_URL and advertise that - the behaviour ADK is missing, not anything clever. It cannot reproduce locally, because on a laptop the bind address and the dial address are the same string. It needs a deployment, which is exactly how it survives into one. Which clients survive it is the opposite of what the ergonomics would predict: | client | against the deployed ADK server | |---|---| 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 ship green in Google's own tests, because locally the two addresses are identical. And the stack that has no seam to patch a resolved card is the one that never needed it, because it dials the URL you constructed it with. Then the failure is reported at the wrong layer. Having dialled 0.0.0.0:8080 and failed, RemoteA2aAgent raises this: 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 - lands on a separate log line. Two defects compounding: the first sends the client to an unrou

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.