Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 40 additions & 11 deletions lib/a2a/json.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]`)
Expand All @@ -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 ->
Expand All @@ -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.

Expand Down
90 changes: 90 additions & 0 deletions test/a2a/json_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

# -------------------------------------------------------------------
Expand Down
Loading