Skip to content
Open
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
42 changes: 42 additions & 0 deletions lib/a2a/agent_extension.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
defmodule A2A.AgentExtension do
@moduledoc """
A declaration of a protocol extension supported by an Agent.

Per the A2A v1.0 spec, agents declare supported extensions in their
Agent Card. Extensions are identified by a URI and may be marked as
required, meaning clients must understand and comply with the
extension's requirements.

## Fields

* `:uri` — the unique URI identifying the extension (required)
* `:description` — a human-readable description of how the agent uses the extension
* `:required` — if `true`, the client must support this extension (default: `false`)
* `:params` — optional extension-specific configuration parameters

## Examples

%A2A.AgentExtension{
uri: "https://a2a-protocol.org/extensions/timestamp",
description: "Adds timestamps to messages",
required: false
}

%A2A.AgentExtension{
uri: "https://a2a-protocol.org/extensions/secure-passport",
description: "Requires secure passport for authentication",
required: true,
params: %{"version" => "1.0"}
}
"""

@type t :: %__MODULE__{
uri: String.t(),
description: String.t() | nil,
required: boolean(),
params: map() | nil
}

@enforce_keys [:uri]
defstruct [:uri, :description, :params, required: false]
end
47 changes: 43 additions & 4 deletions lib/a2a/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ if Code.ensure_loaded?(Req) do
- `:context_id` — set the context ID
- `:configuration` — `MessageSendConfiguration` map
- `:metadata` — arbitrary metadata map
- `:extensions` — list of extension URI strings; sent via
`A2A-Extensions` request header (set at client creation via `new/2`)
- `:headers` — additional HTTP headers
- `:timeout` — HTTP request timeout in ms
"""
Expand All @@ -45,10 +47,11 @@ if Code.ensure_loaded?(Req) do

@type t :: %__MODULE__{
url: String.t(),
req: Req.Request.t()
req: Req.Request.t(),
extensions: [String.t()]
}

defstruct [:url, :req]
defstruct [:url, :req, extensions: []]

@doc """
Creates a new client struct.
Expand All @@ -69,18 +72,28 @@ if Code.ensure_loaded?(Req) do
end

def new(url, opts) when is_binary(url) do
{ext_uris, opts} = Keyword.pop(opts, :extensions, [])

{req_opts, _rest} =
Keyword.split(opts, [:headers, :connect_options, :retry, :plug])

headers = [{"content-type", "application/json"}]

headers =
case ext_uris do
[] -> headers
uris -> [{"a2a-extensions", Enum.join(uris, ", ")} | headers]
end

req =
Req.new(
Keyword.merge(
[base_url: url, headers: [{"content-type", "application/json"}]],
[base_url: url, headers: headers],
req_opts
)
)

%__MODULE__{url: url, req: req}
%__MODULE__{url: url, req: req, extensions: ext_uris}
end

@doc """
Expand Down Expand Up @@ -344,6 +357,32 @@ if Code.ensure_loaded?(Req) do
defp ensure_client(%A2A.AgentCard{} = card), do: new(card)
defp ensure_client(url) when is_binary(url), do: new(url)

@doc """
Parses the `A2A-Extensions` response header from a `Req.Response`.

Returns a list of extension URI strings the server supports.
Returns an empty list if the header is absent.

## Examples

{:ok, response} = Req.post(client.req, body: body)
server_exts = A2A.Client.parse_extensions_header(response)
#=> ["https://a2a-protocol.org/extensions/timestamp"]
"""
@spec parse_extensions_header(Req.Response.t()) :: [String.t()]
def parse_extensions_header(%Req.Response{headers: headers}) do
case Map.get(headers, "a2a-extensions", []) do
[] ->
[]

values when is_list(values) ->
values
|> Enum.flat_map(&String.split(&1, ","))
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
end

# -------------------------------------------------------------------
# Private — Response decoding
# -------------------------------------------------------------------
Expand Down
20 changes: 20 additions & 0 deletions lib/a2a/jsonrpc/error.ex
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,26 @@ defmodule A2A.JSONRPC.Error do
}
end

@doc "Builds an extension support required error (-32008)."
@spec extension_support_required(term()) :: t()
def extension_support_required(data \\ nil) do
%__MODULE__{
code: -32_008,
message: "Extension support required",
data: data
}
end

@doc "Builds a version not supported error (-32009)."
@spec version_not_supported(term()) :: t()
def version_not_supported(data \\ nil) do
%__MODULE__{
code: -32_009,
message: "Version not supported",
data: data
}
end

@doc """
Converts an error struct to a JSON-ready map.

Expand Down
71 changes: 69 additions & 2 deletions lib/a2a/plug.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ if Code.ensure_loaded?(Plug) do
- `:metadata` — static metadata merged into every JSON-RPC call
(default: `%{}`). Useful for deployment-level metadata like
`%{"env" => "prod"}`. Overridden per-request by `put_metadata/2`.
- `:extensions` — list of `%A2A.AgentExtension{}` structs declaring
extensions this server supports. Required extensions are validated
against the client's `A2A-Extensions` request header; missing
required extensions return `ExtensionSupportRequiredError` (-32008).
The server sets the `A2A-Extensions` response header with all
supported extension URIs.

## Per-Request Overrides

Expand Down Expand Up @@ -116,7 +122,8 @@ if Code.ensure_loaded?(Plug) do
agent_card_path: Keyword.get(opts, :agent_card_path, [".well-known", "agent-card.json"]),
json_rpc_path: Keyword.get(opts, :json_rpc_path, []),
agent_card_opts: Keyword.get(opts, :agent_card_opts, []),
metadata: Keyword.get(opts, :metadata, %{})
metadata: Keyword.get(opts, :metadata, %{}),
extensions: Keyword.get(opts, :extensions, [])
}
end

Expand All @@ -129,7 +136,23 @@ if Code.ensure_loaded?(Plug) do

def call(%{method: "POST", path_info: path} = conn, %{json_rpc_path: path} = opts) do
resolved = resolve_opts(conn, opts)
handle_json_rpc(conn, resolved)

case validate_extensions(conn, resolved) do
:ok ->
conn
|> put_extensions_response_header(resolved)
|> handle_json_rpc(resolved)

{:error, missing} ->
error =
Error.extension_support_required(
"Client missing required extensions: #{Enum.join(missing, ", ")}"
)

conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(Response.error(nil, error)))
end
end

def call(%{path_info: path} = conn, %{agent_card_path: path}) do
Expand All @@ -142,6 +165,50 @@ if Code.ensure_loaded?(Plug) do
send_resp(conn, 404, "Not Found")
end

# -- Extension validation --------------------------------------------------

defp validate_extensions(_conn, %{extensions: []}), do: :ok

defp validate_extensions(conn, %{extensions: extensions}) do
required_uris =
extensions
|> Enum.filter(& &1.required)
|> Enum.map(& &1.uri)
|> MapSet.new()

case MapSet.size(required_uris) do
0 ->
:ok

_ ->
client_uris =
conn
|> get_req_header("a2a-extensions")
|> parse_extension_header()
|> MapSet.new()

missing = MapSet.difference(required_uris, client_uris) |> MapSet.to_list()

if missing == [], do: :ok, else: {:error, missing}
end
end

defp parse_extension_header([]), do: []

defp parse_extension_header([header | _]) do
header
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end

defp put_extensions_response_header(conn, %{extensions: []}), do: conn

defp put_extensions_response_header(conn, %{extensions: extensions}) do
uris = Enum.map(extensions, & &1.uri)
put_resp_header(conn, "a2a-extensions", Enum.join(uris, ", "))
end

# -- Option resolution -----------------------------------------------------

defp resolve_opts(conn, opts) do
Expand Down
Loading