DEV Community

Cover image for Six Cross-Cloud A2A Paths, One Benchmark: How AWS, Azure, and GCP Agents Actually Work Together
xbill for AWS Community Builders

Posted on

Six Cross-Cloud A2A Paths, One Benchmark: How AWS, Azure, and GCP Agents Actually Work Together

Over the last six articles in this series I built the same currency benchmark
six times, each time changing which cloud gives the orders and which cloud takes
them. Same domain core, same 38 evaluation cases, same Decimal comparator,
same public rate source on both sides of every hop. Only the orchestration
direction changed.

That is now every directed edge between three clouds, each one deployed and
measured. This article is the rollup: what the numbers say when you line all six
up, what broke in more than one of them, and what is still unmeasured.

The short version: A2A v1.0 interoperated in every direction I deployed. The
protocol was never the expensive part, never the part that failed, and never the
part worth optimizing. Identity was. Packaging was. Timeouts were.

The coverage matrix

Three clouds, three frameworks, six possible directed edges between them:

Master (orchestrates) Remote (answers) Status
Microsoft Foundry, Azure Google ADK, Cloud Run Deployed — full 38-case matrix + hosted smoke
Bedrock AgentCore, AWS Google ADK, Cloud Run Deployed — 38-case matrix (local harness) + hosted smoke
Bedrock AgentCore, AWS Microsoft Foundry, Azure Deployed — hosted smoke
Microsoft Foundry, Azure Bedrock AgentCore, AWS Deployed — hosted smoke
Google ADK, Cloud Run Microsoft Foundry, Azure Deployed — live measured, 5–6 run medians
Google ADK, Cloud Run Bedrock AgentCore, AWS Deployed — live measured, 5-run medians

All six deployed, all six measured. The last one was also the most instructive:
it was code-complete with a green local suite for a day before anything was
deployed, and deploying it surfaced six defects, five of which no local test
could have caught. That ratio is the single most reliable finding in this series.

One more repository, gcp-adk-a2a-currency, has an ADK coordinator on Cloud Run
calling an ADK leaf agent — but its default A2A hop stays inside GCP. It
exercises the protocol; it does not cross a cloud boundary, so it is not
comparable with the six above and is not counted as coverage.

Every run used the same three modes:

Mode Path
mcp_only Master calls its own MCP exchange-rate tool
a2a_only Master delegates to the remote agent over A2A v1.0
verified Both run concurrently; deterministic code compares the results

And in every run, the model picked the tool and explained the answer. It never
did the arithmetic and never judged agreement. That was always this:

difference = abs(primary.converted_amount - verifier.converted_amount)
relative_difference = difference / abs(primary.converted_amount)
agreed = relative_difference <= Decimal("0.005")
Enter fullscreen mode Exit fullscreen mode

The numbers, side by side

Read the provenance column before reading the latency column. Two of these rows
are 114-record evaluation matrices; two are single hosted smoke cases; two are
small medians over repeated live runs. They are not interchangeable evidence.

Master → Remote Remote model / hosting mcp_only a2a_only verified Provenance
Foundry → ADK gemini-2.5-flash, Cloud Run 297 ms 1.69 s 1.71 s 114-record matrix, medians
AgentCore → ADK gemini-2.5-flash, Cloud Run 286 ms 2.09 s 1.87 s 114-record matrix, medians
AgentCore → Foundry gpt-5-mini, Foundry hosted ~4.2 s ~18.8 s ~18.1 s single hosted smoke
Foundry → AgentCore Nova Micro, AgentCore 359 ms 25.1 s 28.2 s single hosted smoke
ADK → Foundry gpt-5-mini, Foundry hosted n/a 23.4 s n/a median of 5 live runs
ADK → AgentCore Nova Micro, AgentCore 2.96 s 18.92 s 16.19 s median of 5 live runs

p95 for the two matrix rows: Foundry → ADK a2a_only 4.82 s, verified 4.15 s;
AgentCore → ADK a2a_only 6.10 s, verified 4.33 s. Ranges for the ADK →
AgentCore row: mcp_only 2.37–3.84 s, a2a_only 16.30–19.68 s, verified
15.64–22.86 s.

Finding 1: the spread is the remote's runtime, not the protocol

A2A leg times in that table range from 1.69 s to 25.1 s — a factor of fifteen,
same protocol, same SDK, same continent-crossing hops. The protocol is not what
varies.

I had a tidier explanation for this before the sixth edge existed: that the
remote's role was the determinant — a leaf answering one question costs ~2 s, a
master running a multi-tool reasoning loop costs ~20 s. The ADK → AgentCore run
killed that theory. Its AgentCore worker is a leaf by any definition, answering
one question with one tool call, and it still cost 18.92 s.

What the six rows actually line up on is the remote's runtime and model, not
its role:

  • Anything whose remote is gemini-2.5-flash on Cloud Run: 1.69–2.09 s
  • Anything whose remote is gpt-5-mini on Foundry hosting: 18.8–23.4 s
  • Anything whose remote is Nova Micro on AgentCore Runtime: 18.9–25.1 s

That grouping holds across both directions and regardless of which cloud is
orchestrating. The honest reading is that delegation costs an inference plus
whatever the remote platform's request overhead is, and the two hosted
agent runtimes are an order of magnitude more expensive per call than a
container on Cloud Run. Which part of that is model speed and which is platform
overhead, I have not separated — that would need the same model on both
runtimes, which I have not run.

What it is not is the protocol. I measured that directly on the ADK → Foundry path: an
authenticated agent-card fetch is 0.35 s warm (0.51 s cold), and the token is
cached across calls. Putting a second cloud in the path — direct workstation call
at 21.2 s versus 23.4 s through the Cloud Run ADK proxy — cost about 2 seconds,
against a run-to-run spread of 18.9–29.5 s. The variance of the master alone was
larger than the entire cost of the extra cloud.

If you want a cross-cloud agent architecture to be faster, the protocol and the
proxy are not where the time is.

Finding 2: verified mode costs one A2A call, not two

In every run, verified came in at roughly the cost of the slower leg rather
than the sum of both legs, because MCP and A2A run concurrently:

  • Foundry → ADK: a2a_only 1.69 s, verified 1.71 s
  • AgentCore → ADK: a2a_only 2.09 s, verified 1.87 s (the matrix median for verified came in below a2a_only — same distribution, different case mix)
  • AgentCore → Foundry: A2A leg ~18.1 s inside a verified run whose MCP leg was ~3.1 s
  • Foundry → AgentCore: a2a_only 25.1 s, verified 28.2 s
  • ADK → AgentCore: a2a_only 18.92 s, verified 16.19 s — verified again landing below a2a_only, by less than the run-to-run spread. Read those two as equal, not as verification being free-plus-a-refund.

The concurrency is explicit, not incidental:

mcp_task = self._call("mcp", self._mcp, request, failures)
a2a_task = self._call("a2a", self._a2a, request, failures)
mcp_quotes, a2a_quotes = await asyncio.gather(mcp_task, a2a_task)
Enter fullscreen mode Exit fullscreen mode

So the price of independent cross-cloud verification is "one remote call,"
not "one remote call plus your baseline."

Finding 3: on the happy path, the accuracy delta is zero

Every cross-cloud verified run I deployed reported exact agreement —
relative_difference: "0", agreed: true, no warnings:

  • AgentCore → ADK, hosted: EUR and CHF, zero difference
  • AgentCore → Foundry, hosted: rate 0.87873, amount 87.87300, both sides
  • Foundry → AgentCore, hosted: rate 0.87138, both sides
  • Foundry → ADK, hosted: agreed: true, relative_difference: 0
  • ADK → Foundry, live: identical quotes on every one of five runs
  • ADK → AgentCore, live: EUR 0.8713887.138 and CHF 0.8124881.248, both agreed, primary mcp-stdio:frankfurter-live, verifier aws-agentcore-a2a-worker

That is by design. Both sides read the same Frankfurter daily reference rates on
purpose, so a disagreement can only come from the protocol, the model, or the
code — never from data-source skew.

One detail from that last run is worth more than the agreement itself: both
targets also carried rate is stale; check the observation timestamp.
Frankfurter's daily reference rate was stamped 2026-07-30T00:00:00Z against a
run at 04:09Z on the 31st — past the 24-hour threshold. The staleness rule
firing on a real published rate rather than an injected fixture, and the
coordinator surfacing it instead of letting the model round it off into
reassuring prose, is the more useful signal.

The 96.77% agreement rate in the 114-record matrices is not a contradiction. The
38 cases include deliberately injected faults (timeouts, stale rates, forced
disagreement, malicious tool text), and the fault cases are what pull the number
below 100%. All 60 live-adapter records agreed within tolerance. The only
disagreements in the entire run were the ones I injected, and the verifier
flagged every one.

Which answers the "is verification worth it" question honestly: on the happy
path you are buying nothing in accuracy. What you are buying for your extra
second — or your extra twenty — is independent failure detection: a second
implementation, on a second cloud, under a different vendor's model, that will
loudly disagree when a tool is compromised, stale, or wrong.

The failures that showed up more than once

Six builds, six sets of bugs, and a lot of overlap. These are the ones that
recurred.

The A2A v0.3.0 → v1.0 wire break

This bit five of the six builds, and it usually looked the same:

a2a.utils.errors.MethodNotFoundError: Method not found
Enter fullscreen mode Exit fullscreen mode

or, over raw JSON-RPC:

{"error": {"code": -32601, "message": "Method not found",
           "data": [{"reason": "METHOD_NOT_FOUND",
                     "metadata": {"detail": "'method' field is not a valid A2A method."}}]}}
Enter fullscreen mode Exit fullscreen mode

v0.3.0 routes message/send. v1.x routes SendMessage with proto-JSON params.
There is no fallback negotiation: the client fetches an agent card that
plainly declares protocolVersion: 0.3.0 and calls the v1.0 method anyway.

If you see MethodNotFoundError on an endpoint you are fully authorized to
call, check the a2a-sdk major version on both sides before you debug anything
else — and check your own client's method spelling, since the Foundry card
advertises three interfaces on one URL (JSONRPC 1.0, JSONRPC 0.3, HTTP+JSON 0.3)
and will happily let you pick the wrong one.

The sixth build found a third way to produce the same skew, and this one is
nastier because both ends were correct. A v1.0 client called a v1.0 worker
advertising "protocolVersion": "1.0", and got:

A2A version '0.3' is not supported by this handler. Expected version '1.0'.
Enter fullscreen mode Exit fullscreen mode

a2a-sdk 1.1.2 does send A2A-Version: 1.0. AgentCore Runtime forwards only
allow-listed request headers, so the header never arrived — and the server-side
validator treats a missing header as v0.3:

# a2a/utils/version_validator.py
if not actual_version:
    return constants.PROTOCOL_VERSION_0_3
Enter fullscreen mode Exit fullscreen mode

A dropped header and an old client are indistinguishable to the server. One line
of runtime config fixes it:

"requestHeaderAllowlist": ["A2A-Version"]
Enter fullscreen mode Exit fullscreen mode

Three builds, three mechanisms — a transitive pin, a framework extra, and now a
proxy silently stripping a header — all landing on the same version-skew
symptom. Any intermediary between two A2A endpoints is a place a protocol
version can go missing, and defaulting absence to the older version turns a
transport bug into what looks like a client bug.

Dependency pins are a protocol constraint, until they aren't

The ecosystem pins were mutually exclusive at various points across these builds:

Package a2a-sdk requirement
strands-agents 1.50.2 >=1.0.0,<2
agent-framework-a2a 1.0.0b260721 >=1.0.0,<2
google-adk 2.1.0 – 2.4.0 >=0.3.4,<0.4
google-adk 2.5.0 >=0.3.4,<2
a2ui-agent-sdk (through 0.4.0) <0.4.0
strands-agents[a2a] (latest) <0.4

google-adk 2.5.0 was the release that unblocked the GCP side. A2UI was dropped
entirely, because a protocol-adjacent extension held the whole agent at v0.3.0 —
audit your extras before you commit to them.

The Foundry → AgentCore build produced the useful lesson here. AgentCore's A2A
server extra wanted the 0.3 line; the Foundry-side client used 1.x; installing
both into one bundle was unsatisfiable. The fix was architectural, not a version
bump: pin the AgentCore application bundle to its compatible SDK, keep the client
separate, and let the two talk. The network protocol interoperated even though
the Python packages did not share a version.
Package-version parity is a
same-process requirement; it is not a wire requirement.

The sixth build took the other available answer and it also works: keep the agent
framework for the agent loop, drop its [a2a] extra, and build the A2A v1.0
server surface from a2a-sdk directly.

# NOT strands.multiagent.a2a.A2AServer
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes

app = Starlette(routes=[
    *create_agent_card_routes(AGENT_CARD),
    *create_jsonrpc_routes(_request_handler, rpc_url="/"),
])
Enter fullscreen mode Exit fullscreen mode

So there are two verified escapes from an extra that pins the wrong wire
version: separate the bundles, or bypass the extra. Both are backed by a
deployed run. A lockfile test on each side keeps a dependency bump from quietly
reintroducing the mismatch.

Every default timeout assumes one cloud

The coordinator's original 10-second per-adapter timeout was generous locally
and fatal in all six cross-cloud runs. It failed cleanly, which was the only
good thing about it:

{"failures": {"a2a": "timeout: adapter timed out"}, "elapsed_ms": 10010}
Enter fullscreen mode Exit fullscreen mode

That failure came from a path a direct call had already shown took 25 seconds.
The adapter was fine; the policy was wrong. Final values: 60 s for the
AgentCore and Foundry-master hosted paths, 120 s for the ADK client and the
Foundry coordinator.

Cold starts do not merely add latency, either. One cold-start reply from the ADK
worker omitted a requested target entirely. Because parsing is strict, that
surfaced as a typed protocol failure rather than a silently short answer.
Design for partial replies.

Identity was the whole job

The single strongest pattern across all six builds: A2A interoperability is
an authentication problem wearing a protocol costume.
The JSON-RPC half was
the easy half in every direction.

Calling into Foundry, from either AWS or GCP:

  • Incoming A2A is opt-in and preview — a hosted Foundry agent speaks Responses until you PATCH A2A on.
  • The PATCH's protocol_configuration replaces, it does not merge. I probed this rather than assume it: patching {"a2a": {}} alone dropped Responses, and took the A2A agent card down with it (400) — and the request that did it returned 200.
  • There is no anonymous /.well-known/agent-card.json. The card sits at <agent>/endpoint/protocols/a2a/agentCard/v1.0 behind Entra. Discovery itself is a privileged call, so a 404 or 401 on the card is more likely a missing role assignment than a wrong path.
  • The caller needs the Foundry Agent Consumer data-plane role on the project; the deployer needs Foundry Project Manager. Azure Owner implies neither.
  • Get the token audience right first: https://ai.azure.com/.default, not the ARM audience. A perfectly valid token for the wrong scope produces a 401 that looks nothing like a scope problem.

Calling into AgentCore, from Azure:

  • AgentCore's default runtime authorization is IAM/SigV4, which is exactly right for AWS callers and useless to a Foundry container that cannot sign an AWS request. A direct AWS CLI invocation worked; the cross-cloud one could not.
  • The fix was a custom JWT authorizer plus a Cognito machine-to-machine client, with the A2A adapter doing an OAuth client-credentials exchange, caching the token, and the authorizer validating issuer, client, and a required currencybench/invoke scope.

Calling into AgentCore, from GCP — the same wall, a different answer:

The sixth build started with the same CUSTOM_JWT approach and replaced it with
keyless federation into AgentCore's default AWS_IAM authorizer: Cloud Run's
metadata server mints a Google OIDC token, STS AssumeRoleWithWebIdentity
exchanges it for temporary credentials, and requests are SigV4-signed. No
long-lived AWS credential exists anywhere in the container.

Getting there cost two IAM defects that both looked like correct configuration.

The condition keys do not mean what they are named. This trust policy is
syntactically valid, reads correctly, and can never match:

"Condition": { "StringEquals": {
  "accounts.google.com:aud": "currencybench-agentcore-worker",
  "accounts.google.com:sub": "1019138736740282766.."
}}
Enter fullscreen mode Exit fullscreen mode
Condition key Actual Google claim
accounts.google.com:oaud the token's aud — your audience string
accounts.google.com:aud the token's azp — the SA's numeric client id
accounts.google.com:sub the token's sub

The audience string is compared against a number, so STS answers "Incorrect
token audience" forever. Pin the audience with oaud.

And pin the subject as well, because audience alone is not authorization.
This is the part that matters even after the keys are right. Any Google
principal can request an ID token for an arbitrary audience string — the
audience is a value the caller chooses, not a permission the platform grants.
A trust policy conditioned only on audience therefore proves nothing beyond
"some Google identity minted this," which in a shared organization is close to
proving nothing at all.

So the policy conditions on sub too — and on the service account's immutable
numeric ID rather than its email, because an email is a name that can be freed
and re-bound if the account is deleted and recreated:

gcloud iam service-accounts describe "$SA_EMAIL" --format='value(uniqueId)'
Enter fullscreen mode Exit fullscreen mode

That is also the argument for federating into the callee's native authorizer
instead of shipping a bearer token: the trust decision ends up in an IAM policy,
with real principal attribution in CloudTrail and revocation by editing the
policy, rather than in a claim-string matcher living in application config.

Registering Google as an OIDC provider breaks Google federation. The obvious
next move — aws iam create-open-id-connect-provider for accounts.google.com
is wrong. AWS federates with Google natively; creating an explicit provider
switches STS to validating against that provider's thumbprint, and every
exchange fails:

<Code>InvalidIdentityToken</Code>
<Message>The web identity token provided could not be validated.</Message>
Enter fullscreen mode Exit fullscreen mode

Deleting the provider fixes it. The principal is the bare domain:
"Federated": "accounts.google.com".

The useful diagnostic here is that these two bugs produce different errors —
InvalidIdentityToken means the token could not be validated at all, while a
trust-policy mismatch is an AccessDenied. That distinction is the fastest way
to tell "my provider setup is wrong" from "my conditions are wrong."

Two cross-cloud calls into the same AWS runtime, then, with two different
answers: a bearer token from Azure, keyless federation from GCP. Both are
deployed and measured.

And the credential always lives on the calling side. Google calling Azure means
an Azure client secret in Google Secret Manager. AWS calling Azure means an
Entra service-principal credential in AWS Secrets Manager, scoped to a single
secret ARN. There is no way around the secret existing; there is a way around it
being in your source tree, your manifest, or your shell history.

One timing trap worth budgeting for: data-plane RBAC took 105 seconds to
propagate
, while az role assignment list already showed the assignment as
present. Agent writes kept returning 403 the whole time. It is propagation, not
misconfiguration. Wait before you start re-assigning roles. A soft-deleted
Foundry account also blocked recreation until purged — Azure resource state has
memory too.

Your tests import the source tree; your users run the image

This class of bug appeared in three builds and was invisible to a green test
suite every time.

aiohttp broke two of them independently. azure.identity.aio builds its async
transport with aiohttp, which is not a hard dependency of azure-identity — it
is present on a normal workstation, so local runs pass:

File "azure/core/pipeline/transport/__init__.py", line 94, in __getattr__
    raise ImportError("aiohttp package is not installed")
Enter fullscreen mode Exit fullscreen mode

On AWS, adding it to the repository-root requirements was not enough; CodeZip
resolves the application bundle independently, so it had to be locked in
app/CurrencyCoordinator/pyproject.toml. On GCP, uv sync --frozen produced the
same absence in the container.

The other two in this family, both from the ADK-calls-Foundry client image: a
COPY pyproject.toml uv.lock agent.py ./ line that never mentioned
entra_auth.py, so the module was not in the image at all; and a flat
from entra_auth import EntraAuth that cannot resolve, because ADK's
single-agent mode imports your file as app.agent — a submodule of a package,
with / on sys.path rather than /app. The relative import works in both
contexts.

And the same shape again on the Foundry-master side: an MCP stdio tool spawns
a real subprocess with a real import path, so -m mcp_server.server cannot
import a monorepo sibling that is not inside the deploy bundle. The fix was an
rsync into the bundle before handing it to azd. Not elegant; hosted runtimes
do not care about your repo layout.

The sixth build produced the cleanest specimen of the pattern. The container
built, every local test passed, and it died on startup:

ModuleNotFoundError: No module named 'sse_starlette'
  File ".../google/adk/a2a/_compat.py", line 769, in attach_a2a_routes_to_app
Enter fullscreen mode Exit fullscreen mode

ADK's to_a2a() imports a2a.server.routes, which needs sse_starlette, which
ships only with the a2a-sdk[http-server] extra. Neither google-adk[a2a] nor
bare a2a-sdk pulls it in. Same shape as aiohttp — an optional transitive
dependency your workstation happens to have — but it failed at startup rather
than first-call, so Cloud Run reported it as a health-check timeout and gcloud
surfaced a misleading Resource 'currency-adk-coordinator' already exists
conflict. The actual error existed only in the revision logs.

Two things find this class of bug and nothing else does: load the container's
real directory layout locally, or deploy it and call it.

from google.adk.cli.utils.agent_loader import AgentLoader
AgentLoader("/app").load_agent("app")   # ModuleNotFoundError before the fix
Enter fullscreen mode Exit fullscreen mode

A corollary: an error that travels through a model is not an observable

Debugging the STS failure above cost two deploy cycles for a stupid reason. The
adapter raised a precise, actionable message — and it came back to me as:

The remote agent reported an issue with the web identity token.

The failure text is returned as a tool result, so it passes through the master
model, which paraphrases. Anything you intend to debug from has to be logged
server-side at the point of failure, not merely raised. The same applies to the
upstream detail: the adapter was discarding the STS response body and reporting
only the HTTP status, which is exactly the half that cannot distinguish an
audience mismatch from an unvalidatable token.

The agent card is only as portable as the URL inside it

ADK's to_a2a() bakes host, port, and scheme into the published card. Left at
its local default, a Cloud Run agent hands out a card advertising
http://127.0.0.1:10001, and the remote client dutifully tries to call itself.
The container has to read A2A_PUBLIC_URL — which the deploy script can only set
on a second pass, because Cloud Run does not reveal the service URL until the
first deploy finishes.

I wrote that up as an ADK quirk after the first build. It is not. The AgentCore
worker's card advertises http://127.0.0.1:9000 — its own container bind
address — for exactly the same reason. a2a-sdk clients route by card URL, so
every cross-cloud client in this series ends up rewriting the card's interfaces
to the endpoint it actually dialled:

for interface in card.supported_interfaces:
    interface.url = endpoint
Enter fullscreen mode Exit fullscreen mode

Treat "the card tells you where to send the message" as advisory across a cloud
boundary, in both directions.

The A2A base URL is not the one that looks like a base URL

agentcore status prints the runtime ARN percent-encoded into the path with an
/invocations suffix. That full URL, suffix included, is the A2A base, and
the card lives beneath it:

https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/arn%3Aaws%3A...%2F<id>/invocations
                                              └── card at <base>/.well-known/agent-card.json
Enter fullscreen mode Exit fullscreen mode

Trimming /invocations to get a tidier base returns UnknownOperationException,
and so does substituting a bare runtime id for the encoded ARN. The
percent-encoding has a second bite: deriving the runtime id for a scoped IAM
policy with a naive ${VAR##*/runtimes/} yields a malformed ARN that deploys
fine and only fails at call time. Decode before you split.

Coming from the other direction, Foundry makes you write the card by hand. After
ADK, where MCP tools show up as skills automatically, that feels like a step
backwards. It is also a step towards honesty: the card is a contract, and on that
side you are made to state it.

Model-layer failures are not protocol failures

Worth separating out, because they will read as interop bugs and they are not:

  • Nova Micro read "Convert 100 USD to EUR," then claimed the target currency was missing and asked the user to confirm it. Tool availability was not the problem; natural-language argument extraction was. The master prompt now forbids confirmation requests for information already present, and a regression test preserves that.
  • Gemini 2.5 Flash reported a genuinely live hosted-adk-a2a quote as non-live, because that source name shares a hosted- prefix with the fixture source hosted-local-a2a and the shared instruction distinguishes them by substring. The data was correct; only the prose was wrong. The same instruction labels correctly on Nova Micro and on gpt-5-mini — which is what makes it a shared-instruction bug rather than a model bug.
  • Both gpt-5-mini and gemini-2.5-flash respected "call the tool, never calculate," across every run. Keeping the arithmetic in Decimal means the agreement checks compare numbers, not model prose.

Make the benchmark fail closed

The most important non-technical lesson. A hosted benchmark tool that silently
falls back to deterministic fixtures when its endpoint configuration is missing
will hand you clean-looking numbers from nothing at all, and you will publish
them. Both hosted coordinators now refuse:

{ "name": "CURRENCY_REQUIRE_GCP_ADK", "value": "1" }
Enter fullscreen mode Exit fullscreen mode

With that set, a2a_only and verified return gcp_adk_not_configured rather
than a plausible fixture answer. Local fixture runs still work, but you have to
ask for them by name (CURRENCY_ALLOW_LOCAL_FIXTURES=true).

What survived being flipped six times

The domain core. Two interfaces — an MCP-backed ExchangeRateTool and an
A2A-backed RemoteCurrencyAgent — with Microsoft Agent Framework, Strands, and
ADK all outside them, and every hosting runtime outside them too.

Flipping which cloud orchestrates never required rewriting the domain service.
Framework-independent Python owned arithmetic, concurrency, timeout policy, and
failure classification in all six builds, and the same 38 cases and the same
comparator ran against every one. That portability is the actual result here —
more so than any individual latency number, and considerably more than getting
two SDKs into one process.

Everything that broke, across all six, broke at the seams: protocol versions,
dependency extras, identity federation, header forwarding, URL shapes. Nothing
broke in the domain logic. That is the shape of the whole project's result.

Typed failures did a lot of work in making the six comparable. Every adapter
exception is normalized into exactly one of validation, provider,
authentication, transport, timeout, or protocol at one boundary, because
"which layer broke" is a research question and not a log-grepping exercise.

What this does not establish

Stated plainly, because the temptation to over-claim from a green smoke test is
the thing this whole series was built to resist:

  • Two of the six rows are single observations. One hosted request is not a latency distribution. The Foundry → AgentCore run took ~45 s end to end against 28.2 s of measured adapter work; that gap is model and hosted-response overhead, and with one sample it would be irresponsible to call either number a benchmark.
  • Only the two GCP-remote paths have a full 38-case matrix. The AgentCore ↔ Foundry pair and both ADK-as-master paths have not been run against it.
  • Token usage and cloud cost are unmeasured everywhere. So is behavior under throttling and token expiry, and so are repeated warm/cold distributions.
  • The AgentCore → ADK matrix ran on a local harness, not through the AgentCore hosting layer. Only the smoke test exercised hosting. Keeping that label explicit avoids attributing harness latency to AgentCore.
  • Nothing here shows any framework or cloud is better than another. The latency differences track which model and which runtime the remote used, not platform quality — and I have not separated model speed from platform request overhead, which would need the same model on both runtimes.
  • The ADK → AgentCore numbers were taken with a wide IAM policy. Scoping bedrock-agentcore:InvokeAgentRuntime to runtime/<id> and runtime/<id>/* was denied with a 403 on the agent-card fetch; only Resource: "*" worked. AgentCore authorises against some ARN shape I have not identified, and data-plane events do not appear in CloudTrail by default, so I could not read the denial. That row is therefore not a least-privilege configuration, and I would not ship it as one.
  • Two rows are n=5, one day, one region pair, one model pair. No p95, no token counts, no cost accounting, no cold/warm separation on those.

The rollup, in six lines

  1. A2A v1.0 interoperated in all six directions. No schema translation, no framework-specific glue beyond asking for parseable JSON.
  2. The protocol is never the slow part. The A2A leg tracked the remote's model and runtime — 1.7–2.1 s to a Cloud Run container, 18.8–25.1 s to either hosted agent runtime. An authenticated card fetch costs 0.35 s; adding a second cloud to the path cost about 2 s.
  3. Cross-cloud A2A is an identity project. Budget for service principals, data-plane roles, token audiences, non-native auth on the callee, and RBAC propagation you cannot see. Where the caller can mint workload OIDC tokens, federating into the callee's native authorizer beats shipping a bearer token — and pin the subject, not just the audience.
  4. Version skew fails loud but late, through three separate mechanisms: a transitive pin, a framework extra, and a proxy stripping the version header. Package pins constrain your process, not your wire. Separate the bundles or bypass the extra; both are verified escapes.
  5. Your image is not your source tree. Every bug that survived a green test suite was a packaging or seam bug — and in the last build, five of six defects were invisible until something real was deployed.
  6. Verification buys independent failure detection, not accuracy. On the happy path both clouds agreed exactly, every time. The injected faults — and the real stale-rate warning that fired on a live Frankfurter quote — are where the second cloud earns its second.

Source

The repositories, each with its deploy scripts, tests, evaluation cases, and raw
results:

Upstream agent from
Getting Started with MCP, ADK and A2A.

If you have run A2A across a cloud boundary — especially if you have worked out
what ARN shape AgentCore actually authorises against, or hit a card/version
mismatch none of these six did — I would like to compare notes.

Top comments (0)