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
60 changes: 56 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,52 @@ Enum.each(stream, &IO.inspect/1)

All functions also accept a URL string directly: `A2A.Client.send_message("https://agent.example.com", "Hello!")`.

## Multi-Transport Support

A2A supports multiple transport protocols for different use cases:

### JSON-RPC 2.0 (Default)
The default transport using single endpoint with method dispatch:

```elixir
# Built into A2A.Plug and A2A.Client
{:ok, card} = A2A.Client.discover("https://agent.example.com")
{:ok, task} = A2A.Client.send_message(card, "Hello!")
```

### REST Transport
Direct HTTP endpoints without JSON-RPC wrapping:

```elixir
# Client
client = A2A.Transport.REST.new_client("http://localhost:8080")
{:ok, message_id} = A2A.Transport.REST.Client.send_message(
"http://localhost:8080", message, agent_card
)

# Server
plug A2A.Transport.REST.Server, agent_handler: MyAgent
```

### gRPC Transport
Protocol Buffers over HTTP/2:

```elixir
# Client
client = A2A.Transport.GRPC.new_client("127.0.0.1:50051")
{:ok, task} = A2A.Transport.GRPC.Client.send_message(client, message)

# Server
{:ok, _pid} = A2A.Transport.GRPC.Server.start_link(agent: MyAgent, port: 50051)
```

Check available transports:

```elixir
A2A.Transport.available_transports()
#=> [:jsonrpc, :rest, :grpc] # Depends on optional deps
```

## Multi-Turn & Streaming

Continue an existing task by passing `task_id`:
Expand Down Expand Up @@ -162,12 +208,18 @@ def deps do
[
{:a2a, "~> 0.2.0"},

# For serving A2A endpoints
# For serving A2A endpoints (JSON-RPC)
{:plug, "~> 1.16"},
{:bandit, "~> 1.5"},

# For calling remote A2A agents
{:req, "~> 0.5"}
# For calling remote A2A agents (JSON-RPC)
{:req, "~> 0.5"},

# For REST transport
# (requires both plug and req)

# For gRPC transport
{:grpcbox, "~> 0.16"}
]
end
```
Expand Down Expand Up @@ -241,7 +293,7 @@ Key A2A spec features not yet covered:

- **Push notifications** — webhook delivery on task state changes
- **Authenticated extended cards** — per-client capability disclosure
- **REST / gRPC transports** — only JSON-RPC is supported
- **Multi-transport ready** — JSON-RPC, REST, and gRPC transports available
- **Version negotiation** — hardcoded to A2A v0.3
- **Task resubscribe** — reconnecting to active SSE streams
- **Security middleware** — agent card signatures and OAuth flows (auth plug,
Expand Down
2 changes: 1 addition & 1 deletion lib/a2a/json.ex
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ 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, %{})
Expand Down
32 changes: 32 additions & 0 deletions lib/a2a/transport.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
defmodule A2A.Transport do
@moduledoc """
Multi-transport support for A2A protocol.

Currently supports:

- **JSON-RPC 2.0** (via HTTP) — Default transport in `A2A.Plug` and `A2A.Client`
- **REST/HTTP-JSON** — Direct HTTP endpoints via `A2A.Transport.REST`
"""

@doc """
Returns a list of available transport protocols.
"""
@spec available_transports() :: [atom()]
def available_transports do
transports = [:jsonrpc]

if A2A.Transport.REST.available?() do
transports ++ [:rest]
else
transports
end
end

@doc """
Checks if a specific transport is available.
"""
@spec available?(atom()) :: boolean()
def available?(:jsonrpc), do: true
def available?(:rest), do: A2A.Transport.REST.available?()
def available?(_), do: false
end
17 changes: 17 additions & 0 deletions lib/a2a/transport/rest.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
defmodule A2A.Transport.REST do
@moduledoc """
REST transport implementation for A2A protocol.

Provides REST/HTTP-JSON transport for A2A agents. The server routes
all requests through `A2A.JSONRPC` dispatch so the extension pipeline
and authorization callbacks are applied consistently.
"""

@doc """
Returns true if REST transport dependencies are available.
"""
@spec available?() :: boolean()
def available? do
Code.ensure_loaded?(Req) and Code.ensure_loaded?(Plug)
end
end
184 changes: 184 additions & 0 deletions lib/a2a/transport/rest/client.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
if Code.ensure_loaded?(Req) do
defmodule A2A.Transport.REST.Client do
@moduledoc """
REST/HTTP-JSON transport client for A2A protocol.

Provides direct HTTP calls to REST endpoints (no JSON-RPC wrapper).
Compatible with Python/Go REST transport implementations.
"""

alias A2A.{AgentCard, Message, Task}

@doc """
Send a message via REST transport.
"""
@spec send_message(String.t(), Message.t(), AgentCard.t(), keyword()) ::
{:ok, String.t()} | {:error, term()}
def send_message(endpoint, message, agent_card, opts \\ []) do
url = build_url(endpoint, "/v1/message:send")

with {:ok, message_json} <- A2A.JSON.encode(message) do
body = %{
message: message_json,
agent_card: A2A.JSON.encode_agent_card(agent_card, url: agent_card.url)
}

case post_json(url, body, opts) do
{:ok, %{"message_id" => message_id}} -> {:ok, message_id}
{:error, reason} -> {:error, reason}
end
end
end

@doc """
Poll for messages via REST transport.
"""
@spec poll_messages(String.t(), AgentCard.t(), keyword()) ::
{:ok, [Message.t()]} | {:error, term()}
def poll_messages(endpoint, agent_card, opts \\ []) do
url = build_url(endpoint, "/v1/messages")
# AgentCard.name is the agent ID
query = %{agent_id: agent_card.name}

case get_json(url, query, opts) do
{:ok, %{"messages" => messages}} ->
parsed_messages =
Enum.map(messages, fn msg_data ->
case A2A.JSON.decode(msg_data, :message) do
{:ok, message} -> message
{:error, _reason} -> nil
end
end)
|> Enum.reject(&is_nil/1)

{:ok, parsed_messages}

{:error, reason} ->
{:error, reason}
end
end

@doc """
Register an agent via REST transport.
"""
@spec register_agent(String.t(), AgentCard.t(), keyword()) ::
{:ok, :registered} | {:error, term()}
def register_agent(endpoint, agent_card, opts \\ []) do
url = build_url(endpoint, "/v1/agents")
body = %{agent_card: A2A.JSON.encode_agent_card(agent_card, url: agent_card.url)}

case post_json(url, body, opts) do
{:ok, _response} -> {:ok, :registered}
{:error, reason} -> {:error, reason}
end
end

@doc """
Get agent information via REST transport.
"""
@spec get_agent(String.t(), String.t(), keyword()) ::
{:ok, AgentCard.t()} | {:error, term()}
def get_agent(endpoint, agent_id, opts \\ []) do
url = build_url(endpoint, "/v1/agents/#{agent_id}")

case get_json(url, %{}, opts) do
{:ok, %{"agent_card" => card_data}} ->
A2A.JSON.decode_agent_card(card_data)

{:error, reason} ->
{:error, reason}
end
end

@doc """
Get agent card (extended) via REST transport.
"""
@spec get_card(String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def get_card(endpoint, opts \\ []) do
url = build_url(endpoint, "/v1/card")
get_json(url, %{}, opts)
end

@doc """
Get task information via REST transport.
"""
@spec get_task(String.t(), String.t(), keyword()) ::
{:ok, Task.t()} | {:error, term()}
def get_task(endpoint, task_id, opts \\ []) do
url = build_url(endpoint, "/v1/tasks/#{task_id}")

case get_json(url, %{}, opts) do
{:ok, task_data} ->
A2A.JSON.decode(task_data, :task)

{:error, reason} ->
{:error, reason}
end
end

@doc """
Cancel a task via REST transport.
"""
@spec cancel_task(String.t(), String.t(), keyword()) ::
{:ok, :cancelled} | {:error, term()}
def cancel_task(endpoint, task_id, opts \\ []) do
url = build_url(endpoint, "/v1/tasks/#{task_id}:cancel")

case post_json(url, %{}, opts) do
{:ok, _response} -> {:ok, :cancelled}
{:error, reason} -> {:error, reason}
end
end

# Private helper functions

defp build_url(endpoint, path) do
endpoint = String.trim_trailing(endpoint, "/")
"#{endpoint}#{path}"
end

defp post_json(url, body, opts) do
timeout = Keyword.get(opts, :timeout, 30_000)

req =
Req.new(
headers: [{"content-type", "application/json"}, {"accept", "application/json"}],
receive_timeout: timeout
)

json_body = Jason.encode!(body)

case Req.post(req, url: url, body: json_body) do
{:ok, %Req.Response{status: status, body: response_body}} when status in 200..299 ->
{:ok, response_body}

{:ok, %Req.Response{status: status, body: error_body}} ->
{:error, %{status: status, body: error_body}}

{:error, reason} ->
{:error, reason}
end
end

defp get_json(url, query, opts) do
timeout = Keyword.get(opts, :timeout, 30_000)

req =
Req.new(
headers: [{"accept", "application/json"}],
receive_timeout: timeout
)

case Req.get(req, url: url, params: query) do
{:ok, %Req.Response{status: status, body: response_body}} when status in 200..299 ->
{:ok, response_body}

{:ok, %Req.Response{status: status, body: error_body}} ->
{:error, %{status: status, body: error_body}}

{:error, reason} ->
{:error, reason}
end
end
end
end
Loading
Loading