From c0b3650180805834c285b37d2ae298737dfa3c5f Mon Sep 17 00:00:00 2001 From: Alan Blount Date: Thu, 11 Jun 2026 13:03:02 +0000 Subject: [PATCH 1/2] fix(json): honor rich %A2A.AgentCard{} fields in encode_agent_card/2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encode_agent_card/2 was typed to accept A2A.AgentCard.t() but only read name/description/version/skills from the card — capabilities, provider, security_schemes, supported_interfaces, signatures, documentation_url and icon_url were sourced exclusively from opts. Passing a fully-populated %A2A.AgentCard{} silently dropped those fields. Resolve each rich field from opts first (explicit override, backward compatible), then fall back to the card struct/map. The A2A.AgentCard.t() spec is now accurate: a populated struct round-trips correctly while every existing opts-based caller (A2A.Plug, A2A.get_agent_card) is unchanged. Adds tests for struct-sourced fields and opts-override precedence. --- lib/a2a/json.ex | 34 ++++++++++++++++++++++++---------- test/a2a/json_test.exs | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/lib/a2a/json.ex b/lib/a2a/json.ex index b83b2ae..8680be0 100644 --- a/lib/a2a/json.ex +++ b/lib/a2a/json.ex @@ -259,19 +259,20 @@ defmodule A2A.JSON do - `:signatures` — list of JWS signature maps (each `%{"protected" => ..., "signature" => ..., "header" => ...}`) """ - @spec encode_agent_card(A2A.Agent.card(), keyword()) :: map() + @spec encode_agent_card(A2A.AgentCard.t(), keyword()) :: map() def encode_agent_card(card, opts \\ []) do url = Keyword.fetch!(opts, :url) - capabilities = Keyword.get(opts, :capabilities, %{}) - input_modes = Keyword.get(opts, :default_input_modes, ["text/plain"]) - output_modes = Keyword.get(opts, :default_output_modes, ["text/plain"]) + capabilities = card_field(opts, card, :capabilities, %{}) + input_modes = card_field(opts, card, :default_input_modes, ["text/plain"]) + output_modes = card_field(opts, card, :default_output_modes, ["text/plain"]) interfaces = Keyword.get(opts, :supported_interfaces) || + non_empty(Map.get(card, :supported_interfaces)) || [%{url: url, protocol_binding: "JSONRPC", protocol_version: "2.0"}] - security_schemes = Keyword.get(opts, :security_schemes, %{}) - security = Keyword.get(opts, :security, []) + security_schemes = card_field(opts, card, :security_schemes, %{}) + security = card_field(opts, card, :security, []) skills = Enum.map(card.skills, fn skill -> @@ -296,16 +297,29 @@ defmodule A2A.JSON do "defaultOutputModes" => output_modes, "supportedInterfaces" => encode_interfaces(interfaces) } - |> put_unless_nil("provider", encode_provider(Keyword.get(opts, :provider))) - |> put_unless_nil("documentationUrl", Keyword.get(opts, :documentation_url)) - |> put_unless_nil("iconUrl", Keyword.get(opts, :icon_url)) + |> put_unless_nil("provider", encode_provider(card_field(opts, card, :provider, nil))) + |> put_unless_nil("documentationUrl", card_field(opts, card, :documentation_url, nil)) + |> put_unless_nil("iconUrl", card_field(opts, card, :icon_url, nil)) |> put_unless_empty("securitySchemes", encode_security_schemes(security_schemes)) |> put_unless_empty("security", security) - |> put_unless_empty("signatures", Keyword.get(opts, :signatures, [])) + |> put_unless_empty("signatures", card_field(opts, card, :signatures, [])) map end + # Resolves a field from caller opts first (explicit override), then from the + # card struct/map, then the given default. Lets callers pass a fully-populated + # %A2A.AgentCard{} OR supply fields via opts (backward compatible). + defp card_field(opts, card, key, default) do + case Keyword.fetch(opts, key) do + {:ok, value} -> value + :error -> Map.get(card, key, default) || default + end + end + + defp non_empty([]), do: nil + defp non_empty(value), do: value + @doc """ Decodes a JSON map into an `%A2A.AgentCard{}` struct. diff --git a/test/a2a/json_test.exs b/test/a2a/json_test.exs index 00cc864..c90727b 100644 --- a/test/a2a/json_test.exs +++ b/test/a2a/json_test.exs @@ -1134,6 +1134,46 @@ defmodule A2A.JSONTest do JSON.encode_agent_card(card, []) end end + + test "encodes rich fields from a populated %A2A.AgentCard{} struct" do + card = %A2A.AgentCard{ + name: "struct-agent", + description: "Built from the struct", + url: "https://example.com/a2a", + version: "2.0.0", + skills: [%{id: "s", name: "S", description: "d", tags: ["t"]}], + capabilities: %{streaming: true, push_notifications: true}, + provider: %{organization: "Acme", url: "https://acme.example.com"}, + documentation_url: "https://docs.example.com" + } + + map = JSON.encode_agent_card(card, url: "https://example.com/a2a") + + # Without these struct values being read, all three would be empty/absent. + assert map["capabilities"] == %{"streaming" => true, "pushNotifications" => true} + assert map["provider"] == %{"organization" => "Acme", "url" => "https://acme.example.com"} + assert map["documentationUrl"] == "https://docs.example.com" + end + + test "opts override struct fields (backward compatible)" do + card = %A2A.AgentCard{ + name: "struct-agent", + description: "d", + url: "https://example.com/a2a", + version: "1.0.0", + skills: [], + capabilities: %{streaming: true} + } + + map = + JSON.encode_agent_card(card, + url: "https://example.com/a2a", + capabilities: %{push_notifications: true} + ) + + # opts win over the struct value + assert map["capabilities"] == %{"pushNotifications" => true} + end end # ------------------------------------------------------------------- From 107bda6c19e2994f83e2d9b09899e0950bd871d0 Mon Sep 17 00:00:00 2001 From: Scion Agent Date: Mon, 15 Jun 2026 02:32:49 +0000 Subject: [PATCH 2/2] fix(json): improve encode_agent_card spec, docs, nil coalescing, and tests - Widen @spec to accept A2A.AgentCard.t() | A2A.Agent.card() since callers pass both structs and plain maps - Fix card_field/4 nil coalescing: replace || (treats false as unset) with explicit nil pattern match via case - Update @doc to document struct fallback behavior and opts precedence - Expand test coverage to verify all struct fields (icon_url, default modes, security_schemes, security, supported_interfaces, signatures) and add a plain-map backward compatibility test --- lib/a2a/json.ex | 23 +++++++++++++--- test/a2a/json_test.exs | 60 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/lib/a2a/json.ex b/lib/a2a/json.ex index 8680be0..c4af4e2 100644 --- a/lib/a2a/json.ex +++ b/lib/a2a/json.ex @@ -235,7 +235,13 @@ defmodule A2A.JSON do end @doc """ - Encodes an agent card map with options into the AgentCard JSON format. + Encodes an agent card into the AgentCard JSON format. + + Accepts either a plain map (as returned by `A2A.Agent.agent_card/0`) or a + fully-populated `%A2A.AgentCard{}` struct. When a struct is passed, fields + like `capabilities`, `provider`, `documentation_url`, etc. are read from + the struct and used as defaults. Options in `opts` always take precedence + over struct fields. In v1.0 the top-level `url` and `protocolVersion` fields are gone from the wire format — both are per-interface. The `:url` option is still required @@ -244,6 +250,9 @@ defmodule A2A.JSON do ## Options + All options override the corresponding struct field when a + `%A2A.AgentCard{}` is passed as `card`. + - `:url` — agent endpoint URL, used as the default `supportedInterfaces[0].url` - `:capabilities` — `AgentCapabilities` map (default: `%{}`) - `:default_input_modes` — list of MIME types (default: `["text/plain"]`) @@ -259,7 +268,7 @@ defmodule A2A.JSON do - `:signatures` — list of JWS signature maps (each `%{"protected" => ..., "signature" => ..., "header" => ...}`) """ - @spec encode_agent_card(A2A.AgentCard.t(), keyword()) :: map() + @spec encode_agent_card(A2A.AgentCard.t() | A2A.Agent.card(), keyword()) :: map() def encode_agent_card(card, opts \\ []) do url = Keyword.fetch!(opts, :url) capabilities = card_field(opts, card, :capabilities, %{}) @@ -312,8 +321,14 @@ defmodule A2A.JSON do # %A2A.AgentCard{} OR supply fields via opts (backward compatible). defp card_field(opts, card, key, default) do case Keyword.fetch(opts, key) do - {:ok, value} -> value - :error -> Map.get(card, key, default) || default + {:ok, value} -> + value + + :error -> + case Map.get(card, key) do + nil -> default + value -> value + end end end diff --git a/test/a2a/json_test.exs b/test/a2a/json_test.exs index c90727b..6a5104b 100644 --- a/test/a2a/json_test.exs +++ b/test/a2a/json_test.exs @@ -1135,7 +1135,7 @@ defmodule A2A.JSONTest do end end - test "encodes rich fields from a populated %A2A.AgentCard{} struct" do + test "encodes all rich fields from a populated %A2A.AgentCard{} struct" do card = %A2A.AgentCard{ name: "struct-agent", description: "Built from the struct", @@ -1144,15 +1144,38 @@ defmodule A2A.JSONTest do skills: [%{id: "s", name: "S", description: "d", tags: ["t"]}], capabilities: %{streaming: true, push_notifications: true}, provider: %{organization: "Acme", url: "https://acme.example.com"}, - documentation_url: "https://docs.example.com" + documentation_url: "https://docs.example.com", + icon_url: "https://example.com/icon.png", + default_input_modes: ["application/json"], + default_output_modes: ["application/json", "text/plain"], + security_schemes: %{ + "bearer" => %A2A.SecurityScheme.HTTPAuth{scheme: "bearer"} + }, + security: [%{"bearer" => []}], + supported_interfaces: [ + %{url: "https://a.example.com", protocol_binding: "grpc", protocol_version: "1.0"} + ], + signatures: [%{"protected" => "p", "signature" => "s"}] } map = JSON.encode_agent_card(card, url: "https://example.com/a2a") - # Without these struct values being read, all three would be empty/absent. assert map["capabilities"] == %{"streaming" => true, "pushNotifications" => true} assert map["provider"] == %{"organization" => "Acme", "url" => "https://acme.example.com"} assert map["documentationUrl"] == "https://docs.example.com" + assert map["iconUrl"] == "https://example.com/icon.png" + assert map["defaultInputModes"] == ["application/json"] + assert map["defaultOutputModes"] == ["application/json", "text/plain"] + + assert %{"httpAuthSecurityScheme" => %{"scheme" => "bearer"}} = + map["securitySchemes"]["bearer"] + + assert map["security"] == [%{"bearer" => []}] + + assert [%{"url" => "https://a.example.com", "protocolBinding" => "grpc"}] = + map["supportedInterfaces"] + + assert map["signatures"] == [%{"protected" => "p", "signature" => "s"}] end test "opts override struct fields (backward compatible)" do @@ -1162,17 +1185,44 @@ defmodule A2A.JSONTest do url: "https://example.com/a2a", version: "1.0.0", skills: [], - capabilities: %{streaming: true} + capabilities: %{streaming: true}, + documentation_url: "https://old.example.com", + icon_url: "https://old-icon.example.com", + default_input_modes: ["application/json"] } map = JSON.encode_agent_card(card, url: "https://example.com/a2a", - capabilities: %{push_notifications: true} + capabilities: %{push_notifications: true}, + documentation_url: "https://new.example.com", + icon_url: "https://new-icon.example.com", + default_input_modes: ["text/plain", "text/html"] ) # opts win over the struct value assert map["capabilities"] == %{"pushNotifications" => true} + assert map["documentationUrl"] == "https://new.example.com" + assert map["iconUrl"] == "https://new-icon.example.com" + assert map["defaultInputModes"] == ["text/plain", "text/html"] + end + + test "plain map card still works (backward compatible)" do + card = %{ + name: "map-agent", + description: "Plain map", + version: "1.0.0", + skills: [%{id: "s", name: "S", description: "d", tags: []}], + opts: [] + } + + map = JSON.encode_agent_card(card, url: "https://example.com/a2a") + + assert map["name"] == "map-agent" + assert map["capabilities"] == %{} + assert map["defaultInputModes"] == ["text/plain"] + refute Map.has_key?(map, "provider") + refute Map.has_key?(map, "documentationUrl") end end