{"slug": "three-clouds-one-brief-what-actually-differs-between-adk-strands-and-agent", "title": "Three Clouds, One Brief: What Actually Differs Between ADK, Strands and Agent Framework", "summary": "A developer built the same agent three times using Google ADK, AWS Strands, and Microsoft Agent Framework, hosting each on its respective cloud runtime and coordinating them via the A2A protocol. The experiment found that while A2A ensures wire-level interoperability, significant differences remain in framework APIs, model configuration, and tool binding. The developer shared all code on GitHub and emphasized the importance of isolating variables to compare frameworks fairly.", "body_md": "All three hyperscalers now ship an agent framework, and all three speak A2A. The\n\nprotocol page will tell you that is the interoperability story finished:\n\nIn a world where agents are built using diverse frameworks and by different\n\nvendors, A2A provides the definitive common language for agent\n\ninteroperability.\n\nThat is true on the wire, and the wire is not the whole job. So I built the\n\nsame agent three times — one research agent, one instruction, one search tool, one word\n\nbudget — on Google ADK, on AWS Strands and on Microsoft Agent Framework, hosted\n\non each vendor's own runtime, and had one coordinator fan the same brief out to\n\nall three and score what came back.\n\n| AWS | Azure | ||\n|---|---|---|---|\n| framework | ADK `LlmAgent`\n|\nStrands `Agent`\n|\nAgent Framework `Agent`\n|\n| model | `gemini-2.5-flash` |\n`us.amazon.nova-micro-v1:0` |\n`gpt-5-mini` on Foundry |\n| served by | `to_a2a()` |\n`a2a-sdk` reference routes |\n`A2AExecutor` |\n| hosted on | Cloud Run, us-central1 | Bedrock AgentCore, us-west-2 | Container Apps, westus2 |\n\nThe code is all here:\n\n[github.com/xbill9/multicloud-a2a-subagent](https://github.com/xbill9/multicloud-a2a-subagent).\n\nNothing below is about A2A being broken. A2A worked. This is about the nine\n\nother things that differ once it does — and about the two questions worth\n\nseparating, which almost nobody separates: **what differs because of the\nplatform**, and\n\nThe first version of this was a demo: three agents, three SDKs, three green\n\nticks. It told me nothing. When three columns differ in nine ways, you cannot\n\nattribute any result to any of them.\n\nSo the rule became one line: **share everything that is not the variable under\ntest.**\n\n| shared, exactly one implementation | different, on purpose |\n|---|---|\n| the brief and its focus questions | the agent framework |\n| the instruction, versioned | the model |\n| the search tool and its six-call budget | the serving stack |\n| the scoring rubric, versioned | the hosting platform |\n| the wire format — markdown, one stamped header | the credential mechanism |\n| the failure taxonomy | the tool-binding API |\n\nThe right column is the article. The left column is what makes it evidence\n\ninstead of an anecdote.\n\nThe one people argue with is the search tool. **I gave all three clouds the same\nsearch function rather than each vendor's own**, and it is the decision I would\n\n`SupportsWebSearchTool`\n\n, which is a protocol a chat client mayWhat is still native is the part I wanted to see anyway — how each framework\n\nbinds and drives a tool. That part is now the only part that varies.\n\nHere is the entire model-side construction on each cloud. Not excerpts — this is\n\nall of it.\n\n**Google, ADK:**\n\n``` python\nfrom google.adk.agents import LlmAgent\n\nLlmAgent(\n    model=\"gemini-2.5-flash\",          # a model id string\n    name=..., description=...,\n    instruction=INSTRUCTION,           # `instruction`\n    tools=[web_search],                # a plain callable\n)\n```\n\n**AWS, Strands:**\n\n``` python\nfrom strands import Agent, tool\nfrom strands.models import BedrockModel\n\nAgent(\n    model=BedrockModel(model_id=\"us.amazon.nova-micro-v1:0\"),   # a model *object*\n    system_prompt=INSTRUCTION,                                  # `system_prompt`\n    tools=[tool(web_search)],                                   # explicitly decorated\n)\n```\n\n**Azure, Agent Framework:**\n\n``` python\nfrom agent_framework import Agent\nfrom agent_framework.foundry import FoundryChatClient\nfrom azure.identity import DefaultAzureCredential\n\nAgent(\n    client=FoundryChatClient(          # a *chat client*, not a model\n        project_endpoint=os.environ[\"FOUNDRY_PROJECT_ENDPOINT\"],\n        model=model_id(),\n        credential=DefaultAzureCredential(),\n    ),\n    instructions=INSTRUCTION,          # `instructions`, plural\n    tools=[web_search],\n    default_options={\"store\": False},\n)\n```\n\nThree names for the system prompt. Three levels at which the model is named: a\n\nstring, a model object, a client holding an endpoint and a credential. Three\n\ntool conventions — ADK wraps the plain callable itself, Strands wants an explicit\n\n`@tool`\n\n, Agent Framework takes the callable and runs it through its own function\n\nmachinery.\n\nNone of that is hard. All of it is **untranslatable**. There is no adapter that\n\nturns these into one object, and every hour I have seen spent trying to build\n\none produced a fourth thing to maintain that then became what was actually under\n\ntest. Share the prompt, the tool and the wire format. Do not try to share the\n\nagent.\n\nStrands hands you a function. Everything else can wrap it from outside:\n\n``` php\nasync def respond(prompt: str) -> str:\n    return str(await agent.invoke_async(prompt))\n```\n\nADK and Agent Framework do not. ** to_a2a() takes an agent and serialises its\nevent stream**, and Agent Framework's\n\n`A2AExecutor`\n\ncalls the agent too. Neither`(prompt) -> reply`\n\nboundary, so anything you need to do between the`BaseAgent`\n\nwrapping the first agent on one cloud, a delegating class`run`\n\non the other.This is not a style complaint. It decides where a fact can be recorded. Every\n\ndraft in this system carries one line:\n\n``` php\n<!-- a2a-research agent=gcp model=gemini-2.5-flash brain=llm -->\n```\n\nThat line is written by the **server**, never by the model. It carries the two\n\nthings the coordinator cannot reconstruct from its own side of the wire — which\n\nmodel actually answered, and whether a model answered at all. Ask the model to\n\nemit its own metadata and a model that gets it wrong misattributes a draft in\n\nthe audit, which is the one error an audit cannot detect from the inside.\n\n**And on ADK that wrapper became load-bearing the moment I added a tool.** The\n\nfirst version concatenated the text of every event in the stream, which was\n\ncorrect while the stream held exactly one event. With `web_search`\n\nattached the\n\nstream also carries the model's commentary around each tool call — \"Let me look\n\nthat up\", a summary of what it found — and concatenating those produces a draft\n\nthat opens with the model narrating its own research. The scorer downstream then\n\ngrades the narration. Keep only `event.is_final_response()`\n\n.\n\nADK and Agent Framework both return a `Task`\n\nin `TASK_STATE_COMPLETED`\n\n. Both are\n\nspec-conformant. They disagree about where the reply goes:\n\n`A2AExecutor`\n\n`submit`\n\n→\n`start_work`\n\n→ `complete`\n\n) and leaves the reply as a `ROLE_AGENT`\n\nmessage in\n`artifacts`\n\nempty.`a2a-sdk`\n\nreference executor`Message`\n\nand runs no task lifecycle at all.So the obvious client — read `task.artifacts`\n\n— works perfectly against Google\n\nand returns an **empty string** against Microsoft. Not an error. Not a timeout. A\n\nsuccessful call with no content, which then fails somewhere downstream as a parse\n\nerror pointing at the wrong layer.\n\nRead every carrier the spec allows and you get the mirror-image bug: ADK's reply\n\narrives twice, once per envelope.\n\nThat one is worth dwelling on, because of *how* it stayed hidden. In the\n\npredecessor version of this project the agents returned an exchange rate, and\n\nthe parser indexed quotes by target currency — so a duplicate object quietly\n\noverwrote its twin and the answer was correct. Change the domain to a written\n\ndraft and the body doubles, the word count doubles, and the scorer marks a\n\ncompliant draft as a 100% length overrun.\n\nI found it by reading output: one cloud returned 202 words of text the other two\n\nreturned in 98. **No test caught it, and the suite was green throughout.** There\n\nis now a live test asserting all three serving stacks return the same canned text\n\nat the same length, which is the cheapest detector I know for the whole class.\n\n\"The call succeeded\" and \"you received the answer\" are different claims in A2A.\n\nA client written against one vendor's server will pass that vendor's tests while\n\nsilently dropping another vendor's replies.\n\n`to_a2a(agent, host, port)`\n\nwrites the **bind** address straight into the card:\n\n``` bash\n$ curl -s https://<the-adk-agent>.run.app/.well-known/agent-card.json\n{\"url\": null,\n \"additionalInterfaces\": [{\"url\": \"http://0.0.0.0:8080\", \"protocolBinding\": \"JSONRPC\"}]}\n```\n\nA public HTTPS endpoint advertising unroutable plaintext. My AWS and Azure agents\n\ntake a `PUBLIC_URL`\n\nand advertise that — the behaviour ADK is missing, not\n\nanything clever.\n\n**It cannot reproduce locally**, because on a laptop the bind address and the\n\ndial address are the same string. It needs a deployment, which is exactly how it\n\nsurvives into one.\n\nWhich clients survive it is the opposite of what the ergonomics would predict:\n\n| client | against the deployed ADK server |\n|---|---|\n`a2a-sdk` |\nok — rewrites the interfaces after card resolution |\n`agent-framework` `A2AAgent`\n|\nok — never routes by card, so a bad card is inert |\n`google-adk` `RemoteA2aAgent`\n|\nfails — routes by card, dials `0.0.0.0:8080`\n|\n\n**ADK's own client cannot reach ADK's own server once hosted.** Both halves ship\n\ngreen in Google's own tests, because locally the two addresses are identical.\n\nAnd the stack that has no seam to patch a resolved card is the one that never\n\nneeded it, because it dials the URL you constructed it with.\n\nThen the failure is reported at the wrong layer. Having dialled `0.0.0.0:8080`\n\nand failed, `RemoteA2aAgent`\n\nraises this:\n\n```\nAttributeError: 'A2AClientError' object has no attribute 'status_code'\n```\n\nThe error handler assumes any `A2AClientError`\n\ncarries a status code, which a\n\ntransport failure does not. The real cause — `All connection attempts failed`\n\n—\n\nlands on a separate log line. Two defects compounding: the first sends the client\n\nto an unroutable address, the second deletes the evidence of where it went.\n\nThe runtime is not a deployment detail either. Each imposes a contract on the\n\ncontainer, and they do not agree:\n\n| Cloud Run | AgentCore Runtime | Container Apps | |\n|---|---|---|---|\n| port |\n`$PORT` , 8080 |\n9000 |\n8080 |\n| invoke path | yours |\n(platform exposes `/` `/invocations/` ) |\nyours |\n| health | yours | `GET /ping` → `{\"status\": \"Healthy\"}` |\nyours |\n| architecture | any | ARM64, required |\namd64 |\n| build | source, buildpack, no Dockerfile | image | image |\n| ingress auth | one deploy flag | IAM + `CUSTOM_JWT`\n|\na separate step |\n| cold-start unit | instance | session → microVM |\nrevision replica |\n\nThree of those rows cost me real time.\n\n**AgentCore does not forward the A2A-Version header.**\n\n`a2a-sdk`\n\nreads the`0.3`\n\n— then\n\n```\nA2A version '0.3' is not supported by this handler. Expected version '1.0'.\n```\n\nCloud Run and Container Apps pass it through untouched. So the same client, the\n\nsame `a2a-sdk`\n\non both ends, the same server code, and the third cloud fails with\n\nan error that blames the protocol version and names nothing about the platform\n\nthat removed it. The fix is to assume the current version when the header is\n\nmissing, and only when it is missing — a header that *says* `0.3`\n\nis a real\n\nclient statement and should still be rejected. **Absent is not evidence of an old\nclient. It is no evidence at all.**\n\nIt had also been latent for a week. The deployed image predated the version\n\ncheck, so that leg had been green for a reason that stopped being true the moment\n\nI rebuilt it.\n\n**An AgentCore session gets its own microVM.** I was minting a fresh session id\n\nper call, so every call paid for a microVM start. It presented as a fixed\n\nper-client cost until I noticed the slow cell *moved between clients* — and a\n\nfixed per-client cost cannot move. Something per-call can:\n\n`google-adk` → AWS |\nruns | measured |\n|---|---|---|\n| fresh session id per call (the default) | 5 | 5953, 5970, 5926, 5984, 6037ms |\n| session id pinned | 2 | 710, 704ms |\n\nPin the session id unless you actually want per-call isolation. There is no\n\nequivalent knob on the other two clouds, and this cost is invisible in any\n\nper-leg average.\n\n**Container Apps splits \"who may get a token\" from \"who must present one.\"** One\n\ndeploy step creates the federated credential; a *separate* step enforces identity\n\non the ingress. Ship only the first and the leg reports its auth mode happily\n\nwhile answering anybody who asks.\n\nThat is not hypothetical. On 2026-08-13 the negative control for that leg\n\nanswered **without a credential**, and a direct check confirmed `/health`\n\n, the\n\nagent card *and* the JSON-RPC invoke endpoint all returned 200 to an anonymous\n\ncaller — on an agent that invokes a billable model. Every other signal in the\n\nproject was green at the time, which is the entire argument for having negative\n\ncontrols at all.\n\nThe framework difference exists on the client side too, and it decides what you\n\nare able to fix:\n\n`agent-framework`\n\n`A2AAgent`\n\n`await .run(prompt)`\n\n, read\n`.text`\n\n. Two lines. Card resolution and transport are internal, which is\nergonomic right up to the moment a server advertises a bad card.`a2a-sdk`\n\n`google-adk`\n\n`RemoteA2aAgent`\n\n`BaseAgent`\n\nmeant to live inside an agent\ntree. Using it as a plain client means standing up a `Runner`\n\n, a session\nservice and a session, per request. Every client against every server, local and with no model in the path:\n\n```\nA2A interop matrix  (the A2A protocol and why agents need one (<=300w), brain=direct)\n\nclient \\ server  gcp               aws               azure\n-----------------------------------------------------------------------\na2a-sdk          ok 134ms          ok 8ms            ok 8ms\nagent-framework  ok 129ms          ok 7ms            ok 8ms\ngoogle-adk       ok 920ms          ok 9ms            ok 10ms\n\n9/9 attempted cells succeeded\n```\n\nRead that as an ordering and nothing more — single runs on loopback. And read it\n\nwith the honest dependency in front of you: all three client stacks resolve to\n\nthe same `a2a-sdk`\n\nwire implementation underneath, and two of my three servers\n\nshare serving scaffolding. **Nine cells is a presentation, not nine independent\nexperiments** — which is what makes the failures above interesting. Shared\n\nNow hold the frameworks still and look at the other axis. Three models, chosen to\n\nbe unmatched — the heterogeneity is the point, not a confound:\n\n`gemini-2.5-flash` |\n`nova-micro` |\n`gpt-5-mini` |\n|\n|---|---|---|---|\n| what it is | fast general model | small and cheap | reasoning deployment |\n| reached through | ADK → Vertex | Strands → Bedrock | Agent Framework → Foundry |\n| why this one | the ADK path's default | inherited from a two-field lookup task, and a poor default for prose |\nforced, see below |\n\nThat last cell is my favourite example of a model choice that is not a\n\npreference. `FoundryChatClient`\n\nspeaks the OpenAI Responses API. Passing\n\n`store=False`\n\nto keep anything from being stored server-side makes the framework\n\nrequest `reasoning.encrypted_content`\n\n, and `gpt-4.1-mini`\n\nrejects that outright —\n\nonly a reasoning model accepts it. The region is forced too: the Container App\n\nlives in westus2, which offers no Azure OpenAI models, so the call crosses to\n\nwestus3. **Two constraints that have nothing to do with writing quality decide\nboth the model and the latency on that leg.**\n\nTwenty-four briefs, each answered by all three, scored twice — once by a\n\ndeterministic rubric, once by re-ranking the same stored drafts with a model\n\njudge:\n\n| cloud / model | availability | win% rubric | win% llm | regret rubric | regret llm |\n|---|---|---|---|---|---|\nazure / `gpt-5-mini`\n|\n96% | 43% | 87% |\n0.97 | 0.52 |\ngcp / `gemini-2.5-flash`\n|\n58% | 43% | 43% | 1.54 | 2.21 |\naws / `nova-micro`\n|\n100% | 33% | 0% |\n1.32 | 9.38 |\n\nFour things fall out of that, and only one of them is about writing.\n\n**Availability moved more than eloquence did.** Gemini answered 58% of the briefs\n\nit was invited to — the lowest of the three, on the one leg that never leaves its\n\nown cloud and is otherwise the most reliable path in the mesh. The failure\n\nrecorded against it is a Vertex `429`\n\n, so quota is the documented cause rather\n\nthan a proven one; I have not attributed the ten missing drafts individually.\n\nEither way, a rate limit is a vendor difference no essay-scoring rubric will ever\n\ncapture, and on this corpus it dominates.\n\n**The scorer changes which model looks good, and by a lot.** The rubric puts Nova\n\n1.32 points behind the panel's best; the model judge puts it **9.38** behind. A\n\nsmall cheap model asked to write prose plausibly *is* much further behind than a\n\nform-counting rubric can see. Under the rubric no model dominates; under the\n\nmodel judge `gpt-5-mini`\n\ntakes 87% and Nova takes none. So best-of-breed is\n\ncurrently a property of the scorer, not of the models — and the model judge here\n\nis Gemini, ranking the Gemini participant at 43% while putting Azure at 87%,\n\nwhich weakens the obvious vendor-bias objection without removing it.\n\n**Latency is a runtime fact before it is a model fact.** The slowest leg is a\n\nreasoning model called across regions because of a storage flag. The fastest is a\n\ntiny model on the platform that also charges you a microVM start when you forget\n\nto pin a session.\n\n**What no scorer can move is whether a draft existed at all.** The availability\n\ncolumn is identical under both judges, which makes it the only column that does\n\nnot wait on calibrating the rubric against human review.\n\nAll three got the same tool, at the same time, with the same six-call budget.\n\nUse of it split by model and by prompt version:\n\n| zero-search drafts | |\n|---|---|\n| aws, instruction v1 | 7 of 7 |\n| aws, v2 | 2 of 9 |\n| aws, v3 | 1 of 7 |\n| azure, all versions | 1 of 16 |\n| gcp, v3 | none — it spends the whole six-call budget every run |\n\nTwo ends of one finding. Nova had to be *told*, twice, and still skips a run in\n\nseven. Gemini sits on the ceiling in every single v3 run, which means the budget\n\nis now shaping the drafts I am comparing — a model that always spends its last\n\nsearch would spend more if it had it.\n\nAnd the first model-backed run gave me the sharpest version of it:\n\n```\nazure  searches=2   evidence 0.0\ngcp    searches=0   evidence 5.0\naws    searches=0   evidence 0.0\n```\n\n**The model that scored full marks on evidence never searched.** Five points of\n\ncitation-shaped text with nothing behind it. The rubric counts the gesture, which\n\nI had written down as a known weakness before search existed and now had as a\n\nmeasured one.\n\nThe cause was upstream of the models: the shared instruction never told anyone to\n\nsearch. Fixing it took three versions, and v2 is a warning in the other\n\ndirection — it said \"one search for each specific figure\", Gemini read that\n\nliterally and spent 24 searches on a 300-word brief, which is 25 model calls and\n\nenough to exhaust the project's Vertex quota on its own. v3 names the budget the\n\ntool enforces, so the model plans against the bound instead of being cut off by\n\nit.\n\n**Version the instruction like you version the rubric.** Runs either side of a\n\nprompt change are answering different questions, and an audit that averages\n\nacross one reports a prompt edit as a change in the models. Mine carries\n\n`INSTRUCTION_VERSION`\n\non every draft next to `RUBRIC_VERSION`\n\n, for exactly that\n\nreason.\n\nThe recurring shape, across all three clouds:\n\n`llm`\n\nmode with `WARNING`\n\n, and answered `/health`\n\nwith 200 the whole\ntime.`200`\n\nand then ten words of\nrefusal, because they were still running the Two habits came out of that and I would carry both to any mesh like this.\n\n**Type your failures.** `transport`\n\n, `protocol`\n\n, `timeout`\n\n, `authentication`\n\n,\n\n`provider`\n\n. The one that earns its keep here is `provider`\n\n: a model that declines\n\nthe topic is a provider outcome, and filing it as `protocol`\n\nturns \"Bedrock\n\nrefused\" into \"AgentCore broke A2A.\"\n\n**Let the agent report its own facts.** Brain, model, degraded flag and search\n\ncount are served by the agent, because only the agent knows them. My matrix used\n\nto print the mode from its *own* process — a different container once deployed —\n\nand duly reported `brain=direct`\n\nfor a mesh of three model-backed agents.\n\n| symptom | what it actually is | fix |\n|---|---|---|\nHTTP 200, task `COMPLETED` , reply is an empty string |\nthe reply is in `task.history` , not `artifacts` (Agent Framework) |\nread every carrier the spec allows |\n| the draft arrives twice and word count doubles | ADK returns it as artifact and history |\ndeduplicate; one reply in two envelopes is one reply |\n`A2A version '0.3' is not supported by this handler` |\nAgentCore dropped the `A2A-Version` header |\nassume the current version when the header is absent only |\n`AttributeError: 'A2AClientError' object has no attribute 'status_code'` |\nthe ADK client dialled the card's bind address and could not connect | advertise `PUBLIC_URL` ; rewrite interfaces after resolution |\n| one leg costs ~6s and the slow leg moves between clients | a fresh AgentCore session id per call, each getting a microVM | pin the session id |\n| the leg reports federated auth and answers anonymous callers | ingress enforcement is a separate deploy step | run it, then probe the leg with no credential |\n| 403 from inference although the role assignment looks right | the container holds a managed-identity token minted before the grant | restart the revision |\n| the draft opens with the model narrating its research | ADK's event stream carries tool-call commentary | keep only `is_final_response()`\n|\n| a model scores full marks for evidence with zero searches | your scorer counts citation-shaped text | record searches per draft and read them next to the score |\n| a quota error is recorded in the audit as a score | a provider error is not short, and only a word count was looking | detect provider signatures before stamping a draft |\n| discovery 403s while invocation would have worked | the agent card is behind the same authorization as the agent | attach the credential to the client, not the request |\n\nTwenty-four briefs is enough to compute a rate and not enough to trust one, and\n\nall of mine were technology surveys. I would not quote these numbers as a model\n\ncomparison and I do not. What I would claim is the shape: the platform\n\ndifferences are structural and repeatable, the model differences are mostly about\n\nwhether you get an answer at all, and every framework will hide a different one\n\nfrom you.\n\nA2A did the thing it promised. Everything above is what is left over — and it\n\nwill be different again for whoever wires the fourth cloud in, which is rather\n\nthe point of writing it down.\n\n**Repo:**\n\n[github.com/xbill9/multicloud-a2a-subagent](https://github.com/xbill9/multicloud-a2a-subagent)\n\n— three agents, the shared instruction and tool, the coordinator and judge, the\n\n3×3 interop matrix, the negative controls and the deploy scripts.\n\n`docs/INTEROP.md`\n\ncarries every finding above with the date it was measured, and\n\n`docs/RUNBOOK.md`\n\nlists which claims are measured and which are still open.", "url": "https://wpnews.pro/news/three-clouds-one-brief-what-actually-differs-between-adk-strands-and-agent", "canonical_source": "https://dev.to/gde/three-clouds-one-brief-what-actually-differs-between-adk-strands-and-agent-framework-2kgc", "published_at": "2026-08-20 13:37:59+00:00", "updated_at": "2026-08-20 13:45:17.895709+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "artificial-intelligence"], "entities": ["Google ADK", "AWS Strands", "Microsoft Agent Framework", "A2A", "Google Cloud Run", "AWS Bedrock AgentCore", "Azure Container Apps", "GitHub"], "alternates": {"html": "https://wpnews.pro/news/three-clouds-one-brief-what-actually-differs-between-adk-strands-and-agent", "markdown": "https://wpnews.pro/news/three-clouds-one-brief-what-actually-differs-between-adk-strands-and-agent.md", "text": "https://wpnews.pro/news/three-clouds-one-brief-what-actually-differs-between-adk-strands-and-agent.txt", "jsonld": "https://wpnews.pro/news/three-clouds-one-brief-what-actually-differs-between-adk-strands-and-agent.jsonld"}}