diff --git a/lib/a2a/json.ex b/lib/a2a/json.ex index b83b2ae..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,19 +268,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() | A2A.Agent.card(), 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 +306,35 @@ 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 -> + case Map.get(card, key) do + nil -> default + value -> value + end + 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..6a5104b 100644 --- a/test/a2a/json_test.exs +++ b/test/a2a/json_test.exs @@ -1134,6 +1134,96 @@ defmodule A2A.JSONTest do JSON.encode_agent_card(card, []) end end + + test "encodes all 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", + 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") + + 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 + card = %A2A.AgentCard{ + name: "struct-agent", + description: "d", + url: "https://example.com/a2a", + version: "1.0.0", + skills: [], + 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}, + 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 # -------------------------------------------------------------------