Skip to content

Migrate A2A protocol from 0.3 to 1.0-preview#4

Merged
rockfordlhotka merged 6 commits intomainfrom
migrate/a2a-1.0
May 4, 2026
Merged

Migrate A2A protocol from 0.3 to 1.0-preview#4
rockfordlhotka merged 6 commits intomainfrom
migrate/a2a-1.0

Conversation

@rockfordlhotka
Copy link
Copy Markdown
Member

Summary

  • Swaps the agent's A2A hosting from Microsoft.Agents.Hosting.AspNetCore.A2A.Preview (M365 Agents SDK lineage, A2A 0.3) to the Microsoft Agent Framework (Microsoft.Agents.AI.Hosting.A2A.AspNetCore) plus the upstream A2A .NET SDK (A2A, A2A.AspNetCore), which speak A2A 1.0.
  • Live agent on the cluster was previously advertising \"protocolVersion\": \"0.3.0\" in its agent card; after this PR it will advertise \"1.0\".
  • Skill behavior, public endpoint paths under /a2a, ApiKey auth, the seven skill IDs, and skill JSON output are all preserved.

Why this isn't a simple version bump

A2A 1.0 lives in a different package family, with a different agent abstraction and a different DI/registration shape. The old SDK's AgentApplication-derived handler doesn't exist in the new framework — the new world models agents as AIAgent + tools, with optional custom IAgentHandler for full control of request processing.

Approach

SocialAgent's seven skills are deterministic JSON returners, not LLM tool calls — forcing them through an AIAgent would have made the LLM mandatory in the request path (currently optional via SkillRouter).

So this PR uses the framework's custom-handler escape hatch:

  • SocialAgentA2AHandler implements A2A.IAgentHandler and is registered as a keyed singleton under the agent name \"social-agent\". The framework's default A2AAgentHandler (which calls into an AIAgent) is bypassed entirely.
  • SocialAgentStubAgent is a minimal AIAgent subclass registered alongside it. Required by AddA2AServer's wiring (it pulls AIAgent keyed by name to read .Name), but all of its abstract members throw NotSupportedException — they are never invoked because our keyed IAgentHandler short-circuits the path.
  • SkillCatalog holds skill metadata (AgentSkill list for the card; keyword + LLM router definitions). SkillDispatcher runs the existing skill logic.
  • The agent card is now built explicitly and served via MapWellKnownAgentCard(agentCard) at /.well-known/agent-card.json with ProtocolVersion = \"1.0\" on each AgentInterface.

Endpoint surface

Path What it serves Auth
GET /.well-known/agent-card.json A2A 1.0 agent card Anonymous (consumers discover capabilities first)
POST /a2a JSON-RPC binding (SendMessage, GetTask, etc. — see A2A.A2AMethods) ApiKey in non-Development
POST /a2a/message:send, GET /a2a/tasks/{id}, … HTTP+JSON binding ApiKey in non-Development
GET /health/ready, /health/live Health probes Anonymous

The old /.well-known/agent.json (no hyphen) is gone — that was an artifact of the M365-lineage SDK's auto-emitted card. RockBot is reportedly 1.0-capable, so this should be the right path for it; if RockBot still hits the old URL it'll need a one-line update.

ApiKey auth is reapplied with AuthorizationPolicyBuilder against the mapped endpoint groups instead of the old requireAuth: bool parameter (the new SDKs don't expose that knob).

Tradeoff considered

The other path was to wrap each skill as an AIFunction tool on a real LLM-backed AIAgent. Cleaner with the new framework's grain, but it would have:

  1. Made an LLM mandatory in the request path (regression — SkillRouter is currently optional with keyword fallback).
  2. Risked the LLM re-formatting tool output JSON the way it sees fit.

Sticking with the custom-handler path keeps observable behavior identical to today's 0.3 deployment.

Smoke test (in Development, no auth)

$ curl http://localhost:18181/.well-known/agent-card.json | jq '.supportedInterfaces[0].protocolVersion'
\"1.0\"

$ curl -sX POST http://localhost:18181/a2a -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"SendMessage\",\"params\":{\"message\":{\"messageId\":\"m1\",\"role\":\"ROLE_USER\",\"parts\":[{\"text\":\"provider-status\"}]}}}'
{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"message\":{\"role\":\"ROLE_AGENT\",\"parts\":[{\"text\":\"[]\"}],\"messageId\":\"…\",\"contextId\":\"…\"}}}

$ curl -sX POST http://localhost:18181/a2a/message:send -H 'Content-Type: application/json' -d '{\"message\":{\"messageId\":\"m2\",\"role\":\"ROLE_USER\",\"parts\":[{\"text\":\"provider-status\"}]}}'
{\"message\":{\"role\":\"ROLE_AGENT\",\"parts\":[{\"text\":\"[]\"}],\"messageId\":\"…\",\"contextId\":\"…\"}}

Both transports route correctly. [] is the expected provider-status output when neither Mastodon nor Bluesky is configured in the smoke environment.

Side cleanup

OpenTelemetry packages were bumped from 1.15.0/1.15.1 to 1.15.3/1.15.2/1.15.1 (latest available on each) to clear pre-existing NU1902 vulnerability advisories that had been silently breaking dotnet restore on a fresh machine. Unrelated to A2A but unavoidable to validate the build.

Test plan

  • dotnet build SocialAgent.slnx — clean (0 warnings, 0 errors)
  • dotnet test SocialAgent.slnx --filter \"FullyQualifiedName!~Integration\" — all unit tests pass
  • Manual smoke: agent card, JSON-RPC SendMessage, HTTP+JSON /message:send all round-trip correctly
  • Verify in cluster after merge: new image deployed; agent card reports protocolVersion: \"1.0\"; RockBot can still call skills end-to-end
  • Confirm RockBot still works: RockBot needs to discover via /.well-known/agent-card.json (the hyphenated path) and use either the JSON-RPC SendMessage method name or the HTTP+JSON /message:send route
  • Pre-existing integration tests (*Integration*) still need live MASTODON_ACCESS_TOKEN / BLUESKY_* env vars to pass — not changed by this PR

Caveats for reviewers

  • All packages added are *-preview*Microsoft.Agents.AI.Hosting.A2A.AspNetCore 1.3.0-preview.260423.1, A2A/A2A.AspNetCore 1.0.0-preview2. The Agent Framework itself shipped 1.0 GA but the A2A hosting subpackage is still in preview.
  • Default InMemoryAgentSessionStore / InMemoryTaskStore are unchanged — fine for our use because our custom handler doesn't use sessions or task continuations. If we ever switch to background/long-running task semantics, we'll need durable stores.
  • The framework's experimental [Experimental(...)] attributes flow through to consumers; we may need to suppress diagnostics if any leak in future.

🤖 Generated with Claude Code

rockfordlhotka and others added 6 commits May 4, 2026 01:42
Swap from Microsoft.Agents.Hosting.AspNetCore.A2A.Preview (M365 Agents
SDK lineage, A2A 0.3) to the Microsoft Agent Framework
(Microsoft.Agents.AI.Hosting.A2A.AspNetCore) plus the upstream A2A .NET
SDK (A2A, A2A.AspNetCore), which speak A2A 1.0.

The previous AgentApplication-derived handler does not exist in the new
framework, which models agents as AIAgent + tools. Since SocialAgent's
seven skills are deterministic JSON returners (not LLM tool calls), the
migration uses a custom A2A.IAgentHandler keyed under the agent name —
this bypasses the framework's default LLM-driven A2AAgentHandler and
preserves the existing dispatch semantics. A minimal AIAgent stub
(SocialAgentStubAgent) is registered alongside it solely as a name
carrier required by AddA2AServer; none of its abstract members are ever
invoked.

Endpoint surface changes:
- Discovery moves from /.well-known/agent.json (auto-emitted) to
  /.well-known/agent-card.json (A2A 1.0 spec path), built explicitly
  from SkillCatalog with ProtocolVersion="1.0".
- /a2a now serves both JSON-RPC (POST /a2a) and HTTP+JSON (e.g.
  POST /a2a/message:send, GET /a2a/tasks/{id}) — clients pick either.
- ApiKey auth is reapplied via AuthorizationPolicyBuilder against the
  mapped JSON-RPC and HTTP+JSON endpoints in non-Development env;
  agent card stays anonymous so consumers can discover capabilities
  before authenticating.

Skill code is unchanged behaviorally — extracted from the old
SocialAgentHandler into SkillCatalog (metadata + keyword routing),
SkillDispatcher (dispatch + analytics calls), and SocialAgentA2AHandler
(A2A bridge). LLM-assisted routing via SkillRouter remains optional and
falls back to keyword matching when LLM__Low is unconfigured.

Also bumps OpenTelemetry packages to 1.15.x patched releases to clear
pre-existing NU1902 vulnerability advisories that were blocking restore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sets <Version>1.3.0</Version> on the host project so the agent card
advertises 1.3.0 instead of the default 1.0.0.0, and pins the k8s
deployment image to rockylhotka/socialagent:1.3.0 (was :latest) so
rollout state is explicit and rollback is a single-line change.

Image rockylhotka/socialagent:1.3.0 has been built and pushed; the
:latest tag has been updated to point at the same digest. Smoke-tested
on the rockbot cluster: agent card reports version 1.3.0.0 and both
JSONRPC and HTTPJSON interfaces report protocolVersion 1.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The A2A v1.0 spec example shows supportedInterfaces[].url as absolute
URLs (e.g. https://georoute-agent.example.com/a2a/v1) so cross-host
clients can resolve the endpoint without depending on the discovery
URL's host. We were emitting bare paths ("/a2a"), which works only
when the client happens to resolve relative to the card URL.

Adds a SocialAgent:PublicBaseUrl configuration value. When set, both
JSONRPC and HTTPJSON interface URLs in the card are prefixed with it.
When unset (e.g. local dev), the card falls back to the relative "/a2a"
path so dotnet run still works without configuration.

The k8s ConfigMap now sets PublicBaseUrl to the in-cluster service DNS
(http://social-agent.rockbot.svc.cluster.local), wired into the
deployment via SocialAgent__PublicBaseUrl.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dispatcher was reading only context.UserText and routing via the
optional LLM SkillRouter (or keyword matcher), so when a client put the
skill discriminator in message metadata — which RockBot does in its v1
SDK send-request builder (BuildV1SendRequest), per the v0.3-compatible
convention — the metadata was ignored and the LLM was free to pick
whatever skill it thought matched the body text. In production, with
LLM__Low configured, the LLM was returning provider-status for both
recent-mentions and other requests, causing both to hand back identical
provider-status JSON.

SocialAgentA2AHandler now reads "skill" (or "skillId") from
message.metadata and request-level metadata. If the value is a known
skill ID, it dispatches directly and skips routing entirely. Falls back
to the existing LLM/keyword routing only when metadata is absent or
unrecognized, so dotnet run -style ad-hoc text invocation still works.

Smoke-tested in cluster: three skills with metadata.skill set to
provider-status, recent-mentions, engagement-summary now return three
distinct payload shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Skills previously took no parameters from the A2A request, so any
client-side filtering (e.g. \"recent-mentions for Bluesky only\") was
silently ignored — the dispatcher always called the analytics methods
with their defaults, returning the all-platforms union. The analytics
layer already supported providerId/count/since arguments; this PR wires
them through.

Convention: parameters arrive as flat keys on message.metadata,
alongside metadata.skill. Recognized keys per skill:

  engagement-summary:    providerId, since
  top-posts:             providerId, count, since
  recent-mentions:       providerId, count
  follower-insights:     providerId, count, since
  platform-comparison:   since
  check-notifications:   providerId
  provider-status:       (none)

Unknown keys are ignored. Strings, numbers, and ISO-8601 timestamps are
all accepted; the dispatcher coerces strings to int/date where needed.
Agent card skill descriptions updated to document the per-skill keys so
v1 clients can discover them via /.well-known/agent-card.json.

Smoke-tested in cluster:
  recent-mentions, no filter:           4 items, providers: mastodon
  recent-mentions, providerId=mastodon: 4 items, providers: mastodon
  recent-mentions, providerId=bluesky:  0 items (no native Bluesky
                                        mentions in the polled window)

The filter is honored — the empty Bluesky result is real, not a bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 1.3.3 metadata-parameter forwarding was hard to verify from logs:
the dispatcher logged only the skill ID, so when RockBot sent a
recent-mentions request with providerId=bluesky there was no way to
tell from the log whether the parameter arrived, was forwarded, or was
silently dropped.

Adds a parameters={...} field to the dispatch log line, summarizing
recognized keys (providerId, count, since) from message.metadata.
Unrecognized keys are intentionally omitted so the log stays compact.
Format: parameters={providerId=bluesky, count=25} or parameters={} when
no recognized keys are present.

Verified in cluster: a recent-mentions request with metadata
{providerId: \"bluesky\", count: 25} now produces:

  Dispatching skill recent-mentions (from request metadata)
  parameters={providerId=bluesky, count=25}

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rockfordlhotka rockfordlhotka merged commit 89b8647 into main May 4, 2026
@rockfordlhotka rockfordlhotka deleted the migrate/a2a-1.0 branch May 4, 2026 19:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant